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:
@@ -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'
|
||||
|
||||
114
packages/shared/src/schemas/pos.schema.ts
Normal file
114
packages/shared/src/schemas/pos.schema.ts
Normal 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>
|
||||
Reference in New Issue
Block a user