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:
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,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user