feat: add core POS module — transactions, discounts, drawer, tax
Phase 3a backend API for point-of-sale. Includes:
Schema (packages/backend/src/db/schema/pos.ts):
- pgEnums: transaction_type, transaction_status, payment_method,
discount_type, discount_applies_to, drawer_status
- Tables: transaction, transaction_line_item, discount,
discount_audit, drawer_session
- Transaction links to accounts, repair_tickets, repair_batches
- Line items link to products and inventory_units
Tax system:
- tax_rate + service_tax_rate columns on location
- tax_category enum (goods/service/exempt) on product
- Tax resolves per line item: goods→tax_rate, service→service_tax_rate,
exempt→0. Repair line items map: part→goods, labor→service
- GET /tax/lookup/:zip stubbed for future API integration (TAX_API_KEY)
Services (export const pattern, matching existing codebase):
- TransactionService: create, addLineItem, removeLineItem, applyDiscount,
recalculateTotals, complete (decrements inventory), void, getReceipt
- DiscountService: CRUD + listAll for dropdowns
- DrawerService: open/close with expected balance + over/short calc
- TaxService: getRateForLocation (by tax category), calculateTax
Routes:
- POST/GET /transactions, GET /transactions/:id, GET /transactions/:id/receipt
- POST /transactions/:id/line-items, DELETE /transactions/:id/line-items/:id
- POST /transactions/:id/discounts, /complete, /void
- POST /drawer/open, POST /drawer/:id/close, GET /drawer/current, GET /drawer
- CRUD /discounts + GET /discounts/all
- GET /products/lookup/upc/:upc (barcode scanner support)
All routes gated by pos.view/pos.edit/pos.admin + withModule('pos').
POS module already seeded in migration 0026.
Still needed: bun install, drizzle-kit generate + migrate, tests, lint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
packages/backend/src/services/tax.service.ts
Normal file
78
packages/backend/src/services/tax.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { locations } from '../db/schema/stores.js'
|
||||
import { AppError } from '../lib/errors.js'
|
||||
|
||||
export type TaxCategory = 'goods' | 'service' | 'exempt'
|
||||
|
||||
export const TaxService = {
|
||||
/**
|
||||
* Get the tax rate for a location, resolved by tax category:
|
||||
* - "goods" → location.taxRate (default)
|
||||
* - "service" → location.serviceTaxRate, falls back to taxRate
|
||||
* - "exempt" → 0
|
||||
*
|
||||
* Returns 0 with no warning for exempt items.
|
||||
* Returns 0 with warning if no rate is configured on the location.
|
||||
*/
|
||||
async getRateForLocation(
|
||||
db: PostgresJsDatabase<any>,
|
||||
locationId: string,
|
||||
taxCategory: TaxCategory = 'goods',
|
||||
): Promise<number> {
|
||||
if (taxCategory === 'exempt') return 0
|
||||
|
||||
const [location] = await db
|
||||
.select({ taxRate: locations.taxRate, serviceTaxRate: locations.serviceTaxRate })
|
||||
.from(locations)
|
||||
.where(eq(locations.id, locationId))
|
||||
.limit(1)
|
||||
|
||||
if (!location) return 0
|
||||
|
||||
if (taxCategory === 'service') {
|
||||
// Use service rate if set, otherwise fall back to goods rate
|
||||
const rate = location.serviceTaxRate ?? location.taxRate
|
||||
return rate ? parseFloat(rate) : 0
|
||||
}
|
||||
|
||||
// Default: goods rate
|
||||
return location.taxRate ? parseFloat(location.taxRate) : 0
|
||||
},
|
||||
|
||||
calculateTax(amount: number, rate: number): number {
|
||||
return Math.round(amount * rate * 100) / 100
|
||||
},
|
||||
|
||||
/**
|
||||
* Map repair line item types to tax categories:
|
||||
* - "part" → goods (taxable)
|
||||
* - "labor" → service (may be taxed differently)
|
||||
* - "flat_rate" → goods (conservative — includes parts)
|
||||
* - "misc" → goods (default)
|
||||
*/
|
||||
repairItemTypeToTaxCategory(itemType: string): TaxCategory {
|
||||
switch (itemType) {
|
||||
case 'labor':
|
||||
return 'service'
|
||||
case 'part':
|
||||
case 'flat_rate':
|
||||
case 'misc':
|
||||
default:
|
||||
return 'goods'
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Integrate with a real tax rate API (TaxJar, Avalara, etc.)
|
||||
// Set TAX_API_KEY env var when ready.
|
||||
async lookupByZip(
|
||||
_zip: string,
|
||||
): Promise<{ zip: string; rate: number; state_rate: number; county_rate: number; city_rate: number }> {
|
||||
if (!process.env.TAX_API_KEY) {
|
||||
throw new AppError('Tax rate lookup is not configured. Set TAX_API_KEY to enable automatic lookup.', 501)
|
||||
}
|
||||
|
||||
// Placeholder — replace with real API call
|
||||
throw new AppError('Tax rate lookup not yet implemented', 501)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user