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) <noreply@anthropic.com>
164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
import {
|
|
pgTable,
|
|
uuid,
|
|
varchar,
|
|
text,
|
|
timestamp,
|
|
boolean,
|
|
integer,
|
|
numeric,
|
|
date,
|
|
pgEnum,
|
|
} from 'drizzle-orm/pg-core'
|
|
import { locations } from './stores.js'
|
|
|
|
export const categories = pgTable('category', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
parentId: uuid('parent_id'),
|
|
name: varchar('name', { length: 255 }).notNull(),
|
|
description: text('description'),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export const suppliers = pgTable('supplier', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
name: varchar('name', { length: 255 }).notNull(),
|
|
contactName: varchar('contact_name', { length: 255 }),
|
|
email: varchar('email', { length: 255 }),
|
|
phone: varchar('phone', { length: 50 }),
|
|
website: varchar('website', { length: 255 }),
|
|
accountNumber: varchar('account_number', { length: 100 }),
|
|
paymentTerms: varchar('payment_terms', { length: 100 }),
|
|
notes: text('notes'),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
// NOTE: item_condition and unit_status pgEnums replaced by lookup tables.
|
|
// 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),
|
|
sku: varchar('sku', { length: 100 }),
|
|
upc: varchar('upc', { length: 100 }),
|
|
name: varchar('name', { length: 255 }).notNull(),
|
|
description: text('description'),
|
|
brand: varchar('brand', { length: 255 }),
|
|
model: varchar('model', { length: 255 }),
|
|
categoryId: uuid('category_id').references(() => categories.id),
|
|
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 }),
|
|
qtyOnHand: integer('qty_on_hand').notNull().default(0),
|
|
qtyReorderPoint: integer('qty_reorder_point'),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
legacyId: varchar('legacy_id', { length: 255 }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export const inventoryUnits = pgTable('inventory_unit', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
productId: uuid('product_id')
|
|
.notNull()
|
|
.references(() => products.id),
|
|
locationId: uuid('location_id').references(() => locations.id),
|
|
serialNumber: varchar('serial_number', { length: 255 }),
|
|
condition: varchar('condition', { length: 100 }).notNull().default('new'),
|
|
status: varchar('status', { length: 100 }).notNull().default('available'),
|
|
purchaseDate: date('purchase_date'),
|
|
purchaseCost: numeric('purchase_cost', { precision: 10, scale: 2 }),
|
|
notes: text('notes'),
|
|
legacyId: varchar('legacy_id', { length: 255 }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export const productSuppliers = pgTable('product_supplier', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
productId: uuid('product_id')
|
|
.notNull()
|
|
.references(() => products.id),
|
|
supplierId: uuid('supplier_id')
|
|
.notNull()
|
|
.references(() => suppliers.id),
|
|
supplierSku: varchar('supplier_sku', { length: 100 }),
|
|
isPreferred: boolean('is_preferred').notNull().default(false),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export type Category = typeof categories.$inferSelect
|
|
export type CategoryInsert = typeof categories.$inferInsert
|
|
export type Supplier = typeof suppliers.$inferSelect
|
|
export type SupplierInsert = typeof suppliers.$inferInsert
|
|
export const stockReceipts = pgTable('stock_receipt', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
locationId: uuid('location_id').references(() => locations.id),
|
|
productId: uuid('product_id')
|
|
.notNull()
|
|
.references(() => products.id),
|
|
supplierId: uuid('supplier_id').references(() => suppliers.id),
|
|
inventoryUnitId: uuid('inventory_unit_id').references(() => inventoryUnits.id),
|
|
qty: integer('qty').notNull().default(1),
|
|
costPerUnit: numeric('cost_per_unit', { precision: 10, scale: 2 }).notNull(),
|
|
totalCost: numeric('total_cost', { precision: 10, scale: 2 }).notNull(),
|
|
receivedDate: date('received_date').notNull(),
|
|
receivedBy: uuid('received_by'),
|
|
invoiceNumber: varchar('invoice_number', { length: 100 }),
|
|
notes: text('notes'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export const priceHistory = pgTable('price_history', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
productId: uuid('product_id')
|
|
.notNull()
|
|
.references(() => products.id),
|
|
previousPrice: numeric('previous_price', { precision: 10, scale: 2 }),
|
|
newPrice: numeric('new_price', { precision: 10, scale: 2 }).notNull(),
|
|
previousMinPrice: numeric('previous_min_price', { precision: 10, scale: 2 }),
|
|
newMinPrice: numeric('new_min_price', { precision: 10, scale: 2 }),
|
|
reason: text('reason'),
|
|
changedBy: uuid('changed_by'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export type PriceHistory = typeof priceHistory.$inferSelect
|
|
|
|
export type StockReceipt = typeof stockReceipts.$inferSelect
|
|
export type StockReceiptInsert = typeof stockReceipts.$inferInsert
|
|
|
|
export const consignmentDetails = pgTable('consignment_detail', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
productId: uuid('product_id')
|
|
.notNull()
|
|
.references(() => products.id),
|
|
consignorAccountId: uuid('consignor_account_id').notNull(),
|
|
commissionPercent: numeric('commission_percent', { precision: 5, scale: 2 }).notNull(),
|
|
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
|
agreementDate: date('agreement_date'),
|
|
notes: text('notes'),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export type ConsignmentDetail = typeof consignmentDetails.$inferSelect
|
|
export type ConsignmentDetailInsert = typeof consignmentDetails.$inferInsert
|
|
|
|
export type Product = typeof products.$inferSelect
|
|
export type ProductInsert = typeof products.$inferInsert
|
|
export type InventoryUnit = typeof inventoryUnits.$inferSelect
|
|
export type InventoryUnitInsert = typeof inventoryUnits.$inferInsert
|
|
export type ProductSupplier = typeof productSuppliers.$inferSelect
|