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:
ryan
2026-04-04 16:26:38 +00:00
parent 7aa81c4e7c
commit 7b15f18e59
17 changed files with 1247 additions and 1 deletions

View File

@@ -163,3 +163,31 @@ export type {
LessonPlanTemplateUpdateInput,
TemplateInstantiateInput,
} from './lessons.schema.js'
export {
TransactionType,
TransactionStatus,
PaymentMethod,
DiscountType,
DiscountAppliesTo,
DrawerStatus,
TaxCategory,
TransactionCreateSchema,
TransactionLineItemCreateSchema,
ApplyDiscountSchema,
CompleteTransactionSchema,
DiscountCreateSchema,
DiscountUpdateSchema,
DrawerOpenSchema,
DrawerCloseSchema,
} from './pos.schema.js'
export type {
TransactionCreateInput,
TransactionLineItemCreateInput,
ApplyDiscountInput,
CompleteTransactionInput,
DiscountCreateInput,
DiscountUpdateInput,
DrawerOpenInput,
DrawerCloseInput,
} from './pos.schema.js'

View File

@@ -0,0 +1,114 @@
import { z } from 'zod'
/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */
function opt<T extends z.ZodTypeAny>(schema: T) {
return z.preprocess((v) => (v === '' ? undefined : v), schema.optional())
}
// --- Enum values (must match pgEnum definitions in pos.ts) ---
export const TransactionType = z.enum([
'sale',
'repair_payment',
'rental_deposit',
'account_payment',
'refund',
])
export type TransactionType = z.infer<typeof TransactionType>
export const TransactionStatus = z.enum(['pending', 'completed', 'voided', 'refunded'])
export type TransactionStatus = z.infer<typeof TransactionStatus>
export const PaymentMethod = z.enum([
'cash',
'card_present',
'card_keyed',
'check',
'account_charge',
])
export type PaymentMethod = z.infer<typeof PaymentMethod>
export const DiscountType = z.enum(['percent', 'fixed'])
export type DiscountType = z.infer<typeof DiscountType>
export const DiscountAppliesTo = z.enum(['order', 'line_item', 'category'])
export type DiscountAppliesTo = z.infer<typeof DiscountAppliesTo>
export const DrawerStatus = z.enum(['open', 'closed'])
export type DrawerStatus = z.infer<typeof DrawerStatus>
export const TaxCategory = z.enum(['goods', 'service', 'exempt'])
export type TaxCategory = z.infer<typeof TaxCategory>
// --- Transaction schemas ---
export const TransactionCreateSchema = z.object({
transactionType: TransactionType,
locationId: opt(z.string().uuid()),
accountId: opt(z.string().uuid()),
repairTicketId: opt(z.string().uuid()),
repairBatchId: opt(z.string().uuid()),
notes: opt(z.string()),
taxExempt: z.boolean().default(false),
taxExemptReason: opt(z.string()),
})
export type TransactionCreateInput = z.infer<typeof TransactionCreateSchema>
export const TransactionLineItemCreateSchema = z.object({
productId: opt(z.string().uuid()),
inventoryUnitId: opt(z.string().uuid()),
description: z.string().min(1).max(255),
qty: z.number().int().min(1).default(1),
unitPrice: z.coerce.number().min(0),
})
export type TransactionLineItemCreateInput = z.infer<typeof TransactionLineItemCreateSchema>
export const ApplyDiscountSchema = z.object({
discountId: opt(z.string().uuid()),
amount: z.coerce.number().min(0),
reason: z.string().min(1),
lineItemId: opt(z.string().uuid()),
})
export type ApplyDiscountInput = z.infer<typeof ApplyDiscountSchema>
export const CompleteTransactionSchema = z.object({
paymentMethod: PaymentMethod,
amountTendered: z.coerce.number().min(0).optional(),
checkNumber: opt(z.string().max(50)),
})
export type CompleteTransactionInput = z.infer<typeof CompleteTransactionSchema>
// --- Discount schemas ---
export const DiscountCreateSchema = z.object({
name: z.string().min(1).max(255),
discountType: DiscountType,
discountValue: z.coerce.number().min(0),
appliesTo: DiscountAppliesTo.default('line_item'),
locationId: opt(z.string().uuid()),
requiresApprovalAbove: z.coerce.number().min(0).optional(),
isActive: z.boolean().default(true),
validFrom: opt(z.string()),
validUntil: opt(z.string()),
})
export type DiscountCreateInput = z.infer<typeof DiscountCreateSchema>
export const DiscountUpdateSchema = DiscountCreateSchema.partial()
export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
// --- Drawer schemas ---
export const DrawerOpenSchema = z.object({
locationId: opt(z.string().uuid()),
openingBalance: z.coerce.number().min(0),
})
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
export const DrawerCloseSchema = z.object({
closingBalance: z.coerce.number().min(0),
denominations: z
.record(z.string(), z.number())
.optional(),
notes: opt(z.string()),
})
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>