- 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>
345 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|