feat: add cash rounding, POS test suite, and fix test harness port cleanup
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 50s

- 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) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 18:23:05 +00:00
parent 7b15f18e59
commit 8256380cd1
15 changed files with 1225 additions and 25 deletions

View File

@@ -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<Subprocess> {
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<string> {
// 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`

View File

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