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) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-05 01:43:02 +00:00
parent 9d51fb2118
commit a29e924544
7 changed files with 422 additions and 128 deletions

View File

@@ -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))
})
})