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/users.js'
|
||||||
export * from './schema/accounts.js'
|
export * from './schema/accounts.js'
|
||||||
export * from './schema/inventory.js'
|
export * from './schema/inventory.js'
|
||||||
|
export * from './schema/pos.js'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
integer,
|
integer,
|
||||||
numeric,
|
numeric,
|
||||||
date,
|
date,
|
||||||
|
pgEnum,
|
||||||
} from 'drizzle-orm/pg-core'
|
} from 'drizzle-orm/pg-core'
|
||||||
import { locations } from './stores.js'
|
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.
|
// See lookups.ts for inventory_unit_status and item_condition tables.
|
||||||
// Columns below use varchar referencing the lookup slug.
|
// Columns below use varchar referencing the lookup slug.
|
||||||
|
|
||||||
|
export const taxCategoryEnum = pgEnum('tax_category', ['goods', 'service', 'exempt'])
|
||||||
|
|
||||||
export const products = pgTable('product', {
|
export const products = pgTable('product', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
locationId: uuid('location_id').references(() => locations.id),
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
@@ -54,6 +57,7 @@ export const products = pgTable('product', {
|
|||||||
isSerialized: boolean('is_serialized').notNull().default(false),
|
isSerialized: boolean('is_serialized').notNull().default(false),
|
||||||
isRental: boolean('is_rental').notNull().default(false),
|
isRental: boolean('is_rental').notNull().default(false),
|
||||||
isDualUseRepair: boolean('is_dual_use_repair').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 }),
|
price: numeric('price', { precision: 10, scale: 2 }),
|
||||||
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
minPrice: numeric('min_price', { precision: 10, scale: 2 }),
|
||||||
rentalRateMonthly: numeric('rental_rate_monthly', { 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', {
|
export const companies = pgTable('company', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -30,6 +30,8 @@ export const locations = pgTable('location', {
|
|||||||
phone: varchar('phone', { length: 50 }),
|
phone: varchar('phone', { length: 50 }),
|
||||||
email: varchar('email', { length: 255 }),
|
email: varchar('email', { length: 255 }),
|
||||||
timezone: varchar('timezone', { length: 100 }),
|
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),
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { fileRoutes } from './routes/v1/files.js'
|
|||||||
import { rbacRoutes } from './routes/v1/rbac.js'
|
import { rbacRoutes } from './routes/v1/rbac.js'
|
||||||
import { repairRoutes } from './routes/v1/repairs.js'
|
import { repairRoutes } from './routes/v1/repairs.js'
|
||||||
import { lessonRoutes } from './routes/v1/lessons.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 { storageRoutes } from './routes/v1/storage.js'
|
||||||
import { storeRoutes } from './routes/v1/store.js'
|
import { storeRoutes } from './routes/v1/store.js'
|
||||||
import { vaultRoutes } from './routes/v1/vault.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('files', storageRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
|
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('lessons', lessonRoutes), { 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' })
|
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
|
||||||
// Register WebDAV custom HTTP methods before routes
|
// Register WebDAV custom HTTP methods before routes
|
||||||
app.addHttpMethod('PROPFIND', { hasBody: true })
|
app.addHttpMethod('PROPFIND', { hasBody: true })
|
||||||
|
|||||||
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'
|
import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js'
|
||||||
|
|
||||||
export const productRoutes: FastifyPluginAsync = async (app) => {
|
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 ---
|
// --- Products ---
|
||||||
|
|
||||||
app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
89
packages/backend/src/services/discount.service.ts
Normal file
89
packages/backend/src/services/discount.service.ts
Normal file
@@ -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<any>, 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<any>, id: string) {
|
||||||
|
const [discount] = await db
|
||||||
|
.select()
|
||||||
|
.from(discounts)
|
||||||
|
.where(eq(discounts.id, id))
|
||||||
|
.limit(1)
|
||||||
|
return discount ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(db: PostgresJsDatabase<any>, 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<string, Column> = {
|
||||||
|
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<any>) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(discounts)
|
||||||
|
.where(eq(discounts.isActive, true))
|
||||||
|
.orderBy(discounts.name)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(db: PostgresJsDatabase<any>, id: string, input: DiscountUpdateInput) {
|
||||||
|
const updates: Record<string, unknown> = { ...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<any>, id: string) {
|
||||||
|
const [discount] = await db
|
||||||
|
.update(discounts)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(discounts.id, id))
|
||||||
|
.returning()
|
||||||
|
return discount ?? null
|
||||||
|
},
|
||||||
|
}
|
||||||
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -140,6 +140,15 @@ export const ProductService = {
|
|||||||
return product ?? null
|
return product ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getByUpc(db: PostgresJsDatabase<any>, 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<any>, productId: string) {
|
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
78
packages/backend/src/services/tax.service.ts
Normal file
78
packages/backend/src/services/tax.service.ts
Normal file
@@ -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<any>,
|
||||||
|
locationId: string,
|
||||||
|
taxCategory: TaxCategory = 'goods',
|
||||||
|
): Promise<number> {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
424
packages/backend/src/services/transaction.service.ts
Normal file
424
packages/backend/src/services/transaction.service.ts
Normal file
@@ -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<any>, 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<any>, 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<any>, 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<any>,
|
||||||
|
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<any>, 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<any>, 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<any>, 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<any>, 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<any>, 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<any>, params: PaginationInput, filters?: {
|
||||||
|
status?: string
|
||||||
|
transactionType?: string
|
||||||
|
locationId?: string
|
||||||
|
}) {
|
||||||
|
const conditions: ReturnType<typeof eq>[] = []
|
||||||
|
|
||||||
|
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<string, Column> = {
|
||||||
|
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<any>): Promise<string> {
|
||||||
|
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')}`
|
||||||
|
}
|
||||||
@@ -163,3 +163,31 @@ export type {
|
|||||||
LessonPlanTemplateUpdateInput,
|
LessonPlanTemplateUpdateInput,
|
||||||
TemplateInstantiateInput,
|
TemplateInstantiateInput,
|
||||||
} from './lessons.schema.js'
|
} 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'
|
||||||
|
|||||||
114
packages/shared/src/schemas/pos.schema.ts
Normal file
114
packages/shared/src/schemas/pos.schema.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */
|
||||||
|
function opt<T extends z.ZodTypeAny>(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<typeof TransactionType>
|
||||||
|
|
||||||
|
export const TransactionStatus = z.enum(['pending', 'completed', 'voided', 'refunded'])
|
||||||
|
export type TransactionStatus = z.infer<typeof TransactionStatus>
|
||||||
|
|
||||||
|
export const PaymentMethod = z.enum([
|
||||||
|
'cash',
|
||||||
|
'card_present',
|
||||||
|
'card_keyed',
|
||||||
|
'check',
|
||||||
|
'account_charge',
|
||||||
|
])
|
||||||
|
export type PaymentMethod = z.infer<typeof PaymentMethod>
|
||||||
|
|
||||||
|
export const DiscountType = z.enum(['percent', 'fixed'])
|
||||||
|
export type DiscountType = z.infer<typeof DiscountType>
|
||||||
|
|
||||||
|
export const DiscountAppliesTo = z.enum(['order', 'line_item', 'category'])
|
||||||
|
export type DiscountAppliesTo = z.infer<typeof DiscountAppliesTo>
|
||||||
|
|
||||||
|
export const DrawerStatus = z.enum(['open', 'closed'])
|
||||||
|
export type DrawerStatus = z.infer<typeof DrawerStatus>
|
||||||
|
|
||||||
|
export const TaxCategory = z.enum(['goods', 'service', 'exempt'])
|
||||||
|
export type TaxCategory = z.infer<typeof TaxCategory>
|
||||||
|
|
||||||
|
// --- 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<typeof TransactionCreateSchema>
|
||||||
|
|
||||||
|
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<typeof TransactionLineItemCreateSchema>
|
||||||
|
|
||||||
|
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<typeof ApplyDiscountSchema>
|
||||||
|
|
||||||
|
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<typeof CompleteTransactionSchema>
|
||||||
|
|
||||||
|
// --- 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<typeof DiscountCreateSchema>
|
||||||
|
|
||||||
|
export const DiscountUpdateSchema = DiscountCreateSchema.partial()
|
||||||
|
export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
|
||||||
|
|
||||||
|
// --- Drawer schemas ---
|
||||||
|
|
||||||
|
export const DrawerOpenSchema = z.object({
|
||||||
|
locationId: opt(z.string().uuid()),
|
||||||
|
openingBalance: z.coerce.number().min(0),
|
||||||
|
})
|
||||||
|
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
|
||||||
|
|
||||||
|
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<typeof DrawerCloseSchema>
|
||||||
Reference in New Issue
Block a user