From 8256380cd1856d1c77869476465a4faf241cc219 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 18:23:05 +0000 Subject: [PATCH] feat: add cash rounding, POS test suite, and fix test harness port cleanup - Add Swedish rounding (nearest nickel) for cash payments at locations with cash_rounding enabled - Add rounding_adjustment column to transactions, cash_rounding to locations - Add POS schema to database plugin for relational query support - Complete/void routes now return full transaction with line items via getById - Test harness killPort falls back to fuser when lsof unavailable (fixes stale process bug) - Add 35-test POS API suite covering discounts, drawer, transactions, tax, rounding, e2e flow - Add unit tests for tax service and POS Zod schemas Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/__tests__/services/tax.test.ts | 119 ++++ packages/backend/api-tests/run.ts | 40 +- packages/backend/api-tests/suites/pos.ts | 566 ++++++++++++++++++ .../src/db/migrations/0038_pos-core.sql | 111 ++++ .../db/migrations/0038_product_sku_unique.sql | 2 - .../src/db/migrations/0039_cash-rounding.sql | 3 + .../src/db/migrations/meta/_journal.json | 14 + packages/backend/src/db/schema/pos.ts | 1 + packages/backend/src/db/schema/stores.ts | 1 + packages/backend/src/plugins/database.ts | 3 +- .../backend/src/routes/v1/transactions.ts | 6 +- .../backend/src/services/drawer.service.ts | 10 +- packages/backend/src/services/tax.service.ts | 8 + .../src/services/transaction.service.ts | 22 +- packages/shared/__tests__/schemas/pos.test.ts | 344 +++++++++++ 15 files changed, 1225 insertions(+), 25 deletions(-) create mode 100644 packages/backend/__tests__/services/tax.test.ts create mode 100644 packages/backend/api-tests/suites/pos.ts create mode 100644 packages/backend/src/db/migrations/0038_pos-core.sql delete mode 100644 packages/backend/src/db/migrations/0038_product_sku_unique.sql create mode 100644 packages/backend/src/db/migrations/0039_cash-rounding.sql create mode 100644 packages/shared/__tests__/schemas/pos.test.ts diff --git a/packages/backend/__tests__/services/tax.test.ts b/packages/backend/__tests__/services/tax.test.ts new file mode 100644 index 0000000..23ecc25 --- /dev/null +++ b/packages/backend/__tests__/services/tax.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'bun:test' +import { TaxService } from '../../src/services/tax.service.js' + +describe('TaxService.calculateTax', () => { + it('calculates tax on a simple amount', () => { + // 8.25% on $100 + expect(TaxService.calculateTax(100, 0.0825)).toBe(8.25) + }) + + it('rounds to 2 decimal places', () => { + // 8.25% on $10.01 = 0.825825 → 0.83 + expect(TaxService.calculateTax(10.01, 0.0825)).toBe(0.83) + }) + + it('returns 0 for zero rate', () => { + expect(TaxService.calculateTax(100, 0)).toBe(0) + }) + + it('returns 0 for zero amount', () => { + expect(TaxService.calculateTax(0, 0.0825)).toBe(0) + }) + + it('handles small amounts', () => { + // 8.25% on $0.99 = 0.081675 → 0.08 + expect(TaxService.calculateTax(0.99, 0.0825)).toBe(0.08) + }) + + it('handles large amounts', () => { + // 8.25% on $9999.99 = 824.999175 → 825.00 + expect(TaxService.calculateTax(9999.99, 0.0825)).toBe(825) + }) + + it('handles 5% service tax rate', () => { + // 5% on $60 = 3.00 + expect(TaxService.calculateTax(60, 0.05)).toBe(3) + }) + + it('handles fractional cent rounding down', () => { + // 7% on $1.01 = 0.0707 → 0.07 + expect(TaxService.calculateTax(1.01, 0.07)).toBe(0.07) + }) + + it('handles fractional cent rounding up', () => { + // 7% on $1.05 = 0.0735 → 0.07 + expect(TaxService.calculateTax(1.05, 0.07)).toBe(0.07) + }) +}) + +describe('TaxService.roundToNickel', () => { + it('rounds .01 down to .00', () => { + expect(TaxService.roundToNickel(10.01)).toBe(10.00) + }) + + it('rounds .02 down to .00', () => { + expect(TaxService.roundToNickel(10.02)).toBe(10.00) + }) + + it('rounds .03 up to .05', () => { + expect(TaxService.roundToNickel(10.03)).toBe(10.05) + }) + + it('rounds .04 up to .05', () => { + expect(TaxService.roundToNickel(10.04)).toBe(10.05) + }) + + it('keeps .05 as is', () => { + expect(TaxService.roundToNickel(10.05)).toBe(10.05) + }) + + it('rounds .06 down to .05', () => { + expect(TaxService.roundToNickel(10.06)).toBe(10.05) + }) + + it('rounds .07 down to .05', () => { + expect(TaxService.roundToNickel(10.07)).toBe(10.05) + }) + + it('rounds .08 up to .10', () => { + expect(TaxService.roundToNickel(10.08)).toBe(10.10) + }) + + it('rounds .09 up to .10', () => { + expect(TaxService.roundToNickel(10.09)).toBe(10.10) + }) + + it('keeps .00 as is', () => { + expect(TaxService.roundToNickel(10.00)).toBe(10.00) + }) + + it('keeps .10 as is', () => { + expect(TaxService.roundToNickel(10.10)).toBe(10.10) + }) + + it('handles zero', () => { + expect(TaxService.roundToNickel(0)).toBe(0) + }) +}) + +describe('TaxService.repairItemTypeToTaxCategory', () => { + it('maps labor to service', () => { + expect(TaxService.repairItemTypeToTaxCategory('labor')).toBe('service') + }) + + it('maps part to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('part')).toBe('goods') + }) + + it('maps flat_rate to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('flat_rate')).toBe('goods') + }) + + it('maps misc to goods', () => { + expect(TaxService.repairItemTypeToTaxCategory('misc')).toBe('goods') + }) + + it('maps unknown type to goods (default)', () => { + expect(TaxService.repairItemTypeToTaxCategory('something_else')).toBe('goods') + }) +}) diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index 54e0187..c95552a 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -5,15 +5,18 @@ import { getSuites, runSuite } from './lib/context.js' import { createClient } from './lib/client.js' // --- Config --- +// Use DATABASE_URL from env if available, otherwise construct from individual vars const DB_HOST = process.env.DB_HOST ?? 'localhost' const DB_PORT = Number(process.env.DB_PORT ?? '5432') const DB_USER = process.env.DB_USER ?? 'lunarfront' const DB_PASS = process.env.DB_PASS ?? 'lunarfront' const TEST_DB = 'lunarfront_api_test' +const DB_URL = process.env.DATABASE_URL ?? `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}` +const USE_EXTERNAL_DB = !!process.env.DATABASE_URL const TEST_PORT = 8001 const BASE_URL = `http://localhost:${TEST_PORT}` -const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' -const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002' +const COMPANY_ID = '10000000-1000-4000-8000-000000000001' +const LOCATION_ID = '10000000-1000-4000-8000-000000000002' // --- Parse CLI args --- const args = process.argv.slice(2) @@ -27,13 +30,16 @@ for (let i = 0; i < args.length; i++) { // --- DB setup --- async function setupDatabase() { - const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`) - const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}` - if (!exists) { - await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) - console.log(` Created database ${TEST_DB}`) + if (!USE_EXTERNAL_DB) { + // Local: create test DB if needed + const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`) + const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}` + if (!exists) { + await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) + console.log(` Created database ${TEST_DB}`) + } + await adminSql.end() } - await adminSql.end() // Run migrations const { execSync } = await import('child_process') @@ -41,13 +47,13 @@ async function setupDatabase() { cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, - DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, + DATABASE_URL: DB_URL, }, stdio: 'pipe', }) // Truncate all tables - const testSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) + const testSql = postgres(DB_URL) await testSql.unsafe(` DO $$ DECLARE r RECORD; BEGIN @@ -61,7 +67,8 @@ async function setupDatabase() { // Seed company + location (company table stays as store settings) await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Store', 'America/Chicago')` - await testSql`INSERT INTO location (id, name) VALUES (${LOCATION_ID}, 'Test Location')` + await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES (${LOCATION_ID}, 'Test Location', '0.0825', '0.0500')` + await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate, cash_rounding) VALUES ('10000000-1000-4000-8000-000000000003', 'Rounding Location', '0.0825', '0.0500', true)` // Seed lookup tables const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js') @@ -115,7 +122,12 @@ async function setupDatabase() { async function killPort(port: number) { try { const { execSync } = await import('child_process') - execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) + // Try lsof first, fall back to fuser + try { + execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) + } catch { + execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: 'pipe' }) + } await new Promise((r) => setTimeout(r, 1000)) } catch {} } @@ -127,7 +139,7 @@ async function startBackend(): Promise { cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, - DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, + DATABASE_URL: DB_URL, REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379', JWT_SECRET: 'test-secret-for-api-tests', PORT: String(TEST_PORT), @@ -181,7 +193,7 @@ async function registerTestUser(): Promise { // Assign admin role to the user via direct SQL if (registerRes.status === 201 && registerData.user) { - const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) + const assignSql = postgres(DB_URL) const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` if (adminRole) { await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts new file mode 100644 index 0000000..6822d39 --- /dev/null +++ b/packages/backend/api-tests/suites/pos.ts @@ -0,0 +1,566 @@ +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) + }) + + // ─── 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('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 ───────────────────────────────────────────────────────── + + 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 () => { + 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) + }) + + // ─── 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') + }) +}) diff --git a/packages/backend/src/db/migrations/0038_pos-core.sql b/packages/backend/src/db/migrations/0038_pos-core.sql new file mode 100644 index 0000000..5b9f353 --- /dev/null +++ b/packages/backend/src/db/migrations/0038_pos-core.sql @@ -0,0 +1,111 @@ +-- POS core: enums, tables, and new columns on existing tables + +-- New enums +CREATE TYPE "public"."transaction_type" AS ENUM('sale', 'repair_payment', 'rental_deposit', 'account_payment', 'refund'); +CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'voided', 'refunded'); +CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card_present', 'card_keyed', 'check', 'account_charge'); +CREATE TYPE "public"."discount_type" AS ENUM('percent', 'fixed'); +CREATE TYPE "public"."discount_applies_to" AS ENUM('order', 'line_item', 'category'); +CREATE TYPE "public"."drawer_status" AS ENUM('open', 'closed'); +CREATE TYPE "public"."tax_category" AS ENUM('goods', 'service', 'exempt'); + +-- New columns on existing tables +ALTER TABLE "product" ADD COLUMN "tax_category" "tax_category" NOT NULL DEFAULT 'goods'; +ALTER TABLE "location" ADD COLUMN "tax_rate" numeric(5, 4) NOT NULL DEFAULT '0'; +ALTER TABLE "location" ADD COLUMN "service_tax_rate" numeric(5, 4) NOT NULL DEFAULT '0'; + +-- Discount table +CREATE TABLE "discount" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "location_id" uuid REFERENCES "location"("id"), + "name" varchar(255) NOT NULL, + "discount_type" "discount_type" NOT NULL, + "discount_value" numeric(10, 2) NOT NULL, + "applies_to" "discount_applies_to" NOT NULL DEFAULT 'line_item', + "requires_approval_above" numeric(10, 2), + "is_active" boolean NOT NULL DEFAULT true, + "valid_from" timestamp with time zone, + "valid_until" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- Drawer session table +CREATE TABLE "drawer_session" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "location_id" uuid REFERENCES "location"("id"), + "opened_by" uuid NOT NULL REFERENCES "user"("id"), + "closed_by" uuid REFERENCES "user"("id"), + "opening_balance" numeric(10, 2) NOT NULL, + "closing_balance" numeric(10, 2), + "expected_balance" numeric(10, 2), + "over_short" numeric(10, 2), + "denominations" jsonb, + "status" "drawer_status" NOT NULL DEFAULT 'open', + "notes" text, + "opened_at" timestamp with time zone NOT NULL DEFAULT now(), + "closed_at" timestamp with time zone +); + +-- Transaction table +CREATE TABLE "transaction" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "location_id" uuid REFERENCES "location"("id"), + "transaction_number" varchar(50) NOT NULL UNIQUE, + "account_id" uuid REFERENCES "account"("id"), + "repair_ticket_id" uuid REFERENCES "repair_ticket"("id"), + "repair_batch_id" uuid REFERENCES "repair_batch"("id"), + "transaction_type" "transaction_type" NOT NULL, + "status" "transaction_status" NOT NULL DEFAULT 'pending', + "subtotal" numeric(10, 2) NOT NULL DEFAULT '0', + "discount_total" numeric(10, 2) NOT NULL DEFAULT '0', + "tax_total" numeric(10, 2) NOT NULL DEFAULT '0', + "total" numeric(10, 2) NOT NULL DEFAULT '0', + "payment_method" "payment_method", + "amount_tendered" numeric(10, 2), + "change_given" numeric(10, 2), + "check_number" varchar(50), + "stripe_payment_intent_id" varchar(255), + "tax_exempt" boolean NOT NULL DEFAULT false, + "tax_exempt_reason" text, + "processed_by" uuid NOT NULL REFERENCES "user"("id"), + "drawer_session_id" uuid REFERENCES "drawer_session"("id"), + "notes" text, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- Transaction line item table +CREATE TABLE "transaction_line_item" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "transaction_id" uuid NOT NULL REFERENCES "transaction"("id"), + "product_id" uuid REFERENCES "product"("id"), + "inventory_unit_id" uuid REFERENCES "inventory_unit"("id"), + "description" varchar(255) NOT NULL, + "qty" integer NOT NULL DEFAULT 1, + "unit_price" numeric(10, 2) NOT NULL, + "discount_amount" numeric(10, 2) NOT NULL DEFAULT '0', + "discount_reason" text, + "tax_rate" numeric(5, 4) NOT NULL DEFAULT '0', + "tax_amount" numeric(10, 2) NOT NULL DEFAULT '0', + "line_total" numeric(10, 2) NOT NULL DEFAULT '0', + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- Discount audit table (append-only) +CREATE TABLE "discount_audit" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "transaction_id" uuid NOT NULL REFERENCES "transaction"("id"), + "transaction_line_item_id" uuid REFERENCES "transaction_line_item"("id"), + "discount_id" uuid REFERENCES "discount"("id"), + "applied_by" uuid NOT NULL REFERENCES "user"("id"), + "approved_by" uuid REFERENCES "user"("id"), + "original_amount" numeric(10, 2) NOT NULL, + "discounted_amount" numeric(10, 2) NOT NULL, + "reason" text NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- SKU unique partial index (from prior untracked migration) +CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL; diff --git a/packages/backend/src/db/migrations/0038_product_sku_unique.sql b/packages/backend/src/db/migrations/0038_product_sku_unique.sql deleted file mode 100644 index b40d166..0000000 --- a/packages/backend/src/db/migrations/0038_product_sku_unique.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add unique index on products.sku (null values are excluded from uniqueness) -CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL; diff --git a/packages/backend/src/db/migrations/0039_cash-rounding.sql b/packages/backend/src/db/migrations/0039_cash-rounding.sql new file mode 100644 index 0000000..7709c79 --- /dev/null +++ b/packages/backend/src/db/migrations/0039_cash-rounding.sql @@ -0,0 +1,3 @@ +-- Cash rounding: location setting + transaction adjustment tracking +ALTER TABLE "location" ADD COLUMN "cash_rounding" boolean NOT NULL DEFAULT false; +ALTER TABLE "transaction" ADD COLUMN "rounding_adjustment" numeric(10, 2) NOT NULL DEFAULT '0'; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 3fe8a45..b61cc4d 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -267,6 +267,20 @@ "when": 1774970000000, "tag": "0037_rate_cycles", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1775321562910, + "tag": "0038_pos-core", + "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1775408000000, + "tag": "0039_cash-rounding", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/pos.ts b/packages/backend/src/db/schema/pos.ts index 5a289ec..ae86504 100644 --- a/packages/backend/src/db/schema/pos.ts +++ b/packages/backend/src/db/schema/pos.ts @@ -104,6 +104,7 @@ export const transactions = pgTable('transaction', { changeGiven: numeric('change_given', { precision: 10, scale: 2 }), checkNumber: varchar('check_number', { length: 50 }), stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }), + roundingAdjustment: numeric('rounding_adjustment', { precision: 10, scale: 2 }).notNull().default('0'), taxExempt: boolean('tax_exempt').notNull().default(false), taxExemptReason: text('tax_exempt_reason'), processedBy: uuid('processed_by') diff --git a/packages/backend/src/db/schema/stores.ts b/packages/backend/src/db/schema/stores.ts index c5d5664..a35cef1 100644 --- a/packages/backend/src/db/schema/stores.ts +++ b/packages/backend/src/db/schema/stores.ts @@ -32,6 +32,7 @@ export const locations = pgTable('location', { timezone: varchar('timezone', { length: 100 }), taxRate: numeric('tax_rate', { precision: 5, scale: 4 }), serviceTaxRate: numeric('service_tax_rate', { precision: 5, scale: 4 }), + cashRounding: boolean('cash_rounding').notNull().default(false), isActive: boolean('is_active').notNull().default(true), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 4f853de..e41dc3e 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -5,8 +5,9 @@ import * as storeSchema from '../db/schema/stores.js' import * as userSchema from '../db/schema/users.js' import * as accountSchema from '../db/schema/accounts.js' import * as inventorySchema from '../db/schema/inventory.js' +import * as posSchema from '../db/schema/pos.js' -const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema } +const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema } declare module 'fastify' { interface FastifyInstance { diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index db264b8..c12192d 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -78,13 +78,15 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => { if (!parsed.success) { return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) } - const txn = await TransactionService.complete(app.db, id, parsed.data) + await TransactionService.complete(app.db, id, parsed.data) + const txn = await TransactionService.getById(app.db, id) return reply.send(txn) }) app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => { const { id } = request.params as { id: string } - const txn = await TransactionService.void(app.db, id, request.user.id) + await TransactionService.void(app.db, id, request.user.id) + const txn = await TransactionService.getById(app.db, id) return reply.send(txn) }) } diff --git a/packages/backend/src/services/drawer.service.ts b/packages/backend/src/services/drawer.service.ts index 69fd59f..bc17496 100644 --- a/packages/backend/src/services/drawer.service.ts +++ b/packages/backend/src/services/drawer.service.ts @@ -32,8 +32,12 @@ export const DrawerService = { if (session.status === 'closed') throw new ConflictError('Drawer session is already closed') // Calculate expected balance from cash transactions in this drawer session - const [cashTotal] = await db - .select({ total: sum(transactions.total) }) + // Net cash kept = total + rounding_adjustment (change is already accounted for) + const [cashTotals] = await db + .select({ + total: sum(transactions.total), + rounding: sum(transactions.roundingAdjustment), + }) .from(transactions) .where( and( @@ -43,7 +47,7 @@ export const DrawerService = { ) ) - const cashIn = parseFloat(cashTotal?.total ?? '0') + const cashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0') const openingBalance = parseFloat(session.openingBalance) const expectedBalance = openingBalance + cashIn const closingBalance = input.closingBalance diff --git a/packages/backend/src/services/tax.service.ts b/packages/backend/src/services/tax.service.ts index 055401c..57de1ca 100644 --- a/packages/backend/src/services/tax.service.ts +++ b/packages/backend/src/services/tax.service.ts @@ -44,6 +44,14 @@ export const TaxService = { return Math.round(amount * rate * 100) / 100 }, + /** + * Swedish rounding: round to nearest $0.05 for cash payments. + * Only affects the final total — tax and line items stay exact. + */ + roundToNickel(amount: number): number { + return Math.round(amount * 20) / 20 + }, + /** * Map repair line item types to tax categories: * - "part" → goods (taxable) diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index 4635273..7e53298 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -231,10 +231,26 @@ export const TransactionService = { if (!txn) throw new NotFoundError('Transaction') if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') - // Validate cash payment + // Validate cash payment (with optional nickel rounding) let changeGiven: string | undefined + let roundingAdjustment = 0 if (input.paymentMethod === 'cash') { - const total = parseFloat(txn.total) + let total = parseFloat(txn.total) + + // Apply Swedish rounding if location has cash_rounding enabled + if (txn.locationId) { + const [loc] = await db + .select({ cashRounding: locations.cashRounding }) + .from(locations) + .where(eq(locations.id, txn.locationId)) + .limit(1) + if (loc?.cashRounding) { + const rounded = TaxService.roundToNickel(total) + roundingAdjustment = Math.round((rounded - total) * 100) / 100 + total = rounded + } + } + if (!input.amountTendered || input.amountTendered < total) { throw new ValidationError('Amount tendered must be >= transaction total for cash payments') } @@ -273,13 +289,13 @@ export const TransactionService = { 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() - return completed }, diff --git a/packages/shared/__tests__/schemas/pos.test.ts b/packages/shared/__tests__/schemas/pos.test.ts new file mode 100644 index 0000000..1880829 --- /dev/null +++ b/packages/shared/__tests__/schemas/pos.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect } from 'bun:test' +import { + TransactionCreateSchema, + TransactionLineItemCreateSchema, + ApplyDiscountSchema, + CompleteTransactionSchema, + DiscountCreateSchema, + DiscountUpdateSchema, + DrawerOpenSchema, + DrawerCloseSchema, + TransactionType, + TransactionStatus, + PaymentMethod, + DiscountType, + DiscountAppliesTo, + DrawerStatus, + TaxCategory, +} from '../../src/schemas/pos.schema.js' + +// ─── Enums ─────────────────────────────────────────────────────────────────── + +describe('POS enums', () => { + it('TransactionType accepts valid values', () => { + expect(TransactionType.parse('sale')).toBe('sale') + expect(TransactionType.parse('repair_payment')).toBe('repair_payment') + expect(TransactionType.parse('refund')).toBe('refund') + }) + + it('TransactionType rejects invalid value', () => { + expect(() => TransactionType.parse('layaway')).toThrow() + }) + + it('TransactionStatus accepts valid values', () => { + expect(TransactionStatus.parse('pending')).toBe('pending') + expect(TransactionStatus.parse('voided')).toBe('voided') + }) + + it('PaymentMethod accepts valid values', () => { + expect(PaymentMethod.parse('cash')).toBe('cash') + expect(PaymentMethod.parse('card_present')).toBe('card_present') + expect(PaymentMethod.parse('account_charge')).toBe('account_charge') + }) + + it('TaxCategory accepts valid values', () => { + expect(TaxCategory.parse('goods')).toBe('goods') + expect(TaxCategory.parse('service')).toBe('service') + expect(TaxCategory.parse('exempt')).toBe('exempt') + }) + + it('TaxCategory rejects invalid value', () => { + expect(() => TaxCategory.parse('luxury')).toThrow() + }) +}) + +// ─── TransactionCreateSchema ───────────────────────────────────────────────── + +describe('TransactionCreateSchema', () => { + it('parses minimal valid input', () => { + const result = TransactionCreateSchema.parse({ transactionType: 'sale' }) + expect(result.transactionType).toBe('sale') + expect(result.taxExempt).toBe(false) + }) + + it('parses full input with optional fields', () => { + const result = TransactionCreateSchema.parse({ + transactionType: 'repair_payment', + locationId: '10000000-1000-4000-8000-000000000001', + accountId: '10000000-1000-4000-8000-000000000002', + taxExempt: true, + taxExemptReason: 'Non-profit', + notes: 'Customer walkup', + }) + expect(result.transactionType).toBe('repair_payment') + expect(result.taxExempt).toBe(true) + expect(result.taxExemptReason).toBe('Non-profit') + }) + + it('rejects missing transactionType', () => { + expect(() => TransactionCreateSchema.parse({})).toThrow() + }) + + it('coerces empty string locationId to undefined', () => { + const result = TransactionCreateSchema.parse({ transactionType: 'sale', locationId: '' }) + expect(result.locationId).toBeUndefined() + }) + + it('rejects invalid UUID for locationId', () => { + expect(() => + TransactionCreateSchema.parse({ transactionType: 'sale', locationId: 'not-a-uuid' }) + ).toThrow() + }) +}) + +// ─── TransactionLineItemCreateSchema ───────────────────────────────────────── + +describe('TransactionLineItemCreateSchema', () => { + it('parses valid line item', () => { + const result = TransactionLineItemCreateSchema.parse({ + description: 'Violin Strings', + qty: 2, + unitPrice: 12.99, + }) + expect(result.description).toBe('Violin Strings') + expect(result.qty).toBe(2) + expect(result.unitPrice).toBe(12.99) + }) + + it('defaults qty to 1', () => { + const result = TransactionLineItemCreateSchema.parse({ + description: 'Capo', + unitPrice: 19.99, + }) + expect(result.qty).toBe(1) + }) + + it('coerces string unitPrice to number', () => { + const result = TransactionLineItemCreateSchema.parse({ + description: 'Pick', + unitPrice: '5.99', + }) + expect(result.unitPrice).toBe(5.99) + }) + + it('rejects empty description', () => { + expect(() => + TransactionLineItemCreateSchema.parse({ description: '', unitPrice: 10 }) + ).toThrow() + }) + + it('rejects description over 255 chars', () => { + expect(() => + TransactionLineItemCreateSchema.parse({ description: 'x'.repeat(256), unitPrice: 10 }) + ).toThrow() + }) + + it('rejects negative unitPrice', () => { + expect(() => + TransactionLineItemCreateSchema.parse({ description: 'Bad', unitPrice: -1 }) + ).toThrow() + }) + + it('rejects qty of 0', () => { + expect(() => + TransactionLineItemCreateSchema.parse({ description: 'Zero', qty: 0, unitPrice: 10 }) + ).toThrow() + }) + + it('rejects non-integer qty', () => { + expect(() => + TransactionLineItemCreateSchema.parse({ description: 'Frac', qty: 1.5, unitPrice: 10 }) + ).toThrow() + }) +}) + +// ─── ApplyDiscountSchema ───────────────────────────────────────────────────── + +describe('ApplyDiscountSchema', () => { + it('parses valid discount application', () => { + const result = ApplyDiscountSchema.parse({ + amount: 10, + reason: 'Employee discount', + lineItemId: '10000000-1000-4000-8000-000000000001', + }) + expect(result.amount).toBe(10) + expect(result.reason).toBe('Employee discount') + }) + + it('rejects missing reason', () => { + expect(() => ApplyDiscountSchema.parse({ amount: 5 })).toThrow() + }) + + it('rejects empty reason', () => { + expect(() => ApplyDiscountSchema.parse({ amount: 5, reason: '' })).toThrow() + }) + + it('rejects negative amount', () => { + expect(() => ApplyDiscountSchema.parse({ amount: -1, reason: 'Nope' })).toThrow() + }) + + it('allows zero amount', () => { + const result = ApplyDiscountSchema.parse({ amount: 0, reason: 'Remove discount' }) + expect(result.amount).toBe(0) + }) +}) + +// ─── CompleteTransactionSchema ─────────────────────────────────────────────── + +describe('CompleteTransactionSchema', () => { + it('parses cash payment with amount tendered', () => { + const result = CompleteTransactionSchema.parse({ + paymentMethod: 'cash', + amountTendered: 50, + }) + expect(result.paymentMethod).toBe('cash') + expect(result.amountTendered).toBe(50) + }) + + it('parses card payment without amount tendered', () => { + const result = CompleteTransactionSchema.parse({ paymentMethod: 'card_present' }) + expect(result.paymentMethod).toBe('card_present') + expect(result.amountTendered).toBeUndefined() + }) + + it('parses check payment with check number', () => { + const result = CompleteTransactionSchema.parse({ + paymentMethod: 'check', + checkNumber: '1234', + }) + expect(result.checkNumber).toBe('1234') + }) + + it('rejects missing paymentMethod', () => { + expect(() => CompleteTransactionSchema.parse({})).toThrow() + }) + + it('rejects invalid payment method', () => { + expect(() => CompleteTransactionSchema.parse({ paymentMethod: 'bitcoin' })).toThrow() + }) + + it('rejects check number over 50 chars', () => { + expect(() => + CompleteTransactionSchema.parse({ paymentMethod: 'check', checkNumber: 'x'.repeat(51) }) + ).toThrow() + }) +}) + +// ─── DiscountCreateSchema ──────────────────────────────────────────────────── + +describe('DiscountCreateSchema', () => { + it('parses valid discount', () => { + const result = DiscountCreateSchema.parse({ + name: '10% Off', + discountType: 'percent', + discountValue: 10, + }) + expect(result.name).toBe('10% Off') + expect(result.appliesTo).toBe('line_item') // default + expect(result.isActive).toBe(true) // default + }) + + it('rejects missing name', () => { + expect(() => DiscountCreateSchema.parse({ discountType: 'fixed', discountValue: 5 })).toThrow() + }) + + it('rejects empty name', () => { + expect(() => + DiscountCreateSchema.parse({ name: '', discountType: 'fixed', discountValue: 5 }) + ).toThrow() + }) + + it('accepts order-level discount', () => { + const result = DiscountCreateSchema.parse({ + name: 'Order Disc', + discountType: 'fixed', + discountValue: 20, + appliesTo: 'order', + }) + expect(result.appliesTo).toBe('order') + }) + + it('accepts optional approval threshold', () => { + const result = DiscountCreateSchema.parse({ + name: 'Big Disc', + discountType: 'percent', + discountValue: 50, + requiresApprovalAbove: 25, + }) + expect(result.requiresApprovalAbove).toBe(25) + }) +}) + +// ─── DiscountUpdateSchema ──────────────────────────────────────────────────── + +describe('DiscountUpdateSchema', () => { + it('accepts partial update', () => { + const result = DiscountUpdateSchema.parse({ name: 'Updated Name' }) + expect(result.name).toBe('Updated Name') + expect(result.discountType).toBeUndefined() + }) + + it('accepts empty object (defaults still apply)', () => { + const result = DiscountUpdateSchema.parse({}) + expect(result.appliesTo).toBe('line_item') + expect(result.isActive).toBe(true) + expect(result.name).toBeUndefined() + }) +}) + +// ─── DrawerOpenSchema ──────────────────────────────────────────────────────── + +describe('DrawerOpenSchema', () => { + it('parses valid drawer open', () => { + const result = DrawerOpenSchema.parse({ + locationId: '10000000-1000-4000-8000-000000000001', + openingBalance: 200, + }) + expect(result.openingBalance).toBe(200) + }) + + it('allows opening without location (floating drawer)', () => { + const result = DrawerOpenSchema.parse({ openingBalance: 100 }) + expect(result.locationId).toBeUndefined() + }) + + it('rejects negative opening balance', () => { + expect(() => DrawerOpenSchema.parse({ openingBalance: -50 })).toThrow() + }) + + it('coerces string opening balance', () => { + const result = DrawerOpenSchema.parse({ openingBalance: '150' }) + expect(result.openingBalance).toBe(150) + }) +}) + +// ─── DrawerCloseSchema ─────────────────────────────────────────────────────── + +describe('DrawerCloseSchema', () => { + it('parses valid drawer close', () => { + const result = DrawerCloseSchema.parse({ closingBalance: 250 }) + expect(result.closingBalance).toBe(250) + }) + + it('accepts denominations', () => { + const result = DrawerCloseSchema.parse({ + closingBalance: 100, + denominations: { ones: 20, fives: 10, tens: 3 }, + }) + expect(result.denominations!.ones).toBe(20) + }) + + it('accepts notes', () => { + const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: 'Short $5' }) + expect(result.notes).toBe('Short $5') + }) + + it('coerces empty notes to undefined', () => { + const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: '' }) + expect(result.notes).toBeUndefined() + }) + + it('rejects negative closing balance', () => { + expect(() => DrawerCloseSchema.parse({ closingBalance: -10 })).toThrow() + }) +})