import { MigrationInterface, QueryRunner } from 'typeorm'; /** * Fold warehouse fee invoices into the central billing system. * * Warehouse fee invoices are no longer a standalone aggregate: each becomes a * global `freight.invoices` row (`source = 'warehouse'`, `source_id = * inventory_id`) with its items as `freight.invoice_lines`. The warehouse * service is now a thin layer over `BillingService`. This migration backfills the * existing rows (preserving ids, numbers, status, amounts and payment history), * then drops the two legacy tables. * * Rows that cannot be billed centrally — no company to bill (`company_id` / * `company_profile_id` underivable from the customer or the booking) — are not * migrated; they could never have been charged through the gateway and are * dropped with the table. */ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { name = 'CentralizeWarehouseInvoices1829000000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'freight' AND table_name = 'invoices' AND column_name = 'booking_id' ) THEN ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'freight' AND table_name = 'invoices' AND column_name = 'amount' ) THEN ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; END IF; END $$; `); // 1. Invoice headers. Keep the same id so items still link, and so any // external reference to the invoice id stays valid. await queryRunner.query(` INSERT INTO freight.invoices ( id, invoice_number, company_id, company_profile_id, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, currency, status, source, source_id, type, issued_at, paid_at, payments, payment_id, due_at, created_at, updated_at, deleted_at ) SELECT fee.id, fee.invoice_number, COALESCE(fee.customer_id, b.company_id), COALESCE( b.company_profile_id, (SELECT cp.id FROM freight.company_profiles cp WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) AND cp.deleted_at IS NULL ORDER BY cp.created_at ASC LIMIT 1) ), fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, fee.currency, fee.status::freight.invoices_status_enum, 'warehouse', fee.inventory_id, fee.invoice_type, fee.issued_at, fee.paid_at, COALESCE(fee.payments, '[]'::jsonb), NULL, COALESCE(fee.due_date, fee.issued_at, fee.created_at), fee.created_at, fee.updated_at, fee.deleted_at FROM freight.warehouse_fee_invoices fee LEFT JOIN freight.bookings b ON b.id = fee.booking_id WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL AND COALESCE( b.company_profile_id, (SELECT cp.id FROM freight.company_profiles cp WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) AND cp.deleted_at IS NULL ORDER BY cp.created_at ASC LIMIT 1) ) IS NOT NULL ON CONFLICT (id) DO NOTHING; `); // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse // fee fields (fee_rule_id / chargeable_days / free_days) move into the // line's jsonb metadata. await queryRunner.query(` INSERT INTO freight.invoice_lines ( id, invoice_id, charge_type, description, quantity, unit_rate, amount, currency, metadata, created_at, updated_at, deleted_at ) SELECT item.id, item.invoice_id, item.fee_type, item.description, item.quantity, item.unit_rate, item.amount, item.currency, jsonb_build_object( 'feeRuleId', item.fee_rule_id, 'chargeableDays', item.chargeable_days, 'freeDays', item.free_days ), item.created_at, item.updated_at, item.deleted_at FROM freight.warehouse_fee_invoice_items item JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' ON CONFLICT (id) DO NOTHING; `); // 3. Drop the legacy tables (items first — FK to invoices). await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); } public async down(queryRunner: QueryRunner): Promise { // Recreate the legacy tables … await queryRunner.query(` CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( id uuid NOT NULL DEFAULT uuid_generate_v4(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, invoice_number varchar(40) NOT NULL, booking_id uuid, customer_id uuid, inventory_id uuid NOT NULL, facility_id uuid, warehouse_id uuid, yard_id uuid, zone_id uuid, invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', status varchar(20) NOT NULL DEFAULT 'DRAFT', subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, tax_amount numeric(14,2) NOT NULL DEFAULT 0, total_amount numeric(14,2) NOT NULL DEFAULT 0, paid_amount numeric(14,2) NOT NULL DEFAULT 0, balance_amount numeric(14,2) NOT NULL DEFAULT 0, currency varchar(8) NOT NULL DEFAULT 'USD', period_start timestamptz, period_end timestamptz, issued_at timestamptz, due_date timestamptz, paid_at timestamptz, cancelled_at timestamptz, payments jsonb NOT NULL DEFAULT '[]', notes text, CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) ); `); await queryRunner.query( `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, ); await queryRunner.query( `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, ); await queryRunner.query( `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, ); await queryRunner.query(` CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( id uuid NOT NULL DEFAULT uuid_generate_v4(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, invoice_id uuid NOT NULL, fee_rule_id uuid, fee_type varchar(32) NOT NULL, description varchar(255) NOT NULL, quantity numeric(12,2) NOT NULL DEFAULT 1, unit_rate numeric(14,2) NOT NULL DEFAULT 0, amount numeric(14,2) NOT NULL DEFAULT 0, currency varchar(8) NOT NULL DEFAULT 'USD', chargeable_days int, free_days int, CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE ); `); await queryRunner.query( `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, ); // … then copy the warehouse-source invoices back, deriving the typed FKs and // period from the linked inventory item. await queryRunner.query(` INSERT INTO freight.warehouse_fee_invoices ( id, created_at, updated_at, deleted_at, invoice_number, booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes ) SELECT i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, i.payments, NULL FROM freight.invoices i LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id WHERE i.source = 'warehouse' ON CONFLICT (id) DO NOTHING; `); await queryRunner.query(` INSERT INTO freight.warehouse_fee_invoice_items ( id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, description, quantity, unit_rate, amount, currency, chargeable_days, free_days ) SELECT l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, NULLIF(l.metadata->>'feeRuleId', '')::uuid, l.charge_type, COALESCE(l.description, ''), l.quantity, l.unit_rate, l.amount, l.currency, NULLIF(l.metadata->>'chargeableDays', '')::int, NULLIF(l.metadata->>'freeDays', '')::int FROM freight.invoice_lines l JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' ON CONFLICT (id) DO NOTHING; `); // Remove the migrated rows from the central tables. await queryRunner.query(` DELETE FROM freight.invoice_lines WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); `); await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); } }