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

- Add Swedish rounding (nearest nickel) for cash payments at locations with cash_rounding enabled
- Add rounding_adjustment column to transactions, cash_rounding to locations
- Add POS schema to database plugin for relational query support
- Complete/void routes now return full transaction with line items via getById
- Test harness killPort falls back to fuser when lsof unavailable (fixes stale process bug)
- Add 35-test POS API suite covering discounts, drawer, transactions, tax, rounding, e2e flow
- Add unit tests for tax service and POS Zod schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 18:23:05 +00:00
parent 7b15f18e59
commit 8256380cd1
15 changed files with 1225 additions and 25 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -267,6 +267,20 @@
"when": 1774970000000,
"tag": "0037_rate_cycles",
"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
}
]
}

View File

@@ -104,6 +104,7 @@ export const transactions = pgTable('transaction', {
changeGiven: numeric('change_given', { precision: 10, scale: 2 }),
checkNumber: varchar('check_number', { length: 50 }),
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),
taxExemptReason: text('tax_exempt_reason'),
processedBy: uuid('processed_by')

View File

@@ -32,6 +32,7 @@ export const locations = pgTable('location', {
timezone: varchar('timezone', { length: 100 }),
taxRate: numeric('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),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -5,8 +5,9 @@ import * as storeSchema from '../db/schema/stores.js'
import * as userSchema from '../db/schema/users.js'
import * as accountSchema from '../db/schema/accounts.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' {
interface FastifyInstance {

View File

@@ -78,13 +78,15 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
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)
})
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
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)
})
}

View File

@@ -32,8 +32,12 @@ export const DrawerService = {
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
// Calculate expected balance from cash transactions in this drawer session
const [cashTotal] = await db
.select({ total: sum(transactions.total) })
// Net cash kept = total + rounding_adjustment (change is already accounted for)
const [cashTotals] = await db
.select({
total: sum(transactions.total),
rounding: sum(transactions.roundingAdjustment),
})
.from(transactions)
.where(
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 expectedBalance = openingBalance + cashIn
const closingBalance = input.closingBalance

View File

@@ -44,6 +44,14 @@ export const TaxService = {
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:
* - "part" → goods (taxable)

View File

@@ -231,10 +231,26 @@ export const TransactionService = {
if (!txn) throw new NotFoundError('Transaction')
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 roundingAdjustment = 0
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) {
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
}
@@ -273,13 +289,13 @@ export const TransactionService = {
paymentMethod: input.paymentMethod,
amountTendered: input.amountTendered?.toString(),
changeGiven,
roundingAdjustment: roundingAdjustment.toString(),
checkNumber: input.checkNumber,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(transactions.id, transactionId))
.returning()
return completed
},