diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 1560cd54..a923cb24 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,6 +1,6 @@ import { pgTable, pgEnum, serial, text, timestamp, varchar, - integer, decimal, boolean, json, jsonb, bigint, index, unique, real, uuid, date + integer, decimal, boolean, json, jsonb, bigint, index, unique, uniqueIndex, real, uuid, date, check } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -1000,7 +1000,7 @@ export type InsertMojaloopTransaction = typeof mojaloopTransactions.$inferInsert export const tbEntryTypeEnum = pgEnum("tb_entry_type", [ "duty_payment", "vat_payment", "levy_payment", "penalty", "bond_deposit", "bond_release", - "drawback_credit", "refund", "adjustment", + "drawback_credit", "refund", "adjustment", "excise_stamp_liability", ]); export const tbEntryStatusEnum = pgEnum("tb_entry_status", [ @@ -3406,3 +3406,320 @@ export const lpcoRecords = pgTable("lpco_records", { ]); export type LPCORecord = typeof lpcoRecords.$inferSelect; export type InsertLPCORecord = typeof lpcoRecords.$inferInsert; + +// ─── EXCISE LICENSING AND DIGITAL TAX STAMPS ───────────────────────────────── + +export const exciseLicenseeTypeEnum = pgEnum("excise_licensee_type", [ + "manufacturer", "importer", "distributor", "retailer", +]); +export const exciseLicenseStatusEnum = pgEnum("excise_license_status", [ + "pending", "active", "suspended", "expired", "revoked", +]); +export const exciseApprovalStatusEnum = pgEnum("excise_approval_status", [ + "pending", "approved", "rejected", +]); +export const exciseSchemeTypeEnum = pgEnum("excise_scheme_type", [ + "specific", "ad_valorem", "hybrid", +]); +export const exciseOrderStatusEnum = pgEnum("excise_order_status", [ + "ordered", "assessed", "payment", "fulfilment", "delivery", "cancelled", +]); +export const exciseMarkStatusEnum = pgEnum("excise_mark_status", [ + "issued", "active", "retired", +]); +export const exciseRetirementReasonEnum = pgEnum("excise_retirement_reason", [ + "wastage", "spoilage", "destruction", "seizure", "other", +]); +export const exciseAggregateTypeEnum = pgEnum("excise_aggregate_type", [ + "carton", "case", "pallet", +]); +export const exciseMovementTypeEnum = pgEnum("excise_movement_type", [ + "dispatch", "receipt", "export", "re_entry", "seizure", "destruction", + "disaggregation", +]); +export const exciseScanSourceEnum = pgEnum("excise_scan_source", [ + "public", "enforcement", +]); + +export const exciseLicences = pgTable("excise_licences", { + id: serial("id").primaryKey(), + licenseNumber: varchar("license_number", { length: 128 }).notNull().unique(), + userId: integer("user_id").notNull().references(() => users.id), + licenseeType: exciseLicenseeTypeEnum("licensee_type").notNull(), + economicOperatorId: varchar("economic_operator_id", { length: 64 }).notNull().unique(), + productCategories: json("product_categories").$type().notNull(), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until").notNull(), + status: exciseLicenseStatusEnum("status").default("pending").notNull(), + suspendedBy: integer("suspended_by").references(() => users.id), + suspendedAt: timestamp("suspended_at"), + suspensionReason: text("suspension_reason"), + approvedBy: integer("approved_by").references(() => users.id), + approvedAt: timestamp("approved_at"), + revokedBy: integer("revoked_by").references(() => users.id), + revokedAt: timestamp("revoked_at"), + revocationReason: text("revocation_reason"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_licence_user").on(t.userId), + index("idx_excise_licence_status").on(t.status), + index("idx_excise_licence_validity").on(t.validFrom, t.validUntil), +]); +export type ExciseLicence = typeof exciseLicences.$inferSelect; +export type InsertExciseLicence = typeof exciseLicences.$inferInsert; + +export const exciseLicenceSuspensions = pgTable("excise_licence_suspensions", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + suspendedBy: integer("suspended_by").notNull().references(() => users.id), + suspendedAt: timestamp("suspended_at").defaultNow().notNull(), + reason: text("reason").notNull(), + liftedAt: timestamp("lifted_at"), + liftedBy: integer("lifted_by").references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_suspension_licence").on(t.licenceId), +]); + +export const exciseFacilities = pgTable("excise_facilities", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + facilityIdentifier: varchar("facility_identifier", { length: 64 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + address: text("address"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_facility_licence").on(t.licenceId), +]); +export type ExciseFacility = typeof exciseFacilities.$inferSelect; + +export const exciseMarkingMachines = pgTable("excise_marking_machines", { + id: serial("id").primaryKey(), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + machineIdentifier: varchar("machine_identifier", { length: 64 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_machine_facility").on(t.facilityId), +]); +export type ExciseMarkingMachine = typeof exciseMarkingMachines.$inferSelect; + +export const exciseTaxSchemes = pgTable("excise_tax_schemes", { + id: serial("id").primaryKey(), + code: varchar("code", { length: 64 }).notNull().unique(), + schemeType: exciseSchemeTypeEnum("scheme_type").notNull(), + specificAmount: decimal("specific_amount", { precision: 15, scale: 6 }), + specificUnitOfMeasure: varchar("specific_unit_of_measure", { length: 32 }), + adValoremRate: decimal("ad_valorem_rate", { precision: 9, scale: 6 }), + hybridWhicheverGreater: boolean("hybrid_whichever_greater").default(false).notNull(), + currency: varchar("currency", { length: 3 }), + active: boolean("active").default(true).notNull(), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); +export type ExciseTaxScheme = typeof exciseTaxSchemes.$inferSelect; + +export const exciseProducts = pgTable("excise_products", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + sku: varchar("sku", { length: 128 }).notNull().unique(), + brand: varchar("brand", { length: 255 }).notNull(), + packSize: integer("pack_size").notNull(), + unitContent: decimal("unit_content", { precision: 15, scale: 6 }).notNull(), + unitOfMeasure: varchar("unit_of_measure", { length: 32 }).notNull(), + strength: decimal("strength", { precision: 15, scale: 6 }), + schemeId: integer("scheme_id").notNull().references(() => exciseTaxSchemes.id), + approvalStatus: exciseApprovalStatusEnum("approval_status").default("pending").notNull(), + approvedBy: integer("approved_by").references(() => users.id), + approvedAt: timestamp("approved_at"), + rejectionReason: text("rejection_reason"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_product_licence").on(t.licenceId), + index("idx_excise_product_status").on(t.approvalStatus), +]); +export type ExciseProduct = typeof exciseProducts.$inferSelect; + +export const exciseStampOrders = pgTable("excise_stamp_orders", { + id: serial("id").primaryKey(), + orderNumber: varchar("order_number", { length: 64 }).notNull().unique(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + declarationId: integer("declaration_id").references(() => declarations.id), + quantity: integer("quantity").notNull(), + declaredValue: decimal("declared_value", { precision: 15, scale: 2 }), + liability: decimal("liability", { precision: 15, scale: 2 }), + currency: varchar("currency", { length: 3 }).notNull(), + status: exciseOrderStatusEnum("status").default("ordered").notNull(), + paymentIdempotencyKey: varchar("payment_idempotency_key", { length: 128 }).unique(), + ledgerTransferId: varchar("ledger_transfer_id", { length: 128 }), + assessedAt: timestamp("assessed_at"), + paidAt: timestamp("paid_at"), + fulfilledAt: timestamp("fulfilled_at"), + deliveredAt: timestamp("delivered_at"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_order_licence").on(t.licenceId), + index("idx_excise_order_declaration").on(t.declarationId), + index("idx_excise_order_status").on(t.status), +]); +export type ExciseStampOrder = typeof exciseStampOrders.$inferSelect; + +export const exciseStampMarks = pgTable("excise_stamp_marks", { + id: serial("id").primaryKey(), + uid: varchar("uid", { length: 192 }).notNull().unique(), + payload: varchar("payload", { length: 128 }).notNull(), + signature: varchar("signature", { length: 64 }).notNull(), + keyId: varchar("key_id", { length: 32 }).notNull(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + machineId: integer("machine_id").references(() => exciseMarkingMachines.id), + status: exciseMarkStatusEnum("status").default("issued").notNull(), + issuedAt: timestamp("issued_at").defaultNow().notNull(), + activatedAt: timestamp("activated_at"), + retiredAt: timestamp("retired_at"), + retirementReason: exciseRetirementReasonEnum("retirement_reason"), + retirementDetails: text("retirement_details"), +}, (t) => [ + index("idx_excise_mark_order").on(t.orderId), + index("idx_excise_mark_status").on(t.status), +]); +export type ExciseStampMark = typeof exciseStampMarks.$inferSelect; + +export const exciseMarkActivations = pgTable("excise_mark_activations", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().unique().references(() => exciseStampMarks.id), + activatedBy: integer("activated_by").notNull().references(() => users.id), + activatedAt: timestamp("activated_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseProductionReports = pgTable("excise_production_reports", { + id: serial("id").primaryKey(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + quantity: integer("quantity").notNull(), + reportedBy: integer("reported_by").notNull().references(() => users.id), + reportedAt: timestamp("reported_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseRetirements = pgTable("excise_retirements", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().references(() => exciseStampMarks.id), + reason: exciseRetirementReasonEnum("reason").notNull(), + details: text("details"), + retiredBy: integer("retired_by").notNull().references(() => users.id), + retiredAt: timestamp("retired_at").defaultNow().notNull(), +}); + +export const exciseAggregates = pgTable("excise_aggregates", { + id: serial("id").primaryKey(), + aggregateUid: varchar("aggregate_uid", { length: 192 }).notNull().unique(), + aggregateType: exciseAggregateTypeEnum("aggregate_type").notNull(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + parentAggregateId: integer("parent_aggregate_id"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseAggregateChildren = pgTable("excise_aggregate_children", { + id: serial("id").primaryKey(), + aggregateId: integer("aggregate_id").notNull().references(() => exciseAggregates.id), + childMarkId: integer("child_mark_id").references(() => exciseStampMarks.id), + childAggregateId: integer("child_aggregate_id").references(() => exciseAggregates.id), + addedBy: integer("added_by").notNull().references(() => users.id), + addedAt: timestamp("added_at").defaultNow().notNull(), + removedBy: integer("removed_by").references(() => users.id), + removedAt: timestamp("removed_at"), +}, (t) => [ + uniqueIndex("uq_excise_active_child_mark").on(t.childMarkId).where(sql`${t.removedAt} IS NULL`), + uniqueIndex("uq_excise_active_child_aggregate").on(t.childAggregateId).where(sql`${t.removedAt} IS NULL`), + index("idx_excise_children_aggregate").on(t.aggregateId), + check("ck_excise_aggregate_child_exactly_one", sql`(child_mark_id IS NOT NULL) <> (child_aggregate_id IS NOT NULL)`), +]); + +export const exciseMovementEvents = pgTable("excise_movement_events", { + id: serial("id").primaryKey(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + aggregateId: integer("aggregate_id").references(() => exciseAggregates.id), + eventType: exciseMovementTypeEnum("event_type").notNull(), + actorId: integer("actor_id").notNull().references(() => users.id), + location: text("location"), + latitude: real("latitude"), + longitude: real("longitude"), + occurredAt: timestamp("occurred_at").defaultNow().notNull(), + metadata: json("metadata"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_movement_mark").on(t.markId), + index("idx_excise_movement_aggregate").on(t.aggregateId), + index("idx_excise_movement_time").on(t.occurredAt), + check("ck_excise_movement_subject_exactly_one", sql`(mark_id IS NOT NULL) <> (aggregate_id IS NOT NULL)`), +]); + +export const exciseScans = pgTable("excise_scans", { + id: serial("id").primaryKey(), + uid: varchar("uid", { length: 192 }).notNull(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + source: exciseScanSourceEnum("source").notNull(), + scannedBy: integer("scanned_by").references(() => users.id), + localityHash: varchar("locality_hash", { length: 128 }), + latitude: real("latitude"), + longitude: real("longitude"), + scannedAt: timestamp("scanned_at").defaultNow().notNull(), + previousScanId: integer("previous_scan_id"), + impliedSpeedKmh: decimal("implied_speed_kmh", { precision: 12, scale: 2 }), + impossibleTravel: boolean("impossible_travel").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_scan_uid").on(t.uid), + index("idx_excise_scan_mark").on(t.markId), + index("idx_excise_scan_time").on(t.scannedAt), +]); + +export const exciseSeizures = pgTable("excise_seizures", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().references(() => exciseStampMarks.id), + seizedBy: integer("seized_by").notNull().references(() => users.id), + location: text("location"), + reason: text("reason").notNull(), + seizedAt: timestamp("seized_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseReconciliationReports = pgTable("excise_reconciliation_reports", { + id: serial("id").primaryKey(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + issuedQuantity: integer("issued_quantity").notNull(), + activatedQuantity: integer("activated_quantity").notNull(), + retiredQuantity: integer("retired_quantity").notNull(), + stillIssuedQuantity: integer("still_issued_quantity").notNull(), + reportedProductionQuantity: integer("reported_production_quantity").notNull(), + stampVariance: integer("stamp_variance").notNull(), + productionVariance: integer("production_variance").notNull(), + computedAt: timestamp("computed_at").defaultNow().notNull(), + computedBy: integer("computed_by").notNull().references(() => users.id), +}); + +export const exciseAnomalies = pgTable("excise_anomalies", { + id: serial("id").primaryKey(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + orderId: integer("order_id").references(() => exciseStampOrders.id), + anomalyType: varchar("anomaly_type", { length: 64 }).notNull(), + details: json("details"), + detectedAt: timestamp("detected_at").defaultNow().notNull(), +}); diff --git a/server/_core/index.ts b/server/_core/index.ts index 97f799d7..add47895 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -26,7 +26,7 @@ import { sanitizeMiddleware } from "./sanitize"; import { closeKafka } from "./kafka"; import { setupWebSocketServer, broadcastVesselUpdate } from "./wsServer"; import { sdk } from "./sdk"; -import { validateWebhookSecrets } from "./webhookSecretsValidator"; +import { validateWebhookSecrets, validateExciseUidKey } from "./webhookSecretsValidator"; // ── Rate limiting ───────────────────────────────────────────────────────────── // General tRPC API: 200 requests per minute per IP @@ -1183,6 +1183,7 @@ async function runPermifySeedOnStartup() { async function startServer() { validateWebhookSecrets(); + validateExciseUidKey(); const app = express(); // Trust the reverse proxy (Manus/nginx) so express-rate-limit reads the correct client IP app.set('trust proxy', 1); diff --git a/server/_core/webhookSecretsValidator.ts b/server/_core/webhookSecretsValidator.ts index 98e344bc..72f5586c 100644 --- a/server/_core/webhookSecretsValidator.ts +++ b/server/_core/webhookSecretsValidator.ts @@ -36,6 +36,21 @@ const WEBHOOK_SECRETS: WebhookSecretConfig[] = [ { envVar: "SANCTIONS_WEBHOOK_SECRET", description: "Sanctions screening result webhook" }, ]; +export const EXCISE_UID_HMAC_ENV = "EXCISE_UID_HMAC_KEY"; +export const EXCISE_UID_KEY_ID_ENV = "EXCISE_UID_KEY_ID"; + +export function validateExciseUidKey(): void { + const value = process.env[EXCISE_UID_HMAC_ENV]; + const isProduction = process.env.NODE_ENV === "production"; + const invalid = !value || value.trim() === "" || value.length < 32 || + DEV_SECRET_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern.toLowerCase())); + if (!invalid) return; + + const message = `[ExciseUid] ${EXCISE_UID_HMAC_ENV} must be a strong random key of at least 32 characters.`; + if (isProduction) throw new Error(`=== FATAL: ${message} ===`); + console.warn(`[WARN] ${message} UID minting will remain unavailable.`); +} + /** * Validates all webhook secrets are set and not using known dev defaults. * In development (NODE_ENV !== 'production'), this only warns. diff --git a/server/excise.behavior.test.ts b/server/excise.behavior.test.ts new file mode 100644 index 00000000..e5809078 --- /dev/null +++ b/server/excise.behavior.test.ts @@ -0,0 +1,557 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { eq, inArray } from "drizzle-orm"; +import { appRouter } from "./routers"; +import { getDb } from "./db"; +import type { TrpcContext } from "./_core/context"; +import { + exciseAggregateChildren, + exciseAggregates, + exciseAnomalies, + exciseFacilities, + exciseLicenceSuspensions, + exciseLicences, + exciseMarkActivations, + exciseMarkingMachines, + exciseMovementEvents, + exciseProducts, + exciseProductionReports, + exciseReconciliationReports, + exciseRetirements, + exciseScans, + exciseSeizures, + exciseStampMarks, + exciseStampOrders, + exciseTaxSchemes, + declarations, + billsOfLading, + manifests, + tigerBeetleLedgerEntries, +} from "../drizzle/schema"; +import { mintExciseUid } from "./routers/excise"; + +const ledgerMocks = vi.hoisted(() => ({ + available: vi.fn(async () => true), + fetch: vi.fn(async (path: string, options?: RequestInit) => { + if (options?.method === "POST") return { id: `excise-transfer-${Date.now()}` }; + return { id: path.split("/").pop() }; + }), +})); + +vi.mock("./routers/ledger", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + tbBridgeAvailable: ledgerMocks.available, + tbFetch: ledgerMocks.fetch, + }; +}); + +type Fixture = { + licenceId: number; + facilityId: number; + schemeId: number; + productId: number; + orderIds: number[]; + declarationIds: number[]; + markIds: number[]; + aggregateIds: number[]; + transferIds: string[]; + scanUids: string[]; + billIds: number[]; + manifestIds: number[]; +}; + +const fixtures: Fixture[] = []; + +function caller(role: "user" | "admin" | "customs_officer" = "user", userId = 1) { + const context: TrpcContext = { + user: { + id: userId, + openId: `excise-behaviour-${userId}`, + name: "Excise Behaviour Test", + email: "excise-behaviour@example.test", + loginMethod: "test", + role, + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { method: "POST", headers: {}, cookies: {} } as TrpcContext["req"], + res: { clearCookie: vi.fn(), cookie: vi.fn() } as unknown as TrpcContext["res"], + }; + return appRouter.createCaller(context); +} + +async function database() { + const db = await getDb(); + if (!db) throw new Error("Postgres is required for excise behaviour tests."); + return db; +} + +async function makeFixture(options: { + licenceStatus?: "pending" | "active" | "suspended" | "revoked" | "expired"; + validUntil?: Date; + orderStatus?: "ordered" | "assessed" | "payment" | "fulfilment" | "delivery"; + quantity?: number; + declaration?: boolean; +} = {}) { + const db = await database(); + const now = new Date(); + const [licence] = await db.insert(exciseLicences).values({ + licenseNumber: `EXC-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + userId: 1, + licenseeType: "importer", + economicOperatorId: `EO-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + productCategories: ["beverages"], + validFrom: new Date(now.getTime() - 60_000), + validUntil: options.validUntil ?? new Date(now.getTime() + 86_400_000), + status: options.licenceStatus ?? "active", + }).returning(); + const fixture: Fixture = { + licenceId: licence.id, + facilityId: 0, + schemeId: 0, + productId: 0, + orderIds: [], + declarationIds: [], + markIds: [], + aggregateIds: [], + transferIds: [], + scanUids: [], + billIds: [], + manifestIds: [], + }; + fixtures.push(fixture); + + const [facility] = await db.insert(exciseFacilities).values({ + licenceId: licence.id, + facilityIdentifier: `FI-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name: "Behaviour Test Facility", + createdBy: 1, + }).returning(); + fixture.facilityId = facility.id; + + const [scheme] = await db.insert(exciseTaxSchemes).values({ + code: `SCHEME-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + schemeType: "specific", + specificAmount: "1.00", + specificUnitOfMeasure: "unit", + currency: "GHS", + createdBy: 1, + }).returning(); + fixture.schemeId = scheme.id; + + const [product] = await db.insert(exciseProducts).values({ + licenceId: licence.id, + sku: `SKU-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + brand: "Behaviour Test Product", + packSize: 1, + unitContent: "1", + unitOfMeasure: "unit", + schemeId: scheme.id, + approvalStatus: "approved", + approvedBy: 1, + approvedAt: now, + createdBy: 1, + }).returning(); + fixture.productId = product.id; + + if (options.declaration) { + const [declaration] = await db.insert(declarations).values({ + declarationNumber: `DEC-EXC-${Math.random().toString(36).slice(2, 14)}`, + ucr: `UCR-EXC-${Math.random().toString(36).slice(2, 14)}`, + traderId: 1, + principalId: 1, + declarationType: "import", + invoiceCurrency: "GHS", + totalDue: "100.00", + }).returning(); + fixture.declarationIds.push(declaration.id); + } + + const [order] = await db.insert(exciseStampOrders).values({ + orderNumber: `EXO-TEST-${Date.now()}-${Math.random().toString(16).slice(2)}`, + licenceId: licence.id, + productId: product.id, + facilityId: facility.id, + declarationId: fixture.declarationIds[0], + quantity: options.quantity ?? 1, + declaredValue: "100.00", + liability: "1.00", + currency: "GHS", + status: options.orderStatus ?? "fulfilment", + createdBy: 1, + }).returning(); + fixture.orderIds.push(order.id); + return { db, fixture, licence, facility, scheme, product, order, declarationId: fixture.declarationIds[0] }; +} + +async function cleanup() { + const db = await getDb(); + if (!db) return; + for (const fixture of fixtures.splice(0)) { + if (fixture.orderIds.length) { + const orderMarks = await db.select({ id: exciseStampMarks.id }) + .from(exciseStampMarks) + .where(inArray(exciseStampMarks.orderId, fixture.orderIds)); + fixture.markIds.push(...orderMarks.map((mark) => mark.id)); + } + if (fixture.markIds.length) { + await db.delete(exciseAnomalies).where(inArray(exciseAnomalies.markId, fixture.markIds)); + await db.delete(exciseScans).where(inArray(exciseScans.markId, fixture.markIds)); + await db.delete(exciseSeizures).where(inArray(exciseSeizures.markId, fixture.markIds)); + await db.delete(exciseMovementEvents).where(inArray(exciseMovementEvents.markId, fixture.markIds)); + await db.delete(exciseAggregateChildren).where(inArray(exciseAggregateChildren.childMarkId, fixture.markIds)); + await db.delete(exciseMarkActivations).where(inArray(exciseMarkActivations.markId, fixture.markIds)); + await db.delete(exciseRetirements).where(inArray(exciseRetirements.markId, fixture.markIds)); + await db.delete(exciseStampMarks).where(inArray(exciseStampMarks.id, fixture.markIds)); + } + if (fixture.scanUids.length) { + await db.delete(exciseScans).where(inArray(exciseScans.uid, fixture.scanUids)); + } + if (fixture.aggregateIds.length) { + await db.delete(exciseMovementEvents).where(inArray(exciseMovementEvents.aggregateId, fixture.aggregateIds)); + await db.delete(exciseAggregateChildren).where(inArray(exciseAggregateChildren.aggregateId, fixture.aggregateIds)); + await db.delete(exciseAggregates).where(inArray(exciseAggregates.id, fixture.aggregateIds)); + } + if (fixture.orderIds.length) { + await db.delete(exciseReconciliationReports).where(inArray(exciseReconciliationReports.orderId, fixture.orderIds)); + await db.delete(exciseProductionReports).where(inArray(exciseProductionReports.orderId, fixture.orderIds)); + await db.delete(tigerBeetleLedgerEntries).where(inArray(tigerBeetleLedgerEntries.reference, fixture.orderIds.map((id) => `EXO-TEST-${id}`))); + if (fixture.transferIds.length) { + await db.delete(tigerBeetleLedgerEntries).where(inArray(tigerBeetleLedgerEntries.tbTransferId, fixture.transferIds)); + } + await db.delete(exciseStampOrders).where(inArray(exciseStampOrders.id, fixture.orderIds)); + } + if (fixture.declarationIds.length) { + await db.delete(tigerBeetleLedgerEntries).where(inArray(tigerBeetleLedgerEntries.declarationId, fixture.declarationIds)); + await db.delete(declarations).where(inArray(declarations.id, fixture.declarationIds)); + } + if (fixture.billIds.length) { + await db.delete(billsOfLading).where(inArray(billsOfLading.id, fixture.billIds)); + } + if (fixture.manifestIds.length) { + await db.delete(manifests).where(inArray(manifests.id, fixture.manifestIds)); + } + await db.delete(exciseProducts).where(eq(exciseProducts.id, fixture.productId)); + await db.delete(exciseTaxSchemes).where(eq(exciseTaxSchemes.id, fixture.schemeId)); + await db.delete(exciseMarkingMachines).where(eq(exciseMarkingMachines.facilityId, fixture.facilityId)); + await db.delete(exciseFacilities).where(eq(exciseFacilities.id, fixture.facilityId)); + await db.delete(exciseLicenceSuspensions).where(eq(exciseLicenceSuspensions.licenceId, fixture.licenceId)); + await db.delete(exciseLicences).where(eq(exciseLicences.id, fixture.licenceId)); + } +} + +afterEach(async () => { + ledgerMocks.available.mockResolvedValue(true); + ledgerMocks.fetch.mockClear(); + delete process.env.EXCISE_UID_HMAC_KEY; + delete process.env.EXCISE_UID_KEY_ID; + await cleanup(); +}); + +describe.sequential("excise money and lifecycle behaviour", () => { + it("posts one transfer for repeated payOrder calls", async () => { + process.env.EXCISE_UID_HMAC_KEY = "a".repeat(64); + const { fixture, order } = await makeFixture({ orderStatus: "assessed" }); + const first = await caller().excise.payOrder({ orderId: order.id }); + const second = await caller().excise.payOrder({ orderId: order.id }); + expect(first.status).toBe("payment"); + expect(second.status).toBe("payment"); + expect(ledgerMocks.fetch.mock.calls.filter(([path, options]) => path === "/api/ledger/transfers" && options?.method === "POST")).toHaveLength(1); + expect(first.ledgerTransferId).toBe(second.ledgerTransferId); + fixture.transferIds.push(first.ledgerTransferId!); + + const recovery = await makeFixture({ orderStatus: "assessed" }); + const recoveredTransferId = `excise-recovered-${recovery.order.id}`; + await recovery.db.insert(tigerBeetleLedgerEntries).values({ + tbTransferId: recoveredTransferId, + debitAccountId: "trader-1", + creditAccountId: "ncs-revenue-account", + amountMinorUnits: 100, + currency: "GHS", + entryType: "excise_stamp_liability", + status: "posted", + reference: recovery.order.orderNumber, + metadata: { idempotencyKey: `excise:pay:${recovery.order.id}` }, + postedAt: new Date(), + }); + const postCountBeforeRecovery = ledgerMocks.fetch.mock.calls.filter(([path, options]) => + path === "/api/ledger/transfers" && options?.method === "POST").length; + const recovered = await caller().excise.payOrder({ orderId: recovery.order.id }); + expect(recovered.ledgerTransferId).toBe(recoveredTransferId); + expect(ledgerMocks.fetch.mock.calls.filter(([path, options]) => + path === "/api/ledger/transfers" && options?.method === "POST")).toHaveLength(postCountBeforeRecovery); + recovery.fixture.transferIds.push(recoveredTransferId); + }); + + it("refuses currency-mismatched, unsettled, and unavailable duty settlement", async () => { + const mismatched = await makeFixture({ declaration: true, orderStatus: "payment" }); + const [mismatchEntry] = await mismatched.db.insert(tigerBeetleLedgerEntries).values({ + tbTransferId: `tb-mismatch-${Date.now()}`, + debitAccountId: "trader-1", + creditAccountId: "ncs-revenue-account", + amountMinorUnits: 10_000, + currency: "USD", + entryType: "duty_payment", + status: "posted", + declarationId: mismatched.declarationId, + reference: "duty-mismatch", + }).returning(); + await expect(caller().excise.fulfilOrder({ orderId: mismatched.order.id })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + await mismatched.db.delete(tigerBeetleLedgerEntries).where(eq(tigerBeetleLedgerEntries.id, mismatchEntry.id)); + + const unsettled = await makeFixture({ declaration: true, orderStatus: "payment" }); + await expect(caller().excise.fulfilOrder({ orderId: unsettled.order.id })).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + ledgerMocks.available.mockResolvedValue(false); + const unavailableLedger = await makeFixture({ declaration: true, orderStatus: "payment" }); + await expect(caller().excise.fulfilOrder({ orderId: unavailableLedger.order.id })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + }); + + it("rejects terminal order re-entry and action through expired or suspended licences", async () => { + const terminal = await makeFixture({ orderStatus: "delivery" }); + await expect(caller().excise.deliverOrder({ orderId: terminal.order.id })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + const expired = await makeFixture({ licenceStatus: "expired", validUntil: new Date(Date.now() - 1_000) }); + await expect(caller().excise.createAggregate({ licenceId: expired.licence.id, aggregateType: "case" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + + const suspended = await makeFixture({ licenceStatus: "suspended" }); + await expect(caller().excise.createAggregate({ licenceId: suspended.licence.id, aggregateType: "case" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("does not approve a revoked licence", async () => { + const { licence } = await makeFixture({ licenceStatus: "revoked" }); + await expect(caller("customs_officer", 2).excise.approveLicence({ licenceId: licence.id })).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); + + it("activates a mark idempotently", async () => { + process.env.EXCISE_UID_HMAC_KEY = "a".repeat(64); + const { db, fixture, order } = await makeFixture({ quantity: 1 }); + const signed = mintExciseUid(); + const [mark] = await db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: order.id, + productId: fixture.productId, + facilityId: fixture.facilityId, + status: "issued", + }).returning(); + fixture.markIds.push(mark.id); + const first = await caller().excise.activateMark({ uid: mark.uid }); + const second = await caller().excise.activateMark({ uid: mark.uid }); + expect(first.status).toBe("active"); + expect(second.status).toBe("active"); + expect(await db.select().from(exciseMarkActivations).where(eq(exciseMarkActivations.markId, mark.id))).toHaveLength(1); + }); + + it("resumes minting and does not over-mint racing calls", async () => { + process.env.EXCISE_UID_HMAC_KEY = "b".repeat(64); + process.env.EXCISE_UID_KEY_ID = "test-mint"; + const { fixture, order } = await makeFixture({ quantity: 6 }); + const first = await caller().excise.mintMarks({ orderId: order.id, batchSize: 2 }); + expect(first.mintedCount).toBe(2); + const results = await Promise.allSettled([ + caller().excise.mintMarks({ orderId: order.id, batchSize: 10 }), + caller().excise.mintMarks({ orderId: order.id, batchSize: 10 }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const db = await database(); + const marks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + if (marks.length < 6) { + await caller().excise.mintMarks({ orderId: order.id, batchSize: 10 }); + } + const completedMarks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + expect(completedMarks).toHaveLength(6); + expect(new Set(completedMarks.map((mark) => mark.uid)).size).toBe(6); + fixture.markIds.push(...completedMarks.map((mark) => mark.id)); + }); + + it("refuses a mark in a second live aggregate", async () => { + process.env.EXCISE_UID_HMAC_KEY = "b".repeat(64); + const { db, fixture, order, licence } = await makeFixture(); + const signed = mintExciseUid(); + const [mark] = await db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: order.id, + productId: fixture.productId, + facilityId: fixture.facilityId, + status: "issued", + }).returning(); + fixture.markIds.push(mark.id); + const first = await caller().excise.createAggregate({ licenceId: licence.id, aggregateType: "case" }); + const second = await caller().excise.createAggregate({ licenceId: licence.id, aggregateType: "case" }); + fixture.aggregateIds.push(first.id, second.id); + await caller().excise.addToAggregate({ aggregateId: first.id, markId: mark.id }); + await expect(caller().excise.addToAggregate({ aggregateId: second.id, markId: mark.id })).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("reports zero stamp and production variance for a clean order", async () => { + process.env.EXCISE_UID_HMAC_KEY = "c".repeat(64); + const { db, fixture, order } = await makeFixture({ quantity: 3 }); + const marks = []; + for (let index = 0; index < 3; index += 1) { + const signed = mintExciseUid(); + const [mark] = await db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: order.id, + productId: fixture.productId, + facilityId: fixture.facilityId, + status: index === 0 ? "active" : index === 1 ? "retired" : "issued", + activatedAt: index === 0 ? new Date() : undefined, + retiredAt: index === 1 ? new Date() : undefined, + }).returning(); + marks.push(mark); + fixture.markIds.push(mark.id); + } + await db.insert(exciseProductionReports).values({ + orderId: order.id, + productId: fixture.productId, + facilityId: fixture.facilityId, + quantity: 1, + reportedBy: 1, + }); + const report = await caller().excise.reconcileOrder({ orderId: order.id }); + expect(report.stampVariance).toBe(0); + expect(report.productionVariance).toBe(0); + }); + + it("keeps public verification status-only and fails closed when signing is unavailable", async () => { + process.env.EXCISE_UID_HMAC_KEY = "c".repeat(64); + const signed = mintExciseUid(); + const result = await caller().excise.publicVerify({ uid: signed.uid }); + expect(Object.keys(result)).toEqual(["status"]); + expect(result.status).toBe("unknown"); + + delete process.env.EXCISE_UID_HMAC_KEY; + const unavailable = await caller().excise.publicVerify({ uid: signed.uid }); + expect(unavailable).toEqual({ status: "unavailable" }); + }); + + it("retains both scans and flags the mark for impossible travel", async () => { + process.env.EXCISE_UID_HMAC_KEY = "d".repeat(64); + const signed = mintExciseUid(); + const fixture = await makeFixture(); + fixture.fixture.scanUids.push(signed.uid); + await caller().excise.publicVerify({ uid: signed.uid, latitude: 0, longitude: 0 }); + const result = await caller().excise.publicVerify({ uid: signed.uid, latitude: 0, longitude: 1 }); + expect(result.status).toBe("unknown"); + const db = await database(); + const scans = await db.select().from(exciseScans).where(eq(exciseScans.uid, signed.uid)); + expect(scans).toHaveLength(2); + expect(scans.some((scan) => scan.impossibleTravel)).toBe(true); + expect(scans[1].previousScanId).toBe(scans[0].id); + }); + + it("returns distinct source-link outcomes and permits self-filed traversal", async () => { + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: "missing-excise-mark" })).resolves.toMatchObject({ + available: false, + reason: "mark_not_found", + }); + process.env.EXCISE_UID_HMAC_KEY = "e".repeat(64); + const noDeclaration = await makeFixture(); + const noDeclarationUid = mintExciseUid(); + const [noDeclarationMark] = await noDeclaration.db.insert(exciseStampMarks).values({ + uid: noDeclarationUid.uid, + payload: noDeclarationUid.payload, + signature: noDeclarationUid.signature, + keyId: noDeclarationUid.keyId, + orderId: noDeclaration.order.id, + productId: noDeclaration.fixture.productId, + facilityId: noDeclaration.fixture.facilityId, + status: "issued", + }).returning(); + noDeclaration.fixture.markIds.push(noDeclarationMark.id); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: noDeclarationMark.uid })).resolves.toMatchObject({ + available: false, + reason: "declaration_missing", + }); + const linked = await makeFixture({ declaration: true }); + const declarationId = linked.declarationId!; + const linkedDeclaration = await linked.db.select().from(declarations).where(eq(declarations.id, declarationId)).limit(1); + expect(linkedDeclaration).toHaveLength(1); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: noDeclarationMark.uid })).resolves.toMatchObject({ + reason: "declaration_missing", + }); + const signed = mintExciseUid(); + const [mark] = await linked.db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: linked.order.id, + productId: linked.fixture.productId, + facilityId: linked.fixture.facilityId, + status: "issued", + }).returning(); + linked.fixture.markIds.push(mark.id); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_not_linked", + }); + await linked.db.update(declarations).set({ billOfLadingNumber: "BL-NOT-FILED" }).where(eq(declarations.id, declarationId)); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_missing", + }); + + const [manifestOne] = await linked.db.insert(manifests).values({ + manifestNumber: `MAN-EXC-${Date.now()}-1`, + manifestType: "IMPORT", + submittedBy: 1, + vesselName: "MV Ambiguous", + voyageNumber: "V1", + portOfLoading: "Lagos", + portOfDischarge: "Tema", + }).returning(); + const [manifestTwo] = await linked.db.insert(manifests).values({ + manifestNumber: `MAN-EXC-${Date.now()}-2`, + manifestType: "IMPORT", + submittedBy: 1, + vesselName: "MV Ambiguous", + voyageNumber: "V2", + portOfLoading: "Lagos", + portOfDischarge: "Tema", + }).returning(); + linked.fixture.manifestIds.push(manifestOne.id, manifestTwo.id); + const [billOne] = await linked.db.insert(billsOfLading).values({ + manifestId: manifestOne.id, + blNumber: "BL-AMBIGUOUS", + shipper: "Test Shipper", + consignee: "Test Consignee", + description: "Test goods", + }).returning(); + const [billTwo] = await linked.db.insert(billsOfLading).values({ + manifestId: manifestTwo.id, + blNumber: "BL-AMBIGUOUS", + shipper: "Test Shipper", + consignee: "Test Consignee", + description: "Test goods", + }).returning(); + linked.fixture.billIds.push(billOne.id, billTwo.id); + await linked.db.update(declarations).set({ billOfLadingNumber: "BL-AMBIGUOUS" }).where(eq(declarations.id, declarationId)); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_ambiguous", + }); + + await linked.db.update(declarations).set({ billOfLadingId: billOne.id, billOfLadingNumber: "BL-AMBIGUOUS", actingAgentId: null }).where(eq(declarations.id, declarationId)); + const traversed = await caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid }); + expect(traversed).toMatchObject({ + available: true, + importerUserId: 1, + actingAgentUserId: null, + billOfLading: { id: billOne.id }, + manifest: { id: manifestOne.id }, + }); + }); +}); diff --git a/server/excise.test.ts b/server/excise.test.ts new file mode 100644 index 00000000..2c6893b6 --- /dev/null +++ b/server/excise.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + IMPOSSIBLE_TRAVEL_SPEED_KMH, + calculateImpossibleTravelSpeedKmh, + calculateExciseLiability, + mintExciseUid, + verifyExciseUid, +} from "./routers/excise"; + +const originalKey = process.env.EXCISE_UID_HMAC_KEY; +const originalKeyId = process.env.EXCISE_UID_KEY_ID; +const originalKeys = process.env.EXCISE_UID_HMAC_KEYS; +const originalIssuedKeyIds = process.env.EXCISE_UID_ISSUED_KEY_IDS; + +afterEach(() => { + if (originalKey === undefined) delete process.env.EXCISE_UID_HMAC_KEY; + else process.env.EXCISE_UID_HMAC_KEY = originalKey; + if (originalKeyId === undefined) delete process.env.EXCISE_UID_KEY_ID; + else process.env.EXCISE_UID_KEY_ID = originalKeyId; + if (originalKeys === undefined) delete process.env.EXCISE_UID_HMAC_KEYS; + else process.env.EXCISE_UID_HMAC_KEYS = originalKeys; + if (originalIssuedKeyIds === undefined) delete process.env.EXCISE_UID_ISSUED_KEY_IDS; + else process.env.EXCISE_UID_ISSUED_KEY_IDS = originalIssuedKeyIds; +}); + +describe("excise digital marks", () => { + it("mints unique, non-sequential signed UIDs", () => { + process.env.EXCISE_UID_HMAC_KEY = "a".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-1"; + const first = mintExciseUid(); + const second = mintExciseUid(); + + expect(first.uid).not.toBe(second.uid); + expect(first.uid).not.toMatch(/000001|000002/); + expect(verifyExciseUid(first.uid)).toEqual({ + status: "signature_valid_pending_reconciliation", + keyId: "rotation-1", + }); + }); + + it("refuses missing, short, and development placeholder signing keys", () => { + delete process.env.EXCISE_UID_HMAC_KEY; + expect(() => mintExciseUid()).toThrow(); + process.env.EXCISE_UID_HMAC_KEY = "dev-excise-key"; + expect(() => mintExciseUid()).toThrow(); + process.env.EXCISE_UID_HMAC_KEY = "b".repeat(64); + expect(verifyExciseUid("v1.random.invalid").status).toBe("invalid_signature"); + process.env.EXCISE_UID_KEY_ID = "issued-but-unavailable"; + process.env.EXCISE_UID_ISSUED_KEY_IDS = "issued-but-unavailable"; + delete process.env.EXCISE_UID_HMAC_KEY; + expect(verifyExciseUid("issued-but-unavailable.random.invalid").status).toBe("verification_unavailable"); + }); + + it("verifies marks signed by a retained rotated key", () => { + process.env.EXCISE_UID_HMAC_KEY = "c".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-2"; + const previous = mintExciseUid(); + process.env.EXCISE_UID_HMAC_KEY = "d".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-3"; + process.env.EXCISE_UID_HMAC_KEYS = JSON.stringify({ "rotation-2": "c".repeat(64) }); + expect(verifyExciseUid(previous.uid).status).toBe("signature_valid_pending_reconciliation"); + delete process.env.EXCISE_UID_HMAC_KEYS; + }); + + it("calculates fiscal liability on the server from the tax scheme", () => { + expect(calculateExciseLiability({ + schemeType: "specific", + specificAmount: "2.50", + adValoremRate: null, + hybridWhicheverGreater: false, + }, { unitContent: "1", unitOfMeasure: "unit" }, 4, undefined)).toBe("10.00"); + expect(calculateExciseLiability({ + schemeType: "hybrid", + specificAmount: "1.00", + adValoremRate: "10", + hybridWhicheverGreater: true, + }, { unitContent: "1", unitOfMeasure: "unit" }, 2, "100.00")).toBe("20.00"); + }); + + it("flags impossible travel only when implied speed exceeds the threshold", () => { + const previous = { latitude: 0, longitude: 0, scannedAt: new Date("2024-01-01T00:00:00Z") }; + const below = calculateImpossibleTravelSpeedKmh(previous, { + latitude: 0, longitude: 1, scannedAt: new Date("2024-01-01T02:00:00Z"), + }); + const above = calculateImpossibleTravelSpeedKmh(previous, { + latitude: 0, longitude: 1, scannedAt: new Date("2024-01-01T00:30:00Z"), + }); + expect(below).not.toBeNull(); + expect(below!).toBeLessThan(IMPOSSIBLE_TRAVEL_SPEED_KMH); + expect(above!).toBeGreaterThan(IMPOSSIBLE_TRAVEL_SPEED_KMH); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 3b92e789..c8783f6c 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -80,6 +80,7 @@ import { redisRouter } from "./routers/redis"; import { kafkaEventsRouter } from "./routers/kafkaEvents"; import { ogaPermitAuditRouter } from "./routers/ogaPermitAudit"; import { temporalRunsRouter } from "./routers/temporalRuns"; +import { exciseRouter } from "./routers/excise"; import { openAppSecRouter } from "./routers/openAppSec"; import { corazaWafRouter } from "./routers/corazaWaf"; import { heartbeatAdminRouter } from "./routers/heartbeatAdmin"; @@ -333,6 +334,7 @@ export const appRouter = router({ kafkaEvents: kafkaEventsRouter, ogaPermitAudit: ogaPermitAuditRouter, temporalRuns: temporalRunsRouter, + excise: exciseRouter, openAppSec: openAppSecRouter, corazaWaf: corazaWafRouter, heartbeatAdmin: heartbeatAdminRouter, diff --git a/server/routers/excise.ts b/server/routers/excise.ts new file mode 100644 index 00000000..6294359f --- /dev/null +++ b/server/routers/excise.ts @@ -0,0 +1,1211 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; +import { TRPCError } from "@trpc/server"; +import { and, asc, count, desc, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; +import { + exciseAggregateChildren, + exciseAggregates, + exciseAnomalies, + exciseFacilities, + exciseLicenceSuspensions, + exciseLicences, + exciseMarkActivations, + exciseMarkingMachines, + exciseMovementEvents, + exciseProducts, + exciseProductionReports, + exciseReconciliationReports, + exciseRetirements, + exciseScans, + exciseSeizures, + exciseStampMarks, + exciseStampOrders, + exciseTaxSchemes, + declarations, + billsOfLading, + manifests, + tigerBeetleLedgerEntries, +} from "../../drizzle/schema"; +import { getDb, logAuditEvent, createLedgerEntry } from "../db"; +import { protectedProcedure, publicRateLimitedProcedure, router } from "../_core/trpc"; +import { tbBridgeAvailable, tbFetch } from "./ledger"; +import { SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; +import { + EXCISE_UID_HMAC_ENV, + EXCISE_UID_KEY_ID_ENV, +} from "../_core/webhookSecretsValidator"; +import { acquireLock, releaseLock } from "../_core/distributedLock"; + +const REVIEWER_ROLES = new Set(["admin", "customs_officer", "oga_officer"]); +const ID_ISSUER_ROLES = new Set(["admin", "customs_officer"]); +const ENFORCEMENT_ROLES = new Set(["admin", "customs_officer", "oga_officer", "inspector"]); +const AGGREGATE_LEVEL: Record<"carton" | "case" | "pallet", number> = { carton: 1, case: 2, pallet: 3 }; +export const EXCISE_MINT_BATCH_CAP = 5_000; +const EXCISE_MINT_CHUNK_SIZE = 500; + +// 120 km/h is above plausible road/rail movement for a tax mark, while avoiding +// false positives from ordinary city-to-city commercial transport. +export const IMPOSSIBLE_TRAVEL_SPEED_KMH = 120; + +export type ExcisePublicStatus = "authentic" | "unknown" | "suspect" | "unavailable"; + +export type ExciseTraversalUnavailableReason = + | "mark_not_found" + | "order_missing" + | "declaration_missing" + | "bill_of_lading_not_linked" + | "bill_of_lading_ambiguous" + | "bill_of_lading_missing" + | "manifest_missing" + | "manifest_vessel_missing" + | "importer_missing" + | "acting_agent_missing"; + +function unavailable(message: string, cause?: unknown): never { + if (cause instanceof TRPCError) throw cause; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message, cause }); +} + +async function requireDb() { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise database is unavailable." }); + return db; +} + +function isOfficer(role: string): boolean { + return REVIEWER_ROLES.has(role); +} + +function isIdIssuer(role: string): boolean { + return ID_ISSUER_ROLES.has(role); +} + +function isEnforcement(role: string): boolean { + return ENFORCEMENT_ROLES.has(role); +} + +async function requireLicence( + licenceId: number, + userId: number, + role: string, + requireActive = true, +) { + const db = await requireDb(); + const [licence] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, licenceId)).limit(1); + if (!licence) throw new TRPCError({ code: "NOT_FOUND", message: "Excise licence not found." }); + if (!isOfficer(role) && licence.userId !== userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + if (requireActive) { + const now = new Date(); + if (licence.status !== "active" || licence.validFrom > now || licence.validUntil <= now) { + throw new TRPCError({ code: "FORBIDDEN", message: "The excise licence is not currently valid." }); + } + } + return { db, licence }; +} + +function authorityIdentifier(prefix: string): string { + return `TG-${prefix}-${randomBytes(12).toString("hex").toUpperCase()}`; +} + +function parseScaled(value: string, scale = 6): bigint { + const normalized = value.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid decimal amount." }); + const [whole, fraction = ""] = normalized.split("."); + if (fraction.length > scale) throw new TRPCError({ code: "BAD_REQUEST", message: "Decimal precision is too high." }); + return BigInt(whole) * (10n ** BigInt(scale)) + BigInt(fraction.padEnd(scale, "0") || "0"); +} + +function formatMoney(cents: bigint): string { + const negative = cents < 0n; + const absolute = negative ? -cents : cents; + return `${negative ? "-" : ""}${absolute / 100n}.${(absolute % 100n).toString().padStart(2, "0")}`; +} + +export function calculateExciseLiability( + scheme: { + schemeType: "specific" | "ad_valorem" | "hybrid"; + specificAmount: string | null; + specificUnitOfMeasure?: string | null; + adValoremRate: string | null; + hybridWhicheverGreater: boolean; + }, + product: { unitContent: string; unitOfMeasure?: string }, + quantity: number, + declaredValue: string | undefined, +): string { + if (scheme.specificUnitOfMeasure && product.unitOfMeasure && scheme.specificUnitOfMeasure !== product.unitOfMeasure) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The tax scheme unit does not match the product unit." }); + } + const scale = 1_000_000n; + const specific = scheme.specificAmount + ? parseScaled(scheme.specificAmount) * parseScaled(product.unitContent) * BigInt(quantity) / scale + : null; + const adValorem = scheme.adValoremRate && declaredValue + ? parseScaled(declaredValue) * parseScaled(scheme.adValoremRate) * BigInt(quantity) / (scale * 100n) + : null; + if (scheme.schemeType === "specific" && specific !== null) { + return formatMoney((specific + 5_000n) / 10_000n); + } + if (scheme.schemeType === "ad_valorem" && adValorem !== null) { + return formatMoney((adValorem + 5_000n) / 10_000n); + } + if (scheme.schemeType === "hybrid" && specific !== null && adValorem !== null) { + const chosen = scheme.hybridWhicheverGreater + ? (specific > adValorem ? specific : adValorem) + : specific + adValorem; + return formatMoney((chosen + 5_000n) / 10_000n); + } + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "The tax scheme is missing the values required for this assessment.", + }); +} + +export function verifyExciseUid(uid: string): { + status: "signature_valid_pending_reconciliation" | "invalid_signature" | "verification_unavailable"; + keyId: string | null; +} { + const parts = uid.split("."); + if (parts.length !== 3) return { status: "invalid_signature", keyId: null }; + const [keyId, nonce, signature] = parts; + const key = getExciseKey(keyId); + if (!key) return { status: isKnownExciseKeyId(keyId) ? "verification_unavailable" : "invalid_signature", keyId }; + if (!isStrongExciseKey(key)) return { status: "verification_unavailable", keyId }; + const expected = createHmac("sha256", key).update(`${keyId}.${nonce}`).digest("hex").slice(0, 32); + const valid = expected.length === signature.length && + timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + return { + status: valid ? "signature_valid_pending_reconciliation" : "invalid_signature", + keyId, + }; +} + +function getExciseKey(keyId: string): string | undefined { + const configuredKeyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (keyId === configuredKeyId) return process.env[EXCISE_UID_HMAC_ENV]; + const rotatedKey = process.env[`${EXCISE_UID_HMAC_ENV}_${keyId}`]; + if (rotatedKey) return rotatedKey; + const configuredKeys = process.env.EXCISE_UID_HMAC_KEYS; + if (!configuredKeys) return undefined; + try { + const keys: unknown = JSON.parse(configuredKeys); + if (typeof keys !== "object" || keys === null || Array.isArray(keys)) return undefined; + const candidate = (keys as Record)[keyId]; + return typeof candidate === "string" ? candidate : undefined; + } catch { + return undefined; + } +} + +function isKnownExciseKeyId(keyId: string): boolean { + const configuredKeyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (keyId === configuredKeyId) return true; + const issuedKeyIds = (process.env.EXCISE_UID_ISSUED_KEY_IDS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if (issuedKeyIds.includes(keyId)) return true; + if (Object.prototype.hasOwnProperty.call(process.env, `${EXCISE_UID_HMAC_ENV}_${keyId}`)) return true; + const configuredKeys = process.env.EXCISE_UID_HMAC_KEYS; + if (!configuredKeys) return false; + try { + const keys: unknown = JSON.parse(configuredKeys); + return typeof keys === "object" && keys !== null && !Array.isArray(keys) && + Object.prototype.hasOwnProperty.call(keys, keyId); + } catch { + return false; + } +} + +function isStrongExciseKey(value: string | undefined): value is string { + if (!value || value.length < 32) return false; + return !value.toLowerCase().includes("dev") && !value.toLowerCase().includes("secret"); +} + +export function mintExciseUid(): { uid: string; payload: string; signature: string; keyId: string } { + const key = process.env[EXCISE_UID_HMAC_ENV]; + const keyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (!isStrongExciseKey(key)) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise UID signing is unavailable." }); + } + const payload = `${keyId}.${randomBytes(24).toString("hex")}`; + const signature = createHmac("sha256", key).update(payload).digest("hex").slice(0, 32); + return { uid: `${payload}.${signature}`, payload, signature, keyId }; +} + +function distanceKm( + first: { latitude: number; longitude: number }, + second: { latitude: number; longitude: number }, +): number { + const radians = (degrees: number) => degrees * Math.PI / 180; + const dLat = radians(second.latitude - first.latitude); + const dLon = radians(second.longitude - first.longitude); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(radians(first.latitude)) * Math.cos(radians(second.latitude)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export function calculateImpossibleTravelSpeedKmh( + previous: { latitude: number; longitude: number; scannedAt: Date }, + current: { latitude: number; longitude: number; scannedAt: Date }, +): number | null { + const elapsedHours = (current.scannedAt.getTime() - previous.scannedAt.getTime()) / 3_600_000; + if (elapsedHours <= 0) return null; + return distanceKm(previous, current) / elapsedHours; +} + +async function recordScan( + db: Awaited>, + uid: string, + markId: number | null, + source: "public" | "enforcement", + scannedBy: number | null, + latitude: number | undefined, + longitude: number | undefined, +) { + const [previous] = await db.select().from(exciseScans) + .where(eq(exciseScans.uid, uid)) + .orderBy(desc(exciseScans.scannedAt)) + .limit(1); + let impossibleTravel = false; + let impliedSpeedKmh: string | undefined; + if (previous && previous.latitude !== null && previous.longitude !== null && + latitude !== undefined && longitude !== undefined) { + const speed = calculateImpossibleTravelSpeedKmh( + { latitude: previous.latitude, longitude: previous.longitude, scannedAt: previous.scannedAt }, + { latitude, longitude, scannedAt: new Date() }, + ); + if (speed !== null) { + impliedSpeedKmh = speed.toFixed(2); + impossibleTravel = speed > IMPOSSIBLE_TRAVEL_SPEED_KMH; + } + } + const [scan] = await db.insert(exciseScans).values({ + uid, + markId, + source, + scannedBy, + localityHash: latitude !== undefined && longitude !== undefined + ? createHash("sha256").update(`${latitude.toFixed(2)}:${longitude.toFixed(2)}`).digest("hex") + : null, + latitude: latitude === undefined ? undefined : Number(latitude.toFixed(2)), + longitude: longitude === undefined ? undefined : Number(longitude.toFixed(2)), + previousScanId: previous?.id, + impliedSpeedKmh, + impossibleTravel, + }).returning(); + if (impossibleTravel) { + await db.insert(exciseAnomalies).values({ + markId, + anomalyType: "impossible_travel", + details: { previousScanId: previous?.id, scanId: scan.id, impliedSpeedKmh }, + }); + } + return scan; +} + +const transitionOrder = { + ordered: "assessed", + assessed: "payment", + payment: "fulfilment", + fulfilment: "delivery", +} as const; + +function requireTransition(status: string, expected: string): void { + if (!(status in transitionOrder) || transitionOrder[status as keyof typeof transitionOrder] !== expected) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Order must transition from ${status} to ${expected}.` }); + } +} + +function metadataHasIdempotencyKey(metadata: unknown, key: string): boolean { + return typeof metadata === "object" && metadata !== null && + (metadata as Record).idempotencyKey === key; +} + +export const exciseRouter = router({ + registerLicence: protectedProcedure + .input(z.object({ + licenseNumber: z.string().min(2).max(128), + licenseeType: z.enum(["manufacturer", "importer", "distributor", "retailer"]), + productCategories: z.array(z.string().min(1).max(64)).min(1).max(30), + validFrom: z.string().datetime(), + validUntil: z.string().datetime(), + })) + .mutation(async ({ ctx, input }) => { + try { + if (new Date(input.validUntil) <= new Date(input.validFrom)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Licence validity window is invalid." }); + } + const db = await requireDb(); + const [licence] = await db.insert(exciseLicences).values({ + licenseNumber: input.licenseNumber, + userId: ctx.user.id, + licenseeType: input.licenseeType, + economicOperatorId: authorityIdentifier("EO"), + productCategories: input.productCategories, + validFrom: new Date(input.validFrom), + validUntil: new Date(input.validUntil), + status: "pending", + }).returning(); + await logAuditEvent({ + entityType: "user", + entityId: ctx.user.id, + action: "excise_licence_registered", + actorId: ctx.user.id, + actorType: input.licenseeType, + newState: { licenceId: licence.id, status: licence.status }, + }); + return licence; + } catch (error) { + return unavailable("Excise licence registration is unavailable.", error); + } + }), + + listLicences: protectedProcedure.query(async ({ ctx }) => { + try { + const db = await requireDb(); + if (isOfficer(ctx.user.role)) return db.select().from(exciseLicences).orderBy(desc(exciseLicences.createdAt)); + return db.select().from(exciseLicences).where(eq(exciseLicences.userId, ctx.user.id)).orderBy(desc(exciseLicences.createdAt)); + } catch (error) { + return unavailable("Excise licences are unavailable.", error); + } + }), + + approveLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [current] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, input.licenceId)).limit(1); + if (!current) throw new TRPCError({ code: "NOT_FOUND" }); + if (current.status !== "pending") { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Only pending licences can be approved." }); + } + if (current.validUntil <= new Date()) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "An expired licence cannot be approved." }); + } + const [licence] = await db.update(exciseLicences).set({ + status: "active", + approvedBy: ctx.user.id, + approvedAt: new Date(), + updatedAt: new Date(), + }).where(and(eq(exciseLicences.id, input.licenceId), eq(exciseLicences.status, "pending"))).returning(); + if (!licence) throw new TRPCError({ code: "CONFLICT", message: "Licence changed before approval." }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_approved", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status } }); + return licence; + } catch (error) { + return unavailable("Excise licence approval is unavailable.", error); + } + }), + + suspendLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), reason: z.string().min(10).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const now = new Date(); + const [licence] = await db.update(exciseLicences).set({ + status: "suspended", suspendedBy: ctx.user.id, suspendedAt: now, suspensionReason: input.reason, updatedAt: now, + }).where(eq(exciseLicences.id, input.licenceId)).returning(); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + await db.insert(exciseLicenceSuspensions).values({ licenceId: licence.id, suspendedBy: ctx.user.id, suspendedAt: now, reason: input.reason }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_suspended", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status, reason: input.reason } }); + return licence; + } catch (error) { + return unavailable("Excise licence suspension is unavailable.", error); + } + }), + + liftSuspension: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [licence] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, input.licenceId)).limit(1); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + if (licence.status !== "suspended") throw new TRPCError({ code: "BAD_REQUEST", message: "Only suspended licences can be reinstated." }); + const now = new Date(); + const status = licence.validUntil > now ? "active" : "expired"; + const [updated] = await db.update(exciseLicences).set({ + status, suspendedBy: null, suspendedAt: null, suspensionReason: null, updatedAt: now, + }).where(eq(exciseLicences.id, licence.id)).returning(); + await db.update(exciseLicenceSuspensions).set({ liftedAt: now, liftedBy: ctx.user.id }) + .where(and(eq(exciseLicenceSuspensions.licenceId, licence.id), isNull(exciseLicenceSuspensions.liftedAt))); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_suspension_lifted", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status } }); + return updated; + } catch (error) { + return unavailable("Excise licence suspension update is unavailable.", error); + } + }), + + revokeLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), reason: z.string().min(10).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [licence] = await db.update(exciseLicences).set({ + status: "revoked", revokedBy: ctx.user.id, revokedAt: new Date(), revocationReason: input.reason, updatedAt: new Date(), + }).where(eq(exciseLicences.id, input.licenceId)).returning(); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_revoked", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status, reason: input.reason } }); + return licence; + } catch (error) { + return unavailable("Excise licence revocation is unavailable.", error); + } + }), + + suspensionHistory: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + try { + const { db } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role, false); + return db.select().from(exciseLicenceSuspensions).where(eq(exciseLicenceSuspensions.licenceId, input.licenceId)).orderBy(desc(exciseLicenceSuspensions.suspendedAt)); + } catch (error) { + return unavailable("Excise suspension history is unavailable.", error); + } + }), + + createFacility: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), name: z.string().min(2).max(255), address: z.string().max(1024).optional() })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + if (!isIdIssuer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN", message: "An ID issuer must create facility identifiers." }); + const [facility] = await db.insert(exciseFacilities).values({ + licenceId: licence.id, facilityIdentifier: authorityIdentifier("FI"), name: input.name, address: input.address, createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_facility_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: { facilityId: facility.id } }); + return facility; + } catch (error) { + return unavailable("Excise facility registration is unavailable.", error); + } + }), + + createMachine: protectedProcedure + .input(z.object({ facilityId: z.number().int().positive(), name: z.string().min(2).max(255) })) + .mutation(async ({ ctx, input }) => { + try { + if (!isIdIssuer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + const db = await requireDb(); + const [facility] = await db.select().from(exciseFacilities).where(eq(exciseFacilities.id, input.facilityId)).limit(1); + if (!facility) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(facility.licenceId, ctx.user.id, ctx.user.role); + const [machine] = await db.insert(exciseMarkingMachines).values({ + facilityId: facility.id, machineIdentifier: authorityIdentifier("MI"), name: input.name, createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_machine_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: { machineId: machine.id } }); + return machine; + } catch (error) { + return unavailable("Excise machine registration is unavailable.", error); + } + }), + + createTaxScheme: protectedProcedure + .input(z.object({ + code: z.string().min(2).max(64), + schemeType: z.enum(["specific", "ad_valorem", "hybrid"]), + specificAmount: z.string().regex(/^\d+(\.\d+)?$/).optional(), + specificUnitOfMeasure: z.string().max(32).optional(), + adValoremRate: z.string().regex(/^\d+(\.\d+)?$/).optional(), + hybridWhicheverGreater: z.boolean().default(false), + currency: z.string().length(3).optional(), + })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [scheme] = await db.insert(exciseTaxSchemes).values({ ...input, createdBy: ctx.user.id }).returning(); + return scheme; + } catch (error) { + return unavailable("Excise tax scheme registration is unavailable.", error); + } + }), + + registerProduct: protectedProcedure + .input(z.object({ + licenceId: z.number().int().positive(), + sku: z.string().min(2).max(128), + brand: z.string().min(1).max(255), + packSize: z.number().int().positive(), + unitContent: z.string().regex(/^\d+(\.\d+)?$/), + unitOfMeasure: z.string().min(1).max(32), + strength: z.string().regex(/^\d+(\.\d+)?$/).optional(), + schemeId: z.number().int().positive(), + })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [product] = await db.insert(exciseProducts).values({ + ...input, licenceId: licence.id, createdBy: ctx.user.id, approvalStatus: "pending", + }).returning(); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_product_registered", actorId: ctx.user.id, actorType: "licensee", newState: { productId: product.id, approvalStatus: product.approvalStatus } }); + return product; + } catch (error) { + return unavailable("Excise product registration is unavailable.", error); + } + }), + + approveProduct: protectedProcedure + .input(z.object({ productId: z.number().int().positive(), approved: z.boolean(), reason: z.string().max(1024).optional() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [product] = await db.update(exciseProducts).set({ + approvalStatus: input.approved ? "approved" : "rejected", + approvedBy: input.approved ? ctx.user.id : null, + approvedAt: input.approved ? new Date() : null, + rejectionReason: input.approved ? null : input.reason, + updatedAt: new Date(), + }).where(eq(exciseProducts.id, input.productId)).returning(); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + return product; + } catch (error) { + return unavailable("Excise product approval is unavailable.", error); + } + }), + + createOrder: protectedProcedure + .input(z.object({ + licenceId: z.number().int().positive(), + productId: z.number().int().positive(), + facilityId: z.number().int().positive(), + declarationId: z.number().int().positive().optional(), + quantity: z.number().int().positive(), + declaredValue: z.string().regex(/^\d+(\.\d+)?$/).optional(), + currency: z.string().length(3), + })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [product] = await db.select().from(exciseProducts).where(and(eq(exciseProducts.id, input.productId), eq(exciseProducts.licenceId, licence.id))).limit(1); + if (!product || product.approvalStatus !== "approved") throw new TRPCError({ code: "PRECONDITION_FAILED", message: "An approved SKU is required." }); + const [facility] = await db.select().from(exciseFacilities).where(and(eq(exciseFacilities.id, input.facilityId), eq(exciseFacilities.licenceId, licence.id))).limit(1); + if (!facility) throw new TRPCError({ code: "FORBIDDEN", message: "Facility does not belong to the licence." }); + const [scheme] = await db.select().from(exciseTaxSchemes).where(eq(exciseTaxSchemes.id, product.schemeId)).limit(1); + if (!scheme || !scheme.active) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The tax scheme is unavailable." }); + if (input.declarationId) { + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, input.declarationId)).limit(1); + if (!declaration) throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found." }); + if (!isOfficer(ctx.user.role) && (declaration.principalId ?? declaration.traderId) !== licence.userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } + const liability = calculateExciseLiability(scheme, product, input.quantity, input.declaredValue); + const [order] = await db.insert(exciseStampOrders).values({ + orderNumber: `EXO-${randomBytes(10).toString("hex").toUpperCase()}`, + licenceId: licence.id, productId: product.id, facilityId: facility.id, + declarationId: input.declarationId, quantity: input.quantity, declaredValue: input.declaredValue, liability, currency: input.currency, + status: "ordered", createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_created", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: order.status, liability } }); + return order; + } catch (error) { + return unavailable("Excise stamp ordering is unavailable.", error); + } + }), + + assessOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), declaredValue: z.string().regex(/^\d+(\.\d+)?$/).optional() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "assessed"); + const [product] = await db.select().from(exciseProducts).where(eq(exciseProducts.id, order.productId)).limit(1); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + const [scheme] = await db.select().from(exciseTaxSchemes).where(eq(exciseTaxSchemes.id, product.schemeId)).limit(1); + if (!scheme) throw new TRPCError({ code: "PRECONDITION_FAILED" }); + const liability = calculateExciseLiability(scheme, product, order.quantity, input.declaredValue ?? order.declaredValue ?? undefined); + const [updated] = await db.update(exciseStampOrders).set({ status: "assessed", liability, assessedAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_assessed", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, liability, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp assessment is unavailable.", error); + } + }), + + payOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + const lock = await acquireLock(`excise:pay:${input.orderId}`, 30_000); + if (lock.token === "no-redis") { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment idempotency lock is unavailable." }); + } + try { + const db = await requireDb(); + let [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + const { licence } = await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (order.status === "payment" || order.status === "fulfilment" || order.status === "delivery") return order; + requireTransition(order.status, "payment"); + if (!order.liability) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Order must be assessed before payment." }); + const liability = order.liability; + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable." }); + const idempotencyKey = order.paymentIdempotencyKey ?? `excise:pay:${order.id}`; + if (!order.paymentIdempotencyKey) { + const [claimed] = await db.update(exciseStampOrders).set({ paymentIdempotencyKey: idempotencyKey, updatedAt: new Date() }) + .where(and(eq(exciseStampOrders.id, order.id), isNull(exciseStampOrders.paymentIdempotencyKey))).returning(); + if (!claimed) { + [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, order.id)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + } else { + order = claimed; + } + } + const existingEntries = await db.select().from(tigerBeetleLedgerEntries).where(and( + eq(tigerBeetleLedgerEntries.entryType, "excise_stamp_liability"), + eq(tigerBeetleLedgerEntries.reference, order.orderNumber), + eq(tigerBeetleLedgerEntries.status, "posted"), + )); + const existingEntry = existingEntries.find((entry) => metadataHasIdempotencyKey(entry.metadata, idempotencyKey)); + if (existingEntry) { + const [reconciled] = await db.update(exciseStampOrders).set({ + status: "payment", ledgerTransferId: existingEntry.tbTransferId, paidAt: order.paidAt ?? new Date(), updatedAt: new Date(), + }).where(eq(exciseStampOrders.id, order.id)).returning(); + return reconciled; + } + let transferId = order.ledgerTransferId; + if (transferId) { + await tbFetch>(`/api/ledger/transfers/${transferId}`); + } else { + const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + idempotencyKey, + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amount: liability, + currency: order.currency, + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + }), + }); + transferId = transfer.id; + await db.update(exciseStampOrders).set({ ledgerTransferId: transferId, updatedAt: new Date() }) + .where(eq(exciseStampOrders.id, order.id)); + } + if (!transferId) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle transfer identity is unavailable." }); + await createLedgerEntry({ + tbTransferId: transferId, + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amountMinorUnits: Number(parseScaled(liability, 2)), + currency: order.currency, + ledger: 1, + entryType: "excise_stamp_liability", + status: "posted", + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + metadata: { idempotencyKey }, + postedAt: new Date(), + }); + const [updated] = await db.update(exciseStampOrders).set({ status: "payment", ledgerTransferId: transferId, paidAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_paid", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status, transferId } }); + return updated; + } catch (error) { + return unavailable("Excise stamp payment is unavailable.", error); + } finally { + await releaseLock(lock); + } + }), + + fulfilOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), machineId: z.number().int().positive().optional() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "fulfilment"); + const ledgerAvailable = order.declarationId ? await tbBridgeAvailable() : true; + if (!ledgerAvailable) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); + if (order.declarationId) { + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); + if (!declaration || declaration.declarationType !== "import" || !declaration.totalDue || !declaration.invoiceCurrency) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Customs duty liability is unavailable." }); + const entries = await db.select().from(tigerBeetleLedgerEntries).where(and( + eq(tigerBeetleLedgerEntries.declarationId, order.declarationId), + eq(tigerBeetleLedgerEntries.entryType, "duty_payment"), + eq(tigerBeetleLedgerEntries.status, "posted"), + )); + const mismatchedCurrency = entries.some((entry) => entry.currency !== declaration.invoiceCurrency); + if (mismatchedCurrency) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Customs duty settlement currency cannot be verified without an authoritative exchange rate.", + }); + } + const settled = entries.reduce((sum, entry) => sum + BigInt(entry.amountMinorUnits), 0n); + const due = parseScaled(declaration.totalDue, 2); + if (settled < due) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Customs duty is not fully settled." }); + } + const [updated] = await db.update(exciseStampOrders).set({ status: "fulfilment", fulfilledAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_fulfilled", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp fulfilment is unavailable.", error); + } + }), + + deliverOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "delivery"); + const [updated] = await db.update(exciseStampOrders).set({ status: "delivery", deliveredAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_delivered", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp delivery is unavailable.", error); + } + }), + + mintMarks: protectedProcedure + .input(z.object({ + orderId: z.number().int().positive(), + machineId: z.number().int().positive().optional(), + batchSize: z.number().int().positive().max(EXCISE_MINT_BATCH_CAP).default(EXCISE_MINT_BATCH_CAP), + })) + .mutation(async ({ ctx, input }) => { + const lock = await acquireLock(`excise:mint:${input.orderId}`, 60_000); + if (lock.token === "no-redis") { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Minting coordination is unavailable." }); + } + try { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (order.status !== "fulfilment") throw new TRPCError({ code: "BAD_REQUEST", message: "Only fulfilment orders can mint marks." }); + const [{ minted }] = await db.select({ minted: count(exciseStampMarks.id) }).from(exciseStampMarks) + .where(eq(exciseStampMarks.orderId, order.id)); + const remaining = Math.max(0, order.quantity - Number(minted)); + if (remaining === 0) return { marks: [], mintedCount: Number(minted), remaining: 0 }; + const [product] = await db.select().from(exciseProducts).where(eq(exciseProducts.id, order.productId)).limit(1); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + const [machine] = input.machineId ? await db.select().from(exciseMarkingMachines).where(eq(exciseMarkingMachines.id, input.machineId)).limit(1) : [undefined]; + if (machine) { + const [facility] = await db.select().from(exciseFacilities).where(eq(exciseFacilities.id, machine.facilityId)).limit(1); + if (!facility || facility.id !== order.facilityId) throw new TRPCError({ code: "FORBIDDEN" }); + } + const marks = await db.transaction(async (tx) => { + const created: typeof exciseStampMarks.$inferSelect[] = []; + const values: typeof exciseStampMarks.$inferInsert[] = []; + for (let index = 0; index < Math.min(input.batchSize, remaining); index += 1) { + const signed = mintExciseUid(); + values.push({ + uid: signed.uid, payload: signed.payload, signature: signed.signature, keyId: signed.keyId, + orderId: order.id, productId: product.id, facilityId: order.facilityId, machineId: machine?.id, + status: "issued", + }); + } + for (let index = 0; index < values.length; index += EXCISE_MINT_CHUNK_SIZE) { + created.push(...await tx.insert(exciseStampMarks).values(values.slice(index, index + EXCISE_MINT_CHUNK_SIZE)).returning()); + } + return created; + }); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_marks_minted", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, quantity: marks.length } }); + return { marks, mintedCount: Number(minted) + marks.length, remaining: remaining - marks.length }; + } catch (error) { + return unavailable("Excise UID minting is unavailable.", error); + } + } finally { + await releaseLock(lock); + } + }), + + offlineVerify: publicRateLimitedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(({ input }) => verifyExciseUid(input.uid)), + + activateMark: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (mark.status === "active") return mark; + if (mark.status !== "issued") throw new TRPCError({ code: "BAD_REQUEST", message: "Retired marks cannot be activated." }); + const [activation] = await db.insert(exciseMarkActivations).values({ markId: mark.id, activatedBy: ctx.user.id }).onConflictDoNothing().returning(); + if (!activation) { + const [current] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, mark.id)).limit(1); + return current ?? mark; + } + const [updated] = await db.update(exciseStampMarks).set({ status: "active", activatedAt: activation.activatedAt }).where(eq(exciseStampMarks.id, mark.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_mark_activated", actorId: ctx.user.id, actorType: "licensee", newState: { markId: mark.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise mark activation is unavailable.", error); + } + }), + + retireMark: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), reason: z.enum(["wastage", "spoilage", "destruction", "seizure", "other"]), details: z.string().min(2).max(1024) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (mark.status === "retired") return mark; + const now = new Date(); + await db.insert(exciseRetirements).values({ markId: mark.id, reason: input.reason, details: input.details, retiredBy: ctx.user.id, retiredAt: now }); + const [updated] = await db.update(exciseStampMarks).set({ status: "retired", retiredAt: now, retirementReason: input.reason, retirementDetails: input.details }).where(eq(exciseStampMarks.id, mark.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_mark_retired", actorId: ctx.user.id, actorType: "licensee", newState: { markId: mark.id, status: updated.status, reason: input.reason } }); + return updated; + } catch (error) { + return unavailable("Excise mark retirement is unavailable.", error); + } + }), + + reportProduction: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), quantity: z.number().int().nonnegative() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + const [report] = await db.insert(exciseProductionReports).values({ orderId: order.id, productId: order.productId, facilityId: order.facilityId, quantity: input.quantity, reportedBy: ctx.user.id }).returning(); + return report; + } catch (error) { + return unavailable("Excise production reporting is unavailable.", error); + } + }), + + reconcileOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role, false); + const marks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + const reports = await db.select().from(exciseProductionReports).where(eq(exciseProductionReports.orderId, order.id)); + const issuedQuantity = marks.length; + const activatedQuantity = marks.filter((mark) => mark.status === "active" || mark.activatedAt !== null).length; + const retiredQuantity = marks.filter((mark) => mark.status === "retired").length; + const stillIssuedQuantity = marks.filter((mark) => mark.status === "issued").length; + const reportedProductionQuantity = reports.reduce((sum, report) => sum + report.quantity, 0); + const stampVariance = issuedQuantity - activatedQuantity - retiredQuantity - stillIssuedQuantity; + const productionVariance = activatedQuantity - reportedProductionQuantity; + const [report] = await db.insert(exciseReconciliationReports).values({ + orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, stillIssuedQuantity, + reportedProductionQuantity, stampVariance, productionVariance, computedBy: ctx.user.id, + }).returning(); + return report; + } catch (error) { + return unavailable("Excise reconciliation is unavailable.", error); + } + }), + + createAggregate: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), aggregateType: z.enum(["carton", "case", "pallet"]) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [aggregate] = await db.insert(exciseAggregates).values({ + aggregateUid: `EXA-${randomBytes(18).toString("hex").toUpperCase()}`, + aggregateType: input.aggregateType, + licenceId: input.licenceId, + createdBy: ctx.user.id, + }).returning(); + return aggregate; + } catch (error) { + return unavailable("Excise aggregation is unavailable.", error); + } + }), + + addToAggregate: protectedProcedure + .input(z.object({ aggregateId: z.number().int().positive(), markId: z.number().int().positive().optional(), childAggregateId: z.number().int().positive().optional() }).refine((input) => Boolean(input.markId) !== Boolean(input.childAggregateId), "Exactly one child is required.")) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [parent] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId)).limit(1); + if (!parent) throw new TRPCError({ code: "NOT_FOUND" }); + if (!isEnforcement(ctx.user.role) && parent.createdBy !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + if (input.markId) { + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, input.markId)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order || order.licenceId !== parent.licenceId) throw new TRPCError({ code: "FORBIDDEN" }); + } else if (input.childAggregateId === parent.id) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } else { + const [childAggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.childAggregateId!)).limit(1); + if (!childAggregate) throw new TRPCError({ code: "NOT_FOUND" }); + if (childAggregate.licenceId !== parent.licenceId) throw new TRPCError({ code: "FORBIDDEN" }); + if (AGGREGATE_LEVEL[childAggregate.aggregateType] >= AGGREGATE_LEVEL[parent.aggregateType]) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aggregate hierarchy cannot skip levels." }); + } + } + const [existing] = await db.select().from(exciseAggregateChildren).where( + and( + input.markId ? eq(exciseAggregateChildren.childMarkId, input.markId) : eq(exciseAggregateChildren.childAggregateId, input.childAggregateId!), + isNull(exciseAggregateChildren.removedAt), + ), + ).limit(1); + if (existing) throw new TRPCError({ code: "CONFLICT", message: "The child already belongs to an aggregate." }); + const [child] = await db.insert(exciseAggregateChildren).values({ aggregateId: parent.id, childMarkId: input.markId, childAggregateId: input.childAggregateId, addedBy: ctx.user.id }).returning(); + if (input.childAggregateId) { + await db.update(exciseAggregates).set({ parentAggregateId: parent.id }).where(eq(exciseAggregates.id, input.childAggregateId)); + } + return child; + } catch (error) { + return unavailable("Excise aggregation is unavailable.", error); + } + }), + + disaggregate: protectedProcedure + .input(z.object({ childId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [child] = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.id, input.childId), isNull(exciseAggregateChildren.removedAt))).limit(1); + if (!child) throw new TRPCError({ code: "NOT_FOUND" }); + const now = new Date(); + await db.update(exciseAggregateChildren).set({ removedAt: now, removedBy: ctx.user.id }).where(eq(exciseAggregateChildren.id, child.id)); + await db.insert(exciseMovementEvents).values({ aggregateId: child.aggregateId, eventType: "disaggregation", actorId: ctx.user.id, metadata: { childId: child.id } }); + if (child.childAggregateId) await db.update(exciseAggregates).set({ parentAggregateId: null }).where(eq(exciseAggregates.id, child.childAggregateId)); + return { ...child, removedAt: now, removedBy: ctx.user.id }; + } catch (error) { + return unavailable("Excise disaggregation is unavailable.", error); + } + }), + + aggregateContents: protectedProcedure + .input(z.object({ aggregateUid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.aggregateUid, input.aggregateUid)).limit(1); + if (!aggregate) throw new TRPCError({ code: "NOT_FOUND" }); + const children = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.aggregateId, aggregate.id), isNull(exciseAggregateChildren.removedAt))); + return { aggregate, children }; + } catch (error) { + return unavailable("Excise aggregate contents are unavailable.", error); + } + }), + + markAggregate: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [child] = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.childMarkId, mark.id), isNull(exciseAggregateChildren.removedAt))).limit(1); + if (!child) return { aggregate: null }; + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, child.aggregateId)).limit(1); + return { aggregate: aggregate ?? null }; + } catch (error) { + return unavailable("Excise mark aggregation is unavailable.", error); + } + }), + + recordMovement: protectedProcedure + .input(z.object({ + markId: z.number().int().positive().optional(), + aggregateId: z.number().int().positive().optional(), + eventType: z.enum(["dispatch", "receipt", "export", "re_entry", "seizure", "destruction"]), + location: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + }).refine((input) => Boolean(input.markId) !== Boolean(input.aggregateId), "Exactly one movement subject is required.")) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + if (input.markId) { + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, input.markId)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + } else { + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId!)).limit(1); + if (!aggregate) throw new TRPCError({ code: "NOT_FOUND" }); + if (!isEnforcement(ctx.user.role) && aggregate.createdBy !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + } + const [event] = await db.insert(exciseMovementEvents).values({ ...input, actorId: ctx.user.id }).returning(); + return event; + } catch (error) { + return unavailable("Excise movement recording is unavailable.", error); + } + }), + + enforcementScan: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), latitude: z.number().min(-90).max(90).optional(), longitude: z.number().min(-180).max(180).optional() })) + .mutation(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + const scan = await recordScan(db, input.uid, mark?.id ?? null, "enforcement", ctx.user.id, input.latitude, input.longitude); + if (!mark) return { status: "unknown" as const, scan, history: [] }; + if (!isStrongExciseKey(getExciseKey(mark.keyId))) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise UID verification is unavailable." }); + } + const [activation] = await db.select().from(exciseMarkActivations).where(eq(exciseMarkActivations.markId, mark.id)).limit(1); + const movements = await db.select().from(exciseMovementEvents).where(eq(exciseMovementEvents.markId, mark.id)).orderBy(asc(exciseMovementEvents.occurredAt)); + const scans = await db.select().from(exciseScans).where(eq(exciseScans.uid, input.uid)).orderBy(asc(exciseScans.scannedAt)); + const signature = verifyExciseUid(mark.uid); + return { + status: signature.status === "verification_unavailable" + ? "unavailable" as const + : signature.status === "invalid_signature" || mark.status === "retired" ? "suspect" as const : "authentic" as const, + mark, activation: activation ?? null, movements, scans, scan, + }; + } catch (error) { + return unavailable("Excise enforcement scan is unavailable.", error); + } + }), + + seize: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), location: z.string().max(255).optional(), reason: z.string().min(5).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [seizure] = await db.insert(exciseSeizures).values({ markId: mark.id, seizedBy: ctx.user.id, location: input.location, reason: input.reason }).returning(); + await db.insert(exciseMovementEvents).values({ markId: mark.id, eventType: "seizure", actorId: ctx.user.id, location: input.location, metadata: { seizureId: seizure.id } }); + return seizure; + } catch (error) { + return unavailable("Excise seizure recording is unavailable.", error); + } + }), + + publicVerify: publicRateLimitedProcedure + .input(z.object({ uid: z.string().min(8).max(192), latitude: z.number().min(-90).max(90).optional(), longitude: z.number().min(-180).max(180).optional() })) + .mutation(async ({ input }) => { + if (!isStrongExciseKey(process.env[EXCISE_UID_HMAC_ENV])) return { status: "unavailable" as const }; + const signature = verifyExciseUid(input.uid); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (mark && !isStrongExciseKey(getExciseKey(mark.keyId))) return { status: "unavailable" as const }; + await recordScan(db, input.uid, mark?.id ?? null, "public", null, input.latitude, input.longitude); + if (!mark) return { status: signature.status === "invalid_signature" ? "suspect" as const : "unknown" as const }; + if (signature.status === "verification_unavailable") return { status: "unavailable" as const }; + if (signature.status === "invalid_signature" || mark.status === "retired") return { status: "suspect" as const }; + return { status: "authentic" as const }; + } catch { + return { status: "unavailable" as const }; + } + }), + + traverseSource: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) return { available: false as const, reason: "mark_not_found" as const }; + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) return { available: false as const, reason: "order_missing" as const }; + if (!order.declarationId) return { available: false as const, reason: "declaration_missing" as const }; + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); + if (!declaration) return { available: false as const, reason: "declaration_missing" as const }; + if (!declaration.billOfLadingId && !declaration.billOfLadingNumber) return { available: false as const, reason: "bill_of_lading_not_linked" as const }; + const bills = declaration.billOfLadingId + ? await db.select().from(billsOfLading).where(eq(billsOfLading.id, declaration.billOfLadingId)).limit(1) + : await db.select().from(billsOfLading).where(eq(billsOfLading.blNumber, declaration.billOfLadingNumber!)); + if (!declaration.billOfLadingId && bills.length > 1) { + return { available: false as const, reason: "bill_of_lading_ambiguous" as const }; + } + const [bl] = bills; + if (!bl) return { available: false as const, reason: "bill_of_lading_missing" as const }; + const [manifest] = await db.select().from(manifests).where(eq(manifests.id, bl.manifestId)).limit(1); + if (!manifest) return { available: false as const, reason: "manifest_missing" as const }; + if (!declaration.principalId && !declaration.traderId) { + return { available: false as const, reason: "importer_missing" as const }; + } + const siblingMarks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + return { + available: true as const, + mark, + order, + declaration: { id: declaration.id, declarationNumber: declaration.declarationNumber, ucr: declaration.ucr }, + billOfLading: { id: bl.id, blNumber: bl.blNumber }, + manifest: { id: manifest.id, manifestNumber: manifest.manifestNumber, vesselName: manifest.vesselName, mmsi: manifest.mmsi, imo: manifest.imo }, + importerUserId: declaration.principalId ?? declaration.traderId, + actingAgentUserId: declaration.actingAgentId ?? null, + siblingMarks, + }; + } catch (error) { + return unavailable("Excise source traversal is unavailable.", error); + } + }), + + analytics: protectedProcedure + .input(z.object({ orderId: z.number().int().positive().optional() }).optional()) + .query(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const orderFilter = input?.orderId ? eq(exciseStampOrders.id, input.orderId) : undefined; + const [orderStats] = await db.select({ + orders: count(exciseStampOrders.id), + paid: sql`count(*) filter (where ${exciseStampOrders.paidAt} is not null)`, + }).from(exciseStampOrders).where(orderFilter); + const [markStats] = await db.select({ + issued: count(exciseStampMarks.id), + activated: sql`count(*) filter (where ${exciseStampMarks.status} = 'active')`, + retired: sql`count(*) filter (where ${exciseStampMarks.status} = 'retired')`, + stillIssued: sql`count(*) filter (where ${exciseStampMarks.status} = 'issued')`, + }).from(exciseStampMarks).where(input?.orderId ? eq(exciseStampMarks.orderId, input.orderId) : undefined); + const [productionStats] = await db.select({ + reported: sql`coalesce(sum(${exciseProductionReports.quantity}), 0)`, + }).from(exciseProductionReports).where(input?.orderId ? eq(exciseProductionReports.orderId, input.orderId) : undefined); + const [anomalyStats] = input?.orderId + ? await db.select({ anomalies: count(exciseAnomalies.id) }).from(exciseAnomalies) + .innerJoin(exciseStampMarks, eq(exciseAnomalies.markId, exciseStampMarks.id)) + .where(eq(exciseStampMarks.orderId, input.orderId)) + : await db.select({ anomalies: count(exciseAnomalies.id) }).from(exciseAnomalies); + const issued = Number(markStats?.issued ?? 0); + const activated = Number(markStats?.activated ?? 0); + const retired = Number(markStats?.retired ?? 0); + const stillIssued = Number(markStats?.stillIssued ?? 0); + const reportedProduction = Number(productionStats?.reported ?? 0); + return { + orders: Number(orderStats?.orders ?? 0), + issued, + activated, + retired, + paid: Number(orderStats?.paid ?? 0), + reportedProduction, + stampAccountabilityVariance: issued - activated - retired - stillIssued, + productionAccountabilityVariance: activated - reportedProduction, + anomalies: Number(anomalyStats?.anomalies ?? 0), + }; + } catch (error) { + return unavailable("Excise analytics are unavailable.", error); + } + }), +}); diff --git a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go new file mode 100644 index 00000000..b49d036c --- /dev/null +++ b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "sync" + "testing" + + "github.com/shopspring/decimal" +) + +func TestPostTransferIsIdempotentByKey(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ID: "trader-test", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "revenue-test", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + + const key = "excise:order:123" + first := &Transfer{ + ID: "transfer-first", + DebitAccountID: "trader-test", + CreditAccountID: "revenue-test", + Amount: decimal.NewFromInt(100), + Currency: "GHS", + IdempotencyKey: key, + } + second := &Transfer{ + ID: "transfer-second", + DebitAccountID: "trader-test", + CreditAccountID: "revenue-test", + Amount: decimal.NewFromInt(100), + Currency: "GHS", + IdempotencyKey: key, + } + + if err := store.PostTransfer(first); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(second); err != nil { + t.Fatal(err) + } + if second.ID != first.ID { + t.Fatalf("expected replay to retain transfer %q, got %q", first.ID, second.ID) + } + if transfers := store.GetTransfersByAccount("trader-test", 10); len(transfers) != 1 { + t.Fatalf("expected one stored transfer, got %d", len(transfers)) + } +} + +func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ID: "trader-race", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "revenue-race", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + + const key = "excise:race:123" + var wg sync.WaitGroup + errs := make(chan error, 2) + for _, id := range []string{"transfer-race-a", "transfer-race-b"} { + wg.Add(1) + go func(transferID string) { + defer wg.Done() + errs <- store.PostTransfer(&Transfer{ + ID: transferID, + DebitAccountID: "trader-race", + CreditAccountID: "revenue-race", + Amount: decimal.NewFromInt(100), + Currency: "GHS", + IdempotencyKey: key, + }) + }(id) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if transfers := store.GetTransfersByAccount("trader-race", 10); len(transfers) != 1 { + t.Fatalf("expected one stored transfer after concurrent replay, got %d", len(transfers)) + } +} diff --git a/services/go/tigerbeetle-bridge/cmd/main.go b/services/go/tigerbeetle-bridge/cmd/main.go index 459e5ed1..eac4379f 100644 --- a/services/go/tigerbeetle-bridge/cmd/main.go +++ b/services/go/tigerbeetle-bridge/cmd/main.go @@ -81,8 +81,8 @@ const ( type TransferFlag string const ( - FlagNone TransferFlag = "none" - FlagPending TransferFlag = "pending" + FlagNone TransferFlag = "none" + FlagPending TransferFlag = "pending" FlagPostPendingTransfer TransferFlag = "post_pending_transfer" FlagVoidPendingTransfer TransferFlag = "void_pending_transfer" ) @@ -121,12 +121,13 @@ type Transfer struct { Reference string `json:"reference,omitempty"` Description string `json:"description,omitempty"` Metadata interface{} `json:"metadata,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` // Timestamps (nanoseconds since epoch, as TigerBeetle stores them) - Timestamp int64 `json:"timestamp"` - CreatedAt time.Time `json:"createdAt"` - PostedAt *time.Time `json:"postedAt,omitempty"` - VoidedAt *time.Time `json:"voidedAt,omitempty"` - Status string `json:"status"` + Timestamp int64 `json:"timestamp"` + CreatedAt time.Time `json:"createdAt"` + PostedAt *time.Time `json:"postedAt,omitempty"` + VoidedAt *time.Time `json:"voidedAt,omitempty"` + Status string `json:"status"` } // ─── In-memory store (simulates TigerBeetle until binary client is wired) ──── @@ -197,6 +198,15 @@ func (s *Store) PostTransfer(t *Transfer) error { s.mu.Lock() defer s.mu.Unlock() + if t.IdempotencyKey != "" { + for _, existing := range s.transfers { + if existing.IdempotencyKey == t.IdempotencyKey { + *t = *existing + return nil + } + } + } + debit, ok := s.accounts[t.DebitAccountID] if !ok { return fmt.Errorf("debit account %s not found", t.DebitAccountID) @@ -270,6 +280,17 @@ func (s *Store) GetTransfer(id string) (*Transfer, bool) { return t, ok } +func (s *Store) GetTransferByIdempotencyKey(key string) (*Transfer, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, transfer := range s.transfers { + if transfer.IdempotencyKey == key { + return transfer, true + } + } + return nil, false +} + func (s *Store) GetTransfersByAccount(accountID string, limit int) []*Transfer { s.mu.RLock() defer s.mu.RUnlock() @@ -395,17 +416,18 @@ func (b *TigerBeetleBridge) handleGetBalance(w http.ResponseWriter, r *http.Requ func (b *TigerBeetleBridge) handlePostTransfer(w http.ResponseWriter, r *http.Request) { var req struct { - DebitAccountID string `json:"debitAccountId"` - CreditAccountID string `json:"creditAccountId"` - Amount string `json:"amount"` - Currency string `json:"currency"` - Ledger uint32 `json:"ledger"` - Code uint16 `json:"code"` - Flag string `json:"flag"` - PendingID string `json:"pendingId,omitempty"` - Reference string `json:"reference,omitempty"` - Description string `json:"description,omitempty"` + DebitAccountID string `json:"debitAccountId"` + CreditAccountID string `json:"creditAccountId"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Ledger uint32 `json:"ledger"` + Code uint16 `json:"code"` + Flag string `json:"flag"` + PendingID string `json:"pendingId,omitempty"` + Reference string `json:"reference,omitempty"` + Description string `json:"description,omitempty"` Metadata interface{} `json:"metadata,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) @@ -415,6 +437,12 @@ func (b *TigerBeetleBridge) handlePostTransfer(w http.ResponseWriter, r *http.Re jsonError(w, "debitAccountId, creditAccountId, and amount are required", http.StatusBadRequest) return } + if req.IdempotencyKey != "" { + if existing, found := b.store.GetTransferByIdempotencyKey(req.IdempotencyKey); found { + jsonOK(w, existing) + return + } + } amount, err := decimal.NewFromString(req.Amount) if err != nil || amount.IsNegative() || amount.IsZero() { jsonError(w, "amount must be a positive decimal", http.StatusBadRequest) @@ -443,6 +471,7 @@ func (b *TigerBeetleBridge) handlePostTransfer(w http.ResponseWriter, r *http.Re Reference: req.Reference, Description: req.Description, Metadata: req.Metadata, + IdempotencyKey: req.IdempotencyKey, } if err := b.store.PostTransfer(t); err != nil { jsonError(w, err.Error(), http.StatusUnprocessableEntity) @@ -460,12 +489,12 @@ func (b *TigerBeetleBridge) handlePostTransfer(w http.ResponseWriter, r *http.Re func (b *TigerBeetleBridge) handlePendingTransfer(w http.ResponseWriter, r *http.Request) { // Convenience endpoint: always sets flag=pending var req struct { - DebitAccountID string `json:"debitAccountId"` - CreditAccountID string `json:"creditAccountId"` - Amount string `json:"amount"` - Currency string `json:"currency"` - Reference string `json:"reference,omitempty"` - Description string `json:"description,omitempty"` + DebitAccountID string `json:"debitAccountId"` + CreditAccountID string `json:"creditAccountId"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Reference string `json:"reference,omitempty"` + Description string `json:"description,omitempty"` Metadata interface{} `json:"metadata,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -511,7 +540,7 @@ func (b *TigerBeetleBridge) handlePostPending(w http.ResponseWriter, r *http.Req t := &Transfer{ ID: uuid.New().String(), DebitAccountID: pending.CreditAccountID, // reverse: pending credit becomes debit - CreditAccountID: "0000000000000003", // customs_revenue_confirmed + CreditAccountID: "0000000000000003", // customs_revenue_confirmed Amount: pending.Amount, Currency: pending.Currency, Ledger: 1,