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

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

View File

@@ -49,7 +49,7 @@ export const TransactionService = {
},
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
.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<any>, 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<any>, transactionId: string, _voidedBy: string) {