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:
110
packages/backend/src/services/drawer.service.ts
Normal file
110
packages/backend/src/services/drawer.service.ts
Normal file
@@ -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<any>, 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<any>, 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<any>, 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<any>, id: string) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(drawerSessions)
|
||||
.where(eq(drawerSessions.id, id))
|
||||
.limit(1)
|
||||
return session ?? null
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user