Registers: - New register table with location association - CRUD service + API routes (POST/GET/PATCH/DELETE /registers) - Drawer sessions now link to a register via registerId - Register ID persisted in localStorage per device X/Z Reports: - ReportService with getDrawerReport() (X or Z depending on session state) - Z report auto-displayed on drawer close in the drawer dialog - X report (Current Shift Report) button on open drawer view - Report shows: sales summary, payment breakdown, discounts, cash accountability, adjustments Daily Rollup: - ReportService.getDailyReport() aggregates all sessions at a location for a date - New /reports/daily endpoint with locationId + date params - Frontend daily report page with date picker, location selector, session breakdown Critical Fix: - drawerSessionId is now populated on transactions when completing (was never set before) - This enables accurate per-drawer reporting and cash accountability Migration 0044: register table, drawer_session.register_id column Tests: 14 new (register CRUD, drawer report X/Z, drawerSessionId population, daily rollup, register-drawer link) Full suite: 367 passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
195 lines
8.3 KiB
TypeScript
195 lines
8.3 KiB
TypeScript
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 adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
|
|
|
|
export const registers = pgTable('register', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
locationId: uuid('location_id')
|
|
.notNull()
|
|
.references(() => locations.id),
|
|
name: varchar('name', { length: 100 }).notNull(),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
export const drawerSessions = pgTable('drawer_session', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
locationId: uuid('location_id').references(() => locations.id),
|
|
registerId: uuid('register_id').references(() => registers.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 drawerAdjustments = pgTable('drawer_adjustment', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
drawerSessionId: uuid('drawer_session_id')
|
|
.notNull()
|
|
.references(() => drawerSessions.id),
|
|
type: adjustmentTypeEnum('type').notNull(),
|
|
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
|
|
reason: text('reason').notNull(),
|
|
createdBy: uuid('created_by')
|
|
.notNull()
|
|
.references(() => users.id),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
})
|
|
|
|
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 }),
|
|
roundingAdjustment: numeric('rounding_adjustment', { precision: 10, scale: 2 }).notNull().default('0'),
|
|
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
|