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)) }) // ─── Registers ──────────────────────────────────────────────────────────── t.test('creates a register', { tags: ['registers', 'create'] }, async () => { const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' }) t.assert.status(res, 201) t.assert.equal(res.data.name, 'Register 1') t.assert.equal(res.data.locationId, LOCATION_ID) t.assert.equal(res.data.isActive, true) }) t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => { await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' }) const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID }) t.assert.status(res, 200) t.assert.ok(res.data.data.length >= 2) t.assert.ok(res.data.pagination) }) t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => { const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID }) t.assert.status(res, 200) t.assert.ok(res.data.data.length >= 2) }) t.test('updates a register name', { tags: ['registers', 'update'] }, async () => { const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' }) const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' }) t.assert.status(res, 200) t.assert.equal(res.data.name, 'New Name') }) t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => { const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' }) const res = await t.api.del(`/v1/registers/${created.data.id}`) t.assert.status(res, 200) t.assert.equal(res.data.isActive, false) }) // ─── Drawer Reports (X/Z) ──────────────────────────────────────────────── t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => { 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 }) } }) t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => { // Open drawer const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 }) t.assert.status(drawer, 201) // Make a cash sale const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 }) await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 }) // Make a card sale const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 }) await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' }) // Void a transaction const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 }) await t.api.post(`/v1/transactions/${txn3.data.id}/void`) // Get X report (drawer still open) const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) t.assert.status(xReport, 200) t.assert.equal(xReport.data.sales.transactionCount, 2) t.assert.greaterThan(xReport.data.sales.grossSales, 0) // Voided transactions don't go through complete() so drawerSessionId isn't set // They won't appear in the drawer report — this is correct behavior t.assert.ok(xReport.data.payments.cash) t.assert.ok(xReport.data.payments.card_present) t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet // Close drawer const closingAmount = 100 + xReport.data.cash.cashSales await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount }) // Get Z report (drawer closed) const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) t.assert.status(zReport, 200) t.assert.ok(zReport.data.session.closedAt) t.assert.ok(zReport.data.cash.actualBalance !== null) t.assert.ok(typeof zReport.data.cash.overShort === 'number') }) t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => { // Cleanup any open drawer const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID }) if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 }) 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: 'Session ID Test', qty: 1, unitPrice: 20 }) await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' }) const completed = await t.api.get(`/v1/transactions/${txn.data.id}`) t.assert.status(completed, 200) t.assert.equal(completed.data.drawerSessionId, drawer.data.id) await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 }) }) // ─── Daily Report ───────────────────────────────────────────────────────── t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => { const today = new Date().toISOString().slice(0, 10) const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today }) t.assert.status(res, 200) t.assert.equal(res.data.date, today) t.assert.ok(res.data.location) t.assert.ok(Array.isArray(res.data.sessions)) t.assert.ok(typeof res.data.sales.grossSales === 'number') t.assert.ok(typeof res.data.payments === 'object') t.assert.ok(typeof res.data.cash.totalExpected === 'number') }) t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => { const res = await t.api.get('/v1/reports/daily', {}) t.assert.status(res, 400) }) t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => { // Cleanup any open drawer const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID }) if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 }) const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' }) t.assert.status(reg, 201) const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 }) t.assert.status(drawer, 201) // Get report to check register info const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) t.assert.status(report, 200) t.assert.ok(report.data.session.register) t.assert.equal(report.data.session.register.name, 'Report Register') // Cleanup await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 }) }) })