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

@@ -26,6 +26,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
const [overrideOpen, setOverrideOpen] = useState(false)
const [priceItemId, setPriceItemId] = useState<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 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}
/>
</div>
)}
@@ -296,6 +319,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
if (pendingDiscount) {
discountMutation.mutate(pendingDiscount)
setPendingDiscount(null)
} else if (pendingOrderDiscount) {
orderDiscountMutation.mutate(pendingOrderDiscount)
setPendingOrderDiscount(null)
}
}}
/>

View File

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

View File

@@ -110,30 +110,41 @@ export function POSReceipt({ data, size = 'thermal', footerText, config }: POSRe
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 }) {
const barcodeRef = useRef<SVGSVGElement>(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<SVGSVGElement>(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)}`