Files
pn-new-crm/src/lib/db/schema/financial.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

189 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
pgTable,
text,
boolean,
numeric,
integer,
timestamp,
date,
index,
uniqueIndex,
primaryKey,
jsonb,
AnyPgColumn,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { files } from './documents';
import { interests } from './interests';
export const expenses = pgTable(
'expenses',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
establishmentName: text('establishment_name'),
amount: numeric('amount').notNull(),
currency: text('currency').notNull().default('USD'),
amountUsd: numeric('amount_usd'),
exchangeRate: numeric('exchange_rate'),
paymentMethod: text('payment_method'),
category: text('category'),
payer: text('payer'),
expenseDate: timestamp('expense_date', { withTimezone: true }).notNull(),
description: text('description'),
receiptFileIds: text('receipt_file_ids').array(), // references to files table
/**
* True when the rep deliberately created the expense WITHOUT a receipt
* (e.g. the receipt was lost or never issued). Surfaces a warning at
* creation time AND in the PDF export - the legacy parent-company flow
* may refuse to reimburse expenses without proof, so the warning is
* load-bearing for ops.
*/
noReceiptAcknowledged: boolean('no_receipt_acknowledged').notNull().default(false),
paymentStatus: text('payment_status').default('unpaid'), // unpaid, paid, partial
paymentDate: date('payment_date'),
paymentReference: text('payment_reference'),
paymentNotes: text('payment_notes'),
/**
* Free-text trip / event label so reps can group expenses for one
* yacht show or business trip (e.g. "Palm Beach 2026"). Deliberately
* un-normalized - events are 612/year and full event-management
* functionality lives outside this CRM. The autocomplete on the
* expense form keeps spellings consistent so group-by works.
*/
tripLabel: text('trip_label'),
/** When set, this expense is flagged as a duplicate of another in the
* same port. Self-referencing FK; the dedup service writes this. */
duplicateOf: text('duplicate_of').references((): AnyPgColumn => expenses.id, {
onDelete: 'set null',
}),
/** Last time the dedup heuristic ran against this row. */
dedupScannedAt: timestamp('dedup_scanned_at', { withTimezone: true }),
/** OCR pipeline state: 'pending'|'ok'|'failed'|'low_confidence'. */
ocrStatus: text('ocr_status').default('pending'),
/** Full Claude Vision response payload for audit/debug. */
ocrRaw: jsonb('ocr_raw'),
/** 0..1; values < 0.6 force the verify-mode UI. */
ocrConfidence: numeric('ocr_confidence'),
createdBy: text('created_by').notNull(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_expenses_port').on(table.portId),
index('idx_expenses_date').on(table.portId, table.expenseDate),
index('idx_expenses_category').on(table.portId, table.category),
// Powers the dedup heuristic lookup (port + vendor + amount + date window).
index('idx_expenses_dedup')
.on(table.portId, table.establishmentName, table.amount, table.expenseDate)
.where(sql`duplicate_of IS NULL`),
// Powers the autocomplete + group-by-trip filter / PDF section.
index('idx_expenses_trip_label').on(table.portId, table.tripLabel),
],
);
export const invoices = pgTable(
'invoices',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
invoiceNumber: text('invoice_number').notNull(), // INV-YYYYMM-### auto-generated
clientName: text('client_name').notNull(),
billingEntityType: text('billing_entity_type').notNull().default('client'), // 'client' | 'company'
billingEntityId: text('billing_entity_id').notNull().default(''),
billingEmail: text('billing_email'),
billingAddress: text('billing_address'),
dueDate: date('due_date').notNull(),
paymentTerms: text('payment_terms').notNull().default('net30'), // immediate, net10, net15, net30, net45, net60
currency: text('currency').notNull().default('USD'),
subtotal: numeric('subtotal').notNull(),
discountPct: numeric('discount_pct').default('0'),
discountAmount: numeric('discount_amount').default('0'),
feePct: numeric('fee_pct').default('0'),
feeAmount: numeric('fee_amount').default('0'),
total: numeric('total').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, paid, overdue, cancelled
paymentStatus: text('payment_status').default('unpaid'),
paymentDate: date('payment_date'),
paymentMethod: text('payment_method'),
paymentReference: text('payment_reference'),
// H-01: nullable - losing the rendered invoice PDF shouldn't bring
// down the invoice row (totals + payments are the source of truth).
pdfFileId: text('pdf_file_id').references(() => files.id, { onDelete: 'set null' }),
/** Optional link to a sales interest. When the invoice is paid and `kind`
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
* to deposit_paid (no-op if already further along). */
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
/** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks
* the 10% berth-purchase deposit and is what triggers the stage advance. */
kind: text('kind').notNull().default('general'), // 'general' | 'deposit'
notes: text('notes'),
createdBy: text('created_by').notNull(),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('idx_invoices_number').on(table.portId, table.invoiceNumber),
index('idx_invoices_port').on(table.portId),
index('idx_invoices_status').on(table.portId, table.status),
index('idx_invoices_billing_entity').on(
table.portId,
table.billingEntityType,
table.billingEntityId,
),
index('idx_invoices_interest').on(table.portId, table.interestId),
],
);
export const invoiceLineItems = pgTable(
'invoice_line_items',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceId: text('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text('description').notNull(),
quantity: numeric('quantity').notNull().default('1'),
unitPrice: numeric('unit_price').notNull(),
total: numeric('total').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_ili_invoice').on(table.invoiceId)],
);
export const invoiceExpenses = pgTable(
'invoice_expenses',
{
invoiceId: text('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
expenseId: text('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
},
(table) => [primaryKey({ columns: [table.invoiceId, table.expenseId] })],
);
export type Expense = typeof expenses.$inferSelect;
export type NewExpense = typeof expenses.$inferInsert;
export type Invoice = typeof invoices.$inferSelect;
export type NewInvoice = typeof invoices.$inferInsert;
export type InvoiceLineItem = typeof invoiceLineItems.$inferSelect;
export type NewInvoiceLineItem = typeof invoiceLineItems.$inferInsert;
export type InvoiceExpense = typeof invoiceExpenses.$inferSelect;
export type NewInvoiceExpense = typeof invoiceExpenses.$inferInsert;