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:
@@ -26,6 +26,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
const [priceItemId, setPriceItemId] = useState<string | null>(null)
|
const [priceItemId, setPriceItemId] = useState<string | null>(null)
|
||||||
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(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 [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
|
||||||
const lineItems = transaction?.lineItems ?? []
|
const lineItems = transaction?.lineItems ?? []
|
||||||
|
|
||||||
@@ -52,6 +53,28 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
onError: (err) => toast.error(err.message),
|
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({
|
const voidMutation = useMutation({
|
||||||
mutationFn: () => posMutations.void(currentTransactionId!),
|
mutationFn: () => posMutations.void(currentTransactionId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -204,13 +227,13 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
onApply={(amount, reason) => {
|
onApply={(amount, reason) => {
|
||||||
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
|
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
|
||||||
if (requiresDiscountOverride(pct)) {
|
if (requiresDiscountOverride(pct)) {
|
||||||
setPendingDiscount({ lineItemId: lineItems[0].id, amount, reason })
|
setPendingOrderDiscount({ amount, reason })
|
||||||
setDiscountOverrideOpen(true)
|
setDiscountOverrideOpen(true)
|
||||||
} else {
|
} else {
|
||||||
discountMutation.mutate({ lineItemId: lineItems[0].id, amount, reason })
|
orderDiscountMutation.mutate({ amount, reason })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isPending={discountMutation.isPending}
|
isPending={orderDiscountMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -296,6 +319,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|||||||
if (pendingDiscount) {
|
if (pendingDiscount) {
|
||||||
discountMutation.mutate(pendingDiscount)
|
discountMutation.mutate(pendingDiscount)
|
||||||
setPendingDiscount(null)
|
setPendingDiscount(null)
|
||||||
|
} else if (pendingOrderDiscount) {
|
||||||
|
orderDiscountMutation.mutate(pendingOrderDiscount)
|
||||||
|
setPendingOrderDiscount(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ export function setDiscountThreshold(pct: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requiresDiscountOverride(discountPct: number): boolean {
|
export function requiresDiscountOverride(discountPct: number): boolean {
|
||||||
|
// Check percentage threshold first
|
||||||
const threshold = getDiscountThreshold()
|
const threshold = getDiscountThreshold()
|
||||||
if (threshold <= 0) return requiresOverride('manual_discount')
|
if (threshold > 0 && discountPct >= threshold) return true
|
||||||
return discountPct >= threshold
|
// Fall back to the blanket manual_discount toggle
|
||||||
|
return requiresOverride('manual_discount')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,30 +110,41 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe
|
|||||||
return <ThermalReceipt data={data} config={config} footerText={footerText} />
|
return <ThermalReceipt data={data} config={config} footerText={footerText} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<SVGSVGElement | null>, 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 }) {
|
function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||||
const { transaction: txn, company, location } = data
|
|
||||||
const logoSrc = useStoreLogo()
|
const logoSrc = useStoreLogo()
|
||||||
|
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone } = useReceiptData(data)
|
||||||
useEffect(() => {
|
useBarcode(barcodeRef, txn.transactionNumber, { width: 1.5, height: 40, fontSize: 10 })
|
||||||
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 s = {
|
const s = {
|
||||||
row: { display: 'flex', justifyContent: 'space-between' } as const,
|
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 }) {
|
function FullPageReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
|
||||||
const barcodeRef = useRef<SVGSVGElement>(null)
|
const barcodeRef = useRef<SVGSVGElement>(null)
|
||||||
const { transaction: txn, company, location } = data
|
|
||||||
const logoSrc = useStoreLogo()
|
const logoSrc = useStoreLogo()
|
||||||
|
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone, email } = useReceiptData(data)
|
||||||
useEffect(() => {
|
useBarcode(barcodeRef, txn.transactionNumber, { width: 2, height: 50, fontSize: 12 })
|
||||||
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 f = (n: number) => `$${n.toFixed(2)}`
|
const f = (n: number) => `$${n.toFixed(2)}`
|
||||||
|
|
||||||
|
|||||||
37
packages/backend/__tests__/services/tax-consumable.test.ts
Normal file
37
packages/backend/__tests__/services/tax-consumable.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -695,4 +695,211 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
t.assert.status(closed, 200)
|
t.assert.status(closed, 200)
|
||||||
t.assert.equal(closed.data.status, 'closed')
|
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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
PaginationSchema,
|
PaginationSchema,
|
||||||
TransactionCreateSchema,
|
TransactionCreateSchema,
|
||||||
@@ -8,6 +9,10 @@ import {
|
|||||||
} from '@lunarfront/shared/schemas'
|
} from '@lunarfront/shared/schemas'
|
||||||
import { TransactionService } from '../../services/transaction.service.js'
|
import { TransactionService } from '../../services/transaction.service.js'
|
||||||
|
|
||||||
|
const FromRepairBodySchema = z.object({
|
||||||
|
locationId: z.string().uuid().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||||
const parsed = TransactionCreateSchema.safeParse(request.body)
|
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) => {
|
app.post('/transactions/from-repair/:ticketId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||||
const { ticketId } = request.params as { ticketId: string }
|
const { ticketId } = request.params as { ticketId: string }
|
||||||
const body = request.body as { locationId?: string } | undefined
|
const parsed = FromRepairBodySchema.safeParse(request.body ?? {})
|
||||||
const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, body?.locationId, request.user.id)
|
if (!parsed.success) {
|
||||||
request.log.info({ transactionId: txn?.id, ticketId, userId: request.user.id }, 'Repair payment transaction created')
|
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)
|
return reply.status(201).send(txn)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const TransactionService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
|
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
|
||||||
// Fetch ticket
|
// Validate ticket exists and is eligible before entering transaction
|
||||||
const [ticket] = await db
|
const [ticket] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(repairTickets)
|
.from(repairTickets)
|
||||||
@@ -81,30 +81,38 @@ export const TransactionService = {
|
|||||||
const billableItems = items.filter((i) => i.itemType !== 'consumable')
|
const billableItems = items.filter((i) => i.itemType !== 'consumable')
|
||||||
if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket')
|
if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket')
|
||||||
|
|
||||||
// Create transaction
|
const resolvedLocationId = locationId ?? ticket.locationId
|
||||||
const txn = await this.create(db, {
|
|
||||||
transactionType: 'repair_payment',
|
// Wrap creation + line items in a DB transaction for atomicity
|
||||||
locationId: locationId ?? ticket.locationId ?? undefined,
|
return db.transaction(async (tx) => {
|
||||||
accountId: ticket.accountId ?? undefined,
|
const transactionNumber = await generateTransactionNumber(tx)
|
||||||
repairTicketId: ticketId,
|
|
||||||
}, processedBy)
|
const [txn] = await tx
|
||||||
|
.insert(transactions)
|
||||||
|
.values({
|
||||||
|
transactionNumber,
|
||||||
|
transactionType: 'repair_payment',
|
||||||
|
locationId: resolvedLocationId,
|
||||||
|
accountId: ticket.accountId,
|
||||||
|
repairTicketId: ticketId,
|
||||||
|
processedBy,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
// Add each billable line item
|
|
||||||
for (const item of billableItems) {
|
for (const item of billableItems) {
|
||||||
const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType)
|
const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType)
|
||||||
let taxRate = 0
|
let taxRate = 0
|
||||||
const txnLocationId = locationId ?? ticket.locationId
|
if (resolvedLocationId) {
|
||||||
if (txnLocationId) {
|
taxRate = await TaxService.getRateForLocation(tx, resolvedLocationId, taxCategory)
|
||||||
taxRate = await TaxService.getRateForLocation(db, txnLocationId, taxCategory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitPrice = parseFloat(item.unitPrice)
|
const unitPrice = parseFloat(item.unitPrice) || 0
|
||||||
const qty = Math.round(parseFloat(item.qty))
|
const qty = Math.max(1, Math.round(parseFloat(item.qty) || 1))
|
||||||
const lineSubtotal = unitPrice * qty
|
const lineSubtotal = unitPrice * qty
|
||||||
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
||||||
const lineTotal = lineSubtotal + taxAmount
|
const lineTotal = lineSubtotal + taxAmount
|
||||||
|
|
||||||
await db.insert(transactionLineItems).values({
|
await tx.insert(transactionLineItems).values({
|
||||||
transactionId: txn.id,
|
transactionId: txn.id,
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -116,8 +124,23 @@ export const TransactionService = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.recalculateTotals(db, txn.id)
|
await this.recalculateTotals(tx, txn.id)
|
||||||
return this.getById(db, txn.id)
|
|
||||||
|
// Return full transaction with line items
|
||||||
|
const lineItemRows = await tx
|
||||||
|
.select()
|
||||||
|
.from(transactionLineItems)
|
||||||
|
.where(eq(transactionLineItems.transactionId, 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<any>, transactionId: string, input: TransactionLineItemCreateInput) {
|
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
|
||||||
@@ -343,22 +366,21 @@ export const TransactionService = {
|
|||||||
changeGiven = (input.amountTendered - total).toString()
|
changeGiven = (input.amountTendered - total).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update inventory for each line item
|
// Wrap inventory updates, transaction completion, and repair status in a DB transaction
|
||||||
const lineItems = await db
|
return db.transaction(async (tx) => {
|
||||||
|
const lineItems = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(transactionLineItems)
|
.from(transactionLineItems)
|
||||||
.where(eq(transactionLineItems.transactionId, transactionId))
|
.where(eq(transactionLineItems.transactionId, transactionId))
|
||||||
|
|
||||||
for (const item of lineItems) {
|
for (const item of lineItems) {
|
||||||
if (item.inventoryUnitId) {
|
if (item.inventoryUnitId) {
|
||||||
// Serialized item — mark as sold
|
await tx
|
||||||
await db
|
|
||||||
.update(inventoryUnits)
|
.update(inventoryUnits)
|
||||||
.set({ status: 'sold' })
|
.set({ status: 'sold' })
|
||||||
.where(eq(inventoryUnits.id, item.inventoryUnitId))
|
.where(eq(inventoryUnits.id, item.inventoryUnitId))
|
||||||
} else if (item.productId) {
|
} else if (item.productId) {
|
||||||
// Non-serialized — decrement qty_on_hand
|
await tx
|
||||||
await db
|
|
||||||
.update(products)
|
.update(products)
|
||||||
.set({
|
.set({
|
||||||
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
|
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
|
||||||
@@ -368,7 +390,7 @@ export const TransactionService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [completed] = await db
|
const [completed] = await tx
|
||||||
.update(transactions)
|
.update(transactions)
|
||||||
.set({
|
.set({
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -385,13 +407,14 @@ export const TransactionService = {
|
|||||||
|
|
||||||
// If this is a repair payment, update ticket status to picked_up
|
// If this is a repair payment, update ticket status to picked_up
|
||||||
if (completed.transactionType === 'repair_payment' && completed.repairTicketId) {
|
if (completed.transactionType === 'repair_payment' && completed.repairTicketId) {
|
||||||
await db
|
await tx
|
||||||
.update(repairTickets)
|
.update(repairTickets)
|
||||||
.set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() })
|
.set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(repairTickets.id, completed.repairTicketId))
|
.where(eq(repairTickets.id, completed.repairTicketId))
|
||||||
}
|
}
|
||||||
|
|
||||||
return completed
|
return completed
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
|
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user