Financial Management Tables¶
Departments (departments)¶
Purpose: Production departments organized by accounting breakdown (Above the Line vs Below the Line) for budgeting and cost tracking.
Use Case Example: Categorize production roles and costs into Above the Line (creative/executive) and Below the Line (technical/crew) departments for accurate budget planning and financial reporting.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the department |
| name | TEXT | Department name (e.g., Production, Camera, Sound) |
| accounting_breakdown | accounting_breakdown | Classification: Above the Line or Below the Line |
| description | TEXT | Detailed description of the department responsibilities |
| is_active | BOOLEAN | Whether this department is currently active (default: true) |
| created_at | TIMESTAMPTZ | Timestamp when the department was created |
| updated_at | TIMESTAMPTZ | Timestamp when the department was last updated |
| created_by_user_id | UUID | User who created this department record |
| updated_by_user_id | UUID | User who last updated this department record |
Key Features:
- Pre-seeded with 14 standard production departments
- Above the Line departments: Production, Direction, Casting, Cast
- Below the Line departments: Camera, Grip, Electric, Art, Costumes and Wardrobe, Hair and Makeup, Sound, Visual Effects, Transportation, Vendors
- Unique constraint on department names
- RLS Policies: Authenticated read-only. Pre-seeded reference data managed by service_role
Accounting Breakdown:
- Above the Line: Creative, executive, and talent costs (directors, actors, producers)
- Below the Line: Technical crew, equipment, and production services
Transactions (transactions)¶
Purpose: Financial transactions for projects including invoices, expenses, and receipts
Use Case Example: Record an invoice from a camera rental company for $5,000, or track expense receipts submitted by crew members.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the transaction |
| project_id | UUID | Reference to the project this transaction belongs to |
| entity_id | UUID | Reference to the supplier/vendor entity for inbound transactions, or project entity for outbound (nullable) |
| customer_entity_id | UUID | Reference to the customer entity for outbound transactions (nullable) |
| transaction_code | TEXT | Per-project transaction code in format TRX-000001, auto-generated on creation (numbered per project; unique on (project_id, transaction_code)) |
| document_type | TEXT | Document type: Tax Invoice, Receipt, Proforma Invoice, Quote, Purchase Order, Other |
| type | TEXT | Transaction categorization: Expense, Invoice, Payroll Invoice, Reimbursement, Unknown |
| direction | TEXT | Transaction direction: 'inbound' (invoice/receipt received from supplier) or 'outbound' (invoice issued to customer). Defaults to inbound |
| total | NUMERIC | Total amount including tax |
| subtotal | NUMERIC | Subtotal amount before tax |
| currency_code | TEXT | Currency code for the transaction amount |
| tax_total | NUMERIC | Total tax amount included in the transaction |
| transaction_date | DATE | Date when the transaction occurred |
| due_date | DATE | Payment due date for invoices |
| status | TEXT | Current status: Received, Processing, In Approval, Approved, Rejected, Queried, Cancelled, Paid, or On Hold (foreign currency with no project rate) |
| cancel_reason | TEXT | Reason for cancellation when status is Cancelled (e.g., duplicate file detection) |
| duplicate_of_transaction_id | UUID | Reference to the original transaction when this is a cancelled duplicate (FK to transactions.id) |
| billing_entity_name | TEXT | For INBOUND: Name of the billing entity (vendor/supplier) |
| billing_address_id | UUID | For INBOUND: Reference to the supplier billing address. For OUTBOUND: Reference to our entity address |
| billing_entity_contact_email | TEXT | For INBOUND: Contact email for the billing entity |
| billing_entity_contact_mobile | TEXT | For INBOUND: Contact mobile for the billing entity |
| customer_entity_name | TEXT | For OUTBOUND: Name of the customer entity receiving the invoice |
| customer_entity_contact_email | TEXT | For OUTBOUND: Contact email for the customer entity |
| customer_entity_contact_mobile | TEXT | For OUTBOUND: Contact mobile for the customer entity |
| customer_registration_number | TEXT | For OUTBOUND: Customer registration/tax number |
| customer_address_id | UUID | For OUTBOUND: Reference to the customer address |
| shipping_address_id | UUID | Reference to the shipping/delivery address |
| payment_terms | TEXT | Payment terms (e.g., Net 30, Net 60) |
| reference_number | TEXT | Invoice or receipt reference number |
| tax_number | TEXT | Tax registration number (e.g., VAT number) |
| accounting_memo | TEXT | Short user-provided description that syncs to the memo field in accounting integrations (e.g. QuickBooks) |
| ai_document_description | TEXT | AI-generated description of the document |
| ai_linking_logic | TEXT | Logic used by AI to link with project relationship |
| ai_type_logic | TEXT | Logic used by AI to determine transaction type |
| ai_justification | TEXT | AI-generated justification for the expense |
| additional_explanation | TEXT | User-provided additional explanation/justification when AI justification is insufficient (Issue Resolution System) |
| handwritten_notes | TEXT | Handwritten notes identified on the document by AI |
| tax_eligibility_assessment | TEXT | AI assessment of tax reclaim eligibility |
| ocr_notes | TEXT | Notes from the OCR process |
| has_project_name | BOOLEAN | Indicates whether the project name appears in the document (for media projects, case-insensitive match with punctuation handling). Always false for non-media |
| submission_note | TEXT | Note provided by the person submitting the document |
| submission_address | TEXT | The mobile number or Email Address from which this transaction was submitted |
| source | TEXT | Indicates the original source of this document (Email, Whatsapp, Manual, CSV, System) |
| attachment_id | UUID | Reference to the supporting document attachment for this transaction |
| needs_justification | BOOLEAN | Flag indicating if the transaction requires additional justification from the user |
| supplier_project_relationship_id | UUID | The supplier relationship for inbound transactions (FK to project_relationships, nullable) |
| customer_project_relationship_id | UUID | The customer relationship for outbound transactions (FK to project_relationships, nullable) |
| reimbursement_project_relationship_id | UUID | The person to be reimbursed for reimbursement transactions (FK to project_relationships, nullable) |
| expense_project_relationship_id | UUID | Person an Expense relates to — snapshot of payment_method's assignee at matching; not updated if assignment changes. Nullable (FK to project_relationships) |
| billing_entity_bank_account_number | TEXT | OCR-extracted bank account number from the transaction document (nullable) |
| billing_entity_bank_name | TEXT | OCR-extracted bank name from the transaction document (nullable) |
| billing_entity_bank_sort_code | TEXT | OCR-extracted bank sort code from the transaction document (nullable) |
| billing_entity_bank_payee_name | TEXT | OCR-extracted bank payee name from the transaction document (nullable) |
| billing_entity_card_last_four | TEXT | OCR-extracted last 4 digits of card number from the transaction document (nullable) |
| billing_entity_registration_number | TEXT | Company registration number of the billing entity, extracted from document via OCR (nullable) |
| integration_data | JSON | Stores raw and parsed API data from integration validations. Uses JSON (not JSONB) to preserve key ordering. Keys: tax_validation, company_validation. Each contains { raw, parsed, validatedAt, providerName } (nullable) |
| reject_reason | TEXT | Reason for rejection when status is 'Rejected' (from approval workflow) |
| exchange_rate_id | UUID | FK to the immutable project_currency_rates version this transaction was processed under — decision-time audit link only. Amounts convert live in views from the currently-active rate (a correction recomputes every figure); this only records which rate version the decision used. Read the rate number via the join. NULL when no conversion applied |
| line_item_mode | ENUM | line_item_processing_mode — the line-item processing mode the pipeline applied to this transaction's current item set: detailed (every source line kept) or summary (condensed). Stamped by replace_transaction_line_items. NULL = pre-feature, semantically detailed |
| search_query | TSVECTOR | Full-text search vector across transaction fields: codes, entity names/contacts, amounts, notes, AI content (auto-generated) |
| created_at | TIMESTAMPTZ | Timestamp when the transaction was created |
| updated_at | TIMESTAMPTZ | Timestamp when the transaction was last updated |
| created_by_user_id | UUID | User who created this transaction |
| updated_by_user_id | UUID | User who last updated this transaction |
Key Features:
- Auto-generated per-project transaction codes (TRX-000001 format; numbered independently per project)
- Supports different transaction types (invoices, expenses, receipts)
- Links to project_relationship for vendor/person tracking
- Includes tax tracking
- Status workflow for approval process
- Full-text search support with GIN index on search_query column
- Search includes multiple transaction code formats (with/without dashes, with/without leading zeros)
- Issue Resolution System Integration: When
needs_justificationis true, aprocessing_issueis created with three resolution options: - Send: Request justification via WhatsApp (Sana)
- Add: User provides
additional_explanationmanually - Ignore: Mark as processed without additional justification
- See ISSUE_RESOLUTION_SYSTEM.md for details
RLS Policies:
- SELECT: Users can view their own transactions (
created_by_user_id) OR all transactions in projects where they have thetransaction:view:allpermission - INSERT: Requires
transaction:createpermission on the project - UPDATE: Users can update their own transactions (
created_by_user_id) OR all transactions in projects where they have thetransaction:editpermission - DELETE: Not allowed (transactions should never be deleted - use Cancelled status instead)
Transaction Items (transaction_items)¶
Purpose: Line items for transactions with budget tracking
Use Case Example: Break down a $5,000 camera rental invoice into individual items: $3,000 for camera body, $1,500 for lenses, $500 for accessories.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the transaction item |
| transaction_id | UUID | Reference to the parent transaction |
| budget_item_id | UUID | Optional link to budget item for tracking |
| description | TEXT | Description of the line item |
| line_number | NUMERIC | Line item number from the source document. NOT NULL — a non-numeric printed identifier is not storable here, so extraction treats it as "no line number" |
| quantity | NUMERIC | Quantity of items |
| unit_price | NUMERIC | Price per unit |
| discount_type | TEXT | Type of discount: 'percentage' or 'fixed'. NULL means no discount |
| discount_value | NUMERIC | Raw discount value from invoice (e.g., 40.00 for 40% or 1200.00 for fixed). NULL means no discount |
| discount_amount | NUMERIC | Computed monetary discount deducted from (qty * unit_price) to derive subtotal. Default 0 |
| subtotal | NUMERIC | Amount before tax for this line item (after discount) |
| tax_rate_id | UUID | Reference to the applicable tax rate |
| tax_total | NUMERIC | Tax amount for this line item |
| total | NUMERIC | Total amount including tax for this line item |
| payroll_classification | TEXT | Classification for payroll invoice items (Salary/Fee, Overtime, Equipment, Expenses, Combination, '') |
| expense_account_reference_id | UUID | FK to integration_reference_data (reference_type=chart_of_accounts). Expense account for this item |
| tracking_category_reference_id | UUID | FK to integration_reference_data (reference_type=class). Tracking category/class for this item |
| ocr_adjusted_for_rounding | BOOLEAN | True if OCR absorbed sub-penny rounding into this line to match doc totals. Default false |
| created_at | TIMESTAMPTZ | Timestamp when the item was created |
| updated_at | TIMESTAMPTZ | Timestamp when the item was last updated |
Key Features:
- Links individual line items to budget items for cost tracking
- Supports quantity and unit price calculations
- Per-item tax rates for complex tax scenarios
- Per-item discounts (percentage or fixed amount) with computed discount_amount
- Subtotal = (quantity * unit_price) - discount_amount
totalmust stay consistent with the discount: OCR often reads the PRE-discount gross into a line'stotal, so on a discounted linenormalizeLineItem(utils/calculations.ts) recomputestotal = subtotal + tax_totalrather than trusting the OCR value (which would leavesubtotal < totalat 0% tax). Non-discount lines keep the printedtotal.ocr_adjusted_for_roundingmarks the single last line whose subtotal/tax_total/total were nudged by the OCR pipeline to reconcile with the document-level totals (supplier banker's rounding / truncation)subtotal,totalandtax_totalmust be FINITE (CHECK-enforced). An unconstrainedNUMERICadmitsNaNand±Infinity, and PostgREST accepts their JSON string spellings, so without a constraint any authorized editor (or service-role job, or AI processor) could store one — and it then propagates into every transaction and budget aggregate that sums the column, turning a whole budget's actuals intoNaN.chk_transaction_items_subtotal_finite,chk_transaction_items_total_finiteandchk_transaction_items_tax_total_finitereject all three spellings with SQLSTATE23514. NULL remains allowed (aCHECKonly fails on FALSE): all three columns are nullable, a NULLsubtotalis read as £0 throughout, andtax_totalstays NULL for zero-rated / no-tax lines.private.rescale_allocation_links_on_subtotal_change's own non-finite guard is defense-in-depth behind the subtotal constraint, not a substitute for it — seeBUDGETS.md.
RLS Policies:
- SELECT: Users can view items for transactions they own (via parent
transactions.created_by_user_id) OR items in projects where they have thetransaction:view:allpermission - INSERT: Requires
transaction:createpermission on the project - UPDATE: Users can update items for transactions they own (via parent
transactions.created_by_user_id) OR items in projects where they have thetransaction:editpermission - DELETE: Users can delete items for transactions they own (via parent
transactions.created_by_user_id) OR items in projects where they have thetransaction:editpermission
Payment Methods (payment_methods)¶
Purpose: Stores payment method details for making payments to suppliers and vendors
Use Case Example: Track company credit cards, bank accounts, and petty cash floats used to pay suppliers.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the payment method |
| project_id | UUID | Reference to the project this payment method belongs to |
| type | TEXT | Payment method type: Card, Bank Account, Petty Cash Float |
| provider | TEXT | Payment provider/bank name (e.g., "Barclays", "HSBC", "American Express") |
| card_type | TEXT | Type of card: Debit or Credit. NULL for non-card payment methods |
| card_last_four | TEXT | Last 4 digits of card number (for Card type, nullable) |
| card_expiry_month | INTEGER | Card expiry month 1-12 (for Card type, nullable) |
| card_expiry_year | INTEGER | Card expiry year (for Card type, nullable) |
| cardholder_name | TEXT | Name on the card (for Card type, nullable) |
| bank_account_number | TEXT | Bank account number (for Bank Account type, nullable) |
| bank_sort_code | TEXT | Bank sort code (for Bank Account type, nullable) |
| bank_payee_name | TEXT | Payee/account holder name (for Bank Account type, nullable) |
| bank_overseas_code | TEXT | SWIFT / BIC / IFSC code for international bank transfers (for Bank Account type, nullable) |
| amount | NUMERIC | Float amount for petty cash payment methods. Stored as NUMERIC for precision |
| country_code | TEXT | Country associated with the payment method. FK to countries(code). NOT NULL |
| linking_code | TEXT | Code for linking transactions to this payment method (nullable) |
| is_active | BOOLEAN | Whether this payment method is currently active (default: true) |
| notes | TEXT | Additional notes about the payment method (nullable) |
| account_reference_id | UUID | Links this payment method to a chart of account in the integration system (e.g., QBO bank or credit card account). Nullable |
| created_at | TIMESTAMPTZ | Timestamp when the payment method was created |
| updated_at | TIMESTAMPTZ | Timestamp when the payment method was last updated |
| created_by_user_id | UUID | User who created this payment method |
| updated_by_user_id | UUID | User who last updated this payment method |
Key Features:
- Supports multiple payment method types (cards, bank accounts, petty cash)
- Links to projects for multi-project payment tracking
- Stores sensitive payment details securely
- Tracks active/inactive status for payment methods
- Links to integration reference data for QBO sync (Purchase AccountRef)
RLS Policies:
- Project-based access via user_accesses table
Project Currency Rates (project_currency_rates)¶
Purpose: Stores static per-project exchange rates used to convert foreign-currency transactions into the project currency (Multi-Currency Conversions V1).
Use Case Example: A GBP project receives an INR invoice. A rate row (from INR, rate 0.0087) is set once; every INR transaction on the project is then valued at native × 0.0087 in budgets, reports and approval-tier evaluation. The conversion target is always the project's own currency, so it is not stored on the rate row.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the rate row |
| project_id | UUID | Reference to the project this rate belongs to; the conversion target is this project's currency. FK to projects(id) ON DELETE CASCADE |
| from_currency_code | TEXT | Foreign (native) currency being converted from. FK to currencies(code) |
| rate | NUMERIC | Multiplier: native_amount × rate = project_currency_amount. Must be > 0 |
| suggested_rate | NUMERIC | FX rate suggested by the external source at creation time (nullable); the user may override |
| suggestion_source | TEXT | Source of the suggested rate, e.g. frankfurter (nullable) |
| suggestion_date | DATE | As-of date the suggested rate was fetched for (nullable) |
| valid_from | TIMESTAMPTZ | When this rate became active |
| valid_to | TIMESTAMPTZ | When this rate was superseded. NULL = currently active |
| superseded_by | UUID | The rate row that replaced this one, when corrected. Self-FK to project_currency_rates(id) (nullable) |
| created_by_user_id | UUID | User who set this rate (default auth.uid()) |
| created_at | TIMESTAMPTZ | Timestamp when the rate was created |
| updated_at | TIMESTAMPTZ | Timestamp when the rate was last updated |
Key Features:
- Target currency is derived, not stored: the conversion target is always the project's
currency_code, resolved live fromproject_id. Noto_currency_codecolumn — that would be a second source of truth that could drift from the project. A rate is only ever created for a currency that differs from the project currency. - Immutable + versioned: rate rows are never updated in place. A correction closes the current row (
valid_to+superseded_by) and inserts a new active row, preserving a full audit trail. Live views always read the single active row (valid_to IS NULL), so corrections self-heal historical figures deterministically. - One active rate per (project, from-currency): enforced by a partial unique index
WHERE valid_to IS NULL. - Native amounts stay native everywhere; conversion is applied live in views (budget actuals,
v_transactions) rather than stored.CHECK (rate > 0)andCHECK (valid_to IS NULL OR valid_to >= valid_from). - Removal invariant (DB-enforced): a rate that live transactions still depend on may be updated (superseded) but never removed. A transaction is "blocking" when it belongs to the same
(project_id, currency_code)and its status is not terminal (terminal =Cancelled/Rejected;On Holdand every other status — and a NULL status — count as blocking). TheBEFORE UPDATEtriggertrg_guard_project_currency_rate_removalraises when a bare removal (an active row being closed with no replacement) is attempted while such transactions exist. A supersede-close (driven by inserting a replacement active row) is always allowed — see the atomic-supersede + removal-guard triggers inVIEWS_AND_FUNCTIONS.md. The same guard also blocks two sideways removals: re-keying an active row onto a different(project, currency)(which would strand the original currency) and hard-deleting an active row (trg_guard_project_currency_rate_delete), both against the original currency's blocking-transaction check. All three paths share theproject_currency_has_blocking_transactions(uuid, text)helper so their definition of "blocking" (including NULL status) is identical. The service layer (closeProjectCurrencyRateGated) applies the same check up front for a friendlier message; the triggers are the airtight backstop against a rawUPDATE/DELETE.
RLS Policies:
- SELECT: any project member (via
v_user_accessible_projects). - INSERT / UPDATE / DELETE: project member and
caller_has_permission('project:manage_currency_rates', project_id)— mirrors thepayment_methodspattern.
Payments (payments)¶
Purpose: Aggregates multiple transactions into scheduled payment batches for a single supplier
Use Case Example: Group three invoices from the same camera rental company totaling $15,000 into a single payment scheduled for next Friday.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the payment |
| project_id | UUID | Reference to the project this payment belongs to |
| project_relationship_id | UUID | Reference to the supplier/vendor relationship |
| entity_id | UUID | Reference to the supplier/vendor entity |
| payment_method_id | UUID | Reference to the payment method used (nullable) |
| scheduled_date | DATE | Date when payment is scheduled |
| status | TEXT | Payment status: Scheduled, Paid, Cancelled |
| total_amount | NUMERIC | Total payment amount (default: 0) |
| currency_code | TEXT | Currency code for the payment amount |
| notes | TEXT | Additional notes about the payment (nullable) |
| paid_at | TIMESTAMPTZ | Timestamp when payment was marked as paid (nullable) |
| paid_by_user_id | UUID | User who marked the payment as paid (nullable) |
| cancelled_at | TIMESTAMPTZ | Timestamp when payment was cancelled (nullable) |
| cancelled_by_user_id | UUID | User who cancelled the payment (nullable) |
| cancel_reason | TEXT | Reason for cancellation (nullable) |
| external_id | TEXT | External provider transaction identifier for CSV-imported payments (e.g. Equals "Transaction ID", Revolut "ID"). Used to dedupe re-imports of the same statement row. NULL for manually-entered payments. Partial index idx_payments_external_id covers the non-NULL set |
| source | ENUM | payment_source enum. Creation provenance, immutable after INSERT. manual (user filled the create form), csv_import (payments-import worker created from a CSV statement line), auto_transaction (transaction processor auto-created the row when an inbound transaction matched a payment_method — covers both the petty-cash float withdrawal path and the generic transaction-processor path). The Reconciled-tab Auto-vs-Manual badge derives from this column. Default manual |
| is_imported_match | BOOLEAN | TRUE when the CSV import worker matched this row on a re-import / overlap (matched_paid / matched_scheduled_promoted outcomes). Independent of source: a manual scheduled payment matched by a later import becomes (manual, true). Default false |
| is_cash_float_bump | BOOLEAN | TRUE for petty-cash float top-ups created by the import worker. The payment row is the audit trail; payment_methods.amount caches total bumps; v_petty_cash_floats derives the live balance. Default false |
| is_fee_movement | BOOLEAN | TRUE for provider-fee lines linked to a main statement-match payment via parent_payment_id. Default false |
| is_manually_reconciled | BOOLEAN | TRUE when a user explicitly reconciled the payment via the manual-reconcile flow. Independent of is_imported_match; the app combines both flags to decide if a balanced payment counts as Reconciled. Default false |
| reconciled_at | TIMESTAMPTZ | Timestamp the payment became fully reconciled. Maintained app-side by services/payment (import worker, processPaymentCreation, manual reconcile action, allocation CRUD). NULL means unreconciled |
| reconciled_by_user_id | UUID | User who reconciled the payment — set alongside reconciled_at. FK to users(id). NULL when not reconciled |
| is_reconciled | BOOLEAN | GENERATED ALWAYS AS (reconciled_at IS NOT NULL) STORED. Free boolean for indexable queries / filters |
| parent_payment_id | UUID | Self-FK linking a fee payment to the main payment it accompanies. Set when a single CSV row produces both a main payment and a paired fee payment (Equals card-with-fee, cash-withdrawal-with-fee). ON DELETE SET NULL. NULL on stand-alone payments |
| import_run_id | UUID | FK to import_runs. Set by the import worker on every action that creates or updates this payment (CASH_BUMP, NEW_PAYMENT, MATCHED_SCHEDULED_PROMOTED, MATCHED_PAID, FEE_LINKED). Lets the Reconciliations tab jump back to the run page for audit. ON DELETE SET NULL |
| created_at | TIMESTAMPTZ | Timestamp when the payment was created |
| updated_at | TIMESTAMPTZ | Timestamp when the payment was last updated |
| created_by_user_id | UUID | User who created this payment |
| updated_by_user_id | UUID | User who last updated this payment |
Key Features:
- Groups multiple transactions into single payments
- Tracks payment scheduling and execution
- Links to project relationships and entities
- Supports payment method tracking
idx_payments_unique_supplier_date— partial UNIQUE on(project_id, entity_id, scheduled_date, sign(total_amount)) WHERE status = 'Scheduled'. One open Scheduled payment per supplier per day per project per DIRECTION: a credit movement owed back to a supplier and a bill owed to them can both be scheduled for the same day, while a second movement in the SAME direction is still refused as the duplicate the index exists to prevent. (The predicate is= 'Scheduled', not!= 'Cancelled'— Paid and Cancelled payments are both outside it.)- CSV-import identity (
external_id,source = 'csv_import',is_imported_match) lives on the payment, so the same identifier is visible across every reconciliation row that belongs to that payment - Fee payments are linked to their main payment via
parent_payment_idand flagged withis_fee_movement; queries that want the pair as a unit joinpayments ptopayments fee ON fee.parent_payment_id = p.id - Reconciliation state is materialised on the payment itself:
reconciled_at/reconciled_by_user_id(maintained app-side) and the generatedis_reconciledboolean for indexable queries - Process-job payment creation persists
paid_atandpaid_by_user_idinsidecreate_transaction_payment_fenced, in the same transaction that inserts the already-Paidpayment and its allocation. Repairing the caller's own orphan first locks the proposed payment and revalidates its complete identity/status tuple, then fills only missing paid stamps; converged and reused payments retain their original audit values. The application re-reads and finalises the canonical payment on retries andconverged, so a prior allocation commit followed by a failedScheduled→Paidpromotion cannot be mistaken for completed work import_run_idlets the Reconciliations tab walk back from a payment to the run that produced or last touched it; the Queue tab read computes candidate transactions live at table-load time rather than reading a denormalised view column
RLS Policies:
- Project-based access via user_accesses table
Payment Reconciliations (payment_reconciliations)¶
Purpose: Links individual transactions and approval instances to payments for reconciliation
Use Case Example: Allocate $5,000 from a $15,000 payment to cover invoice TRX-001234, $8,000 to cover TRX-001235, and $2,000 to cover TRX-001236.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the reconciliation |
| payment_id | UUID | Reference to the parent payment (CASCADE on delete) |
| transaction_id | UUID | Reference to the transaction being paid (nullable) |
| approval_instance_id | UUID | Reference to the approval instance (nullable) |
| amount | NUMERIC | Signed amount allocated to this transaction. Non-zero (chk_payment_reconciliation_amount_nonzero), and its SIGN must match both the parent payment's total_amount (when that total is non-zero) and the transaction's total |
| status | payment_reconciliation_status | Active or Voided. Voided rows are kept for history but excluded from every balance/total/listing (default Active) |
| voided_at | TIMESTAMPTZ | Timestamp when the reconciliation was voided (NULL while Active) |
| voided_by_user_id | UUID | User who voided the reconciliation (NULL while Active) |
| void_reason | TEXT | Reason the reconciliation was voided, carried from the transaction void reason (NULL while Active) |
| created_at | TIMESTAMPTZ | Timestamp when the reconciliation was created |
| updated_at | TIMESTAMPTZ | Timestamp when the reconciliation was last updated |
| created_by_user_id | UUID | User who created this reconciliation |
| updated_by_user_id | UUID | User who last updated this reconciliation |
The CSV-import identity used to live on this table. It moved to
paymentsso a single payment carries the identity once, regardless of how many allocation rows reference it. Readpayment.external_id/payment.source/payment.is_imported_match(the post-DEV-572 column names —is_statement_matchwas renamed tois_imported_matchandsourcewas added at the same time).v_payment_detailsexposes the same fields when joining through the view.
Key Features:
- Links payments to specific transactions
- Supports partial payment allocations
- Signed movements: a refund or credit note is a transaction with a NEGATIVE
total, settled by a payment with a negativetotal_amountthrough an allocation with a negativeamount. An ordinary bill keeps every value positive. Direction is the sign, not a separate column - Finite movements only:
chk_payment_reconciliation_amount_finiterejects PostgreSQL NUMERICNaN,Infinity, and-Infinityat the table boundary. This remains necessary for transaction-less zero-total import stubs, which intentionally bypass direction/cap validation but still feed payment aggregates - Validates that allocated amounts don't exceed transaction totals in MAGNITUDE —
abs(current_allocated + amount) > abs(transaction_total)raises (onlyActiverows on non-Cancelledpayments count toward the cap, self-excluded on UPDATE). The sign rules guarantee every contributing row shares one sign, soabs()of the sum is exact. Under the previous signed comparison a partial refund (-50against-100) was rejected while an unrelated-1000against+100was accepted - Direction agreement, enforced by
trg_check_payment_reconciliation_total: the amount must share the sign of the parent payment's total (every non-voided row, INCLUDINGtransaction_id IS NULLstubs; a ZERO-total payment states no direction and constrains nothing — the state import stubs and mid-recompute payments hold) and, when a transaction is linked, the sign of the transaction total. A NULL or zero transaction total RAISES rather than passing unchecked - Lock protocol: allocation writers first take a transaction-scoped advisory gate keyed by transaction id, then lock the transaction row and payment row.
schedule_payment_group, the finalcreate_transaction_payment_fenceddefinition, andprocess_payments_atomictake the same gate before parent rows. This gate is the serialization point between allocation's transaction → payment order and PostgreSQL's unavoidable payment → AFTER-trigger → transaction path. A row transitioning toVoidedstill returns first and takes no parent locks - Parent direction/cap guards:
trg_check_transaction_total_sign_flip(BEFORE UPDATE OFtotalon transactions) andtrg_check_payment_total_sign_flip(BEFORE UPDATE OFtotal_amounton payments) refuse a sign change while a live allocation depends on the original direction. They also refuse a same-sign shrink below the sum of live allocation magnitudes; equality and growth remain valid, and removing the last allocation before writing zero stays permitted - Promote-only Paid model: all three paid-marking triggers share
private.transaction_is_fully_settled(total, total_paid)— settled means money moved in the SAME direction as the total, reaching at least its magnitude. For a positive total this is exactly the previous strictest form, so the positive flow is unchanged; a negative total is now settled only when actually refunded (the oldtotal - total_paid <= 0was true for every UNALLOCATED refund, flipping it to Paid on the first reconciliation event), and a ZERO total is never auto-Paid - Atomic manual scheduling:
schedule_payment_group(p_existing_payment_id, p_payment, p_items)(SECURITY INVOKER) writes the payment and its whole allocation set in ONE transaction — complete or nothing. Authenticated calls require the exact operation key in addition to invoker RLS:payment:schedulefor Scheduled create/top-up andpayment:processfor fresh Paid; service workers retain their bypass. It validates the payload as a set (non-empty, no zero amounts, one distinct transaction per item, one shared direction, non-zero net), gates/locks every named transaction in id order before the payment, and requires every item to match project, currency, and role-derived payee. A top-up re-compares identity under its row lock and refuses an opposing direction. A newPaidpayment defaultspaid_attonow()and resolvespaid_by_user_idfrom explicit actor → payment creator →auth.uid(), raising if no actor exists. See VIEWS_AND_FUNCTIONS.md § Payment Scheduling Functions - Atomic Paid transition: every existing payment must enter
Paidthroughprocess_payments_atomic. The owner-only BEFORE trigger rejects direct authenticated/service-role UPDATEs before the paid-marking AFTER trigger can acquire locks in reverse order. The RPC checks exactpayment:process, forces an authenticated actor toauth.uid()and its paid timestamp to the database clock, and admits authenticated metadata only for a single-payment promotion tied to that caller's unfinished, same-project payments import run. Under lock it prevents established payee replacement, validates any supplied relationship's project/entity pair, and permits reconciliation stamps only when fully allocated; those stamps are normalized to the Paid timestamp andauth.uid(). Service/owner workers may supply an explicit actor and the fixed metadata allowlist. The RPC gates and locks affected transactions before payments, revalidates Scheduled status under lock, and commits status, paid stamps, optional method/notes, and permitted metadata in one UPDATE. Two partial payments settling one transaction therefore serialize and the second observes the first - Partial unique index on (payment_id, transaction_id)
WHERE status = 'Active'prevents duplicate live allocations while letting aVoidedrow coexist with a futureActiveone - Cascades deletes when parent payment is deleted
- Voiding a transaction soft-voids its reconciliations (
status = 'Voided'); the payment is cancelled outright if this was its onlyActiveallocation, otherwise the payment survives with its total recomputed from the remainingActiverows
RLS Policies:
- SELECT: Visible to anyone who can see the parent payment (scoped through
payments.payment_id, no extra permission) - INSERT / UPDATE / DELETE: Require the
payment:reconciliationspermission on the parent payment's project. The check resolvesproject_idthrough the parentpaymentsrow and callscaller_has_permission(ARRAY['payment:reconciliations'], p.project_id, false)inside the membershipEXISTS— mirroring thepayment_method_assignmentsparent-reach pattern. This is the server-side backstop: an active project member withoutpayment:reconciliationscannot create, amend, or remove allocation rows even by calling the server action directly. - Cascade note: Removing the last reconciliation on a payment deletes the now-empty parent payment (service layer), which runs under the same user's client. The
paymentsDELETE policy therefore admitspayment:reconciliationsalongsidepayment:schedule/payment:schedule:cancel, so a reconciler can complete that cascade.
Payment Method Assignments (payment_method_assignments)¶
Purpose: Links payment methods to specific project relationships (people), enabling the system to trace card/bank transactions back to the assigned person for reimbursement detection.
Use Case Example: Assign a company credit card ending in 4242 to crew member John Doe, with a digital card number (Apple Pay last 4 digits) of 5678, effective from 2026-01-01. When the AI processes a transaction with card ending 5678, it can identify John as the reimbursement person.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the assignment |
| project_relationship_id | UUID | Reference to the person's project relationship (FK to project_relationships) |
| payment_method_id | UUID | Reference to the payment method assigned (FK to payment_methods) |
| digital_card_number | TEXT | Last 4 digits of digital/virtual card (e.g., Apple Pay) if different (nullable) |
| start_date | DATE | Date the assignment becomes effective |
| end_date | DATE | Date the assignment ends (nullable, NULL = still active) |
| notes | TEXT | Additional notes about the assignment (nullable) |
| created_at | TIMESTAMPTZ | Timestamp when the assignment was created |
| updated_at | TIMESTAMPTZ | Timestamp when the assignment was last updated |
| created_by_user_id | UUID | User who created this assignment |
| updated_by_user_id | UUID | User who last updated this assignment |
Key Features:
- Links payment methods to specific people (crew/cast) within a project
- Supports digital card numbers for virtual/mobile payment variants (Apple Pay, Google Pay)
- Temporal validity with start_date/end_date for tracking assignment periods
- CHECK constraint:
end_date IS NULL OR end_date >= start_date - UNIQUE constraint on
(project_relationship_id, payment_method_id, COALESCE(digital_card_number, ''), COALESCE(start_date, '1970-01-01'))prevents exact duplicates - Indexes on project_relationship_id, payment_method_id, digital_card_number (partial), active assignments
- AI prompt enrichment: active assignments are sent to the AI for reimbursement person identification
RLS Policies:
- Project-based access via project_relationships -> v_user_accessible_projects
Import Mappings (import_mappings)¶
Purpose: Saved CSV column-to-field mappings shared across every import domain. Polymorphic over entity_type — today only 'payments' ships, but future domains ('budgets', 'transactions', …) drop their own rows here with zero schema changes. Domain-specific discriminators ride on the opaque domain_config JSONB column rather than dedicated columns, so adding a new domain is a data-only change.
There are three row kinds:
- Universal
genericfallback row —entity_type=NULL,domain_config=NULL,is_custom=false. Rendered by every domain whoseImportFlowConfig.canCreateCustomMappingsis true as the "no preset opinion" tile on the Provider step. - Per-domain built-ins —
entity_type='payments'(etc.),is_custom=false,project_id=NULL. Seeded by the migration with brand logo + example CSV attachments and a startermapping_json. Immutable from the app (UPDATE / DELETE RLS policies requireis_custom=true) — changing one takes a data migration, and the content pins insupabase/tests/17_import_mapping_contents.sqlmust be updated with it. Card built-ins anchorscheduled_dateon the provider's PURCHASE-date column (EqualsCreated date (UTC), RevolutDate started (UTC)), never the settlement date — card movements settle 1–2 days after purchase, and the import worker's date matching anchors onscheduled_date, so a settlement-date mapping makes every match miss and duplicates the payment. - User-saved customs —
is_custom=true,project_idset. Created from the wizard's "Save as custom preset" affordance. Editable + deletable by project members.
Use Case Example: A producer uploads a Revolut card statement; once they've manually mapped the columns and saved the mapping as "Revolut UK", the next import on the same project auto-picks the mapping by name (and by the card.revolut preset key). On a Bank Account import, the wizard looks up domain_config->>'payment_method_id' to find the saved mapping for the picked PM.
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| project_id | UUID | FK to projects(id) ON DELETE CASCADE. NULL for built-ins (global templates); NOT NULL for customs (enforced by the INSERT RLS policy + createImportMappingSchema) |
| entity_type | TEXT | Target table name ('payments' today; future 'budgets' etc.). NULL marks the universal generic built-in. Plain text — adding a domain is a data-only change |
| source_preset | TEXT | Built-in preset key this row is rooted in (generic, bank.generic, card.equals, card.revolut, …). The framework's preset registry resolves the key to a parser implementation |
| name | TEXT | User-supplied preset name. For entity_type='payments': required for card customs (per-project uniqueness via uq_import_mappings_card_name), NULL for bank customs and built-ins |
| description | TEXT | Optional free-text description shown on the saved-preset tile in the wizard. Not used in dedup / matching logic |
| provider | TEXT | Free-text provider tag matching payment_methods.provider (e.g. 'Equals', 'Revolut'). NULL marks provider-agnostic rows. The Provider step narrows candidates so an Equals statement only sees Equals + universal generic |
| is_custom | BOOLEAN | TRUE for user-saved customs; FALSE for the seeded built-ins. Built-ins are immutable from the app (UPDATE / DELETE RLS policies block is_custom=false rows). NOT NULL, default TRUE |
| logo_attachment_id | UUID | FK to attachments(id) ON DELETE SET NULL. Brand logo shown on the Provider step tile. Customs leave this NULL and fall back to initials |
| example_attachment_id | UUID | FK to attachments(id) ON DELETE SET NULL. Downloadable example CSV for the provider. NULL for customs and for built-ins until the asset ships |
| domain_config | JSONB | Opaque per-domain payload. NULL for the universal generic row and for any future domain whose preset shape needs no extra configuration. Payments stores {"payment_method_type": "Bank Account", "payment_method_id": "<uuid>"} for bank customs and {"payment_method_type": "Card"} for card rows. The DB enforces no schema — domain ownership lives in the app-side Zod schema (e.g. paymentsCreateImportMappingSchema) + the partial unique indexes reading domain_config->>'…' |
| mapping_json | JSONB | JSON object mapping CSV column header → ordered list of field keys that feed it. Multiple sources concatenate via ". " in array order. Keys correspond to import_fields.key rows for the active entity_type. NOT NULL |
| created_at | TIMESTAMPTZ | NOT NULL, default now() |
| updated_at | TIMESTAMPTZ | NOT NULL, default now() |
| created_by_user_id | UUID | FK to users(id). NULL for the seeded built-ins (system-authored); NOT NULL for customs |
| updated_by_user_id | UUID | FK to users(id). Same nullability rules as created_by_user_id |
Constraints:
- No DB-level shape CHECK. Domain shape rules (e.g. payments' bank-needs-PM-id, card-needs-name) live in the per-domain Zod schemas. Letting CHECK constraints carry domain logic would make adding a new domain a migration — keeping them off the DB makes it data-only.
Indexes:
idx_import_mappings_project_id— partial onproject_id WHERE project_id IS NOT NULL. List reads scoped to a project.idx_import_mappings_project_entity_type— partial(project_id, entity_type) WHERE project_id IS NOT NULL. The framework wizard always filters by both.idx_import_mappings_builtins_by_provider— partial(entity_type, provider) WHERE project_id IS NULL. Provider step's built-in tile lookup. Domain-agnostic — any future domain reads through this index.idx_import_mappings_customs_by_provider— partial(project_id, entity_type, provider) WHERE project_id IS NOT NULL. Per-project custom-tile lookup.idx_import_mappings_domain_payment_method_id— partial expression index on(domain_config->>'payment_method_id') WHERE domain_config ? 'payment_method_id'. Payments-specific: the bank flow's TARGET step asks "which custom was saved for this PM?". A future domain whosedomain_configcarries its own FK would add its own equivalent.idx_import_mappings_logo_attachment_id,idx_import_mappings_example_attachment_id— FK coverage.idx_import_mappings_created_by_user_id,idx_import_mappings_updated_by_user_id— FK coverage.uq_import_mappings_bank_per_pm— partial UNIQUE(project_id, entity_type, (domain_config->>'payment_method_id')) WHERE entity_type='payments' AND domain_config->>'payment_method_type'='Bank Account' AND is_custom=true. One bank mapping per (project, payment_method). Reads JSONB keys — adding a similar partial unique for another domain ships its own predicate.uq_import_mappings_card_name— partial UNIQUE(project_id, entity_type, lower(name)) WHERE entity_type='payments' AND domain_config->>'payment_method_type'='Card' AND is_custom=true. Case-insensitive name uniqueness within a project.
RLS Policies:
- SELECT: built-ins (
project_id IS NULL) visible to every authenticated user; customs gate on project membership viav_user_accessible_projects. - INSERT / UPDATE / DELETE:
is_custom=trueAND project membership (built-ins are immutable from the app).
Import Fields (import_fields)¶
Purpose: Catalog of destination fields per import entity_type. Drives the Mapping step UI and the parser-side row schema. One row per (entity_type, key). Adding a new field is one INSERT — no TS code change.
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| entity_type | TEXT | Target table name (matches import_mappings.entity_type / import_runs.entity_type). NOT NULL |
| key | TEXT | Stable field key referenced by parsers and mapping_json (e.g. 'scheduled_date'). Never localised. NOT NULL |
| label | TEXT | Human-readable label shown in the Mapping step's first column. NOT NULL |
| description | TEXT | Helper text shown under the label. NULL hides the helper line |
| required | BOOLEAN | TRUE if the field MUST be mapped before the wizard advances past Mapping. Gating is client-side (canAdvance) + server-side (the per-domain TS commit worker skips rows with required values NULL). Default FALSE |
| allow_multiple | BOOLEAN | TRUE if multiple file columns can feed this field (parser concatenates with ". " in pick order). Today only payments.description uses this. Default FALSE |
| value_kind | TEXT | Generic coercion contract. See CHECK constraint below |
| display_order | INTEGER | Render order within an entity_type. Gaps of 10 to leave room for inserts. NOT NULL |
| created_at | TIMESTAMPTZ | NOT NULL, default now() |
| updated_at | TIMESTAMPTZ | NOT NULL, default now() |
Constraints:
chk_import_fields_value_kind— closed framework-level CHECK:value_kind IN ('date','number','text','currency_code','external_id'). Domain-specific display tweaks (e.g. masked-PAN truncation for payments'card_last_fourfield) live in the per-domain typing layer keyed offfield.key, not by adding a domain-flavoured value_kind here.uq_import_fields_entity_key— UNIQUE(entity_type, key). One row per destination per domain.
Coercion contract for value_kind:
| Kind | Output |
|---|---|
date |
ISO yyyy-mm-dd (parsed via per-preset dateFormats) |
number |
JS number (parses thousands separators + parens-for-negative) |
text |
Trimmed string |
currency_code |
ISO 4217 currency code (GBP / USD / …) |
external_id |
Opaque dedupe key (provider's own transaction ID) |
RLS Policies:
- SELECT: granted to
authenticated(catalog is global, read-only from the app). - INSERT / UPDATE / DELETE: not exposed to the app. Mutations go through migrations under
service_role.
Import Runs (import_runs)¶
Purpose: Per-import audit log. One row per import run, written by the per-domain TS commit worker. The source file is stored as a private attachment (source_attachment_id) so the run can be replayed; per-row outcomes live in row_outcomes JSONB (no child table — bounded by file size).
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| project_id | UUID | FK to projects(id) ON DELETE CASCADE. NOT NULL |
| entity_type | TEXT | Target table name (matches import_mappings.entity_type / import_fields.entity_type). Default 'payments'. Lets per-domain audit lists filter to their surface without joining import_mappings |
| import_mapping_id | UUID | FK to import_mappings(id) ON DELETE RESTRICT. NOT NULL — every run is bound to the parser config that produced it; the mapping cannot be deleted while runs reference it. The mapping carries source_preset + domain_config; reads that need those values join through this FK |
| domain_config | JSONB | Opaque per-domain JSONB (mirrors import_mappings.domain_config). Payments stuffs {payment_method_id} for bank-flow runs; future domains store their own keys. Queryable via PostgREST ->> for narrow lookups (e.g. idx_import_runs_payment_method_id expression index) |
| source_attachment_id | UUID | FK to attachments(id) ON DELETE RESTRICT. NOT NULL. The source file stored in a private bucket. Replaying or auditing the run reads bytes via this FK; ON DELETE RESTRICT so we never lose the source while the run still exists |
| total_rows | INTEGER | Total rows in the source file BEFORE the preset's include-filter ran. Includes rows the parser dropped (wrong Type / Pending status / etc.). Always ≥ processed_rows |
| processed_rows | INTEGER | Rows handed to the commit worker after the include-filter. On a clean run: processed_rows = created_rows + updated_rows + skipped_rows + errored_rows. ≤ total_rows |
| created_rows | INTEGER | Rows the worker INSERTed as new payments. For Payments: new_payment + fee_linked + cash_bump + unmatched (needs-review). matched_paid / matched_scheduled_promoted count as updates instead |
| updated_rows | INTEGER | Rows the worker UPDATEd in place (e.g. Scheduled → Paid promotions on existing payments) |
| skipped_rows | INTEGER | Rows the worker dropped intentionally — per-row reason in row_outcomes (missing_required_field, user_skipped, already_imported, parent_payment_not_committed, …) |
| errored_rows | INTEGER | Rows that raised an unexpected error mid-run. Indicates data-quality issues, not worker bugs |
| status | import_run_status | Enum: 'success' (errored_rows = 0), 'partial' (0 < errored_rows < processed_rows), 'failed' (worker threw before reaching the finaliser OR errored_rows ≥ processed_rows). The framework's runImport lifecycle helper picks the value |
| error_summary | JSONB | Error envelope when status = 'failed'. NULL otherwise |
| row_outcomes | JSONB | Per-row audit array — opaque per-domain shape. Payments uses {row_index, action, payment_id?, transaction_id?, score?, reason?, suggestions?[]} with action ∈ {matched_paid, matched_scheduled_promoted, new_payment, fee_linked, cash_bump, unmatched, skipped, errored}. To list "which file lines were skipped" filter the array for action === 'skipped'. Loaded only on the run-detail page (potentially MB-sized); never selected in list views |
| started_at | TIMESTAMPTZ | NOT NULL, default now(). When the commit worker started executing |
| finished_at | TIMESTAMPTZ | When the commit worker reached the finaliser. NULL if the run is still in-flight |
| created_at | TIMESTAMPTZ | NOT NULL, default now(). Record creation timestamp |
| created_by_user_id | UUID | NOT NULL, default auth.uid(). FK to users(id). User who triggered the import |
Constraints:
chk_import_runs_counts_nonneg—total_rows,processed_rows,created_rows,updated_rows,skipped_rows,errored_rowsall ≥ 0.
Indexes:
idx_import_runs_project_id— list per project.idx_import_runs_import_mapping_id— FK coverage onimport_mapping_id. Audit-by-mapping.idx_import_runs_payment_method_id— JSONB expression index on(domain_config->>'payment_method_id') WHERE domain_config ? 'payment_method_id'. Payments-side lookup: "runs for this PM."idx_import_runs_source_attachment_id— FK coverage onsource_attachment_id.idx_import_runs_created_by_user_id— FK coverage.idx_import_runs_project_entity_started_at— composite(project_id, entity_type, started_at DESC). Backs the per-domain "recent runs" list (each domain's import page scopes by its ownentity_type).
RLS Policies:
- SELECT: project membership via
v_user_accessible_projects. - INSERT / UPDATE: written by each domain's TS commit worker (Server Action) on every import run.
- DELETE: not exposed (immutable audit log).