feat: repair-POS integration, receipt formats, manager overrides, price adjustments

- Add thermal/full-page receipt format toggle (per-device, localStorage)
- Full-page receipt uses clean invoice layout matching repair PDF style
- Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced)
- Manager override system: configurable PIN prompt for void, refund, discount, cash in/out
- Discount threshold setting: require manager approval above X%
- Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals
- Repair line item dialog: product picker dropdown for parts/consumables from inventory
- Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods)
- Transaction completion auto-updates repair ticket status to picked_up
- POS Repairs dialog with Pickup and New Intake tabs, customer account lookup
- Inline price adjustment on cart items: % off, $ off, or set price with live preview
- Order-level discount button with same three input modes
- Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint
- Fix: backend dev script uses --env-file for turbo compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-05 01:32:28 +00:00
parent a48da03289
commit 95cf017b4b
32 changed files with 1507 additions and 199 deletions

View File

@@ -8,6 +8,7 @@ import {
drawerSessions,
} from '../db/schema/pos.js'
import { products, inventoryUnits } from '../db/schema/inventory.js'
import { repairTickets, repairLineItems } from '../db/schema/repairs.js'
import { companies, locations } from '../db/schema/stores.js'
import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js'
import { TaxService } from './tax.service.js'
@@ -47,6 +48,78 @@ export const TransactionService = {
return txn
},
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
// Fetch ticket
const [ticket] = await db
.select()
.from(repairTickets)
.where(eq(repairTickets.id, ticketId))
.limit(1)
if (!ticket) throw new NotFoundError('Repair ticket')
if (!['ready', 'approved', 'in_progress'].includes(ticket.status)) {
throw new ValidationError('Ticket must be in ready, approved, or in_progress status to check out')
}
// Check for existing pending repair_payment for this ticket
const [existing] = await db
.select({ id: transactions.id })
.from(transactions)
.where(and(
eq(transactions.repairTicketId, ticketId),
eq(transactions.status, 'pending'),
))
.limit(1)
if (existing) throw new ConflictError('A pending transaction already exists for this ticket')
// Fetch non-consumable line items
const items = await db
.select()
.from(repairLineItems)
.where(eq(repairLineItems.repairTicketId, ticketId))
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)
// 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)
}
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 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(),
})
}
await this.recalculateTotals(db, txn.id)
return this.getById(db, txn.id)
},
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
@@ -309,6 +382,15 @@ export const TransactionService = {
})
.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))
}
return completed
},