feature/pos-core #4
119
packages/backend/__tests__/services/tax.test.ts
Normal file
119
packages/backend/__tests__/services/tax.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,15 +5,18 @@ import { getSuites, runSuite } from './lib/context.js'
|
|||||||
import { createClient } from './lib/client.js'
|
import { createClient } from './lib/client.js'
|
||||||
|
|
||||||
// --- Config ---
|
// --- Config ---
|
||||||
|
// Use DATABASE_URL from env if available, otherwise construct from individual vars
|
||||||
const DB_HOST = process.env.DB_HOST ?? 'localhost'
|
const DB_HOST = process.env.DB_HOST ?? 'localhost'
|
||||||
const DB_PORT = Number(process.env.DB_PORT ?? '5432')
|
const DB_PORT = Number(process.env.DB_PORT ?? '5432')
|
||||||
const DB_USER = process.env.DB_USER ?? 'lunarfront'
|
const DB_USER = process.env.DB_USER ?? 'lunarfront'
|
||||||
const DB_PASS = process.env.DB_PASS ?? 'lunarfront'
|
const DB_PASS = process.env.DB_PASS ?? 'lunarfront'
|
||||||
const TEST_DB = 'lunarfront_api_test'
|
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 TEST_PORT = 8001
|
||||||
const BASE_URL = `http://localhost:${TEST_PORT}`
|
const BASE_URL = `http://localhost:${TEST_PORT}`
|
||||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
const COMPANY_ID = '10000000-1000-4000-8000-000000000001'
|
||||||
const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002'
|
const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
|
||||||
|
|
||||||
// --- Parse CLI args ---
|
// --- Parse CLI args ---
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
@@ -27,6 +30,8 @@ for (let i = 0; i < args.length; i++) {
|
|||||||
|
|
||||||
// --- DB setup ---
|
// --- DB setup ---
|
||||||
async function setupDatabase() {
|
async function setupDatabase() {
|
||||||
|
if (!USE_EXTERNAL_DB) {
|
||||||
|
// Local: create test DB if needed
|
||||||
const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`)
|
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}`
|
const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}`
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -34,6 +39,7 @@ async function setupDatabase() {
|
|||||||
console.log(` Created database ${TEST_DB}`)
|
console.log(` Created database ${TEST_DB}`)
|
||||||
}
|
}
|
||||||
await adminSql.end()
|
await adminSql.end()
|
||||||
|
}
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
const { execSync } = await import('child_process')
|
const { execSync } = await import('child_process')
|
||||||
@@ -41,13 +47,13 @@ async function setupDatabase() {
|
|||||||
cwd: new URL('..', import.meta.url).pathname,
|
cwd: new URL('..', import.meta.url).pathname,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`,
|
DATABASE_URL: DB_URL,
|
||||||
},
|
},
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Truncate all tables
|
// 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(`
|
await testSql.unsafe(`
|
||||||
DO $$ DECLARE r RECORD;
|
DO $$ DECLARE r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -61,7 +67,8 @@ async function setupDatabase() {
|
|||||||
|
|
||||||
// Seed company + location (company table stays as store settings)
|
// 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 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
|
// Seed lookup tables
|
||||||
const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js')
|
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) {
|
async function killPort(port: number) {
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import('child_process')
|
const { execSync } = await import('child_process')
|
||||||
|
// Try lsof first, fall back to fuser
|
||||||
|
try {
|
||||||
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' })
|
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))
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -127,7 +139,7 @@ async function startBackend(): Promise<Subprocess> {
|
|||||||
cwd: new URL('..', import.meta.url).pathname,
|
cwd: new URL('..', import.meta.url).pathname,
|
||||||
env: {
|
env: {
|
||||||
...process.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',
|
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
JWT_SECRET: 'test-secret-for-api-tests',
|
JWT_SECRET: 'test-secret-for-api-tests',
|
||||||
PORT: String(TEST_PORT),
|
PORT: String(TEST_PORT),
|
||||||
@@ -181,7 +193,7 @@ async function registerTestUser(): Promise<string> {
|
|||||||
|
|
||||||
// Assign admin role to the user via direct SQL
|
// Assign admin role to the user via direct SQL
|
||||||
if (registerRes.status === 201 && registerData.user) {
|
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`
|
const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
||||||
if (adminRole) {
|
if (adminRole) {
|
||||||
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
|
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
|
||||||
|
|||||||
566
packages/backend/api-tests/suites/pos.ts
Normal file
566
packages/backend/api-tests/suites/pos.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
111
packages/backend/src/db/migrations/0038_pos-core.sql
Normal file
111
packages/backend/src/db/migrations/0038_pos-core.sql
Normal file
@@ -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;
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
||||||
@@ -267,6 +267,20 @@
|
|||||||
"when": 1774970000000,
|
"when": 1774970000000,
|
||||||
"tag": "0037_rate_cycles",
|
"tag": "0037_rate_cycles",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,7 @@ export const transactions = pgTable('transaction', {
|
|||||||
changeGiven: numeric('change_given', { precision: 10, scale: 2 }),
|
changeGiven: numeric('change_given', { precision: 10, scale: 2 }),
|
||||||
checkNumber: varchar('check_number', { length: 50 }),
|
checkNumber: varchar('check_number', { length: 50 }),
|
||||||
stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }),
|
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),
|
taxExempt: boolean('tax_exempt').notNull().default(false),
|
||||||
taxExemptReason: text('tax_exempt_reason'),
|
taxExemptReason: text('tax_exempt_reason'),
|
||||||
processedBy: uuid('processed_by')
|
processedBy: uuid('processed_by')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const locations = pgTable('location', {
|
|||||||
timezone: varchar('timezone', { length: 100 }),
|
timezone: varchar('timezone', { length: 100 }),
|
||||||
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }),
|
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }),
|
||||||
serviceTaxRate: numeric('service_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),
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import * as storeSchema from '../db/schema/stores.js'
|
|||||||
import * as userSchema from '../db/schema/users.js'
|
import * as userSchema from '../db/schema/users.js'
|
||||||
import * as accountSchema from '../db/schema/accounts.js'
|
import * as accountSchema from '../db/schema/accounts.js'
|
||||||
import * as inventorySchema from '../db/schema/inventory.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' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
|||||||
@@ -78,13 +78,15 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
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)
|
return reply.send(txn)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
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)
|
return reply.send(txn)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ export const DrawerService = {
|
|||||||
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
|
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
|
||||||
|
|
||||||
// Calculate expected balance from cash transactions in this drawer session
|
// Calculate expected balance from cash transactions in this drawer session
|
||||||
const [cashTotal] = await db
|
// Net cash kept = total + rounding_adjustment (change is already accounted for)
|
||||||
.select({ total: sum(transactions.total) })
|
const [cashTotals] = await db
|
||||||
|
.select({
|
||||||
|
total: sum(transactions.total),
|
||||||
|
rounding: sum(transactions.roundingAdjustment),
|
||||||
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
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 openingBalance = parseFloat(session.openingBalance)
|
||||||
const expectedBalance = openingBalance + cashIn
|
const expectedBalance = openingBalance + cashIn
|
||||||
const closingBalance = input.closingBalance
|
const closingBalance = input.closingBalance
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export const TaxService = {
|
|||||||
return Math.round(amount * rate * 100) / 100
|
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:
|
* Map repair line item types to tax categories:
|
||||||
* - "part" → goods (taxable)
|
* - "part" → goods (taxable)
|
||||||
|
|||||||
@@ -231,10 +231,26 @@ export const TransactionService = {
|
|||||||
if (!txn) throw new NotFoundError('Transaction')
|
if (!txn) throw new NotFoundError('Transaction')
|
||||||
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
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 changeGiven: string | undefined
|
||||||
|
let roundingAdjustment = 0
|
||||||
if (input.paymentMethod === 'cash') {
|
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) {
|
if (!input.amountTendered || input.amountTendered < total) {
|
||||||
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
|
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
|
||||||
}
|
}
|
||||||
@@ -273,13 +289,13 @@ export const TransactionService = {
|
|||||||
paymentMethod: input.paymentMethod,
|
paymentMethod: input.paymentMethod,
|
||||||
amountTendered: input.amountTendered?.toString(),
|
amountTendered: input.amountTendered?.toString(),
|
||||||
changeGiven,
|
changeGiven,
|
||||||
|
roundingAdjustment: roundingAdjustment.toString(),
|
||||||
checkNumber: input.checkNumber,
|
checkNumber: input.checkNumber,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(transactions.id, transactionId))
|
.where(eq(transactions.id, transactionId))
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
return completed
|
return completed
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
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