From 7b15f18e59ed106f3a13ff9ecf5f973de599a041 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 16:26:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20core=20POS=20module=20?= =?UTF-8?q?=E2=80=94=20transactions,=20discounts,=20drawer,=20tax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a backend API for point-of-sale. Includes: Schema (packages/backend/src/db/schema/pos.ts): - pgEnums: transaction_type, transaction_status, payment_method, discount_type, discount_applies_to, drawer_status - Tables: transaction, transaction_line_item, discount, discount_audit, drawer_session - Transaction links to accounts, repair_tickets, repair_batches - Line items link to products and inventory_units Tax system: - tax_rate + service_tax_rate columns on location - tax_category enum (goods/service/exempt) on product - Tax resolves per line item: goods→tax_rate, service→service_tax_rate, exempt→0. Repair line items map: part→goods, labor→service - GET /tax/lookup/:zip stubbed for future API integration (TAX_API_KEY) Services (export const pattern, matching existing codebase): - TransactionService: create, addLineItem, removeLineItem, applyDiscount, recalculateTotals, complete (decrements inventory), void, getReceipt - DiscountService: CRUD + listAll for dropdowns - DrawerService: open/close with expected balance + over/short calc - TaxService: getRateForLocation (by tax category), calculateTax Routes: - POST/GET /transactions, GET /transactions/:id, GET /transactions/:id/receipt - POST /transactions/:id/line-items, DELETE /transactions/:id/line-items/:id - POST /transactions/:id/discounts, /complete, /void - POST /drawer/open, POST /drawer/:id/close, GET /drawer/current, GET /drawer - CRUD /discounts + GET /discounts/all - GET /products/lookup/upc/:upc (barcode scanner support) All routes gated by pos.view/pos.edit/pos.admin + withModule('pos'). POS module already seeded in migration 0026. Still needed: bun install, drizzle-kit generate + migrate, tests, lint. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/backend/src/db/index.ts | 1 + packages/backend/src/db/schema/inventory.ts | 4 + packages/backend/src/db/schema/pos.ts | 165 +++++++ packages/backend/src/db/schema/stores.ts | 4 +- packages/backend/src/main.ts | 8 + packages/backend/src/routes/v1/discounts.ts | 51 +++ packages/backend/src/routes/v1/drawer.ts | 49 ++ packages/backend/src/routes/v1/products.ts | 9 + packages/backend/src/routes/v1/tax.ts | 15 + .../backend/src/routes/v1/transactions.ts | 90 ++++ .../backend/src/services/discount.service.ts | 89 ++++ .../backend/src/services/drawer.service.ts | 110 +++++ .../backend/src/services/product.service.ts | 9 + packages/backend/src/services/tax.service.ts | 78 ++++ .../src/services/transaction.service.ts | 424 ++++++++++++++++++ packages/shared/src/schemas/index.ts | 28 ++ packages/shared/src/schemas/pos.schema.ts | 114 +++++ 17 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/db/schema/pos.ts create mode 100644 packages/backend/src/routes/v1/discounts.ts create mode 100644 packages/backend/src/routes/v1/drawer.ts create mode 100644 packages/backend/src/routes/v1/tax.ts create mode 100644 packages/backend/src/routes/v1/transactions.ts create mode 100644 packages/backend/src/services/discount.service.ts create mode 100644 packages/backend/src/services/drawer.service.ts create mode 100644 packages/backend/src/services/tax.service.ts create mode 100644 packages/backend/src/services/transaction.service.ts create mode 100644 packages/shared/src/schemas/pos.schema.ts diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 97dc55e..78541f9 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -2,3 +2,4 @@ export * from './schema/stores.js' export * from './schema/users.js' export * from './schema/accounts.js' export * from './schema/inventory.js' +export * from './schema/pos.js' diff --git a/packages/backend/src/db/schema/inventory.ts b/packages/backend/src/db/schema/inventory.ts index dbe61b6..9612db1 100644 --- a/packages/backend/src/db/schema/inventory.ts +++ b/packages/backend/src/db/schema/inventory.ts @@ -8,6 +8,7 @@ import { integer, numeric, date, + pgEnum, } from 'drizzle-orm/pg-core' import { locations } from './stores.js' @@ -41,6 +42,8 @@ export const suppliers = pgTable('supplier', { // See lookups.ts for inventory_unit_status and item_condition tables. // Columns below use varchar referencing the lookup slug. +export const taxCategoryEnum = pgEnum('tax_category', ['goods', 'service', 'exempt']) + export const products = pgTable('product', { id: uuid('id').primaryKey().defaultRandom(), locationId: uuid('location_id').references(() => locations.id), @@ -54,6 +57,7 @@ export const products = pgTable('product', { isSerialized: boolean('is_serialized').notNull().default(false), isRental: boolean('is_rental').notNull().default(false), isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false), + taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'), price: numeric('price', { precision: 10, scale: 2 }), minPrice: numeric('min_price', { precision: 10, scale: 2 }), rentalRateMonthly: numeric('rental_rate_monthly', { precision: 10, scale: 2 }), diff --git a/packages/backend/src/db/schema/pos.ts b/packages/backend/src/db/schema/pos.ts new file mode 100644 index 0000000..5a289ec --- /dev/null +++ b/packages/backend/src/db/schema/pos.ts @@ -0,0 +1,165 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + boolean, + integer, + numeric, + pgEnum, + jsonb, +} from 'drizzle-orm/pg-core' +import { locations } from './stores.js' +import { accounts } from './accounts.js' +import { products, inventoryUnits } from './inventory.js' +import { users } from './users.js' +import { repairTickets, repairBatches } from './repairs.js' + +// --- Enums --- + +export const transactionTypeEnum = pgEnum('transaction_type', [ + 'sale', + 'repair_payment', + 'rental_deposit', + 'account_payment', + 'refund', +]) + +export const transactionStatusEnum = pgEnum('transaction_status', [ + 'pending', + 'completed', + 'voided', + 'refunded', +]) + +export const paymentMethodEnum = pgEnum('payment_method', [ + 'cash', + 'card_present', + 'card_keyed', + 'check', + 'account_charge', +]) + +export const discountTypeEnum = pgEnum('discount_type', ['percent', 'fixed']) + +export const discountAppliesToEnum = pgEnum('discount_applies_to', [ + 'order', + 'line_item', + 'category', +]) + +export const drawerStatusEnum = pgEnum('drawer_status', ['open', 'closed']) + +// --- Tables --- + +export const discounts = pgTable('discount', { + id: uuid('id').primaryKey().defaultRandom(), + locationId: uuid('location_id').references(() => locations.id), + name: varchar('name', { length: 255 }).notNull(), + discountType: discountTypeEnum('discount_type').notNull(), + discountValue: numeric('discount_value', { precision: 10, scale: 2 }).notNull(), + appliesTo: discountAppliesToEnum('applies_to').notNull().default('line_item'), + requiresApprovalAbove: numeric('requires_approval_above', { precision: 10, scale: 2 }), + isActive: boolean('is_active').notNull().default(true), + validFrom: timestamp('valid_from', { withTimezone: true }), + validUntil: timestamp('valid_until', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const drawerSessions = pgTable('drawer_session', { + id: uuid('id').primaryKey().defaultRandom(), + locationId: uuid('location_id').references(() => locations.id), + openedBy: uuid('opened_by') + .notNull() + .references(() => users.id), + closedBy: uuid('closed_by').references(() => users.id), + openingBalance: numeric('opening_balance', { precision: 10, scale: 2 }).notNull(), + closingBalance: numeric('closing_balance', { precision: 10, scale: 2 }), + expectedBalance: numeric('expected_balance', { precision: 10, scale: 2 }), + overShort: numeric('over_short', { precision: 10, scale: 2 }), + denominations: jsonb('denominations').$type>(), + status: drawerStatusEnum('status').notNull().default('open'), + notes: text('notes'), + openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(), + closedAt: timestamp('closed_at', { withTimezone: true }), +}) + +export const transactions = pgTable('transaction', { + id: uuid('id').primaryKey().defaultRandom(), + locationId: uuid('location_id').references(() => locations.id), + transactionNumber: varchar('transaction_number', { length: 50 }).notNull().unique(), + accountId: uuid('account_id').references(() => accounts.id), + repairTicketId: uuid('repair_ticket_id').references(() => repairTickets.id), + repairBatchId: uuid('repair_batch_id').references(() => repairBatches.id), + transactionType: transactionTypeEnum('transaction_type').notNull(), + status: transactionStatusEnum('status').notNull().default('pending'), + subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull().default('0'), + discountTotal: numeric('discount_total', { precision: 10, scale: 2 }).notNull().default('0'), + taxTotal: numeric('tax_total', { precision: 10, scale: 2 }).notNull().default('0'), + total: numeric('total', { precision: 10, scale: 2 }).notNull().default('0'), + paymentMethod: paymentMethodEnum('payment_method'), + amountTendered: numeric('amount_tendered', { precision: 10, scale: 2 }), + changeGiven: numeric('change_given', { precision: 10, scale: 2 }), + checkNumber: varchar('check_number', { length: 50 }), + stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }), + taxExempt: boolean('tax_exempt').notNull().default(false), + taxExemptReason: text('tax_exempt_reason'), + processedBy: uuid('processed_by') + .notNull() + .references(() => users.id), + drawerSessionId: uuid('drawer_session_id').references(() => drawerSessions.id), + notes: text('notes'), + completedAt: timestamp('completed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const transactionLineItems = pgTable('transaction_line_item', { + id: uuid('id').primaryKey().defaultRandom(), + transactionId: uuid('transaction_id') + .notNull() + .references(() => transactions.id), + productId: uuid('product_id').references(() => products.id), + inventoryUnitId: uuid('inventory_unit_id').references(() => inventoryUnits.id), + description: varchar('description', { length: 255 }).notNull(), + qty: integer('qty').notNull().default(1), + unitPrice: numeric('unit_price', { precision: 10, scale: 2 }).notNull(), + discountAmount: numeric('discount_amount', { precision: 10, scale: 2 }).notNull().default('0'), + discountReason: text('discount_reason'), + taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'), + taxAmount: numeric('tax_amount', { precision: 10, scale: 2 }).notNull().default('0'), + lineTotal: numeric('line_total', { precision: 10, scale: 2 }).notNull().default('0'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const discountAudits = pgTable('discount_audit', { + id: uuid('id').primaryKey().defaultRandom(), + transactionId: uuid('transaction_id') + .notNull() + .references(() => transactions.id), + transactionLineItemId: uuid('transaction_line_item_id').references(() => transactionLineItems.id), + discountId: uuid('discount_id').references(() => discounts.id), + appliedBy: uuid('applied_by') + .notNull() + .references(() => users.id), + approvedBy: uuid('approved_by').references(() => users.id), + originalAmount: numeric('original_amount', { precision: 10, scale: 2 }).notNull(), + discountedAmount: numeric('discounted_amount', { precision: 10, scale: 2 }).notNull(), + reason: text('reason').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +// --- Type exports --- + +export type Discount = typeof discounts.$inferSelect +export type DiscountInsert = typeof discounts.$inferInsert +export type Transaction = typeof transactions.$inferSelect +export type TransactionInsert = typeof transactions.$inferInsert +export type TransactionLineItem = typeof transactionLineItems.$inferSelect +export type TransactionLineItemInsert = typeof transactionLineItems.$inferInsert +export type DiscountAudit = typeof discountAudits.$inferSelect +export type DiscountAuditInsert = typeof discountAudits.$inferInsert +export type DrawerSession = typeof drawerSessions.$inferSelect +export type DrawerSessionInsert = typeof drawerSessions.$inferInsert diff --git a/packages/backend/src/db/schema/stores.ts b/packages/backend/src/db/schema/stores.ts index 0d569d0..c5d5664 100644 --- a/packages/backend/src/db/schema/stores.ts +++ b/packages/backend/src/db/schema/stores.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core' +import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean, numeric } from 'drizzle-orm/pg-core' export const companies = pgTable('company', { id: uuid('id').primaryKey().defaultRandom(), @@ -30,6 +30,8 @@ export const locations = pgTable('location', { phone: varchar('phone', { length: 50 }), email: varchar('email', { length: 255 }), timezone: varchar('timezone', { length: 100 }), + taxRate: numeric('tax_rate', { precision: 5, scale: 4 }), + serviceTaxRate: numeric('service_tax_rate', { precision: 5, scale: 4 }), isActive: boolean('is_active').notNull().default(true), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 302a2aa..90a4f3f 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -21,6 +21,10 @@ import { fileRoutes } from './routes/v1/files.js' import { rbacRoutes } from './routes/v1/rbac.js' import { repairRoutes } from './routes/v1/repairs.js' import { lessonRoutes } from './routes/v1/lessons.js' +import { transactionRoutes } from './routes/v1/transactions.js' +import { drawerRoutes } from './routes/v1/drawer.js' +import { discountRoutes } from './routes/v1/discounts.js' +import { taxRoutes } from './routes/v1/tax.js' import { storageRoutes } from './routes/v1/storage.js' import { storeRoutes } from './routes/v1/store.js' import { vaultRoutes } from './routes/v1/vault.js' @@ -111,6 +115,10 @@ export async function buildApp() { await app.register(withModule('files', storageRoutes), { prefix: '/v1' }) await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' }) await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', discountRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', taxRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) // Register WebDAV custom HTTP methods before routes app.addHttpMethod('PROPFIND', { hasBody: true }) diff --git a/packages/backend/src/routes/v1/discounts.ts b/packages/backend/src/routes/v1/discounts.ts new file mode 100644 index 0000000..4f9d849 --- /dev/null +++ b/packages/backend/src/routes/v1/discounts.ts @@ -0,0 +1,51 @@ +import type { FastifyPluginAsync } from 'fastify' +import { PaginationSchema, DiscountCreateSchema, DiscountUpdateSchema } from '@lunarfront/shared/schemas' +import { DiscountService } from '../../services/discount.service.js' + +export const discountRoutes: FastifyPluginAsync = async (app) => { + app.post('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => { + const parsed = DiscountCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const discount = await DiscountService.create(app.db, parsed.data) + return reply.status(201).send(discount) + }) + + app.get('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const result = await DiscountService.list(app.db, params) + return reply.send(result) + }) + + app.get('/discounts/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const discounts = await DiscountService.listAll(app.db) + return reply.send(discounts) + }) + + app.get('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const discount = await DiscountService.getById(app.db, id) + if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } }) + return reply.send(discount) + }) + + app.patch('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = DiscountUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const discount = await DiscountService.update(app.db, id, parsed.data) + if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } }) + return reply.send(discount) + }) + + app.delete('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const discount = await DiscountService.softDelete(app.db, id) + if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } }) + return reply.send(discount) + }) +} diff --git a/packages/backend/src/routes/v1/drawer.ts b/packages/backend/src/routes/v1/drawer.ts new file mode 100644 index 0000000..2d87183 --- /dev/null +++ b/packages/backend/src/routes/v1/drawer.ts @@ -0,0 +1,49 @@ +import type { FastifyPluginAsync } from 'fastify' +import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema } from '@lunarfront/shared/schemas' +import { DrawerService } from '../../services/drawer.service.js' + +export const drawerRoutes: FastifyPluginAsync = async (app) => { + app.post('/drawer/open', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const parsed = DrawerOpenSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const session = await DrawerService.open(app.db, parsed.data, request.user.id) + return reply.status(201).send(session) + }) + + app.post('/drawer/:id/close', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = DrawerCloseSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const session = await DrawerService.close(app.db, id, parsed.data, request.user.id) + return reply.send(session) + }) + + app.get('/drawer/current', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const locationId = query.locationId + if (!locationId) { + return reply.status(400).send({ error: { message: 'locationId query param is required', statusCode: 400 } }) + } + const session = await DrawerService.getOpen(app.db, locationId) + if (!session) return reply.status(404).send({ error: { message: 'No open drawer session found', statusCode: 404 } }) + return reply.send(session) + }) + + app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const session = await DrawerService.getById(app.db, id) + if (!session) return reply.status(404).send({ error: { message: 'Drawer session not found', statusCode: 404 } }) + return reply.send(session) + }) + + app.get('/drawer', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const result = await DrawerService.list(app.db, params) + return reply.send(result) + }) +} diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts index 0b75c51..371963a 100644 --- a/packages/backend/src/routes/v1/products.ts +++ b/packages/backend/src/routes/v1/products.ts @@ -12,6 +12,15 @@ import { import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js' export const productRoutes: FastifyPluginAsync = async (app) => { + // --- UPC Barcode Lookup --- + + app.get('/products/lookup/upc/:upc', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => { + const { upc } = request.params as { upc: string } + const product = await ProductService.getByUpc(app.db, upc) + if (!product) return reply.status(404).send({ error: { message: 'No product found for this UPC', statusCode: 404 } }) + return reply.send(product) + }) + // --- Products --- app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => { diff --git a/packages/backend/src/routes/v1/tax.ts b/packages/backend/src/routes/v1/tax.ts new file mode 100644 index 0000000..486b445 --- /dev/null +++ b/packages/backend/src/routes/v1/tax.ts @@ -0,0 +1,15 @@ +import type { FastifyPluginAsync } from 'fastify' +import { TaxService } from '../../services/tax.service.js' + +export const taxRoutes: FastifyPluginAsync = async (app) => { + app.get('/tax/lookup/:zip', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { zip } = request.params as { zip: string } + + if (!/^\d{5}(-\d{4})?$/.test(zip)) { + return reply.status(400).send({ error: { message: 'Invalid zip code format', statusCode: 400 } }) + } + + const result = await TaxService.lookupByZip(zip) + return reply.send(result) + }) +} diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts new file mode 100644 index 0000000..db264b8 --- /dev/null +++ b/packages/backend/src/routes/v1/transactions.ts @@ -0,0 +1,90 @@ +import type { FastifyPluginAsync } from 'fastify' +import { + PaginationSchema, + TransactionCreateSchema, + TransactionLineItemCreateSchema, + ApplyDiscountSchema, + CompleteTransactionSchema, +} from '@lunarfront/shared/schemas' +import { TransactionService } from '../../services/transaction.service.js' + +export const transactionRoutes: FastifyPluginAsync = async (app) => { + app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const parsed = TransactionCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const txn = await TransactionService.create(app.db, parsed.data, request.user.id) + return reply.status(201).send(txn) + }) + + app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + + const filters = { + status: query.status, + transactionType: query.transactionType, + locationId: query.locationId, + } + + const result = await TransactionService.list(app.db, params, filters) + return reply.send(result) + }) + + app.get('/transactions/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const txn = await TransactionService.getById(app.db, id) + if (!txn) return reply.status(404).send({ error: { message: 'Transaction not found', statusCode: 404 } }) + return reply.send(txn) + }) + + app.get('/transactions/:id/receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const receipt = await TransactionService.getReceipt(app.db, id) + return reply.send(receipt) + }) + + app.post('/transactions/:id/line-items', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = TransactionLineItemCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const lineItem = await TransactionService.addLineItem(app.db, id, parsed.data) + return reply.status(201).send(lineItem) + }) + + app.delete('/transactions/:id/line-items/:lineItemId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id, lineItemId } = request.params as { id: string; lineItemId: string } + const deleted = await TransactionService.removeLineItem(app.db, id, lineItemId) + return reply.send(deleted) + }) + + app.post('/transactions/:id/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = ApplyDiscountSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + await TransactionService.applyDiscount(app.db, id, parsed.data, request.user.id) + const txn = await TransactionService.getById(app.db, id) + return reply.send(txn) + }) + + app.post('/transactions/:id/complete', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = CompleteTransactionSchema.safeParse(request.body) + 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) + 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) + return reply.send(txn) + }) +} diff --git a/packages/backend/src/services/discount.service.ts b/packages/backend/src/services/discount.service.ts new file mode 100644 index 0000000..57e0b49 --- /dev/null +++ b/packages/backend/src/services/discount.service.ts @@ -0,0 +1,89 @@ +import { eq, and, count, type Column } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { discounts } from '../db/schema/pos.js' +import type { DiscountCreateInput, DiscountUpdateInput, PaginationInput } from '@lunarfront/shared/schemas' +import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' + +export const DiscountService = { + async create(db: PostgresJsDatabase, input: DiscountCreateInput) { + const [discount] = await db + .insert(discounts) + .values({ + ...input, + discountValue: input.discountValue.toString(), + requiresApprovalAbove: input.requiresApprovalAbove?.toString(), + validFrom: input.validFrom ? new Date(input.validFrom) : undefined, + validUntil: input.validUntil ? new Date(input.validUntil) : undefined, + }) + .returning() + return discount + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [discount] = await db + .select() + .from(discounts) + .where(eq(discounts.id, id)) + .limit(1) + return discount ?? null + }, + + async list(db: PostgresJsDatabase, params: PaginationInput) { + const conditions = [eq(discounts.isActive, true)] + + if (params.q) { + conditions.push(buildSearchCondition(params.q, [discounts.name])!) + } + + const where = conditions.length === 1 ? conditions[0] : and(...conditions) + + const sortableColumns: Record = { + name: discounts.name, + discount_type: discounts.discountType, + created_at: discounts.createdAt, + } + + let query = db.select().from(discounts).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, discounts.name) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(discounts).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async listAll(db: PostgresJsDatabase) { + return db + .select() + .from(discounts) + .where(eq(discounts.isActive, true)) + .orderBy(discounts.name) + }, + + async update(db: PostgresJsDatabase, id: string, input: DiscountUpdateInput) { + const updates: Record = { ...input, updatedAt: new Date() } + if (input.discountValue !== undefined) updates.discountValue = input.discountValue.toString() + if (input.requiresApprovalAbove !== undefined) updates.requiresApprovalAbove = input.requiresApprovalAbove.toString() + if (input.validFrom !== undefined) updates.validFrom = input.validFrom ? new Date(input.validFrom) : null + if (input.validUntil !== undefined) updates.validUntil = input.validUntil ? new Date(input.validUntil) : null + + const [discount] = await db + .update(discounts) + .set(updates) + .where(eq(discounts.id, id)) + .returning() + return discount ?? null + }, + + async softDelete(db: PostgresJsDatabase, id: string) { + const [discount] = await db + .update(discounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(discounts.id, id)) + .returning() + return discount ?? null + }, +} diff --git a/packages/backend/src/services/drawer.service.ts b/packages/backend/src/services/drawer.service.ts new file mode 100644 index 0000000..69fd59f --- /dev/null +++ b/packages/backend/src/services/drawer.service.ts @@ -0,0 +1,110 @@ +import { eq, and, count, sum, type Column } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { drawerSessions, transactions } from '../db/schema/pos.js' +import { ConflictError, NotFoundError } from '../lib/errors.js' +import type { DrawerOpenInput, DrawerCloseInput, PaginationInput } from '@lunarfront/shared/schemas' +import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js' + +export const DrawerService = { + async open(db: PostgresJsDatabase, input: DrawerOpenInput, openedBy: string) { + // Ensure no other open session at this location + if (input.locationId) { + const existing = await this.getOpen(db, input.locationId) + if (existing) { + throw new ConflictError('A drawer session is already open at this location') + } + } + + const [session] = await db + .insert(drawerSessions) + .values({ + locationId: input.locationId, + openedBy, + openingBalance: input.openingBalance.toString(), + }) + .returning() + return session + }, + + async close(db: PostgresJsDatabase, sessionId: string, input: DrawerCloseInput, closedBy: string) { + const session = await this.getById(db, sessionId) + if (!session) throw new NotFoundError('Drawer session') + 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) }) + .from(transactions) + .where( + and( + eq(transactions.drawerSessionId, sessionId), + eq(transactions.status, 'completed'), + eq(transactions.paymentMethod, 'cash') + ) + ) + + const cashIn = parseFloat(cashTotal?.total ?? '0') + const openingBalance = parseFloat(session.openingBalance) + const expectedBalance = openingBalance + cashIn + const closingBalance = input.closingBalance + const overShort = closingBalance - expectedBalance + + const [updated] = await db + .update(drawerSessions) + .set({ + closedBy, + closingBalance: closingBalance.toString(), + expectedBalance: expectedBalance.toString(), + overShort: overShort.toString(), + denominations: input.denominations, + notes: input.notes, + status: 'closed', + closedAt: new Date(), + }) + .where(eq(drawerSessions.id, sessionId)) + .returning() + return updated + }, + + async getOpen(db: PostgresJsDatabase, locationId: string) { + const [session] = await db + .select() + .from(drawerSessions) + .where( + and( + eq(drawerSessions.locationId, locationId), + eq(drawerSessions.status, 'open') + ) + ) + .limit(1) + return session ?? null + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [session] = await db + .select() + .from(drawerSessions) + .where(eq(drawerSessions.id, id)) + .limit(1) + return session ?? null + }, + + async list(db: PostgresJsDatabase, params: PaginationInput) { + const sortableColumns: Record = { + opened_at: drawerSessions.openedAt, + closed_at: drawerSessions.closedAt, + status: drawerSessions.status, + } + + let query = db.select().from(drawerSessions).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, drawerSessions.openedAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(drawerSessions), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, +} diff --git a/packages/backend/src/services/product.service.ts b/packages/backend/src/services/product.service.ts index 71c8ab6..6626570 100644 --- a/packages/backend/src/services/product.service.ts +++ b/packages/backend/src/services/product.service.ts @@ -140,6 +140,15 @@ export const ProductService = { return product ?? null }, + async getByUpc(db: PostgresJsDatabase, upc: string) { + const [product] = await db + .select() + .from(products) + .where(and(eq(products.upc, upc), eq(products.isActive, true))) + .limit(1) + return product ?? null + }, + async listPriceHistory(db: PostgresJsDatabase, productId: string) { return db .select() diff --git a/packages/backend/src/services/tax.service.ts b/packages/backend/src/services/tax.service.ts new file mode 100644 index 0000000..055401c --- /dev/null +++ b/packages/backend/src/services/tax.service.ts @@ -0,0 +1,78 @@ +import { eq } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { locations } from '../db/schema/stores.js' +import { AppError } from '../lib/errors.js' + +export type TaxCategory = 'goods' | 'service' | 'exempt' + +export const TaxService = { + /** + * Get the tax rate for a location, resolved by tax category: + * - "goods" → location.taxRate (default) + * - "service" → location.serviceTaxRate, falls back to taxRate + * - "exempt" → 0 + * + * Returns 0 with no warning for exempt items. + * Returns 0 with warning if no rate is configured on the location. + */ + async getRateForLocation( + db: PostgresJsDatabase, + locationId: string, + taxCategory: TaxCategory = 'goods', + ): Promise { + if (taxCategory === 'exempt') return 0 + + const [location] = await db + .select({ taxRate: locations.taxRate, serviceTaxRate: locations.serviceTaxRate }) + .from(locations) + .where(eq(locations.id, locationId)) + .limit(1) + + if (!location) return 0 + + if (taxCategory === 'service') { + // Use service rate if set, otherwise fall back to goods rate + const rate = location.serviceTaxRate ?? location.taxRate + return rate ? parseFloat(rate) : 0 + } + + // Default: goods rate + return location.taxRate ? parseFloat(location.taxRate) : 0 + }, + + calculateTax(amount: number, rate: number): number { + return Math.round(amount * rate * 100) / 100 + }, + + /** + * Map repair line item types to tax categories: + * - "part" → goods (taxable) + * - "labor" → service (may be taxed differently) + * - "flat_rate" → goods (conservative — includes parts) + * - "misc" → goods (default) + */ + repairItemTypeToTaxCategory(itemType: string): TaxCategory { + switch (itemType) { + case 'labor': + return 'service' + case 'part': + case 'flat_rate': + case 'misc': + default: + return 'goods' + } + }, + + // TODO: Integrate with a real tax rate API (TaxJar, Avalara, etc.) + // Set TAX_API_KEY env var when ready. + async lookupByZip( + _zip: string, + ): Promise<{ zip: string; rate: number; state_rate: number; county_rate: number; city_rate: number }> { + if (!process.env.TAX_API_KEY) { + throw new AppError('Tax rate lookup is not configured. Set TAX_API_KEY to enable automatic lookup.', 501) + } + + // Placeholder — replace with real API call + throw new AppError('Tax rate lookup not yet implemented', 501) + }, +} diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts new file mode 100644 index 0000000..4635273 --- /dev/null +++ b/packages/backend/src/services/transaction.service.ts @@ -0,0 +1,424 @@ +import { eq, and, count, sql, desc, type Column } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { + transactions, + transactionLineItems, + discountAudits, + discounts, +} from '../db/schema/pos.js' +import { products, inventoryUnits } from '../db/schema/inventory.js' +import { companies, locations } from '../db/schema/stores.js' +import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js' +import { TaxService } from './tax.service.js' +import type { + TransactionCreateInput, + TransactionLineItemCreateInput, + ApplyDiscountInput, + CompleteTransactionInput, + PaginationInput, +} from '@lunarfront/shared/schemas' +import { + withPagination, + withSort, + buildSearchCondition, + paginatedResponse, +} from '../utils/pagination.js' + +export const TransactionService = { + async create(db: PostgresJsDatabase, input: TransactionCreateInput, processedBy: string) { + const transactionNumber = await generateTransactionNumber(db) + + const [txn] = await db + .insert(transactions) + .values({ + transactionNumber, + transactionType: input.transactionType, + locationId: input.locationId, + accountId: input.accountId, + repairTicketId: input.repairTicketId, + repairBatchId: input.repairBatchId, + notes: input.notes, + taxExempt: input.taxExempt, + taxExemptReason: input.taxExemptReason, + processedBy, + }) + .returning() + return txn + }, + + async addLineItem(db: PostgresJsDatabase, transactionId: string, input: TransactionLineItemCreateInput) { + const txn = await this.getById(db, transactionId) + if (!txn) throw new NotFoundError('Transaction') + if (txn.status !== 'pending') throw new ConflictError('Can only add items to pending transactions') + + // Resolve tax category from the product (defaults to "goods") + let taxCategory: 'goods' | 'service' | 'exempt' = 'goods' + if (input.productId) { + const [product] = await db + .select({ taxCategory: products.taxCategory }) + .from(products) + .where(eq(products.id, input.productId)) + .limit(1) + if (product?.taxCategory) { + taxCategory = product.taxCategory as 'goods' | 'service' | 'exempt' + } + } + + // Snapshot the tax rate at time of sale + let taxRate = 0 + if (!txn.taxExempt && txn.locationId) { + taxRate = await TaxService.getRateForLocation(db, txn.locationId, taxCategory) + } + + const lineSubtotal = input.unitPrice * input.qty + const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate) + const lineTotal = lineSubtotal + taxAmount + + const [lineItem] = await db + .insert(transactionLineItems) + .values({ + transactionId, + productId: input.productId, + inventoryUnitId: input.inventoryUnitId, + description: input.description, + qty: input.qty, + unitPrice: input.unitPrice.toString(), + taxRate: taxRate.toString(), + taxAmount: taxAmount.toString(), + lineTotal: lineTotal.toString(), + }) + .returning() + + await this.recalculateTotals(db, transactionId) + return lineItem + }, + + async removeLineItem(db: PostgresJsDatabase, transactionId: string, lineItemId: string) { + const txn = await this.getById(db, transactionId) + if (!txn) throw new NotFoundError('Transaction') + if (txn.status !== 'pending') throw new ConflictError('Can only remove items from pending transactions') + + const [deleted] = await db + .delete(transactionLineItems) + .where( + and( + eq(transactionLineItems.id, lineItemId), + eq(transactionLineItems.transactionId, transactionId) + ) + ) + .returning() + + if (!deleted) throw new NotFoundError('Line item') + + await this.recalculateTotals(db, transactionId) + return deleted + }, + + async applyDiscount( + db: PostgresJsDatabase, + transactionId: string, + input: ApplyDiscountInput, + appliedBy: string, + ) { + const txn = await this.getById(db, transactionId) + if (!txn) throw new NotFoundError('Transaction') + if (txn.status !== 'pending') throw new ConflictError('Can only apply discounts to pending transactions') + + // If applying a predefined discount, check approval threshold + if (input.discountId) { + const [discount] = await db + .select() + .from(discounts) + .where(eq(discounts.id, input.discountId)) + .limit(1) + + if (!discount) throw new NotFoundError('Discount') + + if ( + discount.requiresApprovalAbove && + input.amount > parseFloat(discount.requiresApprovalAbove) + ) { + throw new ValidationError('Discount amount exceeds approval threshold — manager approval required') + } + } + + // Apply to specific line item or order level + if (input.lineItemId) { + const [lineItem] = await db + .select() + .from(transactionLineItems) + .where( + and( + eq(transactionLineItems.id, input.lineItemId), + eq(transactionLineItems.transactionId, transactionId) + ) + ) + .limit(1) + + if (!lineItem) throw new NotFoundError('Line item') + + const originalAmount = parseFloat(lineItem.unitPrice) * lineItem.qty + + await db + .update(transactionLineItems) + .set({ + discountAmount: input.amount.toString(), + discountReason: input.reason, + }) + .where(eq(transactionLineItems.id, input.lineItemId)) + + // Recalculate line total (subtotal - discount + tax) + const lineSubtotal = originalAmount - input.amount + const taxAmount = TaxService.calculateTax(lineSubtotal, parseFloat(lineItem.taxRate)) + const lineTotal = lineSubtotal + taxAmount + + await db + .update(transactionLineItems) + .set({ + taxAmount: taxAmount.toString(), + lineTotal: lineTotal.toString(), + }) + .where(eq(transactionLineItems.id, input.lineItemId)) + + // Create audit record + await db.insert(discountAudits).values({ + transactionId, + transactionLineItemId: input.lineItemId, + discountId: input.discountId, + appliedBy, + originalAmount: originalAmount.toString(), + discountedAmount: input.amount.toString(), + reason: input.reason, + }) + } + + await this.recalculateTotals(db, transactionId) + }, + + async recalculateTotals(db: PostgresJsDatabase, transactionId: string) { + const lineItems = await db + .select() + .from(transactionLineItems) + .where(eq(transactionLineItems.transactionId, transactionId)) + + let subtotal = 0 + let discountTotal = 0 + let taxTotal = 0 + + for (const item of lineItems) { + const itemSubtotal = parseFloat(item.unitPrice) * item.qty + subtotal += itemSubtotal + discountTotal += parseFloat(item.discountAmount) + taxTotal += parseFloat(item.taxAmount) + } + + const total = subtotal - discountTotal + taxTotal + + await db + .update(transactions) + .set({ + subtotal: subtotal.toString(), + discountTotal: discountTotal.toString(), + taxTotal: taxTotal.toString(), + total: total.toString(), + updatedAt: new Date(), + }) + .where(eq(transactions.id, transactionId)) + }, + + async complete(db: PostgresJsDatabase, transactionId: string, input: CompleteTransactionInput) { + const txn = await this.getById(db, transactionId) + if (!txn) throw new NotFoundError('Transaction') + if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') + + // Validate cash payment + let changeGiven: string | undefined + if (input.paymentMethod === 'cash') { + const total = parseFloat(txn.total) + if (!input.amountTendered || input.amountTendered < total) { + throw new ValidationError('Amount tendered must be >= transaction total for cash payments') + } + changeGiven = (input.amountTendered - total).toString() + } + + // Update inventory for each line item + const lineItems = await db + .select() + .from(transactionLineItems) + .where(eq(transactionLineItems.transactionId, transactionId)) + + for (const item of lineItems) { + if (item.inventoryUnitId) { + // Serialized item — mark as sold + await db + .update(inventoryUnits) + .set({ status: 'sold' }) + .where(eq(inventoryUnits.id, item.inventoryUnitId)) + } else if (item.productId) { + // Non-serialized — decrement qty_on_hand + await db + .update(products) + .set({ + qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`, + updatedAt: new Date(), + }) + .where(eq(products.id, item.productId)) + } + } + + const [completed] = await db + .update(transactions) + .set({ + status: 'completed', + paymentMethod: input.paymentMethod, + amountTendered: input.amountTendered?.toString(), + changeGiven, + checkNumber: input.checkNumber, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(transactions.id, transactionId)) + .returning() + + return completed + }, + + async void(db: PostgresJsDatabase, transactionId: string, _voidedBy: string) { + const txn = await this.getById(db, transactionId) + if (!txn) throw new NotFoundError('Transaction') + if (txn.status !== 'pending') throw new ConflictError('Can only void pending transactions') + + // Restore inventory (in case items were reserved, though we only decrement on complete) + const [voided] = await db + .update(transactions) + .set({ + status: 'voided', + updatedAt: new Date(), + }) + .where(eq(transactions.id, transactionId)) + .returning() + + return voided + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [txn] = await db + .select() + .from(transactions) + .where(eq(transactions.id, id)) + .limit(1) + + if (!txn) return null + + const lineItems = await db + .select() + .from(transactionLineItems) + .where(eq(transactionLineItems.transactionId, id)) + + return { ...txn, lineItems } + }, + + async getReceipt(db: PostgresJsDatabase, id: string) { + const txn = await this.getById(db, id) + if (!txn) throw new NotFoundError('Transaction') + + // Get company info + const [company] = await db.select().from(companies).limit(1) + + // Get location info if available + let location = null + if (txn.locationId) { + const [loc] = await db + .select() + .from(locations) + .where(eq(locations.id, txn.locationId)) + .limit(1) + location = loc ?? null + } + + return { + transaction: txn, + company: company + ? { + name: company.name, + phone: company.phone, + email: company.email, + address: company.address, + } + : null, + location: location + ? { + name: location.name, + phone: location.phone, + email: location.email, + address: location.address, + } + : null, + } + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { + status?: string + transactionType?: string + locationId?: string + }) { + const conditions: ReturnType[] = [] + + if (params.q) { + conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!) + } + if (filters?.status) { + conditions.push(eq(transactions.status, filters.status as any)) + } + if (filters?.transactionType) { + conditions.push(eq(transactions.transactionType, filters.transactionType as any)) + } + if (filters?.locationId) { + conditions.push(eq(transactions.locationId, filters.locationId)) + } + + const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions) + + const sortableColumns: Record = { + transaction_number: transactions.transactionNumber, + total: transactions.total, + status: transactions.status, + created_at: transactions.createdAt, + completed_at: transactions.completedAt, + } + + let query = db.select().from(transactions).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, transactions.createdAt) + query = withPagination(query, params.page, params.limit) + + const countQuery = where + ? db.select({ total: count() }).from(transactions).where(where) + : db.select({ total: count() }).from(transactions) + + const [data, [{ total }]] = await Promise.all([query, countQuery]) + + return paginatedResponse(data, total, params.page, params.limit) + }, +} + +async function generateTransactionNumber(db: PostgresJsDatabase): Promise { + const today = new Date() + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '') + const prefix = `TXN-${dateStr}-` + + // Find the highest sequence number for today + const [latest] = await db + .select({ transactionNumber: transactions.transactionNumber }) + .from(transactions) + .where(sql`${transactions.transactionNumber} LIKE ${prefix + '%'}`) + .orderBy(desc(transactions.transactionNumber)) + .limit(1) + + let seq = 1 + if (latest?.transactionNumber) { + const lastSeq = parseInt(latest.transactionNumber.replace(prefix, ''), 10) + if (!isNaN(lastSeq)) seq = lastSeq + 1 + } + + return `${prefix}${seq.toString().padStart(4, '0')}` +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index f56268c..34b9572 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -163,3 +163,31 @@ export type { LessonPlanTemplateUpdateInput, TemplateInstantiateInput, } from './lessons.schema.js' + +export { + TransactionType, + TransactionStatus, + PaymentMethod, + DiscountType, + DiscountAppliesTo, + DrawerStatus, + TaxCategory, + TransactionCreateSchema, + TransactionLineItemCreateSchema, + ApplyDiscountSchema, + CompleteTransactionSchema, + DiscountCreateSchema, + DiscountUpdateSchema, + DrawerOpenSchema, + DrawerCloseSchema, +} from './pos.schema.js' +export type { + TransactionCreateInput, + TransactionLineItemCreateInput, + ApplyDiscountInput, + CompleteTransactionInput, + DiscountCreateInput, + DiscountUpdateInput, + DrawerOpenInput, + DrawerCloseInput, +} from './pos.schema.js' diff --git a/packages/shared/src/schemas/pos.schema.ts b/packages/shared/src/schemas/pos.schema.ts new file mode 100644 index 0000000..d826247 --- /dev/null +++ b/packages/shared/src/schemas/pos.schema.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */ +function opt(schema: T) { + return z.preprocess((v) => (v === '' ? undefined : v), schema.optional()) +} + +// --- Enum values (must match pgEnum definitions in pos.ts) --- + +export const TransactionType = z.enum([ + 'sale', + 'repair_payment', + 'rental_deposit', + 'account_payment', + 'refund', +]) +export type TransactionType = z.infer + +export const TransactionStatus = z.enum(['pending', 'completed', 'voided', 'refunded']) +export type TransactionStatus = z.infer + +export const PaymentMethod = z.enum([ + 'cash', + 'card_present', + 'card_keyed', + 'check', + 'account_charge', +]) +export type PaymentMethod = z.infer + +export const DiscountType = z.enum(['percent', 'fixed']) +export type DiscountType = z.infer + +export const DiscountAppliesTo = z.enum(['order', 'line_item', 'category']) +export type DiscountAppliesTo = z.infer + +export const DrawerStatus = z.enum(['open', 'closed']) +export type DrawerStatus = z.infer + +export const TaxCategory = z.enum(['goods', 'service', 'exempt']) +export type TaxCategory = z.infer + +// --- Transaction schemas --- + +export const TransactionCreateSchema = z.object({ + transactionType: TransactionType, + locationId: opt(z.string().uuid()), + accountId: opt(z.string().uuid()), + repairTicketId: opt(z.string().uuid()), + repairBatchId: opt(z.string().uuid()), + notes: opt(z.string()), + taxExempt: z.boolean().default(false), + taxExemptReason: opt(z.string()), +}) +export type TransactionCreateInput = z.infer + +export const TransactionLineItemCreateSchema = z.object({ + productId: opt(z.string().uuid()), + inventoryUnitId: opt(z.string().uuid()), + description: z.string().min(1).max(255), + qty: z.number().int().min(1).default(1), + unitPrice: z.coerce.number().min(0), +}) +export type TransactionLineItemCreateInput = z.infer + +export const ApplyDiscountSchema = z.object({ + discountId: opt(z.string().uuid()), + amount: z.coerce.number().min(0), + reason: z.string().min(1), + lineItemId: opt(z.string().uuid()), +}) +export type ApplyDiscountInput = z.infer + +export const CompleteTransactionSchema = z.object({ + paymentMethod: PaymentMethod, + amountTendered: z.coerce.number().min(0).optional(), + checkNumber: opt(z.string().max(50)), +}) +export type CompleteTransactionInput = z.infer + +// --- Discount schemas --- + +export const DiscountCreateSchema = z.object({ + name: z.string().min(1).max(255), + discountType: DiscountType, + discountValue: z.coerce.number().min(0), + appliesTo: DiscountAppliesTo.default('line_item'), + locationId: opt(z.string().uuid()), + requiresApprovalAbove: z.coerce.number().min(0).optional(), + isActive: z.boolean().default(true), + validFrom: opt(z.string()), + validUntil: opt(z.string()), +}) +export type DiscountCreateInput = z.infer + +export const DiscountUpdateSchema = DiscountCreateSchema.partial() +export type DiscountUpdateInput = z.infer + +// --- Drawer schemas --- + +export const DrawerOpenSchema = z.object({ + locationId: opt(z.string().uuid()), + openingBalance: z.coerce.number().min(0), +}) +export type DrawerOpenInput = z.infer + +export const DrawerCloseSchema = z.object({ + closingBalance: z.coerce.number().min(0), + denominations: z + .record(z.string(), z.number()) + .optional(), + notes: opt(z.string()), +}) +export type DrawerCloseInput = z.infer From 8256380cd1856d1c77869476465a4faf241cc219 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 18:23:05 +0000 Subject: [PATCH 2/2] 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) --- .../backend/__tests__/services/tax.test.ts | 119 ++++ packages/backend/api-tests/run.ts | 40 +- packages/backend/api-tests/suites/pos.ts | 566 ++++++++++++++++++ .../src/db/migrations/0038_pos-core.sql | 111 ++++ .../db/migrations/0038_product_sku_unique.sql | 2 - .../src/db/migrations/0039_cash-rounding.sql | 3 + .../src/db/migrations/meta/_journal.json | 14 + packages/backend/src/db/schema/pos.ts | 1 + packages/backend/src/db/schema/stores.ts | 1 + packages/backend/src/plugins/database.ts | 3 +- .../backend/src/routes/v1/transactions.ts | 6 +- .../backend/src/services/drawer.service.ts | 10 +- packages/backend/src/services/tax.service.ts | 8 + .../src/services/transaction.service.ts | 22 +- packages/shared/__tests__/schemas/pos.test.ts | 344 +++++++++++ 15 files changed, 1225 insertions(+), 25 deletions(-) create mode 100644 packages/backend/__tests__/services/tax.test.ts create mode 100644 packages/backend/api-tests/suites/pos.ts create mode 100644 packages/backend/src/db/migrations/0038_pos-core.sql delete mode 100644 packages/backend/src/db/migrations/0038_product_sku_unique.sql create mode 100644 packages/backend/src/db/migrations/0039_cash-rounding.sql create mode 100644 packages/shared/__tests__/schemas/pos.test.ts diff --git a/packages/backend/__tests__/services/tax.test.ts b/packages/backend/__tests__/services/tax.test.ts new file mode 100644 index 0000000..23ecc25 --- /dev/null +++ b/packages/backend/__tests__/services/tax.test.ts @@ -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') + }) +}) diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index 54e0187..c95552a 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -5,15 +5,18 @@ import { getSuites, runSuite } from './lib/context.js' import { createClient } from './lib/client.js' // --- Config --- +// Use DATABASE_URL from env if available, otherwise construct from individual vars const DB_HOST = process.env.DB_HOST ?? 'localhost' const DB_PORT = Number(process.env.DB_PORT ?? '5432') const DB_USER = process.env.DB_USER ?? 'lunarfront' const DB_PASS = process.env.DB_PASS ?? 'lunarfront' 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 BASE_URL = `http://localhost:${TEST_PORT}` -const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' -const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002' +const COMPANY_ID = '10000000-1000-4000-8000-000000000001' +const LOCATION_ID = '10000000-1000-4000-8000-000000000002' // --- Parse CLI args --- const args = process.argv.slice(2) @@ -27,13 +30,16 @@ for (let i = 0; i < args.length; i++) { // --- DB setup --- async function setupDatabase() { - 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}` - if (!exists) { - await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) - console.log(` Created database ${TEST_DB}`) + if (!USE_EXTERNAL_DB) { + // Local: create test DB if needed + 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}` + if (!exists) { + await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) + console.log(` Created database ${TEST_DB}`) + } + await adminSql.end() } - await adminSql.end() // Run migrations const { execSync } = await import('child_process') @@ -41,13 +47,13 @@ async function setupDatabase() { cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, - DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, + DATABASE_URL: DB_URL, }, stdio: 'pipe', }) // 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(` DO $$ DECLARE r RECORD; BEGIN @@ -61,7 +67,8 @@ async function setupDatabase() { // 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 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 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) { try { const { execSync } = await import('child_process') - execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) + // Try lsof first, fall back to fuser + try { + 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)) } catch {} } @@ -127,7 +139,7 @@ async function startBackend(): Promise { cwd: new URL('..', import.meta.url).pathname, 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', JWT_SECRET: 'test-secret-for-api-tests', PORT: String(TEST_PORT), @@ -181,7 +193,7 @@ async function registerTestUser(): Promise { // Assign admin role to the user via direct SQL 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` if (adminRole) { await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts new file mode 100644 index 0000000..6822d39 --- /dev/null +++ b/packages/backend/api-tests/suites/pos.ts @@ -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') + }) +}) diff --git a/packages/backend/src/db/migrations/0038_pos-core.sql b/packages/backend/src/db/migrations/0038_pos-core.sql new file mode 100644 index 0000000..5b9f353 --- /dev/null +++ b/packages/backend/src/db/migrations/0038_pos-core.sql @@ -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; diff --git a/packages/backend/src/db/migrations/0038_product_sku_unique.sql b/packages/backend/src/db/migrations/0038_product_sku_unique.sql deleted file mode 100644 index b40d166..0000000 --- a/packages/backend/src/db/migrations/0038_product_sku_unique.sql +++ /dev/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; diff --git a/packages/backend/src/db/migrations/0039_cash-rounding.sql b/packages/backend/src/db/migrations/0039_cash-rounding.sql new file mode 100644 index 0000000..7709c79 --- /dev/null +++ b/packages/backend/src/db/migrations/0039_cash-rounding.sql @@ -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'; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 3fe8a45..b61cc4d 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/pos.ts b/packages/backend/src/db/schema/pos.ts index 5a289ec..ae86504 100644 --- a/packages/backend/src/db/schema/pos.ts +++ b/packages/backend/src/db/schema/pos.ts @@ -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') diff --git a/packages/backend/src/db/schema/stores.ts b/packages/backend/src/db/schema/stores.ts index c5d5664..a35cef1 100644 --- a/packages/backend/src/db/schema/stores.ts +++ b/packages/backend/src/db/schema/stores.ts @@ -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(), diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 4f853de..e41dc3e 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -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 { diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index db264b8..c12192d 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -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) }) } diff --git a/packages/backend/src/services/drawer.service.ts b/packages/backend/src/services/drawer.service.ts index 69fd59f..bc17496 100644 --- a/packages/backend/src/services/drawer.service.ts +++ b/packages/backend/src/services/drawer.service.ts @@ -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 diff --git a/packages/backend/src/services/tax.service.ts b/packages/backend/src/services/tax.service.ts index 055401c..57de1ca 100644 --- a/packages/backend/src/services/tax.service.ts +++ b/packages/backend/src/services/tax.service.ts @@ -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) diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index 4635273..7e53298 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -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 }, diff --git a/packages/shared/__tests__/schemas/pos.test.ts b/packages/shared/__tests__/schemas/pos.test.ts new file mode 100644 index 0000000..1880829 --- /dev/null +++ b/packages/shared/__tests__/schemas/pos.test.ts @@ -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() + }) +})