From be8cc0ad8b1edf8bb7d29b0d7089b7776d03e264 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 5 Apr 2026 01:43:02 +0000 Subject: [PATCH] fix: code review fixes + unit/API tests for repair-POS integration Code review fixes: - Wrap createFromRepairTicket() in DB transaction for atomicity - Wrap complete() inventory + status updates in DB transaction - Repair ticket status update now atomic with transaction completion - Add Zod validation on from-repair route body - Fix requiresDiscountOverride: threshold and manual_discount are independent checks - Order discount distributes proportionally across line items (not first-only) - Extract shared receipt calculations into useReceiptData/useBarcode hooks - Add error handling for barcode generation Tests: - Unit: consumable tax category mapping, exempt rate short-circuit - API: ready-for-pickup listing + search, from-repair transaction creation, consumable exclusion from line items, tax rate verification (labor=service, part=goods), duplicate prevention, ticket auto-pickup on payment completion, isConsumable product filter Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/pos/pos-cart-panel.tsx | 32 ++- .../components/pos/pos-manager-override.tsx | 6 +- .../admin/src/components/pos/pos-receipt.tsx | 77 +++---- .../__tests__/services/tax-consumable.test.ts | 37 ++++ packages/backend/api-tests/suites/pos.ts | 207 ++++++++++++++++++ .../backend/src/routes/v1/transactions.ts | 14 +- .../src/services/transaction.service.ts | 177 ++++++++------- 7 files changed, 422 insertions(+), 128 deletions(-) create mode 100644 packages/backend/__tests__/services/tax-consumable.test.ts diff --git a/packages/admin/src/components/pos/pos-cart-panel.tsx b/packages/admin/src/components/pos/pos-cart-panel.tsx index b0595da..051ee4f 100644 --- a/packages/admin/src/components/pos/pos-cart-panel.tsx +++ b/packages/admin/src/components/pos/pos-cart-panel.tsx @@ -26,6 +26,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { const [overrideOpen, setOverrideOpen] = useState(false) const [priceItemId, setPriceItemId] = useState(null) const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null) + const [pendingOrderDiscount, setPendingOrderDiscount] = useState<{ amount: number; reason: string } | null>(null) const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false) const lineItems = transaction?.lineItems ?? [] @@ -52,6 +53,28 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { onError: (err) => toast.error(err.message), }) + const orderDiscountMutation = useMutation({ + mutationFn: async ({ amount, reason }: { amount: number; reason: string }) => { + // Distribute discount proportionally across all line items + let remaining = amount + for (let i = 0; i < lineItems.length; i++) { + const item = lineItems[i] + const itemTotal = parseFloat(item.unitPrice) * item.qty + const isLast = i === lineItems.length - 1 + const share = isLast ? remaining : Math.round((itemTotal / subtotal) * amount * 100) / 100 + remaining -= share + if (share > 0) { + await posMutations.applyDiscount(currentTransactionId!, { lineItemId: item.id, amount: share, reason }) + } + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) }) + toast.success('Order discount applied') + }, + onError: (err) => toast.error(err.message), + }) + const voidMutation = useMutation({ mutationFn: () => posMutations.void(currentTransactionId!), onSuccess: () => { @@ -204,13 +227,13 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { onApply={(amount, reason) => { const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0 if (requiresDiscountOverride(pct)) { - setPendingDiscount({ lineItemId: lineItems[0].id, amount, reason }) + setPendingOrderDiscount({ amount, reason }) setDiscountOverrideOpen(true) } else { - discountMutation.mutate({ lineItemId: lineItems[0].id, amount, reason }) + orderDiscountMutation.mutate({ amount, reason }) } }} - isPending={discountMutation.isPending} + isPending={orderDiscountMutation.isPending} /> )} @@ -296,6 +319,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { if (pendingDiscount) { discountMutation.mutate(pendingDiscount) setPendingDiscount(null) + } else if (pendingOrderDiscount) { + orderDiscountMutation.mutate(pendingOrderDiscount) + setPendingOrderDiscount(null) } }} /> diff --git a/packages/admin/src/components/pos/pos-manager-override.tsx b/packages/admin/src/components/pos/pos-manager-override.tsx index 79583e6..b01ad97 100644 --- a/packages/admin/src/components/pos/pos-manager-override.tsx +++ b/packages/admin/src/components/pos/pos-manager-override.tsx @@ -193,7 +193,9 @@ export function setDiscountThreshold(pct: number) { } export function requiresDiscountOverride(discountPct: number): boolean { + // Check percentage threshold first const threshold = getDiscountThreshold() - if (threshold <= 0) return requiresOverride('manual_discount') - return discountPct >= threshold + if (threshold > 0 && discountPct >= threshold) return true + // Fall back to the blanket manual_discount toggle + return requiresOverride('manual_discount') } diff --git a/packages/admin/src/components/pos/pos-receipt.tsx b/packages/admin/src/components/pos/pos-receipt.tsx index d051838..a998d87 100644 --- a/packages/admin/src/components/pos/pos-receipt.tsx +++ b/packages/admin/src/components/pos/pos-receipt.tsx @@ -110,30 +110,41 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe return } +function useReceiptData(data: POSReceiptProps['data']) { + const { transaction: txn, company, location } = data + return { + txn, + company, + location, + date: new Date(txn.completedAt ?? txn.createdAt), + subtotal: parseFloat(txn.subtotal), + discountTotal: parseFloat(txn.discountTotal), + taxTotal: parseFloat(txn.taxTotal), + total: parseFloat(txn.total), + rounding: parseFloat(txn.roundingAdjustment), + tendered: txn.amountTendered ? parseFloat(txn.amountTendered) : null, + change: txn.changeGiven ? parseFloat(txn.changeGiven) : null, + addr: location.address ?? company.address, + phone: location.phone ?? company.phone, + email: location.email ?? company.email, + } +} + +function useBarcode(ref: React.RefObject, value: string, opts: { width: number; height: number; fontSize: number }) { + useEffect(() => { + if (ref.current) { + try { + JsBarcode(ref.current, value, { format: 'CODE128', displayValue: true, margin: 4, ...opts }) + } catch { /* barcode generation failed — show text fallback */ } + } + }, [value]) +} + function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) { const barcodeRef = useRef(null) - const { transaction: txn, company, location } = data const logoSrc = useStoreLogo() - - useEffect(() => { - if (barcodeRef.current) { - JsBarcode(barcodeRef.current, txn.transactionNumber, { - format: 'CODE128', width: 1.5, height: 40, displayValue: true, fontSize: 10, margin: 4, - }) - } - }, [txn.transactionNumber]) - - const date = new Date(txn.completedAt ?? txn.createdAt) - const subtotal = parseFloat(txn.subtotal) - const discountTotal = parseFloat(txn.discountTotal) - const taxTotal = parseFloat(txn.taxTotal) - const total = parseFloat(txn.total) - const rounding = parseFloat(txn.roundingAdjustment) - const tendered = txn.amountTendered ? parseFloat(txn.amountTendered) : null - const change = txn.changeGiven ? parseFloat(txn.changeGiven) : null - - const addr = location.address ?? company.address - const phone = location.phone ?? company.phone + const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone } = useReceiptData(data) + useBarcode(barcodeRef, txn.transactionNumber, { width: 1.5, height: 40, fontSize: 10 }) const s = { row: { display: 'flex', justifyContent: 'space-between' } as const, @@ -245,29 +256,9 @@ function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['d function FullPageReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) { const barcodeRef = useRef(null) - const { transaction: txn, company, location } = data const logoSrc = useStoreLogo() - - useEffect(() => { - if (barcodeRef.current) { - JsBarcode(barcodeRef.current, txn.transactionNumber, { - format: 'CODE128', width: 2, height: 50, displayValue: true, fontSize: 12, margin: 4, - }) - } - }, [txn.transactionNumber]) - - const date = new Date(txn.completedAt ?? txn.createdAt) - const subtotal = parseFloat(txn.subtotal) - const discountTotal = parseFloat(txn.discountTotal) - const taxTotal = parseFloat(txn.taxTotal) - const total = parseFloat(txn.total) - const rounding = parseFloat(txn.roundingAdjustment) - const tendered = txn.amountTendered ? parseFloat(txn.amountTendered) : null - const change = txn.changeGiven ? parseFloat(txn.changeGiven) : null - - const addr = location.address ?? company.address - const phone = location.phone ?? company.phone - const email = location.email ?? company.email + const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone, email } = useReceiptData(data) + useBarcode(barcodeRef, txn.transactionNumber, { width: 2, height: 50, fontSize: 12 }) const f = (n: number) => `$${n.toFixed(2)}` diff --git a/packages/backend/__tests__/services/tax-consumable.test.ts b/packages/backend/__tests__/services/tax-consumable.test.ts new file mode 100644 index 0000000..c82cea1 --- /dev/null +++ b/packages/backend/__tests__/services/tax-consumable.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'bun:test' +import { TaxService } from '../../src/services/tax.service.js' + +describe('TaxService.repairItemTypeToTaxCategory — consumable', () => { + it('maps consumable to exempt', () => { + expect(TaxService.repairItemTypeToTaxCategory('consumable')).toBe('exempt') + }) + + it('maps labor to service', () => { + expect(TaxService.repairItemTypeToTaxCategory('labor')).toBe('service') + }) + + it('maps part to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('part')).toBe('goods') + }) + + it('maps flat_rate to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('flat_rate')).toBe('goods') + }) + + it('maps misc to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('misc')).toBe('goods') + }) + + it('maps unknown type to goods (default)', () => { + expect(TaxService.repairItemTypeToTaxCategory('anything_else')).toBe('goods') + }) +}) + +describe('TaxService.getRateForLocation — exempt category', () => { + it('returns 0 for exempt tax category without DB call', async () => { + // Passing a fake DB and fake locationId — should short-circuit and return 0 + const fakeDb = {} as any + const rate = await TaxService.getRateForLocation(fakeDb, 'fake-id', 'exempt') + expect(rate).toBe(0) + }) +}) diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts index 4f1c48e..7086cf2 100644 --- a/packages/backend/api-tests/suites/pos.ts +++ b/packages/backend/api-tests/suites/pos.ts @@ -695,4 +695,211 @@ suite('POS', { tags: ['pos'] }, (t) => { t.assert.status(closed, 200) t.assert.equal(closed.data.status, 'closed') }) + + // ─── Repair → POS Integration ───────────────────────────────────────────── + + t.test('lists ready-for-pickup repair tickets', { tags: ['repair-pos', 'list'] }, async () => { + // Create a repair ticket and move it to 'ready' + const ticket = await t.api.post('/v1/repair-tickets', { + customerName: 'POS Pickup Customer', + customerPhone: '555-0100', + problemDescription: 'Needs pickup test', + }) + t.assert.status(ticket, 201) + + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' }) + + const res = await t.api.get('/v1/repair-tickets/ready') + t.assert.status(res, 200) + t.assert.ok(res.data.data.length >= 1) + const found = res.data.data.find((t: any) => t.id === ticket.data.id) + t.assert.ok(found) + t.assert.equal(found.status, 'ready') + }) + + t.test('searches ready tickets by customer name', { tags: ['repair-pos', 'search'] }, async () => { + const res = await t.api.get('/v1/repair-tickets/ready', { q: 'POS Pickup' }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.length >= 1) + }) + + t.test('creates repair payment transaction from ticket', { tags: ['repair-pos', 'create'] }, async () => { + // Create ticket with line items + const ticket = await t.api.post('/v1/repair-tickets', { + customerName: 'Repair Checkout Test', + problemDescription: 'Full checkout flow', + locationId: LOCATION_ID, + }) + t.assert.status(ticket, 201) + + // Add line items — labor (service tax) + part (goods tax) + consumable (excluded) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'labor', + description: 'Diagnostic labor', + qty: 1, + unitPrice: 60, + totalPrice: 60, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'part', + description: 'Replacement widget', + qty: 2, + unitPrice: 15, + totalPrice: 30, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'consumable', + description: 'Shop supplies', + qty: 1, + unitPrice: 5, + totalPrice: 5, + }) + + // Move to ready + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' }) + + // Create POS transaction from repair + const txn = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { + locationId: LOCATION_ID, + }) + t.assert.status(txn, 201) + t.assert.equal(txn.data.transactionType, 'repair_payment') + t.assert.equal(txn.data.repairTicketId, ticket.data.id) + + // Should have 2 line items (consumable excluded) + t.assert.equal(txn.data.lineItems.length, 2) + + // Subtotal should be labor ($60) + parts ($30) = $90 + const subtotal = parseFloat(txn.data.subtotal) + t.assert.equal(subtotal, 90) + + // Tax should be > 0 (location has both goods and service rates) + const taxTotal = parseFloat(txn.data.taxTotal) + t.assert.greaterThan(taxTotal, 0) + + // Verify labor line item has service tax rate (5%) + const laborItem = txn.data.lineItems.find((i: any) => i.description === 'Diagnostic labor') + t.assert.ok(laborItem) + t.assert.equal(parseFloat(laborItem.taxRate), 0.05) + + // Verify part line item has goods tax rate (8.25%) + const partItem = txn.data.lineItems.find((i: any) => i.description === 'Replacement widget') + t.assert.ok(partItem) + t.assert.equal(parseFloat(partItem.taxRate), 0.0825) + }) + + t.test('rejects from-repair for non-ready ticket', { tags: ['repair-pos', 'validation'] }, async () => { + const ticket = await t.api.post('/v1/repair-tickets', { + customerName: 'Not Ready', + problemDescription: 'Still in progress', + }) + t.assert.status(ticket, 201) + + const res = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { + locationId: LOCATION_ID, + }) + t.assert.status(res, 400) + }) + + t.test('rejects duplicate pending repair payment', { tags: ['repair-pos', 'validation'] }, async () => { + // Create ready ticket with items + const ticket = await t.api.post('/v1/repair-tickets', { + customerName: 'Duplicate Test', + problemDescription: 'Duplicate check', + locationId: LOCATION_ID, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'labor', description: 'Work', qty: 1, unitPrice: 50, totalPrice: 50, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' }) + + // First creation succeeds + const first = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID }) + t.assert.status(first, 201) + + // Second creation fails (pending transaction exists) + const second = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID }) + t.assert.status(second, 409) + }) + + t.test('completing repair payment marks ticket as picked_up', { tags: ['repair-pos', 'complete', 'e2e'] }, async () => { + // Open drawer + const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 }) + t.assert.status(drawer, 201) + + // Create ready ticket + const ticket = await t.api.post('/v1/repair-tickets', { + customerName: 'Pickup Complete Test', + problemDescription: 'End to end', + locationId: LOCATION_ID, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, { + itemType: 'flat_rate', description: 'Service package', qty: 1, unitPrice: 100, totalPrice: 100, + }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' }) + await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' }) + + // Create transaction from repair + const txn = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID }) + t.assert.status(txn, 201) + + // Complete payment + const completed = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { + paymentMethod: 'card_present', + }) + t.assert.status(completed, 200) + t.assert.equal(completed.data.status, 'completed') + + // Verify ticket was updated to picked_up + const updatedTicket = await t.api.get(`/v1/repair-tickets/${ticket.data.id}`) + t.assert.status(updatedTicket, 200) + t.assert.equal(updatedTicket.data.status, 'picked_up') + t.assert.ok(updatedTicket.data.completedDate) + + // Cleanup + await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 200 }) + }) + + // ─── Product isConsumable Filter ────────────────────────────────────────── + + t.test('isConsumable filter excludes consumables from product search', { tags: ['repair-pos', 'products'] }, async () => { + // Create a consumable product + const consumable = await t.api.post('/v1/products', { + name: 'Test Shop Supply', + isConsumable: true, + price: 2.50, + }) + t.assert.status(consumable, 201) + + // Create a normal product + const normal = await t.api.post('/v1/products', { + name: 'Test Normal Product', + isConsumable: false, + price: 25, + }) + t.assert.status(normal, 201) + + // Search with isConsumable=false should exclude the consumable + const res = await t.api.get('/v1/products', { q: 'Test', isConsumable: 'false' }) + t.assert.status(res, 200) + const ids = res.data.data.map((p: any) => p.id) + t.assert.ok(!ids.includes(consumable.data.id)) + t.assert.ok(ids.includes(normal.data.id)) + + // Search with isConsumable=true should only show consumable + const res2 = await t.api.get('/v1/products', { q: 'Test Shop Supply', isConsumable: 'true' }) + t.assert.status(res2, 200) + t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id)) + }) }) diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index d475e6a..3535c90 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsync } from 'fastify' +import { z } from 'zod' import { PaginationSchema, TransactionCreateSchema, @@ -8,6 +9,10 @@ import { } from '@lunarfront/shared/schemas' import { TransactionService } from '../../services/transaction.service.js' +const FromRepairBodySchema = z.object({ + locationId: z.string().uuid().optional(), +}) + export const transactionRoutes: FastifyPluginAsync = async (app) => { app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { const parsed = TransactionCreateSchema.safeParse(request.body) @@ -21,9 +26,12 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => { app.post('/transactions/from-repair/:ticketId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { const { ticketId } = request.params as { ticketId: string } - const body = request.body as { locationId?: string } | undefined - const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, body?.locationId, request.user.id) - request.log.info({ transactionId: txn?.id, ticketId, userId: request.user.id }, 'Repair payment transaction created') + const parsed = FromRepairBodySchema.safeParse(request.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, parsed.data.locationId, request.user.id) + request.log.info({ transactionId: txn.id, ticketId, userId: request.user.id }, 'Repair payment transaction created') return reply.status(201).send(txn) }) diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index fa6e50c..0193f27 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -49,7 +49,7 @@ export const TransactionService = { }, async createFromRepairTicket(db: PostgresJsDatabase, ticketId: string, locationId: string | undefined, processedBy: string) { - // Fetch ticket + // Validate ticket exists and is eligible before entering transaction const [ticket] = await db .select() .from(repairTickets) @@ -81,43 +81,66 @@ export const TransactionService = { const billableItems = items.filter((i) => i.itemType !== 'consumable') if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket') - // Create transaction - const txn = await this.create(db, { - transactionType: 'repair_payment', - locationId: locationId ?? ticket.locationId ?? undefined, - accountId: ticket.accountId ?? undefined, - repairTicketId: ticketId, - }, processedBy) + const resolvedLocationId = locationId ?? ticket.locationId - // Add each billable line item - for (const item of billableItems) { - const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType) - let taxRate = 0 - const txnLocationId = locationId ?? ticket.locationId - if (txnLocationId) { - taxRate = await TaxService.getRateForLocation(db, txnLocationId, taxCategory) + // Wrap creation + line items in a DB transaction for atomicity + return db.transaction(async (tx) => { + const transactionNumber = await generateTransactionNumber(tx) + + const [txn] = await tx + .insert(transactions) + .values({ + transactionNumber, + transactionType: 'repair_payment', + locationId: resolvedLocationId, + accountId: ticket.accountId, + repairTicketId: ticketId, + processedBy, + }) + .returning() + + for (const item of billableItems) { + const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType) + let taxRate = 0 + if (resolvedLocationId) { + taxRate = await TaxService.getRateForLocation(tx, resolvedLocationId, taxCategory) + } + + const unitPrice = parseFloat(item.unitPrice) || 0 + const qty = Math.max(1, Math.round(parseFloat(item.qty) || 1)) + const lineSubtotal = unitPrice * qty + const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate) + const lineTotal = lineSubtotal + taxAmount + + await tx.insert(transactionLineItems).values({ + transactionId: txn.id, + productId: item.productId, + description: item.description, + qty, + unitPrice: unitPrice.toString(), + taxRate: taxRate.toString(), + taxAmount: taxAmount.toString(), + lineTotal: lineTotal.toString(), + }) } - const unitPrice = parseFloat(item.unitPrice) - const qty = Math.round(parseFloat(item.qty)) - const lineSubtotal = unitPrice * qty - const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate) - const lineTotal = lineSubtotal + taxAmount + await this.recalculateTotals(tx, txn.id) - await db.insert(transactionLineItems).values({ - transactionId: txn.id, - productId: item.productId, - description: item.description, - qty, - unitPrice: unitPrice.toString(), - taxRate: taxRate.toString(), - taxAmount: taxAmount.toString(), - lineTotal: lineTotal.toString(), - }) - } + // Return full transaction with line items + const lineItemRows = await tx + .select() + .from(transactionLineItems) + .where(eq(transactionLineItems.transactionId, txn.id)) - await this.recalculateTotals(db, txn.id) - return this.getById(db, txn.id) + // Re-read the transaction to get updated totals + const [updated] = await tx + .select() + .from(transactions) + .where(eq(transactions.id, txn.id)) + .limit(1) + + return { ...updated, lineItems: lineItemRows } + }) }, async addLineItem(db: PostgresJsDatabase, transactionId: string, input: TransactionLineItemCreateInput) { @@ -343,55 +366,55 @@ export const TransactionService = { changeGiven = (input.amountTendered - total).toString() } - // Update inventory for each line item - const lineItems = await db - .select() - .from(transactionLineItems) - .where(eq(transactionLineItems.transactionId, transactionId)) + // Wrap inventory updates, transaction completion, and repair status in a DB transaction + return db.transaction(async (tx) => { + const lineItems = await tx + .select() + .from(transactionLineItems) + .where(eq(transactionLineItems.transactionId, transactionId)) - for (const item of lineItems) { - if (item.inventoryUnitId) { - // Serialized item — mark as sold - await db - .update(inventoryUnits) - .set({ status: 'sold' }) - .where(eq(inventoryUnits.id, item.inventoryUnitId)) - } else if (item.productId) { - // Non-serialized — decrement qty_on_hand - await db - .update(products) - .set({ - qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`, - updatedAt: new Date(), - }) - .where(eq(products.id, item.productId)) + for (const item of lineItems) { + if (item.inventoryUnitId) { + await tx + .update(inventoryUnits) + .set({ status: 'sold' }) + .where(eq(inventoryUnits.id, item.inventoryUnitId)) + } else if (item.productId) { + await tx + .update(products) + .set({ + qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`, + updatedAt: new Date(), + }) + .where(eq(products.id, item.productId)) + } } - } - const [completed] = await db - .update(transactions) - .set({ - status: 'completed', - paymentMethod: input.paymentMethod, - amountTendered: input.amountTendered?.toString(), - changeGiven, - roundingAdjustment: roundingAdjustment.toString(), - checkNumber: input.checkNumber, - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(transactions.id, transactionId)) - .returning() + const [completed] = await tx + .update(transactions) + .set({ + status: 'completed', + paymentMethod: input.paymentMethod, + amountTendered: input.amountTendered?.toString(), + changeGiven, + roundingAdjustment: roundingAdjustment.toString(), + checkNumber: input.checkNumber, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(transactions.id, transactionId)) + .returning() - // If this is a repair payment, update ticket status to picked_up - if (completed.transactionType === 'repair_payment' && completed.repairTicketId) { - await db - .update(repairTickets) - .set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() }) - .where(eq(repairTickets.id, completed.repairTicketId)) - } + // If this is a repair payment, update ticket status to picked_up + if (completed.transactionType === 'repair_payment' && completed.repairTicketId) { + await tx + .update(repairTickets) + .set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() }) + .where(eq(repairTickets.id, completed.repairTicketId)) + } - return completed + return completed + }) }, async void(db: PostgresJsDatabase, transactionId: string, _voidedBy: string) {