Files
edr-platform/apps/finance-api/src/migrations/3700000000002-FinanceReceivables.ts
2026-08-25 00:11:39 +03:00

122 lines
5.6 KiB
TypeScript

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<void> {
// ── 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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."inbound_events"`);
await queryRunner.query(`DROP TABLE IF EXISTS "finance"."revenue_mappings"`);
}
}