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:
51
packages/backend/src/routes/v1/discounts.ts
Normal file
51
packages/backend/src/routes/v1/discounts.ts
Normal file
@@ -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<string, string | undefined>
|
||||
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)
|
||||
})
|
||||
}
|
||||
49
packages/backend/src/routes/v1/drawer.ts
Normal file
49
packages/backend/src/routes/v1/drawer.ts
Normal file
@@ -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<string, string | undefined>
|
||||
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<string, string | undefined>
|
||||
const params = PaginationSchema.parse(query)
|
||||
const result = await DrawerService.list(app.db, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
15
packages/backend/src/routes/v1/tax.ts
Normal file
15
packages/backend/src/routes/v1/tax.ts
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
90
packages/backend/src/routes/v1/transactions.ts
Normal file
90
packages/backend/src/routes/v1/transactions.ts
Normal file
@@ -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<string, string | undefined>
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user