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>
906 lines
37 KiB
TypeScript
906 lines
37 KiB
TypeScript
import { suite } from '../lib/context.js'
|
|
|
|
const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
|
|
const ROUNDING_LOCATION_ID = '10000000-1000-4000-8000-000000000003'
|
|
|
|
suite('POS', { tags: ['pos'] }, (t) => {
|
|
// ─── Discounts (CRUD) ──────────────────────────────────────────────────────
|
|
|
|
t.test('creates a discount', { tags: ['discounts', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/discounts', {
|
|
name: 'Employee 10%',
|
|
discountType: 'percent',
|
|
discountValue: 10,
|
|
appliesTo: 'line_item',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.name, 'Employee 10%')
|
|
t.assert.equal(res.data.discountType, 'percent')
|
|
t.assert.ok(res.data.id)
|
|
})
|
|
|
|
t.test('rejects discount without name', { tags: ['discounts', 'validation'] }, async () => {
|
|
const res = await t.api.post('/v1/discounts', { discountType: 'fixed', discountValue: 5 })
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('lists discounts with pagination', { tags: ['discounts', 'list'] }, async () => {
|
|
await t.api.post('/v1/discounts', { name: 'Disc A', discountType: 'fixed', discountValue: 5 })
|
|
await t.api.post('/v1/discounts', { name: 'Disc B', discountType: 'percent', discountValue: 15 })
|
|
const res = await t.api.get('/v1/discounts', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 2)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('lists all discounts (lookup)', { tags: ['discounts', 'list'] }, async () => {
|
|
const res = await t.api.get('/v1/discounts/all')
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(Array.isArray(res.data))
|
|
})
|
|
|
|
t.test('gets discount by id', { tags: ['discounts', 'read'] }, async () => {
|
|
const created = await t.api.post('/v1/discounts', { name: 'Get Me', discountType: 'fixed', discountValue: 3 })
|
|
const res = await t.api.get(`/v1/discounts/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'Get Me')
|
|
})
|
|
|
|
t.test('updates a discount', { tags: ['discounts', 'update'] }, async () => {
|
|
const created = await t.api.post('/v1/discounts', { name: 'Before', discountType: 'fixed', discountValue: 1 })
|
|
const res = await t.api.patch(`/v1/discounts/${created.data.id}`, { name: 'After', discountValue: 99 })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'After')
|
|
})
|
|
|
|
t.test('soft-deletes a discount', { tags: ['discounts', 'delete'] }, async () => {
|
|
const created = await t.api.post('/v1/discounts', { name: 'Delete Me', discountType: 'fixed', discountValue: 1 })
|
|
const res = await t.api.del(`/v1/discounts/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.isActive, false)
|
|
})
|
|
|
|
// ─── Drawer Sessions ───────────────────────────────────────────────────────
|
|
|
|
t.test('opens a drawer session', { tags: ['drawer', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.status, 'open')
|
|
t.assert.ok(res.data.id)
|
|
// Close it so future tests can open a new one
|
|
await t.api.post(`/v1/drawer/${res.data.id}/close`, { closingBalance: 200 })
|
|
})
|
|
|
|
t.test('rejects opening second drawer at same location', { tags: ['drawer', 'validation'] }, async () => {
|
|
const first = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(first, 201)
|
|
|
|
const second = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(second, 409)
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${first.data.id}/close`, { closingBalance: 100 })
|
|
})
|
|
|
|
t.test('closes a drawer session with denominations', { tags: ['drawer', 'close'] }, async () => {
|
|
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 150 })
|
|
t.assert.status(opened, 201)
|
|
|
|
const res = await t.api.post(`/v1/drawer/${opened.data.id}/close`, {
|
|
closingBalance: 155,
|
|
denominations: { ones: 50, fives: 20, tens: 5, twenties: 2 },
|
|
notes: 'End of shift',
|
|
})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'closed')
|
|
t.assert.ok(res.data.closedAt)
|
|
})
|
|
|
|
t.test('gets current open drawer for location', { tags: ['drawer', 'read'] }, async () => {
|
|
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(opened, 201)
|
|
|
|
const res = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.id, opened.data.id)
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${opened.data.id}/close`, { closingBalance: 100 })
|
|
})
|
|
|
|
t.test('lists drawer sessions with pagination', { tags: ['drawer', 'list'] }, async () => {
|
|
const res = await t.api.get('/v1/drawer', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
// ─── Drawer Adjustments ─────────────────────────────────────────────────────
|
|
|
|
t.test('adds cash out adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
|
t.assert.status(drawer, 201)
|
|
|
|
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
|
type: 'cash_out',
|
|
amount: 50,
|
|
reason: 'Bank deposit',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.type, 'cash_out')
|
|
t.assert.equal(parseFloat(res.data.amount), 50)
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 150 })
|
|
})
|
|
|
|
t.test('adds cash in adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(drawer, 201)
|
|
|
|
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
|
type: 'cash_in',
|
|
amount: 25,
|
|
reason: 'Change from petty cash',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.type, 'cash_in')
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 125 })
|
|
})
|
|
|
|
t.test('lists drawer adjustments', { tags: ['drawer', 'adjustments'] }, async () => {
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 30, reason: 'Test out' })
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 10, reason: 'Test in' })
|
|
|
|
const res = await t.api.get(`/v1/drawer/${drawer.data.id}/adjustments`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 2)
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 80 })
|
|
})
|
|
|
|
t.test('drawer close includes adjustments in expected balance', { tags: ['drawer', 'adjustments', 'close'] }, async () => {
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
|
t.assert.status(drawer, 201)
|
|
|
|
// Cash out $50, cash in $20 → net adjustment = -$30
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 50, reason: 'Bank drop' })
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 20, reason: 'Extra change' })
|
|
|
|
// Close — expected = 200 (opening) + 0 (no sales) + 20 (in) - 50 (out) = 170
|
|
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 170 })
|
|
t.assert.status(closed, 200)
|
|
t.assert.equal(parseFloat(closed.data.expectedBalance), 170)
|
|
t.assert.equal(parseFloat(closed.data.overShort), 0)
|
|
})
|
|
|
|
t.test('rejects adjustment on closed drawer', { tags: ['drawer', 'adjustments', 'validation'] }, async () => {
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
|
|
|
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
|
type: 'cash_out',
|
|
amount: 10,
|
|
reason: 'Should fail',
|
|
})
|
|
t.assert.status(res, 409)
|
|
})
|
|
|
|
// ─── Transactions ──────────────────────────────────────────────────────────
|
|
|
|
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: LOCATION_ID,
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.transactionType, 'sale')
|
|
t.assert.equal(res.data.status, 'pending')
|
|
t.assert.ok(res.data.transactionNumber)
|
|
t.assert.contains(res.data.transactionNumber, 'TXN-')
|
|
})
|
|
|
|
t.test('rejects transaction without type', { tags: ['transactions', 'validation'] }, async () => {
|
|
const res = await t.api.post('/v1/transactions', { locationId: LOCATION_ID })
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('adds line items and calculates tax', { tags: ['transactions', 'line-items', 'tax'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: LOCATION_ID,
|
|
})
|
|
t.assert.status(txn, 201)
|
|
|
|
// Add a line item (no product — ad hoc, taxed as goods at 8.25%)
|
|
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Violin Strings',
|
|
qty: 2,
|
|
unitPrice: 12.99,
|
|
})
|
|
t.assert.status(item, 201)
|
|
t.assert.equal(item.data.description, 'Violin Strings')
|
|
t.assert.equal(item.data.qty, 2)
|
|
|
|
// Verify tax was applied (8.25% on $25.98 = ~$2.14)
|
|
const taxAmount = parseFloat(item.data.taxAmount)
|
|
t.assert.greaterThan(taxAmount, 0)
|
|
|
|
// Verify transaction totals were recalculated
|
|
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
t.assert.status(updated, 200)
|
|
const total = parseFloat(updated.data.total)
|
|
t.assert.greaterThan(total, 0)
|
|
})
|
|
|
|
t.test('removes a line item and recalculates', { tags: ['transactions', 'line-items'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
const item1 = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Item A',
|
|
qty: 1,
|
|
unitPrice: 10,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Item B',
|
|
qty: 1,
|
|
unitPrice: 20,
|
|
})
|
|
|
|
// Remove item A
|
|
const del = await t.api.del(`/v1/transactions/${txn.data.id}/line-items/${item1.data.id}`)
|
|
t.assert.status(del, 200)
|
|
|
|
// Transaction should only have item B's total
|
|
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
const subtotal = parseFloat(updated.data.subtotal)
|
|
t.assert.equal(subtotal, 20)
|
|
})
|
|
|
|
t.test('tax exempt transaction has zero tax', { tags: ['transactions', 'tax'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: LOCATION_ID,
|
|
taxExempt: true,
|
|
taxExemptReason: 'Non-profit org',
|
|
})
|
|
t.assert.status(txn, 201)
|
|
|
|
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Exempt Item',
|
|
qty: 1,
|
|
unitPrice: 100,
|
|
})
|
|
t.assert.status(item, 201)
|
|
t.assert.equal(parseFloat(item.data.taxAmount), 0)
|
|
|
|
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
t.assert.equal(parseFloat(updated.data.taxTotal), 0)
|
|
t.assert.equal(parseFloat(updated.data.total), 100)
|
|
})
|
|
|
|
t.test('transaction without location has zero tax', { tags: ['transactions', 'tax'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale' })
|
|
t.assert.status(txn, 201)
|
|
|
|
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'No-location Item',
|
|
qty: 1,
|
|
unitPrice: 50,
|
|
})
|
|
t.assert.status(item, 201)
|
|
t.assert.equal(parseFloat(item.data.taxAmount), 0)
|
|
})
|
|
|
|
// ─── Discounts on Transactions ─────────────────────────────────────────────
|
|
|
|
t.test('applies a line-item discount', { tags: ['transactions', 'discounts'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Discountable Item',
|
|
qty: 1,
|
|
unitPrice: 100,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
|
|
amount: 15,
|
|
reason: 'Loyalty discount',
|
|
lineItemId: item.data.id,
|
|
})
|
|
t.assert.status(res, 200)
|
|
|
|
// Verify discount reduced the total
|
|
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
const discountTotal = parseFloat(updated.data.discountTotal)
|
|
t.assert.equal(discountTotal, 15)
|
|
// Total should be (100 - 15) + tax on 85
|
|
const total = parseFloat(updated.data.total)
|
|
t.assert.greaterThan(total, 80)
|
|
})
|
|
|
|
t.test('rejects discount exceeding approval threshold', { tags: ['transactions', 'discounts', 'validation'] }, async () => {
|
|
// Create a discount with approval threshold
|
|
const disc = await t.api.post('/v1/discounts', {
|
|
name: 'Threshold Disc',
|
|
discountType: 'fixed',
|
|
discountValue: 50,
|
|
requiresApprovalAbove: 20,
|
|
})
|
|
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Expensive Item',
|
|
qty: 1,
|
|
unitPrice: 200,
|
|
})
|
|
|
|
// Try to apply $25 discount (above $20 threshold)
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
|
|
discountId: disc.data.id,
|
|
amount: 25,
|
|
reason: 'Trying too much',
|
|
lineItemId: item.data.id,
|
|
})
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
// ─── Complete Transaction ──────────────────────────────────────────────────
|
|
|
|
t.test('rejects completing transaction without open drawer', { tags: ['transactions', 'complete', 'validation', 'drawer'] }, async () => {
|
|
// Ensure no drawer is open at LOCATION_ID
|
|
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
|
if (current.status === 200 && current.data.id) {
|
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
|
|
}
|
|
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'No Drawer Item',
|
|
qty: 1,
|
|
unitPrice: 10,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 20,
|
|
})
|
|
t.assert.status(res, 400)
|
|
|
|
// Void to clean up
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
|
})
|
|
|
|
// Open a drawer for the remaining complete tests
|
|
t.test('opens drawer for complete tests', { tags: ['transactions', 'complete', 'setup'] }, async () => {
|
|
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
|
t.assert.status(res, 201)
|
|
})
|
|
|
|
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Cash Sale Item',
|
|
qty: 1,
|
|
unitPrice: 10,
|
|
})
|
|
|
|
// Get updated total
|
|
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
const total = parseFloat(pending.data.total)
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 20,
|
|
})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'completed')
|
|
t.assert.equal(res.data.paymentMethod, 'cash')
|
|
|
|
const changeGiven = parseFloat(res.data.changeGiven)
|
|
// change = 20 - total
|
|
const expectedChange = 20 - total
|
|
t.assert.equal(changeGiven, expectedChange)
|
|
})
|
|
|
|
t.test('rejects cash payment with insufficient amount', { tags: ['transactions', 'complete', 'validation'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Underpay Item',
|
|
qty: 1,
|
|
unitPrice: 100,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 5,
|
|
})
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('completes a card transaction', { tags: ['transactions', 'complete'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Card Sale Item',
|
|
qty: 1,
|
|
unitPrice: 49.99,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'card_present',
|
|
})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'completed')
|
|
t.assert.equal(res.data.paymentMethod, 'card_present')
|
|
})
|
|
|
|
t.test('rejects completing a non-pending transaction', { tags: ['transactions', 'complete', 'validation'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Double Complete',
|
|
qty: 1,
|
|
unitPrice: 10,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'card_present',
|
|
})
|
|
|
|
// Try completing again
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 100,
|
|
})
|
|
t.assert.status(res, 409)
|
|
})
|
|
|
|
// ─── Void Transaction ──────────────────────────────────────────────────────
|
|
|
|
t.test('voids a pending transaction', { tags: ['transactions', 'void'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Void Me',
|
|
qty: 1,
|
|
unitPrice: 25,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'voided')
|
|
})
|
|
|
|
t.test('rejects voiding a completed transaction', { tags: ['transactions', 'void', 'validation'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'No Void',
|
|
qty: 1,
|
|
unitPrice: 10,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
|
t.assert.status(res, 409)
|
|
})
|
|
|
|
// ─── Receipt ───────────────────────────────────────────────────────────────
|
|
|
|
t.test('gets transaction receipt', { tags: ['transactions', 'receipt'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Receipt Item',
|
|
qty: 1,
|
|
unitPrice: 42,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
|
|
|
const res = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.transaction)
|
|
t.assert.ok(res.data.company)
|
|
t.assert.ok(res.data.location)
|
|
t.assert.equal(res.data.transaction.transactionNumber.startsWith('TXN-'), true)
|
|
})
|
|
|
|
// ─── List Transactions ─────────────────────────────────────────────────────
|
|
|
|
t.test('lists transactions with pagination', { tags: ['transactions', 'list'] }, async () => {
|
|
const res = await t.api.get('/v1/transactions', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 1)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('filters transactions by status', { tags: ['transactions', 'list', 'filter'] }, async () => {
|
|
const res = await t.api.get('/v1/transactions', { status: 'completed' })
|
|
t.assert.status(res, 200)
|
|
for (const txn of res.data.data) {
|
|
t.assert.equal(txn.status, 'completed')
|
|
}
|
|
})
|
|
|
|
// ─── Tax Lookup (stub) ────────────────────────────────────────────────────
|
|
|
|
t.test('tax lookup returns 501 (not configured)', { tags: ['tax'] }, async () => {
|
|
const res = await t.api.get('/v1/tax/lookup/90210')
|
|
t.assert.status(res, 501)
|
|
})
|
|
|
|
t.test('rejects invalid zip format', { tags: ['tax', 'validation'] }, async () => {
|
|
const res = await t.api.get('/v1/tax/lookup/abc')
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
|
|
|
// Close the LOCATION_ID drawer and open one at ROUNDING_LOCATION_ID
|
|
t.test('setup drawer for rounding tests', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
|
// Close drawer at LOCATION_ID
|
|
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
|
if (current.status === 200 && current.data.id) {
|
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
|
}
|
|
// Open drawer at ROUNDING_LOCATION_ID
|
|
const res = await t.api.post('/v1/drawer/open', { locationId: ROUNDING_LOCATION_ID, openingBalance: 200 })
|
|
t.assert.status(res, 201)
|
|
})
|
|
|
|
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
|
// Create transaction at the rounding-enabled location
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: ROUNDING_LOCATION_ID,
|
|
})
|
|
t.assert.status(txn, 201)
|
|
|
|
// Add item that will produce a total not divisible by $0.05
|
|
// $10.01 + 8.25% tax = $10.01 + $0.83 = $10.84 → rounds to $10.85
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Rounding Test Item',
|
|
qty: 1,
|
|
unitPrice: 10.01,
|
|
})
|
|
|
|
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
const exactTotal = parseFloat(pending.data.total)
|
|
|
|
// Complete with cash — should round
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 20,
|
|
})
|
|
t.assert.status(res, 200)
|
|
|
|
const roundingAdj = parseFloat(res.data.roundingAdjustment)
|
|
const changeGiven = parseFloat(res.data.changeGiven)
|
|
const roundedTotal = exactTotal + roundingAdj
|
|
|
|
// Rounded total should be divisible by 0.05
|
|
t.assert.equal(Math.round(roundedTotal * 100) % 5, 0)
|
|
// Change should be based on rounded total
|
|
t.assert.equal(changeGiven, Math.round((20 - roundedTotal) * 100) / 100)
|
|
// Adjustment should be small (-0.02 to +0.02)
|
|
t.assert.ok(Math.abs(roundingAdj) <= 0.02)
|
|
})
|
|
|
|
t.test('card payment skips rounding even at rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: ROUNDING_LOCATION_ID,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Card Rounding Test',
|
|
qty: 1,
|
|
unitPrice: 10.01,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'card_present',
|
|
})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
|
})
|
|
|
|
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
|
// Open drawer at LOCATION_ID for this test
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(drawer, 201)
|
|
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: LOCATION_ID,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'No Rounding Item',
|
|
qty: 1,
|
|
unitPrice: 10.01,
|
|
})
|
|
|
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 20,
|
|
})
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
|
|
|
// Cleanup
|
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
|
})
|
|
|
|
// Close rounding location drawer
|
|
t.test('cleanup rounding drawer', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
|
const current = await t.api.get('/v1/drawer/current', { locationId: ROUNDING_LOCATION_ID })
|
|
if (current.status === 200 && current.data.id) {
|
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
|
}
|
|
})
|
|
|
|
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
|
|
|
t.test('full sale flow: open drawer, sell, close drawer', { tags: ['e2e'] }, async () => {
|
|
// 1. Open drawer
|
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
|
t.assert.status(drawer, 201)
|
|
|
|
// 2. Create transaction
|
|
const txn = await t.api.post('/v1/transactions', {
|
|
transactionType: 'sale',
|
|
locationId: LOCATION_ID,
|
|
})
|
|
t.assert.status(txn, 201)
|
|
|
|
// 3. Add line items
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Guitar Pick (12pk)',
|
|
qty: 3,
|
|
unitPrice: 5.99,
|
|
})
|
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
|
description: 'Capo',
|
|
qty: 1,
|
|
unitPrice: 19.99,
|
|
})
|
|
|
|
// 4. Verify totals
|
|
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
|
t.assert.status(pending, 200)
|
|
const subtotal = parseFloat(pending.data.subtotal)
|
|
const taxTotal = parseFloat(pending.data.taxTotal)
|
|
const total = parseFloat(pending.data.total)
|
|
// subtotal = 3*5.99 + 19.99 = 37.96
|
|
t.assert.equal(subtotal, 37.96)
|
|
t.assert.greaterThan(taxTotal, 0)
|
|
t.assert.equal(total, subtotal + taxTotal)
|
|
t.assert.equal(pending.data.lineItems.length, 2)
|
|
|
|
// 5. Complete with cash
|
|
const completed = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
|
paymentMethod: 'cash',
|
|
amountTendered: 50,
|
|
})
|
|
t.assert.status(completed, 200)
|
|
t.assert.equal(completed.data.status, 'completed')
|
|
const change = parseFloat(completed.data.changeGiven)
|
|
t.assert.equal(change, Math.round((50 - total) * 100) / 100)
|
|
|
|
// 6. Get receipt
|
|
const receipt = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
|
|
t.assert.status(receipt, 200)
|
|
t.assert.equal(receipt.data.transaction.status, 'completed')
|
|
|
|
// 7. Close drawer
|
|
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, {
|
|
closingBalance: 100 + total - change,
|
|
notes: 'End of day',
|
|
})
|
|
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))
|
|
})
|
|
})
|