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>
This commit is contained in:
344
packages/shared/__tests__/schemas/pos.test.ts
Normal file
344
packages/shared/__tests__/schemas/pos.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user