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 6–12/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;