Files
ryan 8256380cd1
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 50s
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) <noreply@anthropic.com>
2026-04-04 18:23:05 +00:00

345 lines
11 KiB
TypeScript

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()
})
})