mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 03:53:39 +00:00
122 lines
5.6 KiB
TypeScript
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"`);
|
|
}
|
|
}
|