import { MigrationInterface, QueryRunner } from "typeorm"; /** * Module 4.2 — receivables and revenue. * * Two tables, and neither of them stores revenue. That is the point: revenue * LIVES in the source systems (freight invoices, passenger bookings) and is * read from there by projection. Finance only stores the two things the source * systems cannot supply — * * 1. `revenue_mappings` — which GL account a given charge type earns into. * Today that knowledge lives in freight's report code as a hard-coded SQL * CASE with fourteen categories, which means adding a charge type requires * a deploy. Here it is data. * * 2. `inbound_events` — every payment event this service has received, with * what it did about it. Delivery is at-least-once, so the unique index on * `event_id` is what makes reprocessing harmless; and an event that could * NOT be posted (no mapping, closed period, unknown currency) is recorded * as FAILED rather than dropped, because a payment that vanishes silently * is the worst outcome available. * * All DDL is idempotent, per the house rule. */ export class FinanceReceivables3700000000002 implements MigrationInterface { name = "FinanceReceivables3700000000002"; public async up(queryRunner: QueryRunner): Promise { // ── revenue_mappings ────────────────────────────────────────────────── await queryRunner.query(` CREATE TABLE IF NOT EXISTS "finance"."revenue_mappings" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "organization_id" uuid NOT NULL, "source_module" varchar(32) NOT NULL, "match_value" varchar(64) NOT NULL, "account_id" uuid NOT NULL REFERENCES "finance"."accounts"("id") ON DELETE RESTRICT, "revenue_category" varchar(48) NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "notes" text, "created_by" uuid, "created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" timestamptz, "deleted_at" timestamptz, CONSTRAINT "ck_revenue_mappings_source" CHECK ("source_module" IN ('freight','passenger','payment')) ) `); // One mapping per charge type per source. Without this a charge type could // resolve to two accounts and the same revenue would post to whichever the // query happened to return first. await queryRunner.query(` CREATE UNIQUE INDEX IF NOT EXISTS "uq_revenue_mappings_lookup" ON "finance"."revenue_mappings" ("organization_id", "source_module", "match_value") WHERE "deleted_at" IS NULL `); await queryRunner.query(` CREATE INDEX IF NOT EXISTS "idx_revenue_mappings_organization_id" ON "finance"."revenue_mappings" ("organization_id") `); await queryRunner.query(` CREATE INDEX IF NOT EXISTS "idx_revenue_mappings_account_id" ON "finance"."revenue_mappings" ("account_id") `); // ── inbound_events ──────────────────────────────────────────────────── await queryRunner.query(` CREATE TABLE IF NOT EXISTS "finance"."inbound_events" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "event_id" varchar(128) NOT NULL, "routing_key" varchar(64) NOT NULL, "organization_id" uuid, "source_module" varchar(32), "source_id" varchar(128), "payload" jsonb NOT NULL, "status" varchar(16) NOT NULL DEFAULT 'RECEIVED', "journal_entry_id" uuid REFERENCES "finance"."journal_entries"("id") ON DELETE SET NULL, "error" text, "attempts" integer NOT NULL DEFAULT 0, "received_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, "processed_at" timestamptz, "created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" timestamptz, CONSTRAINT "ck_inbound_events_status" CHECK ("status" IN ('RECEIVED','POSTED','SKIPPED','FAILED')) ) `); /** * THE idempotency guard. * * The broker guarantees at-least-once, so the same payment event WILL * arrive twice — on redelivery after a consumer restart, or when the * publisher's outbox retries. Inserting this row first means the second * delivery collides here and is acknowledged without posting anything, so * one payment can never become two journal entries. */ await queryRunner.query(` CREATE UNIQUE INDEX IF NOT EXISTS "uq_inbound_events_event_id" ON "finance"."inbound_events" ("event_id") `); await queryRunner.query(` CREATE INDEX IF NOT EXISTS "idx_inbound_events_status" ON "finance"."inbound_events" ("status") `); await queryRunner.query(` CREATE INDEX IF NOT EXISTS "idx_inbound_events_source" ON "finance"."inbound_events" ("source_module", "source_id") `); await queryRunner.query(` CREATE INDEX IF NOT EXISTS "idx_inbound_events_received_at" ON "finance"."inbound_events" ("received_at") `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE IF EXISTS "finance"."inbound_events"`); await queryRunner.query(`DROP TABLE IF EXISTS "finance"."revenue_mappings"`); } }