feat: add core POS module — transactions, discounts, drawer, tax
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>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
165
packages/backend/src/db/schema/pos.ts
Normal file
165
packages/backend/src/db/schema/pos.ts
Normal file
@@ -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<Record<string, number>>(),
|
||||
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
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user