This commit is contained in:
natib21
2026-06-29 15:09:34 +00:00
77 changed files with 4308 additions and 1839 deletions

View File

@@ -0,0 +1,101 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Freight billing — `invoices` + `invoice_lines` tables.
*
* Matches:
* - billing/entities/invoice.entity.ts
* - billing/entities/invoice-line.entity.ts
*
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
* column resolves to it without an explicit `enumName`.
*/
export class CreateInvoices1821000000002 implements MigrationInterface {
name = "CreateInvoices1821000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
await queryRunner.query(`
CREATE TABLE freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
company_profile_id uuid NOT NULL,
total_amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
source varchar(255) NOT NULL,
source_id varchar(255) NOT NULL,
type varchar(255) NOT NULL,
issued_at timestamptz,
payment_id uuid,
due_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoices PRIMARY KEY (id),
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
REFERENCES freight.companies (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
REFERENCES freight.payments (id) ON DELETE SET NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
description varchar(255),
quantity numeric(12, 2) NOT NULL DEFAULT 1,
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
amount numeric(14, 2) NOT NULL,
currency varchar(8) NOT NULL DEFAULT 'ETB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
REFERENCES freight.invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
}
}

View File

@@ -0,0 +1,129 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Government bookings now bill to a real seeded government company + an explicit
* importer/exporter profile, instead of carrying a null company + free-text
* institution. This migration:
*
* 1. Adds `companies.kind` (commercial | government).
* 2. Seeds the Ethiopian government entities + their importer/exporter
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
* 3. Backfills every booking with a NULL company_id / company_profile_id so
* the NOT NULL constraints below can be applied:
* - NULL company_id → the default government company.
* - NULL company_profile_id → the company's profile matching the booking
* trade direction; else any profile of the company; else the default
* government importer profile.
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
*/
export class AddCompanyKindAndGovBookingLinks1821000000003
implements MigrationInterface
{
name = "AddCompanyKindAndGovBookingLinks1821000000003";
// Mirrors src/seed/data/gov-companies.data.ts
private readonly govCompanies = [
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
];
private get defaultCompanyId(): string {
return this.govCompanies[0].id;
}
private get defaultImporterProfileId(): string {
return this.govCompanies[0].im;
}
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. kind column
await queryRunner.query(
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
);
// 2. seed government companies + importer/exporter profiles (idempotent)
for (const g of this.govCompanies) {
await queryRunner.query(
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
ON CONFLICT ("id") DO NOTHING`,
[g.id, g.name, g.tin, g.email, g.phone],
);
await queryRunner.query(
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
ON CONFLICT ("id") DO NOTHING`,
[g.im, g.id, g.imRef, g.ex, g.exRef],
);
}
// 3a. backfill NULL company_id → default government company
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
[this.defaultCompanyId],
);
// 3b. backfill NULL company_profile_id → profile matching trade direction
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = cp."id"
FROM "freight"."company_profiles" cp
WHERE b."company_profile_id" IS NULL
AND cp."company_id" = b."company_id"
AND cp."deleted_at" IS NULL
AND cp."type" = CASE b."trade_direction"
WHEN 'IMPORT' THEN 'importer'
WHEN 'EXPORT' THEN 'exporter'
ELSE NULL END`,
);
// 3c. fallback → any profile of the booking's company
await queryRunner.query(
`UPDATE "freight"."bookings" b
SET "company_profile_id" = (
SELECT cp."id" FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
ORDER BY cp."created_at" ASC LIMIT 1)
WHERE b."company_profile_id" IS NULL
AND EXISTS (
SELECT 1 FROM "freight"."company_profiles" cp
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
);
// 3d. final fallback → default government importer profile
await queryRunner.query(
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
[this.defaultImporterProfileId],
);
// 4. enforce NOT NULL
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
);
await queryRunner.query(
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
);
// Seeded government rows are intentionally left in place.
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Make the payment projection source-agnostic so any domain (not just bookings)
* can own a payment intent.
*
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
* new domain no longer needs an enum migration to write its intents.
* - adds `payments.reference_type varchar(40)` — the gateway reference type
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
* path can query the provider without hardcoding it.
*
* Matches payment/entities/payment.entity.ts.
*/
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
name = "MakePaymentsTypeGeneric1821000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
);
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
await queryRunner.query(
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
);
// Restore the single-value enum. Any non-'booking' rows would block the cast;
// collapse them first so the down migration is safe.
await queryRunner.query(
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
);
await queryRunner.query(
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
);
await queryRunner.query(
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
);
}
}