From 5c8391d25bc24e998e12e0855bc375800a5aff18 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:55:44 +0000 Subject: [PATCH 01/17] feat: add NSW stakeholder mandates Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 56 ++++ server/_core/mandateAuthorization.ts | 69 +++++ server/db.ts | 154 ++++++++++- server/mandate.enforcement.test.ts | 131 ++++++++++ server/routers.ts | 2 + server/routers/declarations.ts | 29 ++- server/routers/payments.ts | 37 ++- server/routers/stakeholderRegistrations.ts | 289 +++++++++++++++++++++ server/routers/tradeFinance.ts | 15 +- server/stakeholderRegistrations.test.ts | 185 +++++++++++++ 10 files changed, 937 insertions(+), 30 deletions(-) create mode 100644 server/_core/mandateAuthorization.ts create mode 100644 server/mandate.enforcement.test.ts create mode 100644 server/routers/stakeholderRegistrations.ts create mode 100644 server/stakeholderRegistrations.test.ts diff --git a/drizzle/schema.ts b/drizzle/schema.ts index ee4ae5dd..76380c9a 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -10,6 +10,7 @@ export const userRoleEnum = pgEnum("user_role", ["user", "admin", "customs_offic export const stakeholderTypeEnum = pgEnum("stakeholder_type", [ "trader", "customs_officer", "oga_officer", "freight_forwarder", + "shipping_line", "shipping_company", "airline_gha", "bank_officer", "port_authority", "system_admin", "auditor" ]); @@ -137,6 +138,58 @@ export const stakeholderProfiles = pgTable("stakeholder_profiles", { export type StakeholderProfile = typeof stakeholderProfiles.$inferSelect; +// ─── NSW PARTY REGISTRATIONS & AGENT MANDATES ──────────────────────────────── + +export const stakeholderRegistrations = pgTable("stakeholder_registrations", { + id: serial("id").primaryKey(), + referenceNumber: varchar("reference_number", { length: 32 }).notNull().unique(), + userId: integer("user_id").notNull().references(() => users.id), + stakeholderType: stakeholderTypeEnum("stakeholder_type").notNull(), + organizationName: varchar("organization_name", { length: 255 }).notNull(), + organizationCode: varchar("organization_code", { length: 64 }), + licenseNumber: varchar("license_number", { length: 128 }), + licenseExpiresAt: timestamp("license_expires_at"), + taxId: varchar("tax_id", { length: 64 }), + country: varchar("country", { length: 2 }).notNull(), + phone: varchar("phone", { length: 32 }), + kycDocumentIds: json("kyc_document_ids").$type().default([]), + status: profileStatusEnum("status").default("pending").notNull(), + approvedBy: integer("approved_by").references(() => users.id), + approvedAt: timestamp("approved_at"), + rejectionReason: text("rejection_reason"), + metadata: json("metadata"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_stakeholder_reg_user_id").on(t.userId), + index("idx_stakeholder_reg_status").on(t.status), + index("idx_stakeholder_reg_type").on(t.stakeholderType), +]); + +export type StakeholderRegistration = typeof stakeholderRegistrations.$inferSelect; +export type InsertStakeholderRegistration = typeof stakeholderRegistrations.$inferInsert; + +export const stakeholderMandates = pgTable("stakeholder_mandates", { + id: serial("id").primaryKey(), + referenceNumber: varchar("reference_number", { length: 32 }).notNull().unique(), + principalUserId: integer("principal_user_id").notNull().references(() => users.id), + agentUserId: integer("agent_user_id").notNull().references(() => users.id), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until").notNull(), + revokedAt: timestamp("revoked_at"), + revokedBy: integer("revoked_by").references(() => users.id), + revocationReason: text("revocation_reason"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_stakeholder_mandate_principal").on(t.principalUserId), + index("idx_stakeholder_mandate_agent").on(t.agentUserId), + index("idx_stakeholder_mandate_window").on(t.validFrom, t.validUntil), +]); + +export type StakeholderMandate = typeof stakeholderMandates.$inferSelect; +export type InsertStakeholderMandate = typeof stakeholderMandates.$inferInsert; + // ─── DECLARATIONS ──────────────────────────────────────────────────────────── export const declarations = pgTable("declarations", { @@ -144,6 +197,8 @@ export const declarations = pgTable("declarations", { declarationNumber: varchar("declaration_number", { length: 32 }).notNull().unique(), ucr: varchar("ucr", { length: 64 }).unique(), traderId: integer("trader_id").notNull(), + principalId: integer("principal_id").references(() => users.id), + actingAgentId: integer("acting_agent_id").references(() => users.id), declarationType: declarationTypeEnum("declaration_type").notNull(), status: declarationStatusEnum("status").default("draft").notNull(), riskLane: riskLaneEnum("risk_lane").default("green"), @@ -229,6 +284,7 @@ export const payments = pgTable("payments", { id: serial("id").primaryKey(), declarationId: integer("declaration_id").notNull().references(() => declarations.id), traderId: integer("trader_id").notNull(), + actingAgentId: integer("acting_agent_id").references(() => users.id), amount: decimal("amount", { precision: 15, scale: 2 }).notNull(), currency: varchar("currency", { length: 3 }).default("USD").notNull(), paymentMethod: paymentMethodEnum("payment_method").notNull(), diff --git a/server/_core/mandateAuthorization.ts b/server/_core/mandateAuthorization.ts new file mode 100644 index 00000000..32651621 --- /dev/null +++ b/server/_core/mandateAuthorization.ts @@ -0,0 +1,69 @@ +import { TRPCError } from "@trpc/server"; +import { getActiveStakeholderMandate } from "../db"; + +const OPERATIONAL_ROLES = new Set([ + "admin", + "customs_officer", + "oga_officer", + "finance", + "inspector", +]); + +export async function requireActiveAgentMandate( + principalUserId: number, + agentUserId: number, + at = new Date(), +) { + try { + const mandate = await getActiveStakeholderMandate(principalUserId, agentUserId, at); + if (!mandate) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "An active mandate from the principal is required.", + }); + } + return mandate; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Mandate authorization is unavailable.", + cause: error, + }); + } +} + +export async function resolveActingPrincipal( + principalUserId: number | undefined, + actor: { id: number; role: string }, +) { + if (!principalUserId || principalUserId === actor.id) { + return { principalUserId: actor.id, actingAgentId: null }; + } + + if (OPERATIONAL_ROLES.has(actor.role)) { + return { principalUserId, actingAgentId: null }; + } + + await requireActiveAgentMandate(principalUserId, actor.id); + return { principalUserId, actingAgentId: actor.id }; +} + +export async function requireDeclarationActor( + declaration: { traderId: number; principalId?: number | null; actingAgentId?: number | null }, + actor: { id: number; role: string }, + options?: { allowOperationalOverride?: boolean }, +) { + const principalUserId = declaration.principalId ?? declaration.traderId; + if (options?.allowOperationalOverride && OPERATIONAL_ROLES.has(actor.role)) { + return { principalUserId, actingAgentId: null }; + } + if (principalUserId === actor.id && !declaration.actingAgentId) { + return { principalUserId, actingAgentId: null }; + } + if (declaration.actingAgentId !== actor.id) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + await requireActiveAgentMandate(principalUserId, actor.id); + return { principalUserId, actingAgentId: actor.id }; +} diff --git a/server/db.ts b/server/db.ts index b5e5336f..cc68600e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,8 +1,9 @@ -import { eq, desc, and, gte, lte, sql, count, inArray, like } from "drizzle-orm"; +import { eq, desc, and, gte, lte, gt, isNull, sql, count, inArray, like } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { InsertUser, users, stakeholderProfiles, declarations, + stakeholderRegistrations, stakeholderMandates, declarationDocuments, ogaPermits, payments, auditEvents, securityAlerts, sanctionsChecks, aeoApplications, notifications, kycDocuments, kycVerifications, visionAnalyses, @@ -205,6 +206,157 @@ export async function getAllProfiles(limit = 50, offset = 0) { .orderBy(desc(stakeholderProfiles.createdAt)); } +// ─── NSW STAKEHOLDER REGISTRATION & MANDATE QUERIES ────────────────────────── + +export async function createStakeholderRegistration( + data: typeof stakeholderRegistrations.$inferInsert, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.insert(stakeholderRegistrations).values(data).returning(); + return registration; +} + +export async function getStakeholderRegistrationById(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.select().from(stakeholderRegistrations) + .where(eq(stakeholderRegistrations.id, id)).limit(1); + return registration; +} + +export async function getStakeholderRegistrationByReference(referenceNumber: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.select().from(stakeholderRegistrations) + .where(eq(stakeholderRegistrations.referenceNumber, referenceNumber)).limit(1); + return registration; +} + +export async function getStakeholderRegistrationsByUser(userId: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + return db.select().from(stakeholderRegistrations) + .where(eq(stakeholderRegistrations.userId, userId)) + .orderBy(desc(stakeholderRegistrations.createdAt)); +} + +export async function getPendingStakeholderRegistrations() { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + return db.select().from(stakeholderRegistrations) + .where(eq(stakeholderRegistrations.status, "pending")) + .orderBy(desc(stakeholderRegistrations.createdAt)); +} + +export async function updateStakeholderRegistration( + id: number, + data: Partial, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.update(stakeholderRegistrations) + .set({ ...data, updatedAt: new Date() }) + .where(eq(stakeholderRegistrations.id, id)) + .returning(); + return registration; +} + +export async function getApprovedAgentRegistration(agentUserId: number, at = new Date()) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.select().from(stakeholderRegistrations) + .where(and( + eq(stakeholderRegistrations.userId, agentUserId), + eq(stakeholderRegistrations.stakeholderType, "freight_forwarder"), + eq(stakeholderRegistrations.status, "approved"), + gt(stakeholderRegistrations.licenseExpiresAt, at), + )) + .orderBy(desc(stakeholderRegistrations.approvedAt)) + .limit(1); + return registration; +} + +export async function getApprovedTraderProfile(userId: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [profile] = await db.select().from(stakeholderProfiles) + .where(and( + eq(stakeholderProfiles.userId, userId), + eq(stakeholderProfiles.stakeholderType, "trader"), + eq(stakeholderProfiles.status, "approved"), + )) + .limit(1); + return profile; +} + +export async function createStakeholderMandate( + data: typeof stakeholderMandates.$inferInsert, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [mandate] = await db.insert(stakeholderMandates).values(data).returning(); + return mandate; +} + +export async function getStakeholderMandateById(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [mandate] = await db.select().from(stakeholderMandates) + .where(eq(stakeholderMandates.id, id)).limit(1); + return mandate; +} + +export async function getStakeholderMandateByReference(referenceNumber: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [mandate] = await db.select().from(stakeholderMandates) + .where(eq(stakeholderMandates.referenceNumber, referenceNumber)).limit(1); + return mandate; +} + +export async function revokeStakeholderMandate( + id: number, + revokedBy: number, + revocationReason?: string, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [mandate] = await db.update(stakeholderMandates) + .set({ + revokedAt: new Date(), + revokedBy, + revocationReason: revocationReason ?? null, + updatedAt: new Date(), + }) + .where(and(eq(stakeholderMandates.id, id), isNull(stakeholderMandates.revokedAt))) + .returning(); + return mandate; +} + +export async function getActiveStakeholderMandate( + principalUserId: number, + agentUserId: number, + at = new Date(), +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [mandate] = await db.select().from(stakeholderMandates) + .where(and( + eq(stakeholderMandates.principalUserId, principalUserId), + eq(stakeholderMandates.agentUserId, agentUserId), + lte(stakeholderMandates.validFrom, at), + gt(stakeholderMandates.validUntil, at), + isNull(stakeholderMandates.revokedAt), + )) + .orderBy(desc(stakeholderMandates.createdAt)) + .limit(1); + if (!mandate) return undefined; + + const registration = await getApprovedAgentRegistration(agentUserId, at); + return registration ? mandate : undefined; +} + // ─── DECLARATION QUERIES ────────────────────────────────────────────────────── export async function createDeclaration(data: typeof declarations.$inferInsert) { diff --git a/server/mandate.enforcement.test.ts b/server/mandate.enforcement.test.ts new file mode 100644 index 00000000..0a6d92a7 --- /dev/null +++ b/server/mandate.enforcement.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +const state = vi.hoisted(() => ({ + mandate: undefined as unknown, + created: [] as any[], + declaration: { + id: 7, + traderId: 1, + principalId: 1, + actingAgentId: 2, + status: "draft" as const, + declarationNumber: "TG-2026-TEST", + }, +})); + +vi.mock("./db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getActiveStakeholderMandate: vi.fn(async () => state.mandate), + getProfileByUserId: vi.fn(async () => ({ + id: 1, + userId: 1, + stakeholderType: "trader", + status: "approved", + })), + createDeclaration: vi.fn(async (data: any) => { + const created = { ...data, id: 8 }; + state.created.push(created); + return created; + }), + getDeclarationById: vi.fn(async () => state.declaration), + getLatestKYCVerification: vi.fn(async () => ({ + status: "APPROVED", + userId: 1, + })), + logAuditEvent: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("./_core/permify", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + setOwner: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("./_core/llm", () => ({ + invokeLLM: vi.fn().mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ score: 10, lane: "green", factors: [], summary: "low" }) } }], + }), +})); + +function context(id: number): TrpcContext { + return { + user: { + id, + openId: `mandate-${id}`, + name: `Mandate ${id}`, + email: `mandate-${id}@example.com`, + loginMethod: "test", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { method: "POST", headers: {}, cookies: {} } as TrpcContext["req"], + res: { cookie: vi.fn(), clearCookie: vi.fn() } as unknown as TrpcContext["res"], + }; +} + +const declarationInput = { + declarationType: "import" as const, + hsCode: "8471.30", + goodsDescription: "Laptop computers for resale", + countryOfOrigin: "US", + portOfEntry: "Lagos", + grossWeight: 10, + netWeight: 9, + numberOfPackages: 1, + invoiceValue: 5000, + invoiceCurrency: "USD", +}; + +describe("mandate enforcement for third-party filing", () => { + it("rejects an agent filing without an active mandate", async () => { + state.mandate = undefined; + await expect( + appRouter.createCaller(context(2)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("accepts an active mandate and records principal and acting agent", async () => { + state.mandate = { id: 1, principalUserId: 1, agentUserId: 2 }; + state.created.length = 0; + const result = await appRouter.createCaller(context(2)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }); + expect(result.traderId).toBe(1); + expect(result.principalId).toBe(1); + expect(result.actingAgentId).toBe(2); + }); + + it.each([ + ["revoked", undefined], + ["expired mandate", undefined], + ["expired agent licence", undefined], + ])("rejects filing with %s", async (_label, mandate) => { + state.mandate = mandate; + await expect( + appRouter.createCaller(context(2)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rechecks the mandate before a draft is submitted", async () => { + state.mandate = undefined; + await expect( + appRouter.createCaller(context(2)).declarations.submit({ id: 7 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 7b20840f..92ef03c7 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -56,6 +56,7 @@ import { portCongestionRouter } from "./routers/portCongestion"; import { traderScorecardRouter } from "./routers/traderScorecard"; import { cargoTrackingRouter } from "./routers/cargoTracking"; import { onboardingRouter } from "./routers/onboarding"; +import { stakeholderRegistrationsRouter } from "./routers/stakeholderRegistrations"; import { geofencesRouter } from "./routers/geofences"; import { webhooksRouter } from "./routers/webhooks"; import { apiChangelogRouter } from "./routers/apiChangelog"; @@ -307,6 +308,7 @@ export const appRouter = router({ traderScorecard: traderScorecardRouter, cargoTracking: cargoTrackingRouter, onboarding: onboardingRouter, + stakeholderRegistrations: stakeholderRegistrationsRouter, geofences: geofencesRouter, webhooks: webhooksRouter, apiChangelog: apiChangelogRouter, diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index 2147f4e8..fbe20527 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -17,6 +17,7 @@ import { publishEvent, TOPICS } from "../_core/kafka"; import { assertValidTransition, assignRiskLane, validateHsCode, checkPermitValidity, calculateDuty, type DeclarationStatus } from "../businessRules"; import { indexDeclaration, searchDeclarations } from "../_core/opensearch"; import { scoreDeclarationRisk, validateDeclarationWithEngine, getCargoPosition } from "../_core/polyglotClients"; +import { resolveActingPrincipal, requireDeclarationActor } from "../_core/mandateAuthorization"; // Generate a unique declaration number: TG-YYYY-XXXXXXXX function generateDeclarationNumber(): string { @@ -177,6 +178,7 @@ export const declarationsRouter = router({ numberOfPackages: z.number().int().positive(), invoiceValue: z.number().positive(), invoiceCurrency: z.string().length(3).default("USD"), + principalUserId: z.number().int().positive().optional(), })) .mutation(async ({ ctx, input }) => { // Business Rule: Validate HS code format (WCO Harmonised System) @@ -184,14 +186,17 @@ export const declarationsRouter = router({ if (!hsValidation.valid) { throw new TRPCError({ code: "BAD_REQUEST", message: hsValidation.error ?? "Invalid HS code" }); } - const profile = await getProfileByUserId(ctx.user.id); + const { principalUserId, actingAgentId } = await resolveActingPrincipal(input.principalUserId, ctx.user); + const profile = await getProfileByUserId(principalUserId); if (!profile || profile.status !== "approved") { throw new TRPCError({ code: "FORBIDDEN", message: "Your trader profile must be approved before submitting declarations." }); } const decl = await createDeclaration({ declarationNumber: generateDeclarationNumber(), ucr: generateUCR(), - traderId: ctx.user.id, + traderId: principalUserId, + principalId: principalUserId, + actingAgentId, declarationType: input.declarationType, status: "draft", hsCode: input.hsCode, @@ -210,11 +215,11 @@ export const declarationsRouter = router({ entityId: decl!.id, action: "created", actorId: ctx.user.id, - actorType: "trader", + actorType: actingAgentId ? "freight_forwarder" : "trader", newState: decl, }); // Permify: register trader as owner of this declaration - await setOwner("declaration", decl!.id, ctx.user.id); + await setOwner("declaration", decl!.id, principalUserId); return decl; }), @@ -224,12 +229,12 @@ export const declarationsRouter = router({ .mutation(async ({ ctx, input }) => { const decl = await getDeclarationById(input.id); if (!decl) throw new TRPCError({ code: "NOT_FOUND" }); - if (decl.traderId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + const { principalUserId, actingAgentId } = await requireDeclarationActor(decl, ctx.user); if (decl.status !== "draft") throw new TRPCError({ code: "BAD_REQUEST", message: "Only draft declarations can be submitted." }); // B5 FIX: KYC gate — trader must have an approved KYC verification before submitting. // This prevents unverified traders from injecting declarations into the customs workflow. - const kycRecord = await getLatestKYCVerification(ctx.user.id); + const kycRecord = await getLatestKYCVerification(principalUserId); if (!kycRecord || kycRecord.status !== 'APPROVED') { throw new TRPCError({ code: 'FORBIDDEN', @@ -249,7 +254,7 @@ export const declarationsRouter = router({ }, { declarationId: String(input.id), - traderId: String(ctx.user.id), + traderId: String(principalUserId), } ); @@ -278,13 +283,13 @@ export const declarationsRouter = router({ entityId: input.id, action: "submitted", actorId: ctx.user.id, - actorType: "trader", + actorType: actingAgentId ? "freight_forwarder" : "trader", previousState: { status: "draft" }, newState: { status: "under_assessment", riskScore: risk.score, riskLane: risk.lane }, }); await createNotification({ - userId: ctx.user.id, + userId: principalUserId, type: "declaration_submitted", title: "Declaration Submitted", message: `Your declaration ${decl.declarationNumber} has been submitted. Risk lane: ${risk.lane.toUpperCase()}. Total duties: ${total.toFixed(2)} ${decl.invoiceCurrency}.`, @@ -294,7 +299,7 @@ export const declarationsRouter = router({ // In-app Notification Centre entry await createUserNotification({ - userId: ctx.user.id, + userId: principalUserId, type: "declaration_submitted", title: "Declaration Submitted ✓", body: `Your declaration ${decl.declarationNumber} has been submitted for assessment. Risk lane assigned: ${risk.lane.toUpperCase()}. Estimated duties: ${total.toFixed(2)} ${decl.invoiceCurrency ?? "USD"}.`, @@ -308,7 +313,7 @@ export const declarationsRouter = router({ payload: { declarationId: input.id, declarationNumber: decl.declarationNumber, - traderId: ctx.user.id, + traderId: principalUserId, riskLane: risk.lane, riskScore: risk.score, hsCode: decl.hsCode, @@ -324,7 +329,7 @@ export const declarationsRouter = router({ id: input.id, declarationNumber: decl.declarationNumber, ucr: decl.ucr, - traderId: ctx.user.id, + traderId: principalUserId, declarationType: decl.declarationType, status: 'under_assessment', riskLane: risk.lane, diff --git a/server/routers/payments.ts b/server/routers/payments.ts index 59e6ff85..896d3258 100644 --- a/server/routers/payments.ts +++ b/server/routers/payments.ts @@ -19,6 +19,7 @@ import { assertCan, setOwner } from "../_core/permify"; import { getDb } from "../db"; import { emitPaymentInitiated } from "../_core/kafkaEventPublisher"; import { getOrProvisionTraderAccount, SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; +import { requireDeclarationActor } from "../_core/mandateAuthorization"; export const paymentsRouter = router({ // ── INITIATE PAYMENT ───────────────────────────────────────────────────────── @@ -34,18 +35,19 @@ export const paymentsRouter = router({ .mutation(async ({ ctx, input }) => { const decl = await getDeclarationById(input.declarationId); if (!decl) throw new TRPCError({ code: "NOT_FOUND" }); - if (decl.traderId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + const { principalUserId, actingAgentId } = await requireDeclarationActor(decl, ctx.user); if (!["under_assessment", "payment_pending"].includes(decl.status)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Declaration is not ready for payment." }); } // R4 FIX: Ensure per-trader payment account exists before enqueuing - const traderAccountId = await getOrProvisionTraderAccount(ctx.user.id, decl.invoiceCurrency ?? 'USD'); + const traderAccountId = await getOrProvisionTraderAccount(principalUserId, decl.invoiceCurrency ?? 'USD'); const reference = `PAY-${nanoid(12).toUpperCase()}`; const payment = await createPayment({ declarationId: input.declarationId, - traderId: ctx.user.id, + traderId: principalUserId, + actingAgentId, amount: decl.totalDue ?? "0", currency: decl.invoiceCurrency ?? "USD", paymentMethod: input.paymentMethod, @@ -54,7 +56,7 @@ export const paymentsRouter = router({ }); if (payment) { - await setOwner("payment", payment.id, ctx.user.id); + await setOwner("payment", payment.id, principalUserId); } await updateDeclaration(input.declarationId, { status: "payment_pending" }); @@ -121,7 +123,7 @@ export const paymentsRouter = router({ entityId: payment?.id ?? 0, action: "payment_initiated", actorId: ctx.user.id, - actorType: "trader", + actorType: actingAgentId ? "freight_forwarder" : "trader", newState: { status: "pending", reference, paymentMethod: input.paymentMethod }, }); // R3: Kafka event @@ -129,7 +131,7 @@ export const paymentsRouter = router({ await emitPaymentInitiated({ paymentId: payment.id, declarationId: input.declarationId, - traderId: ctx.user.id, + traderId: principalUserId, amount: parseFloat(decl.totalDue ?? '0'), currency: decl.invoiceCurrency ?? 'USD', idempotencyKey: reference, @@ -168,9 +170,13 @@ export const paymentsRouter = router({ const [existing] = await db.select().from(payments).where(eq(payments.id, input.paymentId)).limit(1); if (!existing) throw new TRPCError({ code: "NOT_FOUND" }); - if (existing.traderId !== ctx.user.id && ctx.user.role !== "admin") { - throw new TRPCError({ code: "FORBIDDEN" }); - } + const declaration = await getDeclarationById(existing.declarationId); + if (!declaration) throw new TRPCError({ code: "NOT_FOUND" }); + await requireDeclarationActor( + { ...declaration, actingAgentId: existing.actingAgentId }, + ctx.user, + { allowOperationalOverride: true }, + ); if (existing.status === "confirmed") { return existing; // Already confirmed — idempotent @@ -393,9 +399,10 @@ export const paymentsRouter = router({ return []; } const decl = await getDeclarationById(input.declarationId); - if (!decl || decl.traderId !== ctx.user.id) { + if (!decl) { throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found" }); } + await requireDeclarationActor(decl, ctx.user); return withRlsContext({ id: ctx.user.id, role: ctx.user.role }, async (db) => db.select().from(payments).where(eq(payments.declarationId, input.declarationId)) ); @@ -532,9 +539,13 @@ export const paymentsRouter = router({ // Permify RBAC: only the owner or an admin can cancel a payment await assertCan(String(ctx.user.id), "payment", String(input.paymentId), "cancel"); - if (existing.traderId !== ctx.user.id && ctx.user.role !== "admin") { - throw new TRPCError({ code: "FORBIDDEN" }); - } + const declaration = await getDeclarationById(existing.declarationId); + if (!declaration) throw new TRPCError({ code: "NOT_FOUND" }); + await requireDeclarationActor( + { ...declaration, actingAgentId: existing.actingAgentId }, + ctx.user, + { allowOperationalOverride: true }, + ); if (existing.status !== "pending") { throw new TRPCError({ code: "BAD_REQUEST", message: "Only pending payments can be cancelled." }); } diff --git a/server/routers/stakeholderRegistrations.ts b/server/routers/stakeholderRegistrations.ts new file mode 100644 index 00000000..5ad5f2a9 --- /dev/null +++ b/server/routers/stakeholderRegistrations.ts @@ -0,0 +1,289 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { publicProcedure, protectedProcedure, router } from "../_core/trpc"; +import { + createStakeholderRegistration, + getStakeholderRegistrationById, + getStakeholderRegistrationByReference, + getStakeholderRegistrationsByUser, + getPendingStakeholderRegistrations, + updateStakeholderRegistration, + createStakeholderMandate, + getStakeholderMandateById, + getStakeholderMandateByReference, + revokeStakeholderMandate, + getApprovedAgentRegistration, + getApprovedTraderProfile, + logAuditEvent, +} from "../db"; +import { resolveActingPrincipal } from "../_core/mandateAuthorization"; +import { nanoid } from "nanoid"; + +const registrationType = z.enum([ + "freight_forwarder", + "shipping_line", + "shipping_company", + "airline_gha", +]); + +const registrationInput = z.object({ + stakeholderType: registrationType, + organizationName: z.string().min(2).max(255), + organizationCode: z.string().max(64).optional(), + licenseNumber: z.string().max(128).optional(), + licenseExpiresAt: z.string().datetime().optional(), + taxId: z.string().max(64).optional(), + country: z.string().length(2), + phone: z.string().max(32).optional(), + kycDocumentIds: z.array(z.number().int().positive()).max(20).default([]), +}).superRefine((input, ctx) => { + if (input.stakeholderType === "freight_forwarder") { + if (!input.licenseNumber) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["licenseNumber"], message: "A licence number is required for freight-forwarding agents." }); + } + if (!input.licenseExpiresAt) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["licenseExpiresAt"], message: "A licence expiry date is required for freight-forwarding agents." }); + } else if (new Date(input.licenseExpiresAt) <= new Date()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["licenseExpiresAt"], message: "The licence must not already be expired." }); + } + } +}); + +const REVIEWER_ROLES = new Set(["admin", "customs_officer", "oga_officer"]); + +function reference(prefix: string) { + return `NSW-${prefix}-${new Date().getFullYear()}-${nanoid(10).toUpperCase()}`; +} + +function serviceUnavailable(message: string, cause: unknown): never { + if (cause instanceof TRPCError) throw cause; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message, cause }); +} + +export const stakeholderRegistrationsRouter = router({ + register: protectedProcedure + .input(registrationInput) + .mutation(async ({ ctx, input }) => { + try { + const registration = await createStakeholderRegistration({ + referenceNumber: reference("REG"), + userId: ctx.user.id, + stakeholderType: input.stakeholderType, + organizationName: input.organizationName, + organizationCode: input.organizationCode, + licenseNumber: input.licenseNumber, + licenseExpiresAt: input.licenseExpiresAt ? new Date(input.licenseExpiresAt) : null, + taxId: input.taxId, + country: input.country, + phone: input.phone, + kycDocumentIds: input.kycDocumentIds, + status: "pending", + }); + await logAuditEvent({ + entityType: "user", + entityId: ctx.user.id, + action: "stakeholder_registration_created", + actorId: ctx.user.id, + actorType: input.stakeholderType, + newState: { referenceNumber: registration.referenceNumber, status: registration.status }, + }); + return registration; + } catch (error) { + return serviceUnavailable("Stakeholder registration is unavailable.", error); + } + }), + + track: publicProcedure + .input(z.object({ referenceNumber: z.string().min(8).max(32) })) + .query(async ({ input }) => { + try { + const registration = await getStakeholderRegistrationByReference(input.referenceNumber); + if (!registration) throw new TRPCError({ code: "NOT_FOUND", message: "Application not found." }); + return { + referenceNumber: registration.referenceNumber, + stakeholderType: registration.stakeholderType, + organizationName: registration.organizationName, + status: registration.status, + createdAt: registration.createdAt, + updatedAt: registration.updatedAt, + approvedAt: registration.approvedAt, + rejectionReason: registration.rejectionReason, + }; + } catch (error) { + return serviceUnavailable("Application tracking is unavailable.", error); + } + }), + + mine: protectedProcedure.query(async ({ ctx }) => { + try { + return await getStakeholderRegistrationsByUser(ctx.user.id); + } catch (error) { + return serviceUnavailable("Stakeholder registrations are unavailable.", error); + } + }), + + pending: protectedProcedure.query(async ({ ctx }) => { + if (!REVIEWER_ROLES.has(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + return await getPendingStakeholderRegistrations(); + } catch (error) { + return serviceUnavailable("Stakeholder registrations are unavailable.", error); + } + }), + + approve: protectedProcedure + .input(z.object({ registrationId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + if (!REVIEWER_ROLES.has(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const registration = await getStakeholderRegistrationById(input.registrationId); + if (!registration) throw new TRPCError({ code: "NOT_FOUND" }); + if (registration.status === "approved") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Registration is already approved." }); + } + const updated = await updateStakeholderRegistration(input.registrationId, { + status: "approved", + approvedBy: ctx.user.id, + approvedAt: new Date(), + rejectionReason: null, + }); + if (!updated) throw new TRPCError({ code: "NOT_FOUND" }); + await logAuditEvent({ + entityType: "user", + entityId: updated.userId, + action: "stakeholder_registration_approved", + actorId: ctx.user.id, + actorType: ctx.user.role, + newState: { referenceNumber: updated.referenceNumber, status: updated.status }, + }); + return updated; + } catch (error) { + return serviceUnavailable("Stakeholder registration approval is unavailable.", error); + } + }), + + reject: protectedProcedure + .input(z.object({ registrationId: z.number().int().positive(), reason: z.string().min(10).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!REVIEWER_ROLES.has(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const updated = await updateStakeholderRegistration(input.registrationId, { + status: "rejected", + rejectionReason: input.reason, + }); + if (!updated) throw new TRPCError({ code: "NOT_FOUND" }); + await logAuditEvent({ + entityType: "user", + entityId: updated.userId, + action: "stakeholder_registration_rejected", + actorId: ctx.user.id, + actorType: ctx.user.role, + newState: { referenceNumber: updated.referenceNumber, status: updated.status, reason: input.reason }, + }); + return updated; + } catch (error) { + return serviceUnavailable("Stakeholder registration rejection is unavailable.", error); + } + }), + + createMandate: protectedProcedure + .input(z.object({ + agentUserId: z.number().int().positive(), + validFrom: z.string().datetime().optional(), + validUntil: z.string().datetime(), + })) + .mutation(async ({ ctx, input }) => { + try { + const validFrom = input.validFrom ? new Date(input.validFrom) : new Date(); + const validUntil = new Date(input.validUntil); + if (validUntil <= validFrom) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Mandate validity must end after it begins." }); + } + if (validUntil <= new Date()) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Mandate validity must extend into the future." }); + } + if (input.agentUserId === ctx.user.id) { + throw new TRPCError({ code: "BAD_REQUEST", message: "A principal cannot appoint itself as its agent." }); + } + const principalProfile = await getApprovedTraderProfile(ctx.user.id); + if (!principalProfile) { + throw new TRPCError({ code: "FORBIDDEN", message: "An approved importer/exporter profile is required." }); + } + if (!(await getApprovedAgentRegistration(input.agentUserId))) { + throw new TRPCError({ code: "FORBIDDEN", message: "The agent must have an approved, unexpired licence." }); + } + const mandate = await createStakeholderMandate({ + referenceNumber: reference("MND"), + principalUserId: ctx.user.id, + agentUserId: input.agentUserId, + validFrom, + validUntil, + }); + await logAuditEvent({ + entityType: "user", + entityId: ctx.user.id, + action: "stakeholder_mandate_created", + actorId: ctx.user.id, + actorType: "trader", + newState: { + referenceNumber: mandate.referenceNumber, + principalUserId: mandate.principalUserId, + agentUserId: mandate.agentUserId, + validFrom, + validUntil, + }, + }); + return mandate; + } catch (error) { + return serviceUnavailable("Mandate creation is unavailable.", error); + } + }), + + getMandate: protectedProcedure + .input(z.object({ referenceNumber: z.string().min(8).max(32) })) + .query(async ({ ctx, input }) => { + try { + const mandate = await getStakeholderMandateByReference(input.referenceNumber); + if (!mandate) throw new TRPCError({ code: "NOT_FOUND" }); + if (mandate.principalUserId !== ctx.user.id && mandate.agentUserId !== ctx.user.id && !REVIEWER_ROLES.has(ctx.user.role)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return mandate; + } catch (error) { + return serviceUnavailable("Mandate lookup is unavailable.", error); + } + }), + + revokeMandate: protectedProcedure + .input(z.object({ mandateId: z.number().int().positive(), reason: z.string().max(1024).optional() })) + .mutation(async ({ ctx, input }) => { + try { + const mandate = await getStakeholderMandateById(input.mandateId); + if (!mandate) throw new TRPCError({ code: "NOT_FOUND" }); + if (mandate.principalUserId !== ctx.user.id && !REVIEWER_ROLES.has(ctx.user.role)) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const revoked = await revokeStakeholderMandate(input.mandateId, ctx.user.id, input.reason); + if (!revoked) throw new TRPCError({ code: "BAD_REQUEST", message: "Mandate is already revoked." }); + await logAuditEvent({ + entityType: "user", + entityId: mandate.principalUserId, + action: "stakeholder_mandate_revoked", + actorId: ctx.user.id, + actorType: ctx.user.role, + previousState: { referenceNumber: mandate.referenceNumber }, + newState: { revokedAt: revoked.revokedAt, revokedBy: revoked.revokedBy, reason: input.reason }, + }); + return revoked; + } catch (error) { + return serviceUnavailable("Mandate revocation is unavailable.", error); + } + }), + + resolvePrincipal: protectedProcedure + .input(z.object({ principalUserId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + const resolved = await resolveActingPrincipal(input.principalUserId, ctx.user); + return resolved; + }), +}); diff --git a/server/routers/tradeFinance.ts b/server/routers/tradeFinance.ts index d51ed56b..2355a634 100644 --- a/server/routers/tradeFinance.ts +++ b/server/routers/tradeFinance.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { router, protectedProcedure } from "../_core/trpc"; import { fetchWithResilience } from "../_core/middlewareClients"; +import { resolveActingPrincipal } from "../_core/mandateAuthorization"; const TRADE_FINANCE_URL = process.env.TRADE_FINANCE_SERVICE_URL ?? "http://localhost:8097"; @@ -16,6 +17,7 @@ export const tradeFinanceRouter = router({ .input(z.object({ declarationId: z.string().uuid().optional(), applicantId: z.string(), + principalUserId: z.number().int().positive().optional(), applicantName: z.string(), beneficiaryName: z.string(), beneficiaryCountry: z.string().length(3), @@ -31,9 +33,10 @@ export const tradeFinanceRouter = router({ incoterms: z.string().default("CIF"), })) .mutation(async ({ input, ctx }) => { - const applicantId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) + const resolved = await resolveActingPrincipal(input.principalUserId, ctx.user); + const applicantId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) && !input.principalUserId ? input.applicantId - : String(ctx.user.id); + : String(resolved.principalUserId); const res = await fetchWithResilience( `${TRADE_FINANCE_URL}/v1/letters-of-credit`, { @@ -42,6 +45,7 @@ export const tradeFinanceRouter = router({ body: JSON.stringify({ declaration_id: input.declarationId, applicant_id: applicantId, + acting_agent_id: resolved.actingAgentId, applicant_name: input.applicantName, beneficiary_name: input.beneficiaryName, beneficiary_country: input.beneficiaryCountry, @@ -67,6 +71,7 @@ export const tradeFinanceRouter = router({ .input(z.object({ declarationId: z.string().uuid().optional(), traderId: z.string(), + principalUserId: z.number().int().positive().optional(), issuingBank: z.string(), guaranteeType: z.enum(["customs_bond", "duty_deferment", "transit"]), amount: z.number().positive(), @@ -75,9 +80,10 @@ export const tradeFinanceRouter = router({ dutyAmount: z.number().min(0).default(0), })) .mutation(async ({ input, ctx }) => { - const traderId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) + const resolved = await resolveActingPrincipal(input.principalUserId, ctx.user); + const traderId = ["admin", "customs_officer", "finance", "oga_officer"].includes(ctx.user.role) && !input.principalUserId ? input.traderId - : String(ctx.user.id); + : String(resolved.principalUserId); const res = await fetchWithResilience( `${TRADE_FINANCE_URL}/v1/bank-guarantees`, { @@ -86,6 +92,7 @@ export const tradeFinanceRouter = router({ body: JSON.stringify({ declaration_id: input.declarationId, trader_id: traderId, + acting_agent_id: resolved.actingAgentId, issuing_bank: input.issuingBank, guarantee_type: input.guaranteeType, amount: input.amount, diff --git a/server/stakeholderRegistrations.test.ts b/server/stakeholderRegistrations.test.ts new file mode 100644 index 00000000..17617ee7 --- /dev/null +++ b/server/stakeholderRegistrations.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +const dbState = vi.hoisted(() => ({ + registration: { + id: 11, + referenceNumber: "NSW-REG-2026-TEST", + userId: 2, + stakeholderType: "freight_forwarder" as const, + organizationName: "Licensed Clearing Ltd", + licenseNumber: "LIC-001", + licenseExpiresAt: new Date("2030-01-01T00:00:00.000Z"), + status: "pending" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + mandate: { + id: 21, + referenceNumber: "NSW-MND-2026-TEST", + principalUserId: 1, + agentUserId: 2, + validFrom: new Date("2026-01-01T00:00:00.000Z"), + validUntil: new Date("2027-01-01T00:00:00.000Z"), + revokedAt: null, + revokedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + unavailable: false, +})); + +vi.mock("./db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createStakeholderRegistration: vi.fn(async (data: any) => { + if (dbState.unavailable) throw new Error("database unavailable"); + return { ...dbState.registration, ...data }; + }), + getStakeholderRegistrationByReference: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return dbState.registration; + }), + getStakeholderRegistrationById: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return dbState.registration; + }), + getStakeholderRegistrationsByUser: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return [dbState.registration]; + }), + getPendingStakeholderRegistrations: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return [dbState.registration]; + }), + updateStakeholderRegistration: vi.fn(async (_id: number, data: any) => ({ + ...dbState.registration, + ...data, + })), + getApprovedTraderProfile: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return { + id: 1, + userId: 1, + stakeholderType: "trader", + status: "approved", + }; + }), + getApprovedAgentRegistration: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return { + ...dbState.registration, + status: "approved", + }; + }), + createStakeholderMandate: vi.fn(async (data: any) => { + if (dbState.unavailable) throw new Error("database unavailable"); + return { ...dbState.mandate, ...data }; + }), + getStakeholderMandateById: vi.fn(async () => dbState.mandate), + getStakeholderMandateByReference: vi.fn(async () => dbState.mandate), + revokeStakeholderMandate: vi.fn(async (_id: number, revokedBy: number, reason?: string) => ({ + ...dbState.mandate, + revokedAt: new Date(), + revokedBy, + revocationReason: reason ?? null, + })), + logAuditEvent: vi.fn().mockResolvedValue(undefined), + }; +}); + +function context(id: number, role: "user" | "admin" = "user"): TrpcContext { + return { + user: { + id, + openId: `stakeholder-${id}`, + name: `Stakeholder ${id}`, + email: `stakeholder-${id}@example.com`, + loginMethod: "test", + role, + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { method: "POST", headers: {}, cookies: {} } as TrpcContext["req"], + res: { cookie: vi.fn(), clearCookie: vi.fn() } as unknown as TrpcContext["res"], + }; +} + +describe("NSW stakeholder registrations and mandates", () => { + it("mints a reference and starts a registration pending without changing user capability", async () => { + dbState.unavailable = false; + const result = await appRouter.createCaller(context(2)).stakeholderRegistrations.register({ + stakeholderType: "freight_forwarder", + organizationName: "Licensed Clearing Ltd", + licenseNumber: "LIC-001", + licenseExpiresAt: "2030-01-01T00:00:00.000Z", + country: "NG", + }); + + expect(result.referenceNumber).toMatch(/^NSW-REG-/); + expect(result.status).toBe("pending"); + expect(result.userId).toBe(2); + expect(result).not.toHaveProperty("role"); + }); + + it("allows only an officer to approve and transitions the application", async () => { + const result = await appRouter.createCaller(context(1, "admin")).stakeholderRegistrations.approve({ + registrationId: 11, + }); + expect(result.status).toBe("approved"); + expect(result.approvedBy).toBe(1); + }); + + it("creates and revokes a durable principal-to-agent mandate", async () => { + const principal = appRouter.createCaller(context(1)); + const mandate = await principal.stakeholderRegistrations.createMandate({ + agentUserId: 2, + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2027-01-01T00:00:00.000Z", + }); + expect(mandate.referenceNumber).toMatch(/^NSW-MND-/); + expect(mandate.principalUserId).toBe(1); + expect(mandate.agentUserId).toBe(2); + + const revoked = await principal.stakeholderRegistrations.revokeMandate({ + mandateId: 21, + reason: "Principal ended the engagement", + }); + expect(revoked.revokedBy).toBe(1); + expect(revoked.revokedAt).toBeInstanceOf(Date); + }); + + it("fails closed when registration persistence is unavailable", async () => { + dbState.unavailable = true; + await expect( + appRouter.createCaller(context(2)).stakeholderRegistrations.register({ + stakeholderType: "shipping_line", + organizationName: "Ocean Carrier Ltd", + country: "NG", + }), + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + dbState.unavailable = false; + }); + + it("fails closed when mandate persistence is unavailable", async () => { + dbState.unavailable = true; + await expect( + appRouter.createCaller(context(1)).stakeholderRegistrations.createMandate({ + agentUserId: 2, + validUntil: "2027-01-01T00:00:00.000Z", + }), + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + dbState.unavailable = false; + }); + + it("fails closed when registration listing is unavailable", async () => { + dbState.unavailable = true; + await expect( + appRouter.createCaller(context(2)).stakeholderRegistrations.mine(), + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + dbState.unavailable = false; + }); +}); From a3e7e47dc3c17b7d5a4f84dea0382f3cc380a5e3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:07:33 +0000 Subject: [PATCH 02/17] feat(nsw): complete registration tracking and cargo parity Co-Authored-By: Patrick Munis --- server/_core/applicationTracking.ts | 48 ++ server/_core/mandateAuthorization.ts | 15 +- server/_core/trpc.ts | 16 + server/cargoTracking.persisted.test.ts | 59 ++ server/db.ts | 73 +- server/mandate.enforcement.test.ts | 62 +- server/nsw.parity.followup.test.ts | 176 +++++ server/routers.ts | 2 + server/routers/applicationTracking.ts | 21 + server/routers/cargoTracking.ts | 750 +++++++-------------- server/routers/oga.ts | 45 +- server/routers/stakeholderRegistrations.ts | 41 +- server/sprint69-71.test.ts | 4 +- server/stakeholderRegistrations.test.ts | 39 ++ server/v87-v105.test.ts | 6 +- 15 files changed, 820 insertions(+), 537 deletions(-) create mode 100644 server/_core/applicationTracking.ts create mode 100644 server/cargoTracking.persisted.test.ts create mode 100644 server/nsw.parity.followup.test.ts create mode 100644 server/routers/applicationTracking.ts diff --git a/server/_core/applicationTracking.ts b/server/_core/applicationTracking.ts new file mode 100644 index 00000000..eeba932c --- /dev/null +++ b/server/_core/applicationTracking.ts @@ -0,0 +1,48 @@ +import { TRPCError } from "@trpc/server"; +import { + getPublicDeclarationTracking, + getPublicPermitTracking, + getStakeholderRegistrationByReference, +} from "../db"; + +export async function lookupPublicApplication(referenceNumber: string) { + const registration = await getStakeholderRegistrationByReference(referenceNumber); + if (registration) { + return { + referenceNumber: registration.referenceNumber, + type: registration.stakeholderType, + status: registration.status, + createdAt: registration.createdAt, + updatedAt: registration.updatedAt, + approvedAt: registration.approvedAt, + }; + } + + const declaration = await getPublicDeclarationTracking(referenceNumber); + if (declaration) { + return { + referenceNumber: declaration.declarationNumber, + type: declaration.declarationType, + status: declaration.status, + createdAt: declaration.createdAt, + updatedAt: declaration.updatedAt, + submittedAt: declaration.submittedAt, + clearedAt: declaration.clearedAt, + }; + } + + const permit = await getPublicPermitTracking(referenceNumber); + if (permit) { + return { + referenceNumber: permit.permitNumber, + type: permit.permitType ?? "oga_permit", + status: permit.status, + createdAt: permit.createdAt, + updatedAt: permit.updatedAt, + respondedAt: permit.respondedAt, + expiresAt: permit.expiresAt, + }; + } + + throw new TRPCError({ code: "NOT_FOUND", message: "Application not found." }); +} diff --git a/server/_core/mandateAuthorization.ts b/server/_core/mandateAuthorization.ts index 32651621..691b95df 100644 --- a/server/_core/mandateAuthorization.ts +++ b/server/_core/mandateAuthorization.ts @@ -1,12 +1,15 @@ import { TRPCError } from "@trpc/server"; import { getActiveStakeholderMandate } from "../db"; -const OPERATIONAL_ROLES = new Set([ +const DECLARATION_CREATION_OVERRIDE_ROLES = new Set([ "admin", "customs_officer", - "oga_officer", +]); + +const PAYMENT_OPERATIONAL_OVERRIDE_ROLES = new Set([ + "admin", "finance", - "inspector", + "customs_officer", ]); export async function requireActiveAgentMandate( @@ -41,7 +44,7 @@ export async function resolveActingPrincipal( return { principalUserId: actor.id, actingAgentId: null }; } - if (OPERATIONAL_ROLES.has(actor.role)) { + if (DECLARATION_CREATION_OVERRIDE_ROLES.has(actor.role)) { return { principalUserId, actingAgentId: null }; } @@ -55,10 +58,10 @@ export async function requireDeclarationActor( options?: { allowOperationalOverride?: boolean }, ) { const principalUserId = declaration.principalId ?? declaration.traderId; - if (options?.allowOperationalOverride && OPERATIONAL_ROLES.has(actor.role)) { + if (principalUserId === actor.id) { return { principalUserId, actingAgentId: null }; } - if (principalUserId === actor.id && !declaration.actingAgentId) { + if (options?.allowOperationalOverride && PAYMENT_OPERATIONAL_OVERRIDE_ROLES.has(actor.role)) { return { principalUserId, actingAgentId: null }; } if (declaration.actingAgentId !== actor.id) { diff --git a/server/_core/trpc.ts b/server/_core/trpc.ts index 8249d359..79220f6d 100644 --- a/server/_core/trpc.ts +++ b/server/_core/trpc.ts @@ -3,6 +3,7 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { TrpcContext } from "./context"; import { redisRateLimit } from './redis'; +import { incrementRateLimit } from './redisRateLimiter'; import crypto from 'crypto'; // ─── CSRF Token Utilities (B3 FIX) ──────────────────────────────────────────── @@ -269,6 +270,21 @@ export const rateLimitedProcedure = protectedProcedure.use( }) ); +export const publicRateLimitedProcedure = publicProcedure.use( + t.middleware(async opts => { + const { ctx, next } = opts; + const forwardedFor = ctx.req.headers["x-forwarded-for"] as string | undefined; + const ip = forwardedFor?.split(",")[0]?.trim() + ?? (ctx.req.socket as any)?.remoteAddress + ?? "unknown"; + const count = await incrementRateLimit(`rl:public:ip:${ip}`, 60_000); + if (count > 60) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Rate limit exceeded. Try again in 60 seconds." }); + } + return next({ ctx }); + }) +); + // ─── Async audit log writer ─────────────────────────────────────────────────── async function _writeAuditLog(p: { userId?: number | null; action: string; resourceType: string; entityId?: number; diff --git a/server/cargoTracking.persisted.test.ts b/server/cargoTracking.persisted.test.ts new file mode 100644 index 00000000..26ee2e40 --- /dev/null +++ b/server/cargoTracking.persisted.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ rows: [] as any[] })); + +vi.mock("./db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDb: vi.fn(async () => ({})), + getPool: vi.fn(() => ({ + query: vi.fn(async (query: string) => { + if (query.includes("COUNT(DISTINCT mmsi)")) { + return { + rows: state.rows.length + ? [{ total: String(state.rows.length), moored: "0", anchored: "0", underway: String(state.rows.length), red_flag: "0", amber_flag: "0" }] + : [{ total: "0", moored: "0", anchored: "0", underway: "0", red_flag: "0", amber_flag: "0" }], + }; + } + return { rows: state.rows }; + }), + })), + }; +}); + +describe("persisted cargo tracking", () => { + it("returns persisted vessel events without fabricating shipment or port metadata", async () => { + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + state.rows = [{ + mmsi: "123456789", + vessel_name: "Persisted Vessel", + imo_number: "IMO123", + latitude: 6.4, + longitude: 3.4, + speed: 12, + heading: 90, + destination_port: "Lagos", + eta: new Date("2030-01-01T00:00:00.000Z"), + cargo_type: "container", + flag_country: "NG", + recorded_at: new Date("2026-01-01T00:00:00.000Z"), + }]; + const caller = cargoTrackingRouter.createCaller({} as any); + const result = await caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" }); + expect(result.vessels).toHaveLength(1); + expect(result.vessels[0]).toMatchObject({ mmsi: "123456789", vesselName: "Persisted Vessel", lat: 6.4 }); + expect(result.vessels[0].declarationRef).toBeNull(); + expect(result.sourceService).toBe("vessel_tracking_events"); + }); + + it("returns explicit unavailability when no persisted tracking rows exist", async () => { + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + state.rows = []; + const caller = cargoTrackingRouter.createCaller({} as any); + await expect(caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" })) + .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + await expect(caller.getVesselStats()) + .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + }); +}); diff --git a/server/db.ts b/server/db.ts index cc68600e..08d9e7d4 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, gte, lte, gt, isNull, sql, count, inArray, like } from "drizzle-orm"; +import { eq, desc, and, or, gte, lte, gt, isNull, sql, count, inArray, like } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { @@ -241,6 +241,23 @@ export async function getStakeholderRegistrationsByUser(userId: number) { .orderBy(desc(stakeholderRegistrations.createdAt)); } +export async function getPendingStakeholderRegistrationForUser( + userId: number, + stakeholderType: typeof stakeholderRegistrations.$inferSelect.stakeholderType, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [registration] = await db.select().from(stakeholderRegistrations) + .where(and( + eq(stakeholderRegistrations.userId, userId), + eq(stakeholderRegistrations.stakeholderType, stakeholderType), + eq(stakeholderRegistrations.status, "pending"), + )) + .orderBy(desc(stakeholderRegistrations.createdAt)) + .limit(1); + return registration; +} + export async function getPendingStakeholderRegistrations() { const db = await getDb(); if (!db) throw new Error("Database unavailable"); @@ -315,6 +332,22 @@ export async function getStakeholderMandateByReference(referenceNumber: string) return mandate; } +export async function getStakeholderMandatesByPrincipal(principalUserId: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + return db.select().from(stakeholderMandates) + .where(eq(stakeholderMandates.principalUserId, principalUserId)) + .orderBy(desc(stakeholderMandates.createdAt)); +} + +export async function getStakeholderMandatesByAgent(agentUserId: number) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + return db.select().from(stakeholderMandates) + .where(eq(stakeholderMandates.agentUserId, agentUserId)) + .orderBy(desc(stakeholderMandates.createdAt)); +} + export async function revokeStakeholderMandate( id: number, revokedBy: number, @@ -381,6 +414,27 @@ export async function getDeclarationByNumber(declarationNumber: string) { return result[0] ?? undefined; } +export async function getPublicDeclarationTracking(reference: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [declaration] = await db.select({ + declarationNumber: declarations.declarationNumber, + ucr: declarations.ucr, + declarationType: declarations.declarationType, + status: declarations.status, + createdAt: declarations.createdAt, + updatedAt: declarations.updatedAt, + submittedAt: declarations.submittedAt, + clearedAt: declarations.clearedAt, + }).from(declarations) + .where(or( + eq(declarations.declarationNumber, reference), + eq(declarations.ucr, reference), + )) + .limit(1); + return declaration; +} + export async function getDeclarationsByTrader(traderId: number, limit = 20, offset = 0) { const db = await getDb(); if (!db) return []; @@ -518,6 +572,23 @@ export async function getPermitsByDeclaration(declarationId: number) { return db.select().from(ogaPermits).where(eq(ogaPermits.declarationId, declarationId)); } +export async function getPublicPermitTracking(permitNumber: string) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [permit] = await db.select({ + permitNumber: ogaPermits.permitNumber, + permitType: ogaPermits.permitType, + status: ogaPermits.status, + createdAt: ogaPermits.createdAt, + updatedAt: ogaPermits.updatedAt, + respondedAt: ogaPermits.respondedAt, + expiresAt: ogaPermits.expiresAt, + }).from(ogaPermits) + .where(eq(ogaPermits.permitNumber, permitNumber)) + .limit(1); + return permit; +} + export async function updateOgaPermit(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database unavailable"); diff --git a/server/mandate.enforcement.test.ts b/server/mandate.enforcement.test.ts index 0a6d92a7..716a29f8 100644 --- a/server/mandate.enforcement.test.ts +++ b/server/mandate.enforcement.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { appRouter } from "./routers"; import type { TrpcContext } from "./_core/context"; +import { requireDeclarationActor } from "./_core/mandateAuthorization"; const state = vi.hoisted(() => ({ mandate: undefined as unknown, @@ -54,7 +55,7 @@ vi.mock("./_core/llm", () => ({ }), })); -function context(id: number): TrpcContext { +function context(id: number, role: NonNullable["role"] = "user"): TrpcContext { return { user: { id, @@ -62,7 +63,7 @@ function context(id: number): TrpcContext { name: `Mandate ${id}`, email: `mandate-${id}@example.com`, loginMethod: "test", - role: "user", + role, createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), @@ -86,6 +87,12 @@ const declarationInput = { }; describe("mandate enforcement for third-party filing", () => { + it("keeps the principal authorized after an agent files the declaration", async () => { + await expect( + requireDeclarationActor(state.declaration, { id: 1, role: "user" }), + ).resolves.toEqual({ principalUserId: 1, actingAgentId: null }); + }); + it("rejects an agent filing without an active mandate", async () => { state.mandate = undefined; await expect( @@ -96,6 +103,57 @@ describe("mandate enforcement for third-party filing", () => { ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); + it.each(["oga_officer", "inspector"] as const)( + "does not allow %s to create for an arbitrary principal", + async (role) => { + state.mandate = undefined; + await expect( + appRouter.createCaller(context(3, role)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }, + ); + + it.each(["admin", "customs_officer"] as const)( + "allows %s to use the creation override", + async (role) => { + const result = await appRouter.createCaller(context(3, role)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }); + expect(result.principalId).toBe(1); + expect(result.actingAgentId).toBeNull(); + }, + ); + + it.each(["admin", "finance", "customs_officer"] as const)( + "retains %s payment operational override", + async (role) => { + await expect( + requireDeclarationActor( + state.declaration, + { id: 3, role }, + { allowOperationalOverride: true }, + ), + ).resolves.toEqual({ principalUserId: 1, actingAgentId: null }); + }, + ); + + it.each(["oga_officer", "inspector"] as const)( + "does not grant %s payment operational override", + async (role) => { + await expect( + requireDeclarationActor( + state.declaration, + { id: 3, role }, + { allowOperationalOverride: true }, + ), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }, + ); + it("accepts an active mandate and records principal and acting agent", async () => { state.mandate = { id: 1, principalUserId: 1, agentUserId: 2 }; state.created.length = 0; diff --git a/server/nsw.parity.followup.test.ts b/server/nsw.parity.followup.test.ts new file mode 100644 index 00000000..4def44ed --- /dev/null +++ b/server/nsw.parity.followup.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +const state = vi.hoisted(() => ({ + registration: undefined as any, + declaration: undefined as any, + permit: undefined as any, + rateCount: 0, + dbUnavailable: false, +})); + +vi.mock("./_core/redisRateLimiter", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + incrementRateLimit: vi.fn(async () => ++state.rateCount), + }; +}); + +vi.mock("./db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getStakeholderRegistrationByReference: vi.fn(async (reference: string) => + state.dbUnavailable + ? (() => { throw new Error("database unavailable"); })() + : state.registration?.referenceNumber === reference ? state.registration : undefined), + getPublicDeclarationTracking: vi.fn(async (reference: string) => + state.dbUnavailable + ? (() => { throw new Error("database unavailable"); })() + : state.declaration && [state.declaration.declarationNumber, state.declaration.ucr].includes(reference) + ? state.declaration + : undefined), + getPublicPermitTracking: vi.fn(async (reference: string) => + state.dbUnavailable + ? (() => { throw new Error("database unavailable"); })() + : state.permit?.permitNumber === reference ? state.permit : undefined), + getDb: vi.fn(async () => { + if (state.dbUnavailable) throw new Error("database unavailable"); + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => state.permit ? [state.permit] : [], + }), + }), + }), + }; + }), + }; +}); + +function publicContext(): TrpcContext { + return { + user: null, + keycloakRoles: [], + req: { + method: "GET", + headers: { "x-forwarded-for": "198.51.100.10" }, + socket: { remoteAddress: "198.51.100.10" }, + } as unknown as TrpcContext["req"], + res: {} as TrpcContext["res"], + }; +} + +describe("NSW public tracking and permit validation", () => { + it("tracks registrations, declarations by UCR, and permits without sensitive fields", async () => { + state.rateCount = 0; + state.registration = { + referenceNumber: "NSW-REG-2026-TRACK", + stakeholderType: "shipping_line", + status: "under_review", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + approvedAt: null, + organizationName: "Private Carrier", + rejectionReason: "Suspected fraud referral", + }; + state.declaration = { + declarationNumber: "DEC-TRACK-1", + ucr: "UCR-TRACK-1", + declarationType: "import", + status: "submitted", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + submittedAt: new Date("2026-01-02T00:00:00.000Z"), + clearedAt: null, + goodsDescription: "Secret goods", + invoiceValue: "999999", + }; + state.permit = { + permitNumber: "PERMIT-TRACK-1", + permitType: "health", + status: "approved", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + respondedAt: new Date("2026-01-02T00:00:00.000Z"), + expiresAt: new Date("2030-01-01T00:00:00.000Z"), + agencyCode: "FDA", + agencyName: "Food and Drug Authority", + }; + const caller = appRouter.createCaller(publicContext()); + + const registration = await caller.applicationTracking.track({ referenceNumber: state.registration.referenceNumber }); + expect(registration).toMatchObject({ referenceNumber: state.registration.referenceNumber, type: "shipping_line" }); + expect(registration).not.toHaveProperty("organizationName"); + expect(registration).not.toHaveProperty("rejectionReason"); + + const declaration = await caller.applicationTracking.track({ referenceNumber: state.declaration.ucr }); + expect(declaration).toMatchObject({ referenceNumber: state.declaration.declarationNumber, status: "submitted" }); + expect(declaration).not.toHaveProperty("goodsDescription"); + expect(declaration).not.toHaveProperty("invoiceValue"); + + const permit = await caller.applicationTracking.track({ referenceNumber: state.permit.permitNumber }); + expect(permit).toMatchObject({ referenceNumber: state.permit.permitNumber, status: "approved" }); + expect(permit).not.toHaveProperty("agencyName"); + + const validation = await caller.oga.validatePermit({ permitNumber: state.permit.permitNumber }); + expect(validation).toMatchObject({ + permitNumber: state.permit.permitNumber, + agencyCode: "FDA", + agencyName: "Food and Drug Authority", + isValid: true, + isExpired: false, + }); + expect(validation).not.toHaveProperty("reviewNotes"); + }); + + it("returns one not-found shape and throttles public reference probing per IP", async () => { + state.registration = undefined; + state.declaration = undefined; + state.permit = undefined; + state.rateCount = 0; + const caller = appRouter.createCaller(publicContext()); + const first = caller.applicationTracking.track({ referenceNumber: "UNKNOWN-TRACK-1" }); + await expect(first).rejects.toMatchObject({ code: "NOT_FOUND", message: "Application not found." }); + + for (let i = 0; i < 59; i++) { + await expect(caller.applicationTracking.track({ referenceNumber: `UNKNOWN-TRACK-${i + 2}` })) + .rejects.toMatchObject({ code: "NOT_FOUND", message: "Application not found." }); + } + await expect(caller.applicationTracking.track({ referenceNumber: "UNKNOWN-TRACK-LAST" })) + .rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + }); + + it("reports an expired permit as authentic but not currently valid", async () => { + state.rateCount = 0; + state.permit = { + permitNumber: "PERMIT-EXPIRED", + permitType: "health", + status: "approved", + createdAt: new Date("2025-01-01T00:00:00.000Z"), + updatedAt: new Date("2025-01-02T00:00:00.000Z"), + expiresAt: new Date("2025-01-03T00:00:00.000Z"), + agencyCode: "FDA", + agencyName: "Food and Drug Authority", + }; + const result = await appRouter.createCaller(publicContext()).oga.validatePermit({ + permitNumber: state.permit.permitNumber, + }); + expect(result.isExpired).toBe(true); + expect(result.isValid).toBe(false); + }); + + it("fails closed when public tracking storage is unavailable", async () => { + state.dbUnavailable = true; + await expect(appRouter.createCaller(publicContext()).applicationTracking.track({ + referenceNumber: "NSW-REG-2026-UNAVAILABLE", + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + await expect(appRouter.createCaller(publicContext()).oga.validatePermit({ + permitNumber: "PERMIT-UNAVAILABLE", + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + state.dbUnavailable = false; + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 92ef03c7..3b92e789 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -57,6 +57,7 @@ import { traderScorecardRouter } from "./routers/traderScorecard"; import { cargoTrackingRouter } from "./routers/cargoTracking"; import { onboardingRouter } from "./routers/onboarding"; import { stakeholderRegistrationsRouter } from "./routers/stakeholderRegistrations"; +import { applicationTrackingRouter } from "./routers/applicationTracking"; import { geofencesRouter } from "./routers/geofences"; import { webhooksRouter } from "./routers/webhooks"; import { apiChangelogRouter } from "./routers/apiChangelog"; @@ -309,6 +310,7 @@ export const appRouter = router({ cargoTracking: cargoTrackingRouter, onboarding: onboardingRouter, stakeholderRegistrations: stakeholderRegistrationsRouter, + applicationTracking: applicationTrackingRouter, geofences: geofencesRouter, webhooks: webhooksRouter, apiChangelog: apiChangelogRouter, diff --git a/server/routers/applicationTracking.ts b/server/routers/applicationTracking.ts new file mode 100644 index 00000000..aaba54c7 --- /dev/null +++ b/server/routers/applicationTracking.ts @@ -0,0 +1,21 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { publicRateLimitedProcedure, router } from "../_core/trpc"; +import { lookupPublicApplication } from "../_core/applicationTracking"; + +export const applicationTrackingRouter = router({ + track: publicRateLimitedProcedure + .input(z.object({ referenceNumber: z.string().min(8).max(64) })) + .query(async ({ input }) => { + try { + return await lookupPublicApplication(input.referenceNumber); + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Application tracking is unavailable.", + cause: error, + }); + } + }), +}); diff --git a/server/routers/cargoTracking.ts b/server/routers/cargoTracking.ts index a5ddbf0f..c434f99c 100644 --- a/server/routers/cargoTracking.ts +++ b/server/routers/cargoTracking.ts @@ -1,18 +1,20 @@ /** - * Sprint 66 — Cargo Tracking Real-Time Map - * tRPC router: live AIS vessel positions, route polylines, shipment linkage - * Simulates sedona-svc AIS feed with realistic vessel data for Mombasa Port area + * Cargo tracking backed by persisted AIS events. + * + * A tracking response is never synthesized when the persisted source has no + * data. Callers receive SERVICE_UNAVAILABLE so they can render that tracking + * is unavailable instead of an empty or fabricated map. */ import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { publicProcedure, protectedProcedure, router } from "../_core/trpc"; import { publishEvent, TOPICS } from "../_core/kafka"; - -// ─── TYPES ──────────────────────────────────────────────────────────────────── +import { getDb, getPool } from "../db"; export type VesselStatus = "underway" | "moored" | "anchored" | "restricted" | "aground"; export type CargoStatus = "pre-arrival" | "arrived" | "berthed" | "loading" | "unloading" | "departed"; -export type RiskFlag = "green" | "amber" | "red" | null; +export type RiskFlag = "green" | "amber" | "red"; export type VesselType = "container" | "bulk" | "tanker" | "general" | "roro" | "passenger"; export interface LiveVessel { @@ -25,18 +27,18 @@ export interface LiveVessel { callSign: string; lat: number; lon: number; - speed: number; // knots - heading: number; // degrees 0-359 - course: number; // degrees 0-359 + speed: number; + heading: number; + course: number; status: VesselStatus; cargoStatus: CargoStatus; declarationRef: string | null; riskFlag: RiskFlag; - eta: string | null; // ISO timestamp + eta: string | null; destination: string; - draught: number; // metres - length: number; // metres - lastUpdate: string; // ISO timestamp + draught: number; + length: number; + lastUpdate: string; originPort: string; originLat: number; originLon: number; @@ -47,423 +49,248 @@ export interface RouteWaypoint { lon: number; timestamp: string; speed: number; - event?: string; } -// ─── SEED DATA ──────────────────────────────────────────────────────────────── - -// Base vessel positions near Mombasa Port, Kenya (-4.05°, 39.67°) -const BASE_VESSELS: LiveVessel[] = [ - { - id: "v1", - mmsi: "636091234", - imo: "9234567", - vesselName: "MSC NAIROBI", - vesselType: "container", - flag: "LR", - callSign: "A8KN9", - lat: -4.0435, - lon: 39.6682, - speed: 12.4, - heading: 285, - course: 283, - status: "underway", - cargoStatus: "pre-arrival", - declarationRef: "URN-2026-001234", - riskFlag: "green", - eta: new Date(Date.now() + 6 * 3600000).toISOString(), - destination: "KEMBA", - draught: 12.4, - length: 294, - lastUpdate: new Date().toISOString(), - originPort: "Port Said", - originLat: 31.2653, - originLon: 32.3019, - }, - { - id: "v2", - mmsi: "636092345", - imo: "9345678", - vesselName: "EVER GOLDEN GATE", - vesselType: "container", - flag: "TW", - callSign: "BPKL2", - lat: -4.0612, - lon: 39.6891, - speed: 0.2, - heading: 180, - course: 180, - status: "moored", - cargoStatus: "berthed", - declarationRef: "URN-2026-001235", - riskFlag: "amber", - eta: null, - destination: "KEMBA", - draught: 13.1, - length: 366, - lastUpdate: new Date().toISOString(), - originPort: "Shanghai", - originLat: 31.2304, - originLon: 121.4737, - }, - { - id: "v3", - mmsi: "636093456", - imo: "9456789", - vesselName: "AFRICAN EXPLORER", - vesselType: "bulk", - flag: "GH", - callSign: "9GA12", - lat: -4.0789, - lon: 39.7012, - speed: 8.1, - heading: 310, - course: 308, - status: "underway", - cargoStatus: "arrived", - declarationRef: null, - riskFlag: null, - eta: new Date(Date.now() + 2 * 3600000).toISOString(), - destination: "KEMBA", - draught: 9.8, - length: 189, - lastUpdate: new Date().toISOString(), - originPort: "Durban", - originLat: -29.8587, - originLon: 31.0218, - }, - { - id: "v4", - mmsi: "636094567", - imo: "9567890", - vesselName: "KENYA TRADER", - vesselType: "general", - flag: "KE", - callSign: "5YKT4", - lat: -4.0234, - lon: 39.6543, - speed: 0.0, - heading: 90, - course: 90, - status: "anchored", - cargoStatus: "loading", - declarationRef: "URN-2026-001236", - riskFlag: "green", - eta: null, - destination: "TZDAR", - draught: 6.2, - length: 142, - lastUpdate: new Date().toISOString(), - originPort: "Mombasa", - originLat: -4.0435, - originLon: 39.6682, - }, - { - id: "v5", - mmsi: "636095678", - imo: "9678901", - vesselName: "GULF PIONEER", - vesselType: "tanker", - flag: "AE", - callSign: "A6GP5", - lat: -4.0956, - lon: 39.7234, - speed: 5.3, - heading: 270, - course: 268, - status: "underway", - cargoStatus: "pre-arrival", - declarationRef: "URN-2026-001237", - riskFlag: "red", - eta: new Date(Date.now() + 4 * 3600000).toISOString(), - destination: "KEMBA", - draught: 14.2, - length: 228, - lastUpdate: new Date().toISOString(), - originPort: "Fujairah", - originLat: 25.1288, - originLon: 56.3264, - }, - { - id: "v6", - mmsi: "636096789", - imo: "9789012", - vesselName: "EAST AFRICA EXPRESS", - vesselType: "roro", - flag: "KE", - callSign: "5YEA6", - lat: -4.0123, - lon: 39.6789, - speed: 0.1, - heading: 0, - course: 0, - status: "moored", - cargoStatus: "unloading", - declarationRef: "URN-2026-001238", - riskFlag: "green", - eta: null, - destination: "KEMBA", - draught: 7.8, - length: 176, - lastUpdate: new Date().toISOString(), - originPort: "Dar es Salaam", - originLat: -6.7924, - originLon: 39.2083, - }, - { - id: "v7", - mmsi: "636097890", - imo: "9890123", - vesselName: "MAERSK MOMBASA", - vesselType: "container", - flag: "DK", - callSign: "OUMD7", - lat: -3.9876, - lon: 39.6234, - speed: 14.2, - heading: 220, - course: 218, - status: "underway", - cargoStatus: "departed", - declarationRef: "URN-2026-001239", - riskFlag: "green", - eta: new Date(Date.now() + 12 * 3600000).toISOString(), - destination: "ZACPT", - draught: 11.6, - length: 294, - lastUpdate: new Date().toISOString(), - originPort: "Mombasa", - originLat: -4.0435, - originLon: 39.6682, - }, - { - id: "v8", - mmsi: "636098901", - imo: "9901234", - vesselName: "NILE CARRIER", - vesselType: "bulk", - flag: "EG", - callSign: "SUEG8", - lat: -4.1234, - lon: 39.7456, - speed: 3.2, - heading: 315, - course: 312, - status: "underway", - cargoStatus: "pre-arrival", - declarationRef: "URN-2026-001240", - riskFlag: "amber", - eta: new Date(Date.now() + 8 * 3600000).toISOString(), - destination: "KEMBA", - draught: 10.4, - length: 195, - lastUpdate: new Date().toISOString(), - originPort: "Alexandria", - originLat: 31.2001, - originLon: 29.9187, - }, -]; - -// ─── POSITION DRIFT SIMULATION ──────────────────────────────────────────────── -// Simulates AIS position updates: underway vessels drift along their heading - -function driftVessel(vessel: LiveVessel, tickSeconds: number): LiveVessel { - if (vessel.speed < 0.5) return { ...vessel, lastUpdate: new Date().toISOString() }; - - const speedMs = (vessel.speed * 0.514444) * tickSeconds; // knots → m/s → metres - const headingRad = (vessel.heading * Math.PI) / 180; - - // Approximate: 1 degree lat ≈ 111,320 m; 1 degree lon ≈ 111,320 * cos(lat) m - const latDelta = (speedMs * Math.cos(headingRad)) / 111320; - const lonDelta = (speedMs * Math.sin(headingRad)) / (111320 * Math.cos((vessel.lat * Math.PI) / 180)); - - return { - ...vessel, - lat: vessel.lat + latDelta, - lon: vessel.lon + lonDelta, - lastUpdate: new Date().toISOString(), - }; +type VesselRow = { + mmsi: string; + vessel_name: string | null; + imo_number: string | null; + latitude: number; + longitude: number; + speed: number | null; + heading: number | null; + destination_port: string | null; + eta: Date | string | null; + cargo_type: string | null; + flag_country: string | null; + recorded_at: Date | string; +}; + +async function pgQuery>(query: string, params: unknown[] = []): Promise { + await getDb(); + const pool = getPool(); + if (!pool) throw new Error("Database unavailable"); + const { rows } = await pool.query(query, params); + return rows as T[]; } -// ─── ROUTE GENERATION ──────────────────────────────────────────────────────── - -function generateRoute(vessel: LiveVessel): RouteWaypoint[] { - const waypoints: RouteWaypoint[] = []; - const steps = 12; - const now = Date.now(); - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const lat = vessel.originLat + (vessel.lat - vessel.originLat) * t; - const lon = vessel.originLon + (vessel.lon - vessel.originLon) * t; - const hoursAgo = (steps - i) * 2; - const speed = vessel.status === "underway" ? vessel.speed * (0.8 + Math.random() * 0.4) : 0; - - waypoints.push({ - lat, - lon, - timestamp: new Date(now - hoursAgo * 3600000).toISOString(), - speed: Math.round(speed * 10) / 10, - event: i === 0 ? `Departed ${vessel.originPort}` : i === steps ? "Current position" : undefined, +async function persistedQuery>(query: string, params: unknown[] = []) { + try { + return await pgQuery(query, params); + } catch (error) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Cargo tracking is unavailable.", + cause: error, }); } - - return waypoints; } -// ─── PORT ARRIVALS ──────────────────────────────────────────────────────────── - -const PORT_ARRIVALS = [ - { vesselName: "MSC NAIROBI", mmsi: "636091234", eta: new Date(Date.now() + 6 * 3600000).toISOString(), berth: "Berth 12", cargoType: "Container", teu: 2840, riskFlag: "green" as RiskFlag }, - { vesselName: "GULF PIONEER", mmsi: "636095678", eta: new Date(Date.now() + 4 * 3600000).toISOString(), berth: "Berth 7 (Liquid)", cargoType: "Crude Oil", teu: null, riskFlag: "red" as RiskFlag }, - { vesselName: "AFRICAN EXPLORER", mmsi: "636093456", eta: new Date(Date.now() + 2 * 3600000).toISOString(), berth: "Berth 3 (Bulk)", cargoType: "Grain", teu: null, riskFlag: null }, - { vesselName: "NILE CARRIER", mmsi: "636098901", eta: new Date(Date.now() + 8 * 3600000).toISOString(), berth: "Berth 5 (Bulk)", cargoType: "Fertiliser", teu: null, riskFlag: "amber" as RiskFlag }, -]; - -// ─── ROUTER// ─── SHARED DATA HELPER (used by Sprint 70 WS broadcast) ─────────────────────── - -import { getDb, getPool } from "../db"; - -async function pgQuery>(sql: string, params: unknown[] = []): Promise { - await getDb(); - const pool = getPool(); - if (!pool) return []; - const { rows } = await pool.query(sql, params); - return rows as T[]; +function vesselType(cargoType: string | null): VesselType | null { + const normalized = cargoType?.toLowerCase(); + if (normalized === "container" || normalized === "bulk" || normalized === "tanker" + || normalized === "general" || normalized === "roro" || normalized === "passenger") { + return normalized; + } + return null; } -// getLiveVesselsData (sync) is defined below the router — see end of file. +function mapVessel(row: VesselRow): LiveVessel { + const speed = Number(row.speed ?? 0); + const flag = String(row.flag_country ?? ""); + const highRisk = ["IRN", "PRK", "SYR", "RUS", "BLR"]; + const mediumRisk = ["LBY", "SOM", "SDN", "YEM", "MMR"]; + return { + id: String(row.mmsi), + mmsi: String(row.mmsi), + imo: String(row.imo_number ?? ""), + vesselName: String(row.vessel_name ?? ""), + vesselType: vesselType(row.cargo_type) ?? "general", + flag, + callSign: "", + lat: Number(row.latitude), + lon: Number(row.longitude), + speed, + heading: Number(row.heading ?? 0), + course: Number(row.heading ?? 0), + status: speed < 0.5 ? "moored" : speed < 2 ? "anchored" : "underway", + cargoStatus: "" as CargoStatus, + declarationRef: null, + riskFlag: highRisk.includes(flag) ? "red" : mediumRisk.includes(flag) ? "amber" : "green", + eta: row.eta ? new Date(row.eta).toISOString() : null, + destination: String(row.destination_port ?? ""), + draught: 0, + length: 0, + lastUpdate: new Date(row.recorded_at).toISOString(), + originPort: "", + originLat: 0, + originLon: 0, + }; +} -// ─── ROUTER ────────────────────────────────────────────────────── +const latestVesselsQuery = ` + SELECT DISTINCT ON (mmsi) mmsi, vessel_name, imo_number, latitude, longitude, + speed, heading, destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + ORDER BY mmsi, recorded_at DESC +`; export const cargoTrackingRouter = router({ - /** - * getLiveVessels — returns current AIS positions for all tracked vessels. - * In production this calls sedona-svc /api/v1/ais/vessels - */ getLiveVessels: publicProcedure .input(z.object({ riskFilter: z.enum(["all", "green", "amber", "red"]).optional().default("all"), statusFilter: z.enum(["all", "underway", "moored", "anchored"]).optional().default("all"), })) - .query(({ input }) => { - // Simulate 30-second drift from base positions (capped at 120 ticks = 1 hour) - const tick = Math.floor(Date.now() / 30000) % 120; - const vessels = BASE_VESSELS.map(v => driftVessel(v, tick * 0.5)); - - let filtered = vessels; - if (input.riskFilter !== "all") { - filtered = filtered.filter(v => v.riskFlag === input.riskFilter); - } - if (input.statusFilter !== "all") { - filtered = filtered.filter(v => v.status === input.statusFilter); + .query(async ({ input }) => { + const rows = await persistedQuery(latestVesselsQuery); + if (rows.length === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); } - + let vessels = rows.map(mapVessel); + if (input.riskFilter !== "all") vessels = vessels.filter(v => v.riskFlag === input.riskFilter); + if (input.statusFilter !== "all") vessels = vessels.filter(v => v.status === input.statusFilter); return { - vessels: filtered, - totalCount: BASE_VESSELS.length, + vessels, + totalCount: rows.length, lastRefresh: new Date().toISOString(), - sourceService: "sedona-svc", - coverageArea: "Indian Ocean — East Africa Corridor", + sourceService: "vessel_tracking_events", }; }), - /** - * getVesselRoute — returns historical track (polyline) for a specific vessel - */ getVesselRoute: publicProcedure - .input(z.object({ mmsi: z.string() })) - .query(({ input }) => { - const vessel = BASE_VESSELS.find(v => v.mmsi === input.mmsi); - if (!vessel) { - return { waypoints: [], vessel: null }; + .input(z.object({ mmsi: z.string().min(1) })) + .query(async ({ input }) => { + const rows = await persistedQuery(` + SELECT mmsi, vessel_name, imo_number, latitude, longitude, speed, heading, + destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + WHERE mmsi = $1 + ORDER BY recorded_at ASC + `, [input.mmsi]); + if (rows.length === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); } + const latest = mapVessel(rows[rows.length - 1]); return { - waypoints: generateRoute(vessel), - vessel, + waypoints: rows.map(row => ({ + lat: Number(row.latitude), + lon: Number(row.longitude), + timestamp: new Date(row.recorded_at).toISOString(), + speed: Number(row.speed ?? 0), + })), + vessel: latest, }; }), - /** - * getShipmentPosition — returns position for a specific declaration reference - */ getShipmentPosition: publicProcedure - .input(z.object({ declarationRef: z.string() })) - .query(({ input }) => { - const vessel = BASE_VESSELS.find(v => v.declarationRef === input.declarationRef); - if (!vessel) return { found: false, vessel: null }; - return { found: true, vessel: driftVessel(vessel, Math.floor(Date.now() / 30000) * 0.5) }; + .input(z.object({ declarationRef: z.string().min(1) })) + .query(async () => { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Shipment cargo tracking is unavailable.", + }); }), - /** - * getPortArrivals — upcoming vessel arrivals at the home port - */ - getPortArrivals: publicProcedure.query(() => { + getPortArrivals: publicProcedure.query(async () => { + const manifests = await persistedQuery<{ + vessel_name: string; + eta: Date | string | null; + port_of_discharge: string; + }>(` + SELECT vessel_name, eta, port_of_discharge + FROM manifests + WHERE eta IS NOT NULL + ORDER BY eta ASC + LIMIT 200 + `); + if (manifests.length > 0) { + const now = new Date(); + return { + arrivals: manifests + .filter(manifest => manifest.eta && new Date(manifest.eta) >= now) + .map(manifest => ({ + vesselName: manifest.vessel_name, + mmsi: null, + eta: manifest.eta ? new Date(manifest.eta).toISOString() : null, + berth: null, + cargoType: null, + teu: null, + riskFlag: null, + port: manifest.port_of_discharge, + })), + lastUpdate: new Date().toISOString(), + sourceService: "manifests", + }; + } + + const rows = await persistedQuery(` + SELECT DISTINCT ON (mmsi) mmsi, vessel_name, imo_number, latitude, longitude, + speed, heading, destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + ORDER BY mmsi, recorded_at DESC + `); + if (rows.length === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); + } + const arrivals = rows.filter(row => row.eta && new Date(row.eta) >= new Date()); return { - arrivals: PORT_ARRIVALS, - port: "Mombasa International Port", - portCode: "KEMBA", + arrivals: arrivals.map(row => { + const vessel = mapVessel(row); + return { + vesselName: vessel.vesselName, + mmsi: vessel.mmsi, + eta: vessel.eta, + berth: null, + cargoType: row.cargo_type, + teu: null, + riskFlag: vessel.riskFlag, + port: vessel.destination, + }; + }), lastUpdate: new Date().toISOString(), + sourceService: "vessel_tracking_events", }; }), - /** - * getVesselStats — summary statistics from DB (falls back to static) - */ getVesselStats: publicProcedure.query(async () => { - try { - const [stats] = await pgQuery( - `SELECT - COUNT(DISTINCT mmsi) AS total, - SUM(CASE WHEN speed < 0.5 THEN 1 ELSE 0 END) AS moored, - SUM(CASE WHEN speed >= 0.5 AND speed < 2 THEN 1 ELSE 0 END) AS anchored, - SUM(CASE WHEN speed >= 2 THEN 1 ELSE 0 END) AS underway, - SUM(CASE WHEN flag_country IN ('IRN','PRK','SYR','RUS','BLR') THEN 1 ELSE 0 END) AS red_flag, - SUM(CASE WHEN flag_country IN ('LBY','SOM','SDN','YEM','MMR') THEN 1 ELSE 0 END) AS amber_flag - FROM (SELECT DISTINCT ON (mmsi) mmsi, speed, flag_country FROM vessel_tracking_events ORDER BY mmsi, recorded_at DESC) l` - ) as any[]; - if (stats) { - const total = parseInt(stats.total ?? "0", 10); - const red = parseInt(stats.red_flag ?? "0", 10); - const amber = parseInt(stats.amber_flag ?? "0", 10); - return { - total, - underway: parseInt(stats.underway ?? "0", 10), - moored: parseInt(stats.moored ?? "0", 10), - anchored: parseInt(stats.anchored ?? "0", 10), - redFlag: red, - amberFlag: amber, - greenFlag: total - red - amber, - withDeclaration: 0, - }; - } - } catch { /* fallback */ } - // Static fallback - const underway = BASE_VESSELS.filter(v => v.status === "underway").length; - const moored = BASE_VESSELS.filter(v => v.status === "moored").length; - const anchored = BASE_VESSELS.filter(v => v.status === "anchored").length; - const redFlag = BASE_VESSELS.filter(v => v.riskFlag === "red").length; - const amberFlag = BASE_VESSELS.filter(v => v.riskFlag === "amber").length; + const [stats] = await persistedQuery<{ + total: string; moored: string; anchored: string; underway: string; + red_flag: string; amber_flag: string; + }>(` + SELECT + COUNT(DISTINCT mmsi) AS total, + SUM(CASE WHEN speed < 0.5 THEN 1 ELSE 0 END) AS moored, + SUM(CASE WHEN speed >= 0.5 AND speed < 2 THEN 1 ELSE 0 END) AS anchored, + SUM(CASE WHEN speed >= 2 THEN 1 ELSE 0 END) AS underway, + SUM(CASE WHEN flag_country IN ('IRN','PRK','SYR','RUS','BLR') THEN 1 ELSE 0 END) AS red_flag, + SUM(CASE WHEN flag_country IN ('LBY','SOM','SDN','YEM','MMR') THEN 1 ELSE 0 END) AS amber_flag + FROM (${latestVesselsQuery}) latest + `); + const total = Number(stats?.total ?? 0); + if (!stats || total === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); + } + const redFlag = Number(stats.red_flag ?? 0); + const amberFlag = Number(stats.amber_flag ?? 0); return { - total: BASE_VESSELS.length, - underway, moored, anchored, redFlag, amberFlag, - greenFlag: BASE_VESSELS.filter(v => v.riskFlag === "green").length, - withDeclaration: BASE_VESSELS.filter(v => v.declarationRef !== null).length, + total, + underway: Number(stats.underway ?? 0), + moored: Number(stats.moored ?? 0), + anchored: Number(stats.anchored ?? 0), + redFlag, + amberFlag, + greenFlag: Math.max(0, total - redFlag - amberFlag), + withDeclaration: 0, + sourceService: "vessel_tracking_events", }; }), - /** - * searchVessels — search by name, MMSI, or IMO number from DB. - */ - /** - * logCargoEvent — record an arrival or departure event and publish to Kafka - */ logCargoEvent: protectedProcedure .input(z.object({ mmsi: z.string(), vesselName: z.string(), eventType: z.enum(["arrived", "departed"]), - portCode: z.string().default("KEMBA"), + portCode: z.string(), declarationRef: z.string().optional(), lat: z.number().optional(), lon: z.number().optional(), @@ -489,152 +316,63 @@ export const cargoTrackingRouter = router({ searchVessels: publicProcedure .input(z.object({ q: z.string().min(2).max(100) })) .query(async ({ input }) => { - const rows = await pgQuery( - `SELECT DISTINCT ON (mmsi) mmsi, vessel_name, imo_number, latitude, longitude, - speed, heading, destination_port, eta, cargo_type, flag_country, recorded_at - FROM vessel_tracking_events - WHERE vessel_name ILIKE $1 OR mmsi ILIKE $1 OR imo_number ILIKE $1 - ORDER BY mmsi, recorded_at DESC LIMIT 20`, - [`%${input.q}%`] - ); - const highRisk = ["IRN","PRK","SYR","RUS","BLR"]; - const medRisk = ["LBY","SOM","SDN","YEM","MMR"]; - return rows.map((r: any) => { - const flag = String(r.flag_country ?? ""); - const speed = Number(r.speed ?? 0); - return { - mmsi: String(r.mmsi), - vesselName: String(r.vessel_name), - imoNumber: String(r.imo_number ?? ""), - lat: Number(r.latitude), - lon: Number(r.longitude), - speed, - heading: Number(r.heading ?? 0), - status: speed < 0.5 ? "moored" : speed < 2 ? "anchored" : "underway", - destinationPort: String(r.destination_port ?? ""), - eta: r.eta ? new Date(r.eta).toISOString() : null, - cargoType: String(r.cargo_type ?? "General"), - flagCountry: flag, - riskFlag: highRisk.includes(flag) ? "red" : medRisk.includes(flag) ? "amber" : "green", - lastUpdate: r.recorded_at ? new Date(r.recorded_at).toISOString() : new Date().toISOString(), - }; - }); + const rows = await persistedQuery(` + SELECT DISTINCT ON (mmsi) mmsi, vessel_name, imo_number, latitude, longitude, + speed, heading, destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + WHERE vessel_name ILIKE $1 OR mmsi ILIKE $1 OR imo_number ILIKE $1 + ORDER BY mmsi, recorded_at DESC LIMIT 20 + `, [`%${input.q}%`]); + if (rows.length === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); + } + return rows.map(mapVessel); }), - /** - * v100: Get cargo vessel tracking events as heatmap data points (lat/lng/weight). - */ + getCargoHeatmapData: protectedProcedure .input(z.object({ hours: z.number().int().min(1).max(168).default(24), limit: z.number().int().min(1).max(2000).default(500), })) .query(async ({ input }) => { - const { getDb } = await import("../db"); - const db = await getDb(); - if (!db) return []; - const { vesselTrackingEvents } = await import("../../drizzle/schema"); - const { gte, desc, isNotNull, and } = await import("drizzle-orm"); - const since = new Date(Date.now() - input.hours * 60 * 60 * 1000); - const rows = await db.select({ - lat: vesselTrackingEvents.latitude, - lng: vesselTrackingEvents.longitude, - speed: vesselTrackingEvents.speed, - recordedAt: vesselTrackingEvents.recordedAt, - mmsi: vesselTrackingEvents.mmsi, - }) - .from(vesselTrackingEvents) - .where(and( - gte(vesselTrackingEvents.recordedAt, since), - isNotNull(vesselTrackingEvents.latitude), - isNotNull(vesselTrackingEvents.longitude), - )) - .orderBy(desc(vesselTrackingEvents.recordedAt)) - .limit(input.limit); - return rows.map(r => ({ - lat: Number(r.lat), - lng: Number(r.lng), - weight: r.speed ? Math.min(Number(r.speed) / 30, 1) : 0.5, - vesselId: r.mmsi, - timestamp: r.recordedAt, + const rows = await persistedQuery<{ + lat: number; lng: number; speed: number | null; recorded_at: Date; mmsi: string; + }>(` + SELECT latitude AS lat, longitude AS lng, speed, recorded_at, mmsi + FROM vessel_tracking_events + WHERE recorded_at >= NOW() - ($1 * INTERVAL '1 hour') + ORDER BY recorded_at DESC + LIMIT $2 + `, [input.hours, input.limit]); + if (rows.length === 0) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); + } + return rows.map(row => ({ + lat: Number(row.lat), + lng: Number(row.lng), + weight: row.speed === null ? 0.5 : Math.min(Number(row.speed) / 30, 1), + vesselId: row.mmsi, + timestamp: row.recorded_at, })); }), - }); -// ─── Sync shim for WebSocket broadcaster and legacy tests ──────────────────── -// Maintains a hot in-memory cache refreshed every 30s by the async DB query. -// The sync getLiveVesselsData() returns the last known snapshot immediately. -let _vesselCache: Array<{ - mmsi: string; vesselName: string; imoNumber: string; - lat: number; lon: number; speed: number; heading: number; - status: string; destinationPort: string; eta: string | null; - cargoType: string; flagCountry: string; riskFlag: "green" | "amber" | "red"; - lastUpdate: string; -}> = BASE_VESSELS.map(v => ({ - mmsi: v.mmsi, - vesselName: v.vesselName, - imoNumber: v.imo, - lat: v.lat, - lon: v.lon, - speed: v.speed, - heading: v.heading, - status: v.status, - destinationPort: v.destination, - eta: v.eta, - cargoType: "Container", - flagCountry: v.flag, - riskFlag: v.riskFlag ?? "green", - lastUpdate: v.lastUpdate, -})); +let _vesselCache: LiveVessel[] = []; -// Refresh cache from DB asynchronously (best-effort, non-blocking) async function _refreshVesselCache(): Promise { try { - const rows = await pgQuery( - `SELECT DISTINCT ON (mmsi) mmsi, vessel_name, imo_number, latitude, longitude, - speed, heading, destination_port, eta, cargo_type, flag_country, recorded_at - FROM vessel_tracking_events - ORDER BY mmsi, recorded_at DESC LIMIT 200` - ); - if (rows.length > 0) { - const highRisk = ["IR", "KP", "SY", "CU", "IRN", "PRK", "SYR"]; - const medRisk = ["RU", "BY", "VE", "MM", "RUS", "BLR", "MMR"]; - _vesselCache = rows.map((r: any) => { - const flag = String(r.flag_country ?? ""); - const speed = Number(r.speed ?? 0); - return { - mmsi: String(r.mmsi), - vesselName: String(r.vessel_name), - imoNumber: String(r.imo_number ?? ""), - lat: Number(r.latitude), - lon: Number(r.longitude), - speed, - heading: Number(r.heading ?? 0), - status: speed < 0.5 ? "moored" : speed < 2 ? "anchored" : "underway", - destinationPort: String(r.destination_port ?? ""), - eta: r.eta ? new Date(r.eta as string).toISOString() : null, - cargoType: String(r.cargo_type ?? "General"), - flagCountry: flag, - riskFlag: highRisk.includes(flag) ? "red" : medRisk.includes(flag) ? "amber" : "green", - lastUpdate: r.recorded_at ? new Date(r.recorded_at as string).toISOString() : new Date().toISOString(), - }; - }); - } + const rows = await pgQuery(latestVesselsQuery); + _vesselCache = rows.map(mapVessel); } catch { - // Silently keep existing cache on DB error + _vesselCache = []; } } -// Schedule background refresh every 30 seconds + if (typeof setInterval !== "undefined") { setInterval(() => { void _refreshVesselCache(); }, 30_000); void _refreshVesselCache(); } -/** - * getLiveVesselsData — synchronous accessor for the in-memory vessel cache. - * Returns the last known snapshot from the DB (refreshed every 30s). - * Falls back to BASE_VESSELS seed data when DB is unavailable. - */ export function getLiveVesselsData() { return _vesselCache; } diff --git a/server/routers/oga.ts b/server/routers/oga.ts index bdb562bb..25f0d663 100644 --- a/server/routers/oga.ts +++ b/server/routers/oga.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { protectedProcedure, router } from "../_core/trpc"; +import { protectedProcedure, publicRateLimitedProcedure, router } from "../_core/trpc"; import { createOgaPermit, getPermitsByDeclaration, updateOgaPermit, getPermitsByOfficer, getDeclarationById, logAuditEvent, createNotification, @@ -61,6 +61,49 @@ function getRequiredOGAs(hsCode: string): typeof OGA_AGENCIES { } export const ogaRouter = router({ + validatePermit: publicRateLimitedProcedure + .input(z.object({ permitNumber: z.string().min(1).max(64) })) + .query(async ({ input }) => { + try { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const [permit] = await db.select({ + permitNumber: ogaPermits.permitNumber, + agencyCode: ogaPermits.agencyCode, + agencyName: ogaPermits.agencyName, + permitType: ogaPermits.permitType, + status: ogaPermits.status, + createdAt: ogaPermits.createdAt, + expiresAt: ogaPermits.expiresAt, + }).from(ogaPermits) + .where(eq(ogaPermits.permitNumber, input.permitNumber)) + .limit(1); + if (!permit) { + throw new TRPCError({ code: "NOT_FOUND", message: "Application not found." }); + } + const now = new Date(); + const isExpired = permit.expiresAt !== null && permit.expiresAt <= now; + return { + permitNumber: permit.permitNumber, + agencyCode: permit.agencyCode, + agencyName: permit.agencyName, + permitType: permit.permitType, + status: permit.status, + issuedAt: permit.createdAt, + expiresAt: permit.expiresAt, + isExpired, + isValid: permit.status === "approved" && !isExpired, + }; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Permit validation is unavailable.", + cause: error, + }); + } + }), + // Create permits for a declaration (called on submission) createForDeclaration: protectedProcedure .input(z.object({ declarationId: z.number() })) diff --git a/server/routers/stakeholderRegistrations.ts b/server/routers/stakeholderRegistrations.ts index 5ad5f2a9..3cf396ba 100644 --- a/server/routers/stakeholderRegistrations.ts +++ b/server/routers/stakeholderRegistrations.ts @@ -1,10 +1,10 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { publicProcedure, protectedProcedure, router } from "../_core/trpc"; +import { protectedProcedure, publicRateLimitedProcedure, router } from "../_core/trpc"; import { createStakeholderRegistration, getStakeholderRegistrationById, - getStakeholderRegistrationByReference, + getPendingStakeholderRegistrationForUser, getStakeholderRegistrationsByUser, getPendingStakeholderRegistrations, updateStakeholderRegistration, @@ -12,11 +12,14 @@ import { getStakeholderMandateById, getStakeholderMandateByReference, revokeStakeholderMandate, + getStakeholderMandatesByPrincipal, + getStakeholderMandatesByAgent, getApprovedAgentRegistration, getApprovedTraderProfile, logAuditEvent, } from "../db"; import { resolveActingPrincipal } from "../_core/mandateAuthorization"; +import { lookupPublicApplication } from "../_core/applicationTracking"; import { nanoid } from "nanoid"; const registrationType = z.enum([ @@ -65,6 +68,13 @@ export const stakeholderRegistrationsRouter = router({ .input(registrationInput) .mutation(async ({ ctx, input }) => { try { + const existing = await getPendingStakeholderRegistrationForUser(ctx.user.id, input.stakeholderType); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: `A pending application already exists: ${existing.referenceNumber}`, + }); + } const registration = await createStakeholderRegistration({ referenceNumber: reference("REG"), userId: ctx.user.id, @@ -93,22 +103,11 @@ export const stakeholderRegistrationsRouter = router({ } }), - track: publicProcedure + track: publicRateLimitedProcedure .input(z.object({ referenceNumber: z.string().min(8).max(32) })) .query(async ({ input }) => { try { - const registration = await getStakeholderRegistrationByReference(input.referenceNumber); - if (!registration) throw new TRPCError({ code: "NOT_FOUND", message: "Application not found." }); - return { - referenceNumber: registration.referenceNumber, - stakeholderType: registration.stakeholderType, - organizationName: registration.organizationName, - status: registration.status, - createdAt: registration.createdAt, - updatedAt: registration.updatedAt, - approvedAt: registration.approvedAt, - rejectionReason: registration.rejectionReason, - }; + return await lookupPublicApplication(input.referenceNumber); } catch (error) { return serviceUnavailable("Application tracking is unavailable.", error); } @@ -252,6 +251,18 @@ export const stakeholderRegistrationsRouter = router({ } catch (error) { return serviceUnavailable("Mandate lookup is unavailable.", error); } + }), + + mineMandates: protectedProcedure + .input(z.object({ side: z.enum(["principal", "agent"]) })) + .query(async ({ ctx, input }) => { + try { + return input.side === "principal" + ? await getStakeholderMandatesByPrincipal(ctx.user.id) + : await getStakeholderMandatesByAgent(ctx.user.id); + } catch (error) { + return serviceUnavailable("Mandate history is unavailable.", error); + } }), revokeMandate: protectedProcedure diff --git a/server/sprint69-71.test.ts b/server/sprint69-71.test.ts index 646a794a..04a64b0d 100644 --- a/server/sprint69-71.test.ts +++ b/server/sprint69-71.test.ts @@ -86,7 +86,6 @@ describe("Sprint 70 — WebSocket vessel broadcast", () => { const { getLiveVesselsData } = await import("./routers/cargoTracking"); const vessels = getLiveVesselsData(); expect(Array.isArray(vessels)).toBe(true); - expect(vessels.length).toBeGreaterThan(0); }); it("each vessel position should have required fields", async () => { @@ -121,8 +120,7 @@ describe("Sprint 70 — WebSocket vessel broadcast", () => { const a = getLiveVesselsData(); const b = getLiveVesselsData(); expect(a.length).toBe(b.length); - // Same tick → same positions - expect(a[0].mmsi).toBe(b[0].mmsi); + if (a.length > 0 && b.length > 0) expect(a[0].mmsi).toBe(b[0].mmsi); }); describe("WebSocket message protocol", () => { diff --git a/server/stakeholderRegistrations.test.ts b/server/stakeholderRegistrations.test.ts index 17617ee7..f8400f06 100644 --- a/server/stakeholderRegistrations.test.ts +++ b/server/stakeholderRegistrations.test.ts @@ -28,6 +28,7 @@ const dbState = vi.hoisted(() => ({ updatedAt: new Date(), }, unavailable: false, + pendingDuplicate: undefined as unknown, })); vi.mock("./db", async (importOriginal) => { @@ -46,6 +47,10 @@ vi.mock("./db", async (importOriginal) => { if (dbState.unavailable) throw new Error("database unavailable"); return dbState.registration; }), + getPendingStakeholderRegistrationForUser: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return dbState.pendingDuplicate; + }), getStakeholderRegistrationsByUser: vi.fn(async () => { if (dbState.unavailable) throw new Error("database unavailable"); return [dbState.registration]; @@ -80,6 +85,13 @@ vi.mock("./db", async (importOriginal) => { }), getStakeholderMandateById: vi.fn(async () => dbState.mandate), getStakeholderMandateByReference: vi.fn(async () => dbState.mandate), + getStakeholderMandatesByPrincipal: vi.fn(async () => [dbState.mandate]), + getStakeholderMandatesByAgent: vi.fn(async () => [{ + ...dbState.mandate, + revokedAt: new Date("2026-06-01T00:00:00.000Z"), + revokedBy: 1, + revocationReason: "Engagement ended", + }]), revokeStakeholderMandate: vi.fn(async (_id: number, revokedBy: number, reason?: string) => ({ ...dbState.mandate, revokedAt: new Date(), @@ -133,6 +145,23 @@ describe("NSW stakeholder registrations and mandates", () => { expect(result.approvedBy).toBe(1); }); + it("rejects a duplicate pending registration and points to the existing reference", async () => { + dbState.pendingDuplicate = dbState.registration; + await expect( + appRouter.createCaller(context(2)).stakeholderRegistrations.register({ + stakeholderType: "freight_forwarder", + organizationName: "Licensed Clearing Ltd", + licenseNumber: "LIC-001", + licenseExpiresAt: "2030-01-01T00:00:00.000Z", + country: "NG", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining(dbState.registration.referenceNumber), + }); + dbState.pendingDuplicate = undefined; + }); + it("creates and revokes a durable principal-to-agent mandate", async () => { const principal = appRouter.createCaller(context(1)); const mandate = await principal.stakeholderRegistrations.createMandate({ @@ -152,6 +181,16 @@ describe("NSW stakeholder registrations and mandates", () => { expect(revoked.revokedAt).toBeInstanceOf(Date); }); + it("lists historical mandates from both relationship sides", async () => { + const granted = await appRouter.createCaller(context(1)).stakeholderRegistrations.mineMandates({ side: "principal" }); + expect(granted[0].principalUserId).toBe(1); + + const held = await appRouter.createCaller(context(2)).stakeholderRegistrations.mineMandates({ side: "agent" }); + expect(held[0].revokedAt).toBeInstanceOf(Date); + expect(held[0].revokedBy).toBe(1); + expect(held[0].revocationReason).toBe("Engagement ended"); + }); + it("fails closed when registration persistence is unavailable", async () => { dbState.unavailable = true; await expect( diff --git a/server/v87-v105.test.ts b/server/v87-v105.test.ts index 8e2b81ec..98b453b8 100644 --- a/server/v87-v105.test.ts +++ b/server/v87-v105.test.ts @@ -350,11 +350,11 @@ describe("v100 — Cargo tracking heatmap", () => { expect((cargoTrackingRouter as any)._def.procedures.getCargoHeatmapData).toBeDefined(); }); - it("getCargoHeatmapData returns array for admin caller", async () => { + it("getCargoHeatmapData fails explicitly when tracking data is unavailable", async () => { const { cargoTrackingRouter } = await import("./routers/cargoTracking"); const caller = cargoTrackingRouter.createCaller(adminCtx); - const result = await caller.getCargoHeatmapData({ hours: 24, limit: 100 }); - expect(Array.isArray(result)).toBe(true); + await expect(caller.getCargoHeatmapData({ hours: 24, limit: 100 })) + .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("heatmap data points have lat, lng, and weight fields", () => { From 17a5d7ac8197c778ed32aae5a54e6aca05037b81 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:17:37 +0000 Subject: [PATCH 03/17] feat: complete NSW parity registration and cargo surfaces Co-Authored-By: Patrick Munis --- client/src/App.tsx | 30 +++- client/src/hooks/useVesselWebSocket.ts | 10 +- client/src/pages/app/CargoTrackingMap.tsx | 124 ++++++++------- client/src/pages/app/MandateManagement.tsx | 37 +++++ client/src/pages/app/PortHeatmap.tsx | 17 ++- .../src/pages/app/StakeholderRegistration.tsx | 60 ++++++++ .../app/StakeholderRegistrationReview.tsx | 17 +++ .../src/pages/public/ApplicationTracker.tsx | 68 +++++++++ client/src/pages/public/PermitValidation.tsx | 48 ++++++ server/_core/wsServer.ts | 10 +- server/cargoTracking.persisted.test.ts | 61 +++++++- server/openapi.ts | 3 +- server/routers/cargoTracking.ts | 144 +++++++----------- server/smoke.stakeholders.test.ts | 1 - server/sprint66-68.test.ts | 4 +- 15 files changed, 472 insertions(+), 162 deletions(-) create mode 100644 client/src/pages/app/MandateManagement.tsx create mode 100644 client/src/pages/app/StakeholderRegistration.tsx create mode 100644 client/src/pages/app/StakeholderRegistrationReview.tsx create mode 100644 client/src/pages/public/ApplicationTracker.tsx create mode 100644 client/src/pages/public/PermitValidation.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 94e5ef26..a978880e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,7 +8,7 @@ import NotFound from "@/pages/NotFound"; import { Route, Switch, Redirect } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; -import { AdminGuard, CustomsGuard, OGAGuard, FinanceGuard, SecurityGuard, ExecutiveGuard } from "./components/RoleGuard"; +import RoleGuard, { AdminGuard, CustomsGuard, OGAGuard, FinanceGuard, SecurityGuard, ExecutiveGuard } from "./components/RoleGuard"; import Home from "./pages/Home"; import TraderDashboard from "./pages/app/TraderDashboard"; import NewDeclaration from "./pages/app/NewDeclaration"; @@ -100,6 +100,11 @@ const AdminProductionChecklist = lazy(() => import('./pages/app/AdminProductionC const ServiceHealth = lazy(() => import('./pages/app/ServiceHealth')); const AuditLog = lazy(() => import('./pages/app/AuditLog')); const CertVerify = lazy(() => import('./pages/public/CertVerify')); +const ApplicationTracker = lazy(() => import('./pages/public/ApplicationTracker')); +const PermitValidation = lazy(() => import('./pages/public/PermitValidation')); +const StakeholderRegistration = lazy(() => import('./pages/app/StakeholderRegistration')); +const StakeholderRegistrationReview = lazy(() => import('./pages/app/StakeholderRegistrationReview')); +const MandateManagement = lazy(() => import('./pages/app/MandateManagement')); const SystemStatus = lazy(() => import('./pages/SystemStatus')); const DemoLogin = lazy(() => import('./pages/DemoLogin')); const NLFinancialQuery = lazy(() => import('./pages/app/NLFinancialQuery')); @@ -170,6 +175,18 @@ function Router() { }> + + }> + + + }> + + + }> + + + }> + {/* Public system status page — no authentication required */} @@ -430,6 +447,17 @@ function Router() { }> + + }> + + + }> + + + + }> + + {/* Sprint 68 — OpenAPI Explorer */} diff --git a/client/src/hooks/useVesselWebSocket.ts b/client/src/hooks/useVesselWebSocket.ts index ce793518..df16d329 100644 --- a/client/src/hooks/useVesselWebSocket.ts +++ b/client/src/hooks/useVesselWebSocket.ts @@ -12,13 +12,13 @@ import { useEffect, useRef, useState, useCallback } from "react"; export type VesselPosition = { mmsi: string; - vesselName: string; + vesselName: string | null; lat: number; lon: number; - speed: number; - heading: number; - status: string; - riskFlag: "green" | "amber" | "red"; + speed: number | null; + heading: number | null; + status: string | null; + riskFlag: "green" | "amber" | "red" | null; lastUpdate: string; }; diff --git a/client/src/pages/app/CargoTrackingMap.tsx b/client/src/pages/app/CargoTrackingMap.tsx index b39d9486..15c4519a 100644 --- a/client/src/pages/app/CargoTrackingMap.tsx +++ b/client/src/pages/app/CargoTrackingMap.tsx @@ -25,28 +25,28 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ interface Vessel { id: string; mmsi: string; - imo: string; - vesselName: string; - vesselType: string; - flag: string; - callSign: string; + imo: string | null; + vesselName: string | null; + vesselType: string | null; + flag: string | null; + callSign: string | null; lat: number; lon: number; - speed: number; - heading: number; - course: number; - status: string; - cargoStatus: string; + speed: number | null; + heading: number | null; + course: number | null; + status: string | null; + cargoStatus: string | null; declarationRef: string | null; riskFlag: string | null; eta: string | null; - destination: string; - draught: number; - length: number; + destination: string | null; + draught: number | null; + length: number | null; lastUpdate: string; - originPort: string; - originLat: number; - originLon: number; + originPort: string | null; + originLat: number | null; + originLon: number | null; } // ─── HELPERS ────────────────────────────────────────────────────────────────── @@ -121,17 +121,17 @@ export default function CargoTrackingMap() { // ─── DATA FETCHING (polling fallback) ──────────────────────────────────────────────── - const { data: vesselData, refetch: refetchVessels } = trpc.cargoTracking.getLiveVessels.useQuery( + const { data: vesselData, isError: vesselsUnavailable, refetch: refetchVessels } = trpc.cargoTracking.getLiveVessels.useQuery( { riskFilter, statusFilter }, // Only poll when WebSocket is not live { refetchInterval: isWsLive ? false : 30000 } ); - const { data: statsData } = trpc.cargoTracking.getVesselStats.useQuery(undefined, { + const { data: statsData, isError: statsUnavailable } = trpc.cargoTracking.getVesselStats.useQuery(undefined, { refetchInterval: 30000, }); - const { data: arrivalsData } = trpc.cargoTracking.getPortArrivals.useQuery(undefined, { + const { data: arrivalsData, isError: arrivalsUnavailable } = trpc.cargoTracking.getPortArrivals.useQuery(undefined, { refetchInterval: 60000, }); @@ -169,7 +169,7 @@ export default function CargoTrackingMap() { vessels.forEach(vessel => { const position = { lat: vessel.lat, lng: vessel.lon }; const riskColor = vessel.riskFlag ? RISK_COLORS[vessel.riskFlag] : "#6b7280"; - const icon = VESSEL_ICONS[vessel.vesselType] ?? "🚢"; + const icon = VESSEL_ICONS[vessel.vesselType ?? "general"] ?? "🚢"; // Create marker element const el = document.createElement("div"); @@ -185,7 +185,7 @@ export default function CargoTrackingMap() { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; font-size: 18px; border: 2px solid white; box-shadow: 0 0 0 2px ${riskColor}40; - transform: rotate(${vessel.heading}deg); + ${vessel.heading === null ? "" : `transform: rotate(${vessel.heading}deg);`} `; bubble.textContent = icon; @@ -209,7 +209,7 @@ export default function CargoTrackingMap() { const marker = new google.maps.marker.AdvancedMarkerElement({ map: mapRef.current!, position, - title: vessel.vesselName, + title: vessel.vesselName ?? vessel.mmsi, content: el, }); marker.addListener("click", () => setSelectedVessel(vessel)); @@ -300,12 +300,12 @@ export default function CargoTrackingMap() { // v100: Apply/remove heatmap layer when toggle changes useEffect(() => { if (!mapReady || typeof google === 'undefined') return; - if (showHeatmap && heatmapData && heatmapData.length > 0) { + if (showHeatmap && heatmapData && heatmapData.points.length > 0) { if (heatmapLayerRef.current) { heatmapLayerRef.current.setMap(null); } - const points = heatmapData.map((p: { lat: number; lng: number; weight: number }) => - new google.maps.LatLng(p.lat, p.lng) + const points = heatmapData.points.flatMap((p: { lat: number; lng: number; weight: number | null }) => + p.weight === null ? [] : [new google.maps.LatLng(p.lat, p.lng)] ); heatmapLayerRef.current = new (google.maps.visualization as any).HeatmapLayer({ data: points, @@ -326,13 +326,13 @@ export default function CargoTrackingMap() { {/* ── HEADER STATS ── */}
{[ - { label: "Total Vessels", value: statsData?.total ?? 0, icon: Ship, color: "text-blue-400" }, - { label: "Underway", value: statsData?.underway ?? 0, icon: Navigation, color: "text-blue-400" }, - { label: "Moored", value: statsData?.moored ?? 0, icon: Anchor, color: "text-purple-400" }, - { label: "Anchored", value: statsData?.anchored ?? 0, icon: Anchor, color: "text-yellow-400" }, - { label: "Red Flag", value: statsData?.redFlag ?? 0, icon: AlertTriangle, color: "text-red-400" }, - { label: "Amber Flag", value: statsData?.amberFlag ?? 0, icon: AlertTriangle, color: "text-yellow-400" }, - { label: "Declared", value: statsData?.withDeclaration ?? 0, icon: Package, color: "text-green-400" }, + { label: "Total Vessels", value: statsUnavailable ? "—" : statsData?.total ?? "—", icon: Ship, color: "text-blue-400" }, + { label: "Underway", value: statsUnavailable ? "—" : statsData?.underway ?? "—", icon: Navigation, color: "text-blue-400" }, + { label: "Moored", value: statsUnavailable ? "—" : statsData?.moored ?? "—", icon: Anchor, color: "text-purple-400" }, + { label: "Anchored", value: statsUnavailable ? "—" : statsData?.anchored ?? "—", icon: Anchor, color: "text-yellow-400" }, + { label: "Red Flag", value: statsUnavailable ? "—" : statsData?.redFlag ?? "—", icon: AlertTriangle, color: "text-red-400" }, + { label: "Amber Flag", value: statsUnavailable ? "—" : statsData?.amberFlag ?? "—", icon: AlertTriangle, color: "text-yellow-400" }, + { label: "Declared", value: statsUnavailable ? "—" : statsData?.withDeclaration ?? "—", icon: Package, color: "text-green-400" }, ].map(stat => ( @@ -406,16 +406,30 @@ export default function CargoTrackingMap() {
{/* ── MAIN CONTENT ── */} + {vesselsUnavailable && ( + + + Cargo tracking is unavailable. The persisted AIS source could not be reached; this is not an empty result. + + + )}
{/* MAP */}
- +
+ + {vesselsUnavailable && ( +
+ Persisted vessel positions are unavailable. +
+ )} +
{/* SIDE PANEL */} @@ -428,11 +442,11 @@ export default function CargoTrackingMap() {
- {VESSEL_ICONS[selectedVessel.vesselType] ?? "🚢"} - {selectedVessel.vesselName} + {VESSEL_ICONS[selectedVessel.vesselType ?? "general"] ?? "🚢"} + {selectedVessel.vesselName ?? selectedVessel.mmsi}

- {flagEmoji(selectedVessel.flag)} {selectedVessel.flag} · {selectedVessel.callSign} + {flagEmoji(selectedVessel.flag ?? "")} {selectedVessel.flag ?? "Flag unavailable"} · {selectedVessel.callSign ?? "Call sign unavailable"}

@@ -465,11 +479,11 @@ export default function CargoTrackingMap() {
{[ { label: "MMSI", value: selectedVessel.mmsi, icon: Radio }, - { label: "IMO", value: selectedVessel.imo, icon: Ship }, - { label: "Speed", value: `${selectedVessel.speed} kn`, icon: Gauge }, - { label: "Heading", value: `${selectedVessel.heading}°`, icon: Compass }, - { label: "Draught", value: `${selectedVessel.draught} m`, icon: TrendingUp }, - { label: "Length", value: `${selectedVessel.length} m`, icon: Ship }, + { label: "IMO", value: selectedVessel.imo ?? "—", icon: Ship }, + { label: "Speed", value: selectedVessel.speed === null ? "—" : `${selectedVessel.speed} kn`, icon: Gauge }, + { label: "Heading", value: selectedVessel.heading === null ? "—" : `${selectedVessel.heading}°`, icon: Compass }, + { label: "Draught", value: selectedVessel.draught === null ? "—" : `${selectedVessel.draught} m`, icon: TrendingUp }, + { label: "Length", value: selectedVessel.length === null ? "—" : `${selectedVessel.length} m`, icon: Ship }, ].map(item => (
@@ -483,12 +497,12 @@ export default function CargoTrackingMap() {
Origin: - {selectedVessel.originPort} + {selectedVessel.originPort ?? "—"}
Destination: - {selectedVessel.destination} + {selectedVessel.destination ?? "—"}
@@ -546,10 +560,12 @@ export default function CargoTrackingMap() { }} className={`w-full text-left flex items-center gap-2 p-2 rounded hover:bg-accent transition-colors text-xs ${selectedVessel?.id === v.id ? "bg-accent" : ""}`} > - {VESSEL_ICONS[v.vesselType] ?? "🚢"} + {VESSEL_ICONS[v.vesselType ?? "general"] ?? "🚢"}
-

{v.vesselName}

-

{v.status} · {v.speed} kn

+

{v.vesselName ?? v.mmsi}

+

+ {v.status ?? "Status unavailable"} · {v.speed === null ? "Speed unavailable" : `${v.speed} kn`} +

{v.riskFlag && (
now) return "scheduled"; + return "active"; +} + +export default function MandateManagement() { + const [agentUserId, setAgentUserId] = useState(""); + const [validUntil, setValidUntil] = useState(""); + const utils = trpc.useUtils(); + const principal = trpc.stakeholderRegistrations.mineMandates.useQuery({ side: "principal" }); + const agent = trpc.stakeholderRegistrations.mineMandates.useQuery({ side: "agent" }); + const create = trpc.stakeholderRegistrations.createMandate.useMutation({ onSuccess: () => { toast.success("Mandate granted"); principal.refetch(); setAgentUserId(""); setValidUntil(""); }, onError: error => toast.error("Mandate creation failed", { description: error.message }) }); + const revoke = trpc.stakeholderRegistrations.revokeMandate.useMutation({ onSuccess: () => { toast.success("Mandate revoked"); utils.stakeholderRegistrations.mineMandates.invalidate(); }, onError: error => toast.error("Mandate revocation failed", { description: error.message }) }); + const submit = (event: React.FormEvent) => { event.preventDefault(); create.mutate({ agentUserId: Number(agentUserId), validUntil: new Date(`${validUntil}T23:59:59.000Z`).toISOString() }); }; + return
+ Grant a principal-to-agent mandate
setAgentUserId(e.target.value)} />
setValidUntil(e.target.value)} />
+ revoke.mutate({ mandateId: id })} /> + +
; +} + +function MandateList({ title, mandates, canRevoke, onRevoke }: { title: string; mandates?: Array; canRevoke?: boolean; onRevoke?: (id: number) => void }) { + return {title}{mandates?.length ?
{mandates.map(mandate => { const state = stateOf(mandate); return

{mandate.referenceNumber}

Principal #{mandate.principalUserId} · Agent #{mandate.agentUserId}

{new Date(mandate.validFrom).toLocaleDateString()} – {new Date(mandate.validUntil).toLocaleDateString()}{mandate.revokedAt ? ` · revoked ${new Date(mandate.revokedAt).toLocaleString()}` : ""}

{mandate.revocationReason &&

{mandate.revocationReason}

}
{state}{canRevoke && state === "active" && }
; })}
:

No mandate history.

}
; +} diff --git a/client/src/pages/app/PortHeatmap.tsx b/client/src/pages/app/PortHeatmap.tsx index 3cfa9d2e..df7d96e2 100644 --- a/client/src/pages/app/PortHeatmap.tsx +++ b/client/src/pages/app/PortHeatmap.tsx @@ -188,7 +188,7 @@ function VesselTrackingPanel({ selectedPort }: { selectedPort: string | null }) // Dynamic port list from DB const { data: portList, isError} = trpc.portCongestion.listPorts.useQuery(); - const { data: vessels, isLoading } = trpc.geospatial.getVesselTrack.useQuery( + const { data: vessels, isLoading, isError: vesselsUnavailable } = trpc.geospatial.getVesselTrack.useQuery( { portCode: portFilter || undefined, limit: 100 }, { refetchInterval: 30_000 } ); @@ -241,6 +241,11 @@ function VesselTrackingPanel({ selectedPort }: { selectedPort: string | null }) {isLoading ? (
{Array.from({ length: 5 }).map((_, i) => )}
+ ) : vesselsUnavailable ? ( +
+ + Vessel tracking is unavailable — persisted AIS events cannot be reached. +
) : filtered.length === 0 ? (
@@ -556,6 +561,10 @@ export default function PortHeatmap() { {isLoading ? ( + ) : isError ? ( +
+ Port congestion data is unavailable. The map is intentionally not shown as empty. +
) : ( {Array.from({ length: 8 }).map((_, i) => )}
+ ) : isError ? ( +

Port status is unavailable.

) : ( + (heatmapData ?? []).length === 0 ? ( +

No persisted port congestion records are available.

+ ) : (
{(heatmapData ?? []).map((port) => (
+ ) )}
diff --git a/client/src/pages/app/StakeholderRegistration.tsx b/client/src/pages/app/StakeholderRegistration.tsx new file mode 100644 index 00000000..3447ac86 --- /dev/null +++ b/client/src/pages/app/StakeholderRegistration.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import DashboardLayout from "@/components/DashboardLayout"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +const types = [ + ["freight_forwarder", "Licensed customs / freight-forwarding agent"], + ["shipping_line", "Shipping line"], + ["shipping_company", "Shipping company"], + ["airline_gha", "Airline / GHA"], +] as const; + +export default function StakeholderRegistration() { + const [type, setType] = useState("freight_forwarder"); + const [form, setForm] = useState({ organizationName: "", organizationCode: "", licenseNumber: "", licenseExpiresAt: "", taxId: "", country: "NG", phone: "" }); + const mutation = trpc.stakeholderRegistrations.register.useMutation({ + onSuccess: result => { toast.success("Registration submitted", { description: result.referenceNumber }); }, + onError: error => toast.error("Registration failed", { description: error.message }), + }); + const set = (key: keyof typeof form, value: string) => setForm(previous => ({ ...previous, [key]: value })); + const submit = (event: React.FormEvent) => { + event.preventDefault(); + mutation.mutate({ + stakeholderType: type, + organizationName: form.organizationName, + organizationCode: form.organizationCode || undefined, + licenseNumber: form.licenseNumber || undefined, + licenseExpiresAt: form.licenseExpiresAt ? `${form.licenseExpiresAt}T23:59:59.000Z` : undefined, + taxId: form.taxId || undefined, + country: form.country.toUpperCase(), + phone: form.phone || undefined, + kycDocumentIds: [], + }); + }; + return
+ Register a stakeholder party

Your application remains pending until reviewed by an authorised officer.

+
+
+
set("organizationName", e.target.value)} />
+
set("organizationCode", e.target.value)} />
set("taxId", e.target.value)} />
+ {type === "freight_forwarder" &&
set("licenseNumber", e.target.value)} />
set("licenseExpiresAt", e.target.value)} />
} +
set("country", e.target.value)} />
set("phone", e.target.value)} />
+ +
+
+ +
; +} + +function MyRegistrations() { + const { data, isLoading } = trpc.stakeholderRegistrations.mine.useQuery(); + return My applications{isLoading ? : data?.length ?
{data.map(item =>
{item.referenceNumber}{item.organizationName}{item.status}{item.rejectionReason && {item.rejectionReason}}
)}
:

No stakeholder applications submitted.

}
; +} diff --git a/client/src/pages/app/StakeholderRegistrationReview.tsx b/client/src/pages/app/StakeholderRegistrationReview.tsx new file mode 100644 index 00000000..26b41f5a --- /dev/null +++ b/client/src/pages/app/StakeholderRegistrationReview.tsx @@ -0,0 +1,17 @@ +import DashboardLayout from "@/components/DashboardLayout"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +export default function StakeholderRegistrationReview() { + const utils = trpc.useUtils(); + const { data, isLoading, isError } = trpc.stakeholderRegistrations.pending.useQuery(); + const approve = trpc.stakeholderRegistrations.approve.useMutation({ onSuccess: () => { toast.success("Registration approved"); utils.stakeholderRegistrations.pending.invalidate(); }, onError: error => toast.error("Approval failed", { description: error.message }) }); + const reject = trpc.stakeholderRegistrations.reject.useMutation({ onSuccess: () => { toast.success("Registration rejected"); utils.stakeholderRegistrations.pending.invalidate(); }, onError: error => toast.error("Rejection failed", { description: error.message }) }); + return
+ Pending registrations{isLoading ? : isError ?

Registration review is unavailable.

: data?.length ?
{data.map(item =>
{item.referenceNumber}{item.stakeholderType.replace(/_/g, " ")}

{item.organizationName}

Country: {item.country} · Applicant #{item.userId}

{item.licenseNumber &&

Licence: {item.licenseNumber} (expires {item.licenseExpiresAt ? new Date(item.licenseExpiresAt).toLocaleDateString() : "—"})

}
)}
:

No registrations are pending review.

}
+
; +} diff --git a/client/src/pages/public/ApplicationTracker.tsx b/client/src/pages/public/ApplicationTracker.tsx new file mode 100644 index 00000000..3cd62c88 --- /dev/null +++ b/client/src/pages/public/ApplicationTracker.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { AlertCircle, CheckCircle2, Clock, Loader2, Search, Shield } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { trpc } from "@/lib/trpc"; + +export default function ApplicationTracker() { + const params = useParams<{ referenceNumber?: string }>(); + const [, navigate] = useLocation(); + const [input, setInput] = useState(params.referenceNumber ?? ""); + const referenceNumber = params.referenceNumber?.trim() ?? ""; + const query = trpc.applicationTracking.track.useQuery( + { referenceNumber: referenceNumber || "________" }, + { enabled: referenceNumber.length >= 8, retry: false }, + ); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const value = input.trim(); + if (value.length >= 8) navigate(`/track-application/${encodeURIComponent(value)}`); + }; + + return ( +
+
+
+ +

Track your Application

+

Nigeria National Single Window public services

+
+ + Application reference + +
+ setInput(e.target.value)} placeholder="Enter your reference number" className="bg-slate-900 text-white" /> + +
+
+
+ {query.isLoading && Checking the application registry…} + {!query.isLoading && query.isError && ( + + + {query.error.data?.code === "NOT_FOUND" ? "Application not found." : "Application tracking is unavailable. Please try again later."} + + )} + {query.data && ( + + +
+ {query.data.referenceNumber} + {query.data.status.replace(/_/g, " ")} +
+
Type: {query.data.type.replace(/_/g, " ")}
+
+

Submitted: {new Date(query.data.createdAt).toLocaleString()}

+

Last updated: {new Date(query.data.updatedAt).toLocaleString()}

+
+
+
+ )} +
+
+ ); +} diff --git a/client/src/pages/public/PermitValidation.tsx b/client/src/pages/public/PermitValidation.tsx new file mode 100644 index 00000000..89511e81 --- /dev/null +++ b/client/src/pages/public/PermitValidation.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { AlertCircle, CheckCircle2, Loader2, Search, Shield, XCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { trpc } from "@/lib/trpc"; + +export default function PermitValidation() { + const params = useParams<{ permitNumber?: string }>(); + const [, navigate] = useLocation(); + const [input, setInput] = useState(params.permitNumber ?? ""); + const permitNumber = params.permitNumber?.trim() ?? ""; + const query = trpc.oga.validatePermit.useQuery( + { permitNumber: permitNumber || "________" }, + { enabled: permitNumber.length > 0, retry: false }, + ); + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const value = input.trim(); + if (value) navigate(`/verify/permit/${encodeURIComponent(value)}`); + }; + return ( +
+
+
+ +

Permit Validation

+

Verify an OGA permit before relying on it

+
+ + Permit number +
+ setInput(e.target.value)} placeholder="Enter permit number" className="bg-slate-900 text-white" /> + +
+
+ {query.isLoading && Checking the permit registry…} + {!query.isLoading && query.isError && {query.error.data?.code === "NOT_FOUND" ? "Permit not found." : "Permit validation is unavailable."}} + {query.data && +
{query.data.isValid ? : }

{query.data.isValid ? "Permit valid" : query.data.isExpired ? "Permit expired" : "Permit not valid"}

{query.data.permitNumber}

+
Agency{query.data.agencyName} ({query.data.agencyCode})Permit type{query.data.permitType ?? "—"}Status{query.data.status}Expires{query.data.expiresAt ? new Date(query.data.expiresAt).toLocaleDateString() : "No expiry recorded"}
+
} +
+
+ ); +} diff --git a/server/_core/wsServer.ts b/server/_core/wsServer.ts index f9db69a5..9e187960 100644 --- a/server/_core/wsServer.ts +++ b/server/_core/wsServer.ts @@ -33,13 +33,13 @@ export interface WsVesselUpdateEvent { payload: { vessels: Array<{ mmsi: string; - vesselName: string; + vesselName: string | null; lat: number; lon: number; - speed: number; - heading: number; - status: string; - riskFlag: "green" | "amber" | "red"; + speed: number | null; + heading: number | null; + status: string | null; + riskFlag: "green" | "amber" | "red" | null; lastUpdate: string; }>; totalCount: number; diff --git a/server/cargoTracking.persisted.test.ts b/server/cargoTracking.persisted.test.ts index 26ee2e40..6378a6ce 100644 --- a/server/cargoTracking.persisted.test.ts +++ b/server/cargoTracking.persisted.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -const state = vi.hoisted(() => ({ rows: [] as any[] })); +const state = vi.hoisted(() => ({ rows: [] as any[], queryError: null as Error | null })); vi.mock("./db", async (importOriginal) => { const actual = await importOriginal(); @@ -9,6 +9,7 @@ vi.mock("./db", async (importOriginal) => { getDb: vi.fn(async () => ({})), getPool: vi.fn(() => ({ query: vi.fn(async (query: string) => { + if (state.queryError) throw state.queryError; if (query.includes("COUNT(DISTINCT mmsi)")) { return { rows: state.rows.length @@ -22,6 +23,16 @@ vi.mock("./db", async (importOriginal) => { }; }); +vi.mock("./_core/kafka", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, publishEvent: vi.fn() }; +}); + +afterEach(() => { + state.rows = []; + state.queryError = null; +}); + describe("persisted cargo tracking", () => { it("returns persisted vessel events without fabricating shipment or port metadata", async () => { const { cargoTrackingRouter } = await import("./routers/cargoTracking"); @@ -42,18 +53,56 @@ describe("persisted cargo tracking", () => { const caller = cargoTrackingRouter.createCaller({} as any); const result = await caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" }); expect(result.vessels).toHaveLength(1); - expect(result.vessels[0]).toMatchObject({ mmsi: "123456789", vesselName: "Persisted Vessel", lat: 6.4 }); + expect(result.vessels[0]).toMatchObject({ + mmsi: "123456789", + vesselName: "Persisted Vessel", + lat: 6.4, + riskFlag: null, + cargoStatus: null, + callSign: null, + draught: null, + length: null, + originLat: null, + originLon: null, + }); expect(result.vessels[0].declarationRef).toBeNull(); expect(result.sourceService).toBe("vessel_tracking_events"); }); - it("returns explicit unavailability when no persisted tracking rows exist", async () => { + it("returns successful empty results when no persisted tracking rows exist", async () => { const { cargoTrackingRouter } = await import("./routers/cargoTracking"); - state.rows = []; const caller = cargoTrackingRouter.createCaller({} as any); await expect(caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" })) - .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + .resolves.toMatchObject({ vessels: [], totalCount: 0, sourceService: "vessel_tracking_events" }); + await expect(caller.searchVessels({ q: "missing" })).resolves.toEqual([]); + await expect(caller.getVesselRoute({ mmsi: "missing" })) + .resolves.toMatchObject({ waypoints: [], vessel: null, sourceService: "vessel_tracking_events" }); await expect(caller.getVesselStats()) + .resolves.toMatchObject({ total: 0, redFlag: null, amberFlag: null, greenFlag: null, withDeclaration: null }); + }); + + it("maps persisted query failures to service unavailability", async () => { + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + state.queryError = new Error("database offline"); + const caller = cargoTrackingRouter.createCaller({} as any); + await expect(caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" })) .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); + + it("does not report cargo-event success when Kafka publication fails", async () => { + const kafka = await import("./_core/kafka"); + const publish = vi.mocked(kafka.publishEvent).mockRejectedValueOnce(new Error("Kafka offline")); + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + const caller = cargoTrackingRouter.createCaller({ + user: { id: 42 }, + req: { method: "GET" }, + } as any); + await expect(caller.logCargoEvent({ + mmsi: "123456789", + vesselName: "Persisted Vessel", + eventType: "arrived", + portCode: "NGAPP", + })).rejects.toThrow("Kafka offline"); + expect(publish).toHaveBeenCalledOnce(); + }); }); diff --git a/server/openapi.ts b/server/openapi.ts index c9ef04f7..acc99b2a 100644 --- a/server/openapi.ts +++ b/server/openapi.ts @@ -56,9 +56,8 @@ const ROUTER_CATALOGUE: Record> = { getVesselPositions: { type: "query", summary: "Get vessel positions", description: "Returns current AIS positions for tracked vessels.", tags: ["Geospatial"], requiresAuth: true }, }, cargoTracking: { - getLiveVessels: { type: "query", summary: "Get live vessel positions", description: "Returns current AIS positions for all tracked vessels in the East Africa corridor. Refreshes every 30 seconds.", tags: ["Cargo Tracking"], requiresAuth: false, responseExample: { vessels: [{ mmsi: "636091234", vesselName: "MSC NAIROBI", lat: -4.0435, lon: 39.6682, speed: 12.4, heading: 285, status: "underway", riskFlag: "green" }], totalCount: 8, lastRefresh: "2026-03-09T15:00:00Z", sourceService: "sedona-svc" } }, + getLiveVessels: { type: "query", summary: "Get live vessel positions", description: "Returns persisted AIS positions when available. Successful queries may return an empty vessel collection.", tags: ["Cargo Tracking"], requiresAuth: false, responseExample: { vessels: [], totalCount: 0, lastRefresh: "2026-03-09T15:00:00Z", sourceService: "vessel_tracking_events" } }, getVesselRoute: { type: "query", summary: "Get vessel route polyline", description: "Returns historical track waypoints for a specific vessel identified by MMSI.", tags: ["Cargo Tracking"], requiresAuth: false, requestExample: { mmsi: "636091234" } }, - getShipmentPosition: { type: "query", summary: "Get shipment position by declaration", description: "Returns the current vessel position linked to a specific declaration reference.", tags: ["Cargo Tracking"], requiresAuth: true, requestExample: { declarationRef: "URN-2026-001234" } }, getPortArrivals: { type: "query", summary: "Get upcoming port arrivals", description: "Returns the list of vessels with upcoming ETAs at the home port.", tags: ["Cargo Tracking"], requiresAuth: false }, getVesselStats: { type: "query", summary: "Get vessel statistics", description: "Returns summary statistics: total vessels, underway/moored/anchored counts, risk flag breakdown.", tags: ["Cargo Tracking"], requiresAuth: false }, }, diff --git a/server/routers/cargoTracking.ts b/server/routers/cargoTracking.ts index c434f99c..779507b7 100644 --- a/server/routers/cargoTracking.ts +++ b/server/routers/cargoTracking.ts @@ -1,9 +1,8 @@ /** * Cargo tracking backed by persisted AIS events. * - * A tracking response is never synthesized when the persisted source has no - * data. Callers receive SERVICE_UNAVAILABLE so they can render that tracking - * is unavailable instead of an empty or fabricated map. + * A tracking response is never synthesized. Persisted-source failures are + * surfaced as unavailable, while successful empty queries remain empty. */ import { z } from "zod"; @@ -14,41 +13,41 @@ import { getDb, getPool } from "../db"; export type VesselStatus = "underway" | "moored" | "anchored" | "restricted" | "aground"; export type CargoStatus = "pre-arrival" | "arrived" | "berthed" | "loading" | "unloading" | "departed"; -export type RiskFlag = "green" | "amber" | "red"; +export type RiskFlag = "green" | "amber" | "red" | null; export type VesselType = "container" | "bulk" | "tanker" | "general" | "roro" | "passenger"; export interface LiveVessel { id: string; mmsi: string; - imo: string; - vesselName: string; - vesselType: VesselType; - flag: string; - callSign: string; + imo: string | null; + vesselName: string | null; + vesselType: VesselType | null; + flag: string | null; + callSign: string | null; lat: number; lon: number; - speed: number; - heading: number; - course: number; - status: VesselStatus; - cargoStatus: CargoStatus; + speed: number | null; + heading: number | null; + course: number | null; + status: VesselStatus | null; + cargoStatus: CargoStatus | null; declarationRef: string | null; riskFlag: RiskFlag; eta: string | null; - destination: string; - draught: number; - length: number; + destination: string | null; + draught: number | null; + length: number | null; lastUpdate: string; - originPort: string; - originLat: number; - originLon: number; + originPort: string | null; + originLat: number | null; + originLon: number | null; } export interface RouteWaypoint { lat: number; lon: number; timestamp: string; - speed: number; + speed: number | null; } type VesselRow = { @@ -96,35 +95,32 @@ function vesselType(cargoType: string | null): VesselType | null { } function mapVessel(row: VesselRow): LiveVessel { - const speed = Number(row.speed ?? 0); - const flag = String(row.flag_country ?? ""); - const highRisk = ["IRN", "PRK", "SYR", "RUS", "BLR"]; - const mediumRisk = ["LBY", "SOM", "SDN", "YEM", "MMR"]; + const speed = row.speed === null ? null : Number(row.speed); return { id: String(row.mmsi), mmsi: String(row.mmsi), - imo: String(row.imo_number ?? ""), - vesselName: String(row.vessel_name ?? ""), - vesselType: vesselType(row.cargo_type) ?? "general", - flag, - callSign: "", + imo: row.imo_number, + vesselName: row.vessel_name, + vesselType: vesselType(row.cargo_type), + flag: row.flag_country, + callSign: null, lat: Number(row.latitude), lon: Number(row.longitude), speed, - heading: Number(row.heading ?? 0), - course: Number(row.heading ?? 0), - status: speed < 0.5 ? "moored" : speed < 2 ? "anchored" : "underway", - cargoStatus: "" as CargoStatus, + heading: row.heading === null ? null : Number(row.heading), + course: row.heading === null ? null : Number(row.heading), + status: speed === null ? null : speed < 0.5 ? "moored" : speed < 2 ? "anchored" : "underway", + cargoStatus: null, declarationRef: null, - riskFlag: highRisk.includes(flag) ? "red" : mediumRisk.includes(flag) ? "amber" : "green", + riskFlag: null, eta: row.eta ? new Date(row.eta).toISOString() : null, - destination: String(row.destination_port ?? ""), - draught: 0, - length: 0, + destination: row.destination_port, + draught: null, + length: null, lastUpdate: new Date(row.recorded_at).toISOString(), - originPort: "", - originLat: 0, - originLon: 0, + originPort: null, + originLat: null, + originLon: null, }; } @@ -143,9 +139,6 @@ export const cargoTrackingRouter = router({ })) .query(async ({ input }) => { const rows = await persistedQuery(latestVesselsQuery); - if (rows.length === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); - } let vessels = rows.map(mapVessel); if (input.riskFilter !== "all") vessels = vessels.filter(v => v.riskFlag === input.riskFilter); if (input.statusFilter !== "all") vessels = vessels.filter(v => v.status === input.statusFilter); @@ -168,7 +161,11 @@ export const cargoTrackingRouter = router({ ORDER BY recorded_at ASC `, [input.mmsi]); if (rows.length === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); + return { + waypoints: [], + vessel: null, + sourceService: "vessel_tracking_events", + }; } const latest = mapVessel(rows[rows.length - 1]); return { @@ -176,21 +173,12 @@ export const cargoTrackingRouter = router({ lat: Number(row.latitude), lon: Number(row.longitude), timestamp: new Date(row.recorded_at).toISOString(), - speed: Number(row.speed ?? 0), + speed: row.speed === null ? null : Number(row.speed), })), vessel: latest, }; }), - getShipmentPosition: publicProcedure - .input(z.object({ declarationRef: z.string().min(1) })) - .query(async () => { - throw new TRPCError({ - code: "SERVICE_UNAVAILABLE", - message: "Shipment cargo tracking is unavailable.", - }); - }), - getPortArrivals: publicProcedure.query(async () => { const manifests = await persistedQuery<{ vessel_name: string; @@ -229,9 +217,6 @@ export const cargoTrackingRouter = router({ FROM vessel_tracking_events ORDER BY mmsi, recorded_at DESC `); - if (rows.length === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); - } const arrivals = rows.filter(row => row.eta && new Date(row.eta) >= new Date()); return { arrivals: arrivals.map(row => { @@ -255,32 +240,24 @@ export const cargoTrackingRouter = router({ getVesselStats: publicProcedure.query(async () => { const [stats] = await persistedQuery<{ total: string; moored: string; anchored: string; underway: string; - red_flag: string; amber_flag: string; }>(` SELECT COUNT(DISTINCT mmsi) AS total, SUM(CASE WHEN speed < 0.5 THEN 1 ELSE 0 END) AS moored, SUM(CASE WHEN speed >= 0.5 AND speed < 2 THEN 1 ELSE 0 END) AS anchored, - SUM(CASE WHEN speed >= 2 THEN 1 ELSE 0 END) AS underway, - SUM(CASE WHEN flag_country IN ('IRN','PRK','SYR','RUS','BLR') THEN 1 ELSE 0 END) AS red_flag, - SUM(CASE WHEN flag_country IN ('LBY','SOM','SDN','YEM','MMR') THEN 1 ELSE 0 END) AS amber_flag + SUM(CASE WHEN speed >= 2 THEN 1 ELSE 0 END) AS underway FROM (${latestVesselsQuery}) latest `); const total = Number(stats?.total ?? 0); - if (!stats || total === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); - } - const redFlag = Number(stats.red_flag ?? 0); - const amberFlag = Number(stats.amber_flag ?? 0); return { total, underway: Number(stats.underway ?? 0), moored: Number(stats.moored ?? 0), anchored: Number(stats.anchored ?? 0), - redFlag, - amberFlag, - greenFlag: Math.max(0, total - redFlag - amberFlag), - withDeclaration: 0, + redFlag: null, + amberFlag: null, + greenFlag: null, + withDeclaration: null, sourceService: "vessel_tracking_events", }; }), @@ -309,7 +286,7 @@ export const cargoTrackingRouter = router({ lon: input.lon ?? null, loggedBy: ctx.user.id, }, - }).catch(() => {}); + }); return { success: true, eventType: input.eventType, mmsi: input.mmsi }; }), @@ -323,9 +300,6 @@ export const cargoTrackingRouter = router({ WHERE vessel_name ILIKE $1 OR mmsi ILIKE $1 OR imo_number ILIKE $1 ORDER BY mmsi, recorded_at DESC LIMIT 20 `, [`%${input.q}%`]); - if (rows.length === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); - } return rows.map(mapVessel); }), @@ -344,16 +318,16 @@ export const cargoTrackingRouter = router({ ORDER BY recorded_at DESC LIMIT $2 `, [input.hours, input.limit]); - if (rows.length === 0) { - throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Cargo tracking is unavailable." }); - } - return rows.map(row => ({ - lat: Number(row.lat), - lng: Number(row.lng), - weight: row.speed === null ? 0.5 : Math.min(Number(row.speed) / 30, 1), - vesselId: row.mmsi, - timestamp: row.recorded_at, - })); + return { + points: rows.map(row => ({ + lat: Number(row.lat), + lng: Number(row.lng), + weight: row.speed === null ? null : Math.min(Number(row.speed) / 30, 1), + vesselId: row.mmsi, + timestamp: row.recorded_at, + })), + sourceService: "vessel_tracking_events", + }; }), }); diff --git a/server/smoke.stakeholders.test.ts b/server/smoke.stakeholders.test.ts index 1bd24945..a9216d78 100644 --- a/server/smoke.stakeholders.test.ts +++ b/server/smoke.stakeholders.test.ts @@ -264,7 +264,6 @@ describe("D. Finance Officer — TigerBeetle Ledger", () => { describe("E. Port Operator — Cargo & Vessel Tracking", () => { it("E1: can view live vessels", () => expectQuery("cargoTracking.getLiveVessels")); it("E2: can view vessel route", () => expectQuery("cargoTracking.getVesselRoute")); - it("E3: can view shipment position", () => expectQuery("cargoTracking.getShipmentPosition")); it("E4: can view port arrivals", () => expectQuery("cargoTracking.getPortArrivals")); it("E5: can view vessel stats", () => expectQuery("cargoTracking.getVesselStats")); it("E6: can log a cargo event", () => expectMutation("cargoTracking.logCargoEvent")); diff --git a/server/sprint66-68.test.ts b/server/sprint66-68.test.ts index d8dfde7a..9e22e4a7 100644 --- a/server/sprint66-68.test.ts +++ b/server/sprint66-68.test.ts @@ -184,11 +184,11 @@ describe("Sprint 68 — OpenAPI Specification", () => { }); it("cargo tracking procedures are in catalogue", () => { - const cargoProcs = ["getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats", "getShipmentPosition"]; + const cargoProcs = ["getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats"]; for (const proc of cargoProcs) { expect(proc).toBeTruthy(); } - expect(cargoProcs.length).toBe(5); + expect(cargoProcs.length).toBe(4); }); it("onboarding procedures are in catalogue", () => { From c67d71652d4b14154776081068c3497e2efbbab7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:21:49 +0000 Subject: [PATCH 04/17] feat: link mandate and stakeholder services Co-Authored-By: Patrick Munis --- client/src/App.tsx | 12 ++--- client/src/components/DashboardLayout.tsx | 5 ++ client/src/lib/trpc.ts | 1 + client/src/pages/Home.tsx | 54 ++++++++++++++++++++++ client/src/pages/app/MandateManagement.tsx | 36 ++++++++++++--- server/db.ts | 21 ++++++++- server/routers/stakeholderRegistrations.ts | 9 ++++ server/stakeholderRegistrations.test.ts | 24 ++++++++++ 8 files changed, 148 insertions(+), 14 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index a978880e..fbec6740 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -172,6 +172,12 @@ function Router() { {/* Sprint 80 — Public certificate verification (no auth required, QR-scannable) */} + + }> + + + }> + }> @@ -181,12 +187,6 @@ function Router() { }> - - }> - - - }> - {/* Public system status page — no authentication required */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 2df36035..36fcd181 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -133,6 +133,7 @@ function getNavGroups(role: string): NavGroup[] { { icon: ShieldCheck, label: "Trusted Trader Programme", path: "/app/admin/aeo" }, { icon: Award, label: "AEO Applications", path: "/app/aeo/applications" }, { icon: UserCheck, label: "Trader Verification", path: "/app/admin/kyc-review" }, + { icon: Users, label: "Stakeholder Registration Review", path: "/app/admin/stakeholder-registrations" }, { icon: BarChart3, label: "Performance Reports", path: "/app/admin/analytics" }, ], }, @@ -270,6 +271,7 @@ function getNavGroups(role: string): NavGroup[] { { icon: Camera, label: "Cargo Inspection", path: "/app/customs/vision" }, { icon: ScanLine, label: "Vision Batch Analysis", path: "/app/customs/vision-batch" }, { icon: Package, label: "Agency Permits", path: "/app/oga" }, + ...(role === "customs_officer" ? [{ icon: Users, label: "Stakeholder Registration Review", path: "/app/admin/stakeholder-registrations" }] : []), { icon: ClipboardCheck, label: "Post-Clearance Review", path: "/app/customs/audit" }, ], }, @@ -310,6 +312,7 @@ function getNavGroups(role: string): NavGroup[] { { icon: CalendarClock, label: "Expiry Calendar", path: "/app/oga/expiry-calendar" }, { icon: FileText, label: "Related Declarations", path: "/app/customs" }, { icon: Globe, label: "Rules of Origin (AfCFTA)", path: "/app/oga/rules-of-origin" }, + { icon: Users, label: "Stakeholder Registration Review", path: "/app/admin/stakeholder-registrations" }, ], }, { @@ -366,6 +369,8 @@ function getNavGroups(role: string): NavGroup[] { { icon: Layers, label: "Payment Queue", path: "/app/trader/payment-queue" }, { icon: Fingerprint, label: "Identity Verification", path: "/app/trader/kyc" }, { icon: Anchor, label: "Cargo Tracking Map", path: "/app/geo/cargo-tracking" }, + { icon: UserCheck, label: "Stakeholder Registration", path: "/app/stakeholder-registration" }, + { icon: Users, label: "Mandate Management", path: "/app/mandates" }, { icon: CheckCircle, label: "Account Setup Wizard", path: "/app/onboarding" }, ], }, diff --git a/client/src/lib/trpc.ts b/client/src/lib/trpc.ts index df386074..1c9706bf 100644 --- a/client/src/lib/trpc.ts +++ b/client/src/lib/trpc.ts @@ -2,3 +2,4 @@ import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "../../../server/routers"; export const trpc = createTRPCReact(); +export type { AppRouter }; diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index eb373db5..771e15e3 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -87,6 +87,21 @@ const PORTALS = [ }, ]; +const PUBLIC_UTILITIES = [ + { + title: "Track your Application", + description: "Check the status and latest update for a submitted application.", + href: "/track-application", + icon: Clock, + }, + { + title: "Permit / COO Validation", + description: "Verify a government permit or certificate of origin before relying on it.", + href: "/verify/permit", + icon: ShieldCheck, + }, +]; + const CAPABILITIES = [ { icon: Clock, @@ -281,6 +296,45 @@ export default function Home() {
+ {/* ── Public eServices ── */} +
+
+
+ Public eServices +

+ Verify and track with confidence +

+

+ Use official registry services to check an application or validate a trade document. +

+
+
+ {PUBLIC_UTILITIES.map((utility) => { + const Icon = utility.icon; + return ( + +
+
+ +
+
+

+ {utility.title} +

+

{utility.description}

+
+ +
+ + ); + })} +
+
+
+ {/* ── Portal Access ── */}
diff --git a/client/src/pages/app/MandateManagement.tsx b/client/src/pages/app/MandateManagement.tsx index 720fa831..2a420574 100644 --- a/client/src/pages/app/MandateManagement.tsx +++ b/client/src/pages/app/MandateManagement.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; +import type { inferRouterOutputs } from "@trpc/server"; import DashboardLayout from "@/components/DashboardLayout"; -import { trpc } from "@/lib/trpc"; +import { trpc, type AppRouter } from "@/lib/trpc"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -8,6 +9,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +type RouterOutputs = inferRouterOutputs; +type Mandate = RouterOutputs["stakeholderRegistrations"]["mineMandates"][number]; +type ApprovedAgent = RouterOutputs["stakeholderRegistrations"]["approvedAgents"][number]; + function stateOf(mandate: { revokedAt: Date | string | null; validFrom: Date | string; validUntil: Date | string }) { if (mandate.revokedAt) return "revoked"; const now = Date.now(); @@ -17,21 +22,38 @@ function stateOf(mandate: { revokedAt: Date | string | null; validFrom: Date | s } export default function MandateManagement() { - const [agentUserId, setAgentUserId] = useState(""); + const [agentSearch, setAgentSearch] = useState(""); + const [selectedAgent, setSelectedAgent] = useState(null); const [validUntil, setValidUntil] = useState(""); const utils = trpc.useUtils(); const principal = trpc.stakeholderRegistrations.mineMandates.useQuery({ side: "principal" }); const agent = trpc.stakeholderRegistrations.mineMandates.useQuery({ side: "agent" }); - const create = trpc.stakeholderRegistrations.createMandate.useMutation({ onSuccess: () => { toast.success("Mandate granted"); principal.refetch(); setAgentUserId(""); setValidUntil(""); }, onError: error => toast.error("Mandate creation failed", { description: error.message }) }); + const approvedAgents = trpc.stakeholderRegistrations.approvedAgents.useQuery(); + const create = trpc.stakeholderRegistrations.createMandate.useMutation({ onSuccess: () => { toast.success("Mandate granted"); principal.refetch(); setSelectedAgent(null); setAgentSearch(""); setValidUntil(""); }, onError: error => toast.error("Mandate creation failed", { description: error.message }) }); const revoke = trpc.stakeholderRegistrations.revokeMandate.useMutation({ onSuccess: () => { toast.success("Mandate revoked"); utils.stakeholderRegistrations.mineMandates.invalidate(); }, onError: error => toast.error("Mandate revocation failed", { description: error.message }) }); - const submit = (event: React.FormEvent) => { event.preventDefault(); create.mutate({ agentUserId: Number(agentUserId), validUntil: new Date(`${validUntil}T23:59:59.000Z`).toISOString() }); }; + const matchingAgents = (approvedAgents.data ?? []).filter(candidate => { + const query = agentSearch.trim().toLowerCase(); + return !query || + candidate.organizationName.toLowerCase().includes(query) || + candidate.licenseNumber?.toLowerCase().includes(query); + }); + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedAgent) return; + create.mutate({ agentUserId: selectedAgent.userId, validUntil: new Date(`${validUntil}T23:59:59.000Z`).toISOString() }); + }; return
- Grant a principal-to-agent mandate
setAgentUserId(e.target.value)} />
setValidUntil(e.target.value)} />
+ Grant a principal-to-agent mandate

Select an approved, currently licensed agent from the directory.

+
{ setSelectedAgent(null); setAgentSearch(event.target.value); }} placeholder="Search by organisation or licence number" /> + {!selectedAgent && agentSearch.trim() &&
{approvedAgents.isLoading ?

Loading approved agents…

: matchingAgents.length ? matchingAgents.map(candidate => ) :

No approved licensed agents match that search.

}
} +
+
setValidUntil(e.target.value)} />
+
revoke.mutate({ mandateId: id })} />
; } -function MandateList({ title, mandates, canRevoke, onRevoke }: { title: string; mandates?: Array; canRevoke?: boolean; onRevoke?: (id: number) => void }) { - return {title}{mandates?.length ?
{mandates.map(mandate => { const state = stateOf(mandate); return

{mandate.referenceNumber}

Principal #{mandate.principalUserId} · Agent #{mandate.agentUserId}

{new Date(mandate.validFrom).toLocaleDateString()} – {new Date(mandate.validUntil).toLocaleDateString()}{mandate.revokedAt ? ` · revoked ${new Date(mandate.revokedAt).toLocaleString()}` : ""}

{mandate.revocationReason &&

{mandate.revocationReason}

}
{state}{canRevoke && state === "active" && }
; })}
:

No mandate history.

}
; +function MandateList({ title, mandates, canRevoke, onRevoke }: { title: string; mandates?: Mandate[]; canRevoke?: boolean; onRevoke?: (id: number) => void }) { + return {title}{mandates?.length ?
{mandates.map(mandate => { const state = stateOf(mandate); const stateClass = { active: "border-emerald-500 text-emerald-600", scheduled: "border-blue-500 text-blue-600", expired: "border-amber-500 text-amber-700", revoked: "border-red-500 bg-red-50 text-red-700" }[state]; return

{mandate.referenceNumber}

Principal #{mandate.principalUserId} · Agent #{mandate.agentUserId}

{new Date(mandate.validFrom).toLocaleDateString()} – {new Date(mandate.validUntil).toLocaleDateString()}{mandate.revokedAt ? ` · revoked ${new Date(mandate.revokedAt).toLocaleString()}` : ""}

{mandate.revocationReason &&

{mandate.revocationReason}

}
{state}{canRevoke && state === "active" && }
; })}
:

No mandate history.

}
; } diff --git a/server/db.ts b/server/db.ts index 08d9e7d4..e24105aa 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, or, gte, lte, gt, isNull, sql, count, inArray, like } from "drizzle-orm"; +import { eq, desc, and, or, gte, lte, gt, isNull, isNotNull, sql, count, inArray, like } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { @@ -294,6 +294,25 @@ export async function getApprovedAgentRegistration(agentUserId: number, at = new return registration; } +export async function getApprovedAgentRegistrations(at = new Date()) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + return db.select({ + userId: stakeholderRegistrations.userId, + organizationName: stakeholderRegistrations.organizationName, + licenseNumber: stakeholderRegistrations.licenseNumber, + licenseExpiresAt: stakeholderRegistrations.licenseExpiresAt, + }).from(stakeholderRegistrations) + .where(and( + eq(stakeholderRegistrations.stakeholderType, "freight_forwarder"), + eq(stakeholderRegistrations.status, "approved"), + isNotNull(stakeholderRegistrations.licenseNumber), + isNotNull(stakeholderRegistrations.licenseExpiresAt), + gt(stakeholderRegistrations.licenseExpiresAt, at), + )) + .orderBy(stakeholderRegistrations.organizationName); +} + export async function getApprovedTraderProfile(userId: number) { const db = await getDb(); if (!db) throw new Error("Database unavailable"); diff --git a/server/routers/stakeholderRegistrations.ts b/server/routers/stakeholderRegistrations.ts index 3cf396ba..2beddf3d 100644 --- a/server/routers/stakeholderRegistrations.ts +++ b/server/routers/stakeholderRegistrations.ts @@ -15,6 +15,7 @@ import { getStakeholderMandatesByPrincipal, getStakeholderMandatesByAgent, getApprovedAgentRegistration, + getApprovedAgentRegistrations, getApprovedTraderProfile, logAuditEvent, } from "../db"; @@ -265,6 +266,14 @@ export const stakeholderRegistrationsRouter = router({ } }), + approvedAgents: protectedProcedure.query(async () => { + try { + return await getApprovedAgentRegistrations(); + } catch (error) { + return serviceUnavailable("Approved agent directory is unavailable.", error); + } + }), + revokeMandate: protectedProcedure .input(z.object({ mandateId: z.number().int().positive(), reason: z.string().max(1024).optional() })) .mutation(async ({ ctx, input }) => { diff --git a/server/stakeholderRegistrations.test.ts b/server/stakeholderRegistrations.test.ts index f8400f06..a305eb97 100644 --- a/server/stakeholderRegistrations.test.ts +++ b/server/stakeholderRegistrations.test.ts @@ -79,6 +79,15 @@ vi.mock("./db", async (importOriginal) => { status: "approved", }; }), + getApprovedAgentRegistrations: vi.fn(async () => { + if (dbState.unavailable) throw new Error("database unavailable"); + return [{ + userId: 2, + organizationName: "Licensed Clearing Ltd", + licenseNumber: "LIC-001", + licenseExpiresAt: new Date("2030-01-01T00:00:00.000Z"), + }]; + }), createStakeholderMandate: vi.fn(async (data: any) => { if (dbState.unavailable) throw new Error("database unavailable"); return { ...dbState.mandate, ...data }; @@ -191,6 +200,18 @@ describe("NSW stakeholder registrations and mandates", () => { expect(held[0].revocationReason).toBe("Engagement ended"); }); + it("lists only the approved agent directory fields needed to grant a mandate", async () => { + const agents = await appRouter.createCaller(context(1)).stakeholderRegistrations.approvedAgents(); + expect(agents).toEqual([{ + userId: 2, + organizationName: "Licensed Clearing Ltd", + licenseNumber: "LIC-001", + licenseExpiresAt: new Date("2030-01-01T00:00:00.000Z"), + }]); + expect(agents[0]).not.toHaveProperty("email"); + expect(agents[0]).not.toHaveProperty("role"); + }); + it("fails closed when registration persistence is unavailable", async () => { dbState.unavailable = true; await expect( @@ -219,6 +240,9 @@ describe("NSW stakeholder registrations and mandates", () => { await expect( appRouter.createCaller(context(2)).stakeholderRegistrations.mine(), ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + await expect( + appRouter.createCaller(context(2)).stakeholderRegistrations.approvedAgents(), + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); dbState.unavailable = false; }); }); From ed3965453ffb2485e229179e63a05f57b75fa48d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:22:22 +0000 Subject: [PATCH 05/17] docs(nsw): add nsw.gov.ng parity analysis Co-Authored-By: Patrick Munis --- docs/nsw-gov-ng-parity.md | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/nsw-gov-ng-parity.md diff --git a/docs/nsw-gov-ng-parity.md b/docs/nsw-gov-ng-parity.md new file mode 100644 index 00000000..cb828bf7 --- /dev/null +++ b/docs/nsw-gov-ng-parity.md @@ -0,0 +1,93 @@ +# nsw.gov.ng parity analysis + +Comparison of the live Nigeria National Single Window portal (`https://nsw.gov.ng`, public surface +observed 2026-08-24, including the `cusLogin/login.cl` landing page) against this platform. + +Only the public surface of nsw.gov.ng is observable — the portal is a login wall, so its eServices +catalogue, "Facts and Figures" panel and public utility tiles are the evidence base. Anything behind +authentication (the actual declaration and manifest screens) cannot be compared and is not claimed +about below. + +## What NSW exposes publicly + +| NSW eService tile | Nature | +|---|---| +| Importer/Exporter Registration | Self-service party registration | +| Licensed Customs / Freight Forwarding Agent Registration | Self-service party registration, licence-backed | +| Shipping Lines Registration | Self-service party registration | +| Shipping Company Registration | Self-service party registration | +| Airlines / GHA Registration | Self-service party registration | +| Freight Forwarders Registration | Self-service party registration | +| LCFFA Authorization Registration | Principal→agent mandate creation | +| LCFFA Authorization De-Registration | Principal→agent mandate revocation | +| Track your Application | Unauthenticated status lookup by reference | +| Cargo Tracking | Unauthenticated cargo status lookup | +| Permit/COO Validation | Unauthenticated document authenticity check | + +## Gap assessment + +### G1 — Only one party type can register. CONFIRMED gap + +Onboarding is a single trader/company wizard: `server/routers/onboarding.ts` drives the five steps of +`onboardingStepEnum` (`company_profile`, `kyc_documents`, `bank_account`, `test_declaration`, +`aeo_eligibility`) and `selectRole` now accepts only `z.enum(["user"])`. There is no registration path +for a licensed customs/freight-forwarding agent, shipping line, shipping company, or airline/GHA. + +`stakeholderTypeEnum` (`drizzle/schema.ts:11`) does contain `freight_forwarder`, `bank_officer` and +`port_authority`, but no registration flow, licence capture, or approval queue references them — the +values are unreachable. Six of the NSW eServices tiles have no counterpart here. + +Consequence: a carrier cannot become a user of this platform at all, yet `manifests.submit` +(`server/routers/manifests.ts:41`) is a plain `protectedProcedure` that any authenticated user may +call — so the party type that *should* file manifests cannot register, while every party type that +should not can file them. + +### G2 — No agent mandate model. CONFIRMED gap, and it now blocks a legitimate flow + +NSW treats agent authorization as two first-class eServices; here there is no concept of one party +acting for another. `declarant` appears in the codebase only as a display string on audit tasks +(`drizzle/schema.ts:1874`), never as an authorization relationship. + +This is now load-bearing: the ownership hardening in PR #33 derives trader identity from +`ctx.user.id` for declarations, trade finance and payments. That is correct as a default and closed a +real horizontal-privilege hole, but with no mandate model the platform cannot express the single most +common real-world customs arrangement — an importer engaging a licensed agent to clear on their +behalf. The honest fix is a mandate, not a relaxation of the ownership check. + +### G3 — No "Track your Application". CONFIRMED gap + +The only unauthenticated routes are `/verify/:certNumber`, `/status` and `/specification` +(`client/src/App.tsx:167-186`). There is no reference-number status lookup, and no user-facing +reference number is minted for a registration or permit application in the first place. + +### G4 — Permit validation is COO-only. PARTIAL gap + +`/verify/:certNumber` covers AfCFTA certificates of origin +(`rulesOfOrigin.verifyCertificate`), which is half of the NSW "Permit/COO Validation" tile. OGA +permits — the other half — have no public validation: every procedure in `server/routers/oga.ts` is +`protectedProcedure`, and `ogaPermits` (`drizzle/schema.ts:206`) already carries the +`permitNumber` and `expiresAt` a validation check needs. + +### G5 — Public cargo tracking is fabricated, and for the wrong country. CONFIRMED defect + +`cargoTracking.getLiveVessels`, `getVesselRoute`, `getShipmentPosition`, `getPortArrivals` and +`searchVessels` are `publicProcedure`s served from the hardcoded `BASE_VESSELS` array +(`server/routers/cargoTracking.ts:56`) — `MSC NAIROBI`, `coverageArea: "Indian Ocean — East Africa +Corridor"`, `port: "Mombasa International Port"`, `portCode: "KEMBA"`, positions synthesised by +`driftVessel()` from the wall clock. + +Two problems, in order of severity. First, this is the same fabricated-operational-data family the +PR #33 audit remediated elsewhere: an unauthenticated caller asking where their cargo is receives an +invented vessel position. Second, the data is Kenyan — Mombasa, an East-Africa corridor, and a Kenyan +port code — in a platform whose declarations, duties and FSPs are Nigerian. The audit's currency +incoherence finding (GHS/NGN/USD) and this are the same underlying issue: the demo content was never +localised to the jurisdiction the platform claims to serve. + +### Not gaps + +- **Declarations, duty/tax assessment, risk lanes, manifests, OGA permits, AEO, drawback, bonded + warehousing, free zones, payments.** This platform goes substantially *beyond* the observable NSW + surface here; NSW's own portal lists Declarations as "Coming Soon" for Q1-2026. +- **The published statistics panel.** NSW renders `Active Users 0`, `Uptime 0%`, `Daily Volume ₦0` + alongside hardcoded-looking quarterly counts. Not a gap to close — a reminder that this platform's + own dashboards must not do the same, which is what the PR #33 remediation enforced. From d93059d5dcc94baacb8090dab5bac569dce4f453 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:39:31 +0000 Subject: [PATCH 06/17] feat: restore declaration shipment tracking Co-Authored-By: Patrick Munis --- client/src/App.tsx | 7 + client/src/pages/Home.tsx | 6 + client/src/pages/public/ShipmentTracker.tsx | 82 +++++++++ drizzle/schema.ts | 7 + server/cargoTracking.persisted.test.ts | 103 ++++++++++- server/db.ts | 29 ++- server/declarations.test.ts | 24 +++ server/openapi.ts | 13 ++ server/routers/cargoTracking.ts | 191 +++++++++++++++++++- server/routers/declarations.ts | 24 ++- server/routers/manifests.ts | 4 + server/smoke.stakeholders.test.ts | 1 + server/sprint66-68.test.ts | 4 +- 13 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/public/ShipmentTracker.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index fbec6740..2719c145 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -101,6 +101,7 @@ const ServiceHealth = lazy(() => import('./pages/app/ServiceHealth')); const AuditLog = lazy(() => import('./pages/app/AuditLog')); const CertVerify = lazy(() => import('./pages/public/CertVerify')); const ApplicationTracker = lazy(() => import('./pages/public/ApplicationTracker')); +const ShipmentTracker = lazy(() => import('./pages/public/ShipmentTracker')); const PermitValidation = lazy(() => import('./pages/public/PermitValidation')); const StakeholderRegistration = lazy(() => import('./pages/app/StakeholderRegistration')); const StakeholderRegistrationReview = lazy(() => import('./pages/app/StakeholderRegistrationReview')); @@ -187,6 +188,12 @@ function Router() { }> + + }> + + + }> + {/* Public system status page — no authentication required */} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 771e15e3..73630728 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -100,6 +100,12 @@ const PUBLIC_UTILITIES = [ href: "/verify/permit", icon: ShieldCheck, }, + { + title: "Track your Shipment", + description: "Find the latest available vessel position for a declaration or UCR.", + href: "/track-shipment", + icon: MapPin, + }, ]; const CAPABILITIES = [ diff --git a/client/src/pages/public/ShipmentTracker.tsx b/client/src/pages/public/ShipmentTracker.tsx new file mode 100644 index 00000000..0439807b --- /dev/null +++ b/client/src/pages/public/ShipmentTracker.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { AlertCircle, CheckCircle2, Clock, Loader2, MapPin, Search, Shield } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { trpc } from "@/lib/trpc"; + +export default function ShipmentTracker() { + const params = useParams<{ declarationRef?: string }>(); + const [, navigate] = useLocation(); + const [input, setInput] = useState(params.declarationRef ?? ""); + const declarationRef = params.declarationRef?.trim() ?? ""; + const query = trpc.cargoTracking.getShipmentPosition.useQuery( + { declarationRef: declarationRef || "________" }, + { enabled: declarationRef.length >= 8, retry: false }, + ); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const value = input.trim(); + if (value.length >= 8) navigate(`/track-shipment/${encodeURIComponent(value)}`); + }; + + return ( +
+
+
+ +

Track your Shipment

+

Nigeria National Single Window public services

+
+ + Declaration or UCR reference + +
+ setInput(e.target.value)} placeholder="Enter declaration number or UCR" className="bg-slate-900 text-white" /> + +
+
+
+ {query.isLoading && Checking vessel tracking…} + {!query.isLoading && query.isError && ( + + + {query.error.data?.code === "NOT_FOUND" ? "Shipment reference not found." : "Shipment tracking is unavailable. Please try again later."} + + )} + {query.data?.trackingStatus === "not_linked" && ( + + + {query.data.message} + + )} + {query.data?.trackingStatus === "unavailable" && ( + + + {query.data.message} + + )} + {query.data?.trackingStatus === "position" && ( + + +
+ {query.data.vesselName ?? "Vessel name unavailable"} + Position available +
+
{query.data.latitude.toFixed(5)}, {query.data.longitude.toFixed(5)}
+
Destination: {query.data.destination ?? "Unavailable"}
+
+

ETA: {query.data.eta ? new Date(query.data.eta).toLocaleString() : "Unavailable"}

+

Last update: {new Date(query.data.lastUpdate).toLocaleString()}

+

Linkage: {query.data.linkage}

+
+
+
+ )} +
+
+ ); +} diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 76380c9a..1560cd54 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -199,6 +199,8 @@ export const declarations = pgTable("declarations", { traderId: integer("trader_id").notNull(), principalId: integer("principal_id").references(() => users.id), actingAgentId: integer("acting_agent_id").references(() => users.id), + billOfLadingId: integer("bill_of_lading_id").references(() => billsOfLading.id, { onDelete: "set null" }), + billOfLadingNumber: varchar("bill_of_lading_number", { length: 64 }), declarationType: declarationTypeEnum("declaration_type").notNull(), status: declarationStatusEnum("status").default("draft").notNull(), riskLane: riskLaneEnum("risk_lane").default("green"), @@ -233,6 +235,7 @@ export const declarations = pgTable("declarations", { index("idx_decl_submitted_at").on(t.submittedAt), index("idx_decl_risk_lane_status").on(t.riskLane, t.status), index("idx_decl_assigned_officer").on(t.assignedOfficerId), + index("idx_decl_bill_of_lading_id").on(t.billOfLadingId), ]); export type Declaration = typeof declarations.$inferSelect; @@ -3260,6 +3263,8 @@ export const manifests = pgTable("manifests", { submittedBy: integer("submitted_by").notNull().references(() => users.id), vesselName: varchar("vessel_name", { length: 128 }).notNull(), voyageNumber: varchar("voyage_number", { length: 64 }).notNull(), + mmsi: varchar("mmsi", { length: 16 }), + imo: varchar("imo", { length: 16 }), portOfLoading: varchar("port_of_loading", { length: 64 }).notNull(), portOfDischarge: varchar("port_of_discharge", { length: 64 }).notNull(), eta: timestamp("eta"), @@ -3276,6 +3281,8 @@ export const manifests = pgTable("manifests", { index("idx_manifests_status").on(t.status), index("idx_manifests_type").on(t.manifestType), index("idx_manifests_port").on(t.portOfDischarge), + index("idx_manifests_mmsi").on(t.mmsi), + index("idx_manifests_imo").on(t.imo), ]); export type Manifest = typeof manifests.$inferSelect; export type InsertManifest = typeof manifests.$inferInsert; diff --git a/server/cargoTracking.persisted.test.ts b/server/cargoTracking.persisted.test.ts index 6378a6ce..77c36630 100644 --- a/server/cargoTracking.persisted.test.ts +++ b/server/cargoTracking.persisted.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -const state = vi.hoisted(() => ({ rows: [] as any[], queryError: null as Error | null })); +const state = vi.hoisted(() => ({ + rows: [] as any[], + queryError: null as Error | null, + shipment: { + declaration: { bill_of_lading_id: null as number | null, bill_of_lading_number: null as string | null }, + bills: [] as any[], + manifest: null as any, + distinctVessels: [] as any[], + ais: [] as any[], + }, +})); vi.mock("./db", async (importOriginal) => { const actual = await importOriginal(); @@ -10,6 +20,11 @@ vi.mock("./db", async (importOriginal) => { getPool: vi.fn(() => ({ query: vi.fn(async (query: string) => { if (state.queryError) throw state.queryError; + if (query.includes("FROM declarations")) return { rows: state.shipment.declaration ? [state.shipment.declaration] : [] }; + if (query.includes("FROM bills_of_lading")) return { rows: state.shipment.bills }; + if (query.includes("SELECT vessel_name, mmsi, imo")) return { rows: state.shipment.manifest ? [state.shipment.manifest] : [] }; + if (query.includes("SELECT DISTINCT mmsi")) return { rows: state.shipment.distinctVessels }; + if (query.includes("FROM vessel_tracking_events") && query.includes("LIMIT 1")) return { rows: state.shipment.ais }; if (query.includes("COUNT(DISTINCT mmsi)")) { return { rows: state.rows.length @@ -31,9 +46,94 @@ vi.mock("./_core/kafka", async (importOriginal) => { afterEach(() => { state.rows = []; state.queryError = null; + state.shipment = { + declaration: { bill_of_lading_id: null, bill_of_lading_number: null }, + bills: [], + manifest: null, + distinctVessels: [], + ais: [], + }; }); describe("persisted cargo tracking", () => { + async function track(headers: Record = {}) { + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + return cargoTrackingRouter.createCaller({ req: { headers, socket: {} } } as any).getShipmentPosition({ declarationRef: "TG-2026-TRACK01" }); + } + + it("returns NOT_FOUND for an unknown declaration reference", async () => { + state.shipment.declaration = null as any; + await expect(track()).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("reports a declaration that is not yet linked to a bill of lading", async () => { + await expect(track()).resolves.toMatchObject({ trackingStatus: "not_linked", reason: "bill_of_lading_not_linked" }); + }); + + it("reports a filed bill of lading without an available manifest", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "bill_of_lading_not_in_manifest" }); + }); + + it("reports a manifest without an identifier or unambiguous AIS name match", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + state.shipment.manifest = { vessel_name: "MV Unknown", mmsi: null, imo: null, eta: null, port_of_discharge: "Lagos" }; + await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "vessel_identifier_missing" }); + }); + + it("reports ambiguous vessel-name fallback matches as unavailable", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + state.shipment.manifest = { vessel_name: "MV Duplicate", mmsi: null, imo: null, eta: null, port_of_discharge: "Lagos" }; + state.shipment.distinctVessels = [{ mmsi: "111" }, { mmsi: "222" }]; + await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "ambiguous_vessel_name" }); + }); + + it("reports a resolved vessel with no AIS position as unavailable", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + state.shipment.manifest = { vessel_name: "MV No Fix", mmsi: "123", imo: null, eta: null, port_of_discharge: "Lagos" }; + await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "no_ais_position" }); + }); + + it("returns an identifier-derived AIS position without consignment data", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + state.shipment.manifest = { vessel_name: "MV Identified", mmsi: "123", imo: "IMO123", eta: new Date("2030-01-01T00:00:00.000Z"), port_of_discharge: "Lagos" }; + state.shipment.ais = [{ + mmsi: "123", vessel_name: "MV Identified", imo_number: "IMO123", latitude: 6.4, longitude: 3.4, + speed: null, heading: null, destination_port: "Lagos", eta: null, cargo_type: null, flag_country: null, + recorded_at: new Date("2026-01-01T00:00:00.000Z"), + }]; + const result = await track(); + expect(result).toMatchObject({ trackingStatus: "position", latitude: 6.4, longitude: 3.4, linkage: "identifier-derived" }); + expect(result).not.toHaveProperty("consignee"); + expect(result).not.toHaveProperty("goodsDescription"); + }); + + it("returns a name-matched AIS position only when the name resolves uniquely", async () => { + state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; + state.shipment.manifest = { vessel_name: "MV Named", mmsi: null, imo: null, eta: null, port_of_discharge: "Lagos" }; + state.shipment.distinctVessels = [{ mmsi: "123" }]; + state.shipment.ais = [{ + mmsi: "123", vessel_name: "MV Named", imo_number: null, latitude: 6.5, longitude: 3.5, + speed: 10, heading: 90, destination_port: "Lagos", eta: null, cargo_type: null, flag_country: null, + recorded_at: new Date("2026-01-02T00:00:00.000Z"), + }]; + await expect(track()).resolves.toMatchObject({ trackingStatus: "position", latitude: 6.5, longitude: 3.5, linkage: "name-matched" }); + }); + + it("rate-limits public shipment lookups by IP", async () => { + const headers = { "x-forwarded-for": `shipment-rate-test-${Date.now()}` }; + for (let attempt = 0; attempt < 60; attempt += 1) { + await expect(track(headers)).resolves.toMatchObject({ trackingStatus: "not_linked" }); + } + await expect(track(headers)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" }); + }); + it("returns persisted vessel events without fabricating shipment or port metadata", async () => { const { cargoTrackingRouter } = await import("./routers/cargoTracking"); state.rows = [{ @@ -87,6 +187,7 @@ describe("persisted cargo tracking", () => { const caller = cargoTrackingRouter.createCaller({} as any); await expect(caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" })) .rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + await expect(track()).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); it("does not report cargo-event success when Kafka publication fails", async () => { diff --git a/server/db.ts b/server/db.ts index e24105aa..79206ee7 100644 --- a/server/db.ts +++ b/server/db.ts @@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { InsertUser, users, stakeholderProfiles, declarations, - stakeholderRegistrations, stakeholderMandates, + stakeholderRegistrations, stakeholderMandates, manifests, billsOfLading, declarationDocuments, ogaPermits, payments, auditEvents, securityAlerts, sanctionsChecks, aeoApplications, notifications, kycDocuments, kycVerifications, visionAnalyses, @@ -433,6 +433,33 @@ export async function getDeclarationByNumber(declarationNumber: string) { return result[0] ?? undefined; } +export class AmbiguousBillOfLadingError extends Error { + constructor(blNumber: string) { + super(`Bill of lading ${blNumber} matches multiple records; provide a manifest number.`); + this.name = "AmbiguousBillOfLadingError"; + } +} + +export async function resolveBillOfLadingReference( + blNumber: string, + manifestNumber?: string, +) { + const db = await getDb(); + if (!db) throw new Error("Database unavailable"); + const conditions = [eq(billsOfLading.blNumber, blNumber)]; + if (manifestNumber) { + conditions.push(eq(manifests.manifestNumber, manifestNumber)); + } + const rows = await db.select({ id: billsOfLading.id }) + .from(billsOfLading) + .innerJoin(manifests, eq(billsOfLading.manifestId, manifests.id)) + .where(and(...conditions)); + if (rows.length > 1) { + throw new AmbiguousBillOfLadingError(blNumber); + } + return rows[0]?.id ?? null; +} + export async function getPublicDeclarationTracking(reference: string) { const db = await getDb(); if (!db) throw new Error("Database unavailable"); diff --git a/server/declarations.test.ts b/server/declarations.test.ts index 03aeb42c..56c9ebe9 100644 --- a/server/declarations.test.ts +++ b/server/declarations.test.ts @@ -70,6 +70,7 @@ vi.mock("./db", () => ({ companyName: "Test Co", tinNumber: "TIN-001", }), + resolveBillOfLadingReference: vi.fn().mockResolvedValue(42), getUserById: vi.fn().mockResolvedValue(null), createNotification: vi.fn().mockResolvedValue(undefined), getNotificationsByUser: vi.fn().mockResolvedValue([]), @@ -204,6 +205,29 @@ describe("declarations router — create", () => { expect(result).toHaveProperty("ucr"); }); + it("stores a declared bill of lading number and its resolved record id", async () => { + const { createDeclaration, resolveBillOfLadingReference } = await import("./db"); + const caller = appRouter.createCaller(createTraderCtx()); + await caller.declarations.create({ + hsCode: "8471.30", + goodsDescription: "Laptop computers", + countryOfOrigin: "US", + portOfEntry: "GHTEM", + grossWeight: 50, + netWeight: 45, + numberOfPackages: 10, + invoiceValue: 5000, + declarationType: "import", + billOfLadingNumber: "BL-42", + manifestNumber: "MAN-42", + }); + expect(resolveBillOfLadingReference).toHaveBeenCalledWith("BL-42", "MAN-42"); + expect(createDeclaration).toHaveBeenLastCalledWith(expect.objectContaining({ + billOfLadingId: 42, + billOfLadingNumber: "BL-42", + })); + }); + it("rejects create for trader without approved profile", async () => { const { getProfileByUserId } = await import("./db"); vi.mocked(getProfileByUserId).mockResolvedValueOnce(null); diff --git a/server/openapi.ts b/server/openapi.ts index acc99b2a..fad43319 100644 --- a/server/openapi.ts +++ b/server/openapi.ts @@ -56,6 +56,19 @@ const ROUTER_CATALOGUE: Record> = { getVesselPositions: { type: "query", summary: "Get vessel positions", description: "Returns current AIS positions for tracked vessels.", tags: ["Geospatial"], requiresAuth: true }, }, cargoTracking: { + getShipmentPosition: { + type: "query", + summary: "Track shipment vessel position", + description: "Returns the latest persisted vessel position linked to a declaration number or UCR without disclosing consignment details.", + tags: ["Cargo Tracking"], + requiresAuth: false, + requestExample: { declarationRef: "TG-2026-AB12CD34" }, + responseExample: { + trackingStatus: "unavailable", + reason: "no_ais_position", + message: "No current AIS position is available for this vessel.", + }, + }, getLiveVessels: { type: "query", summary: "Get live vessel positions", description: "Returns persisted AIS positions when available. Successful queries may return an empty vessel collection.", tags: ["Cargo Tracking"], requiresAuth: false, responseExample: { vessels: [], totalCount: 0, lastRefresh: "2026-03-09T15:00:00Z", sourceService: "vessel_tracking_events" } }, getVesselRoute: { type: "query", summary: "Get vessel route polyline", description: "Returns historical track waypoints for a specific vessel identified by MMSI.", tags: ["Cargo Tracking"], requiresAuth: false, requestExample: { mmsi: "636091234" } }, getPortArrivals: { type: "query", summary: "Get upcoming port arrivals", description: "Returns the list of vessels with upcoming ETAs at the home port.", tags: ["Cargo Tracking"], requiresAuth: false }, diff --git a/server/routers/cargoTracking.ts b/server/routers/cargoTracking.ts index 779507b7..2fc80f20 100644 --- a/server/routers/cargoTracking.ts +++ b/server/routers/cargoTracking.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { publicProcedure, protectedProcedure, router } from "../_core/trpc"; +import { publicProcedure, publicRateLimitedProcedure, protectedProcedure, router } from "../_core/trpc"; import { publishEvent, TOPICS } from "../_core/kafka"; import { getDb, getPool } from "../db"; @@ -85,6 +85,178 @@ async function persistedQuery>(query: string, params } } +type ShipmentTrackingUnavailableReason = + | "bill_of_lading_not_linked" + | "bill_of_lading_not_in_manifest" + | "vessel_identifier_missing" + | "ambiguous_vessel_name" + | "no_ais_position"; + +export type ShipmentTrackingResult = + | { + trackingStatus: "position"; + vesselName: string | null; + latitude: number; + longitude: number; + eta: string | null; + lastUpdate: string; + destination: string | null; + linkage: "identifier-derived" | "name-matched"; + } + | { + trackingStatus: "not_linked" | "unavailable"; + reason: ShipmentTrackingUnavailableReason; + message: string; + }; + +function shipmentUnavailable( + trackingStatus: "not_linked" | "unavailable", + reason: ShipmentTrackingUnavailableReason, + message: string, +): ShipmentTrackingResult { + return { trackingStatus, reason, message }; +} + +export async function getShipmentPosition( + declarationRef: string, +): Promise { + const [declaration] = await pgQuery<{ + bill_of_lading_id: number | null; + bill_of_lading_number: string | null; + }>(` + SELECT bill_of_lading_id, bill_of_lading_number + FROM declarations + WHERE declaration_number = $1 OR ucr = $1 + LIMIT 1 + `, [declarationRef]); + if (!declaration) return undefined; + if (!declaration.bill_of_lading_id && !declaration.bill_of_lading_number) { + return shipmentUnavailable( + "not_linked", + "bill_of_lading_not_linked", + "This declaration is not yet linked to a bill of lading.", + ); + } + + const billConditions = declaration.bill_of_lading_id ? "bl.id = $1" : "bl.bl_number = $1"; + const billParam = declaration.bill_of_lading_id ?? declaration.bill_of_lading_number; + const billRows = await pgQuery<{ + id: number; + manifest_id: number; + bl_number: string; + }>(` + SELECT bl.id, bl.manifest_id, bl.bl_number + FROM bills_of_lading bl + WHERE ${billConditions} + `, [billParam]); + if (billRows.length === 0) { + return shipmentUnavailable( + "not_linked", + "bill_of_lading_not_linked", + "The declared bill of lading has not been filed yet.", + ); + } + if (billRows.length > 1) { + return shipmentUnavailable( + "unavailable", + "bill_of_lading_not_in_manifest", + "The bill of lading reference is ambiguous and cannot be linked safely.", + ); + } + + const [manifest] = await pgQuery<{ + vessel_name: string; + mmsi: string | null; + imo: string | null; + eta: Date | string | null; + port_of_discharge: string; + }>(` + SELECT vessel_name, mmsi, imo, eta, port_of_discharge + FROM manifests + WHERE id = $1 + LIMIT 1 + `, [billRows[0].manifest_id]); + if (!manifest) { + return shipmentUnavailable( + "unavailable", + "bill_of_lading_not_in_manifest", + "The bill of lading is not associated with an available manifest.", + ); + } + + let vesselRows: VesselRow[]; + let linkage: "identifier-derived" | "name-matched"; + if (manifest.mmsi || manifest.imo) { + const conditions: string[] = []; + const params: string[] = []; + if (manifest.mmsi) { + params.push(manifest.mmsi); + conditions.push(`mmsi = $${params.length}`); + } + if (manifest.imo) { + params.push(manifest.imo); + conditions.push(`imo_number = $${params.length}`); + } + vesselRows = await pgQuery(` + SELECT mmsi, vessel_name, imo_number, latitude, longitude, speed, heading, + destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + WHERE ${conditions.join(" OR ")} + ORDER BY recorded_at DESC + LIMIT 1 + `, params); + linkage = "identifier-derived"; + } else { + const distinctVessels = await pgQuery<{ mmsi: string }>(` + SELECT DISTINCT mmsi + FROM vessel_tracking_events + WHERE vessel_name = $1 + `, [manifest.vessel_name]); + if (distinctVessels.length > 1) { + return shipmentUnavailable( + "unavailable", + "ambiguous_vessel_name", + "The manifest vessel name matches more than one tracked vessel.", + ); + } + if (distinctVessels.length === 0) { + return shipmentUnavailable( + "unavailable", + "vessel_identifier_missing", + "The manifest has no vessel identifier and no unambiguous AIS vessel match.", + ); + } + vesselRows = await pgQuery(` + SELECT mmsi, vessel_name, imo_number, latitude, longitude, speed, heading, + destination_port, eta, cargo_type, flag_country, recorded_at + FROM vessel_tracking_events + WHERE mmsi = $1 + ORDER BY recorded_at DESC + LIMIT 1 + `, [distinctVessels[0].mmsi]); + linkage = "name-matched"; + } + if (vesselRows.length === 0) { + return shipmentUnavailable( + "unavailable", + "no_ais_position", + "No current AIS position is available for this vessel.", + ); + } + + const vessel = vesselRows[0]; + return { + trackingStatus: "position", + vesselName: vessel.vessel_name ?? manifest.vessel_name, + latitude: Number(vessel.latitude), + longitude: Number(vessel.longitude), + eta: vessel.eta ? new Date(vessel.eta).toISOString() : manifest.eta ? new Date(manifest.eta).toISOString() : null, + lastUpdate: new Date(vessel.recorded_at).toISOString(), + destination: vessel.destination_port ?? manifest.port_of_discharge, + linkage, + }; +} + function vesselType(cargoType: string | null): VesselType | null { const normalized = cargoType?.toLowerCase(); if (normalized === "container" || normalized === "bulk" || normalized === "tanker" @@ -132,6 +304,23 @@ const latestVesselsQuery = ` `; export const cargoTrackingRouter = router({ + getShipmentPosition: publicRateLimitedProcedure + .input(z.object({ declarationRef: z.string().min(8).max(64) })) + .query(async ({ input }) => { + try { + const result = await getShipmentPosition(input.declarationRef); + if (!result) throw new TRPCError({ code: "NOT_FOUND" }); + return result; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Cargo tracking is unavailable.", + cause: error, + }); + } + }), + getLiveVessels: publicProcedure .input(z.object({ riskFilter: z.enum(["all", "green", "amber", "red"]).optional().default("all"), diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index fbe20527..1e9dc07a 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -5,7 +5,8 @@ import { createDeclaration, getDeclarationById, getDeclarationsByTrader, getAllDeclarations, updateDeclaration, getDeclarationStats, getDeclarationStatsByTrader, logAuditEvent, createNotification, createUserNotification, getProfileByUserId, - getLatestKYCVerification, withRlsContext, getDb + getLatestKYCVerification, withRlsContext, getDb, + resolveBillOfLadingReference, AmbiguousBillOfLadingError } from "../db"; import { declarations, declarationDocuments, clearanceCertificates } from "../../drizzle/schema"; import { eq, desc, and, inArray } from "drizzle-orm"; @@ -179,6 +180,8 @@ export const declarationsRouter = router({ invoiceValue: z.number().positive(), invoiceCurrency: z.string().length(3).default("USD"), principalUserId: z.number().int().positive().optional(), + billOfLadingNumber: z.string().min(1).max(64).optional(), + manifestNumber: z.string().min(1).max(64).optional(), })) .mutation(async ({ ctx, input }) => { // Business Rule: Validate HS code format (WCO Harmonised System) @@ -191,12 +194,31 @@ export const declarationsRouter = router({ if (!profile || profile.status !== "approved") { throw new TRPCError({ code: "FORBIDDEN", message: "Your trader profile must be approved before submitting declarations." }); } + let billOfLadingId: number | null = null; + try { + if (input.manifestNumber && !input.billOfLadingNumber) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A bill of lading number is required when a manifest number is provided.", + }); + } + if (input.billOfLadingNumber) { + billOfLadingId = await resolveBillOfLadingReference(input.billOfLadingNumber, input.manifestNumber); + } + } catch (error) { + if (error instanceof AmbiguousBillOfLadingError) { + throw new TRPCError({ code: "BAD_REQUEST", message: error.message }); + } + throw error; + } const decl = await createDeclaration({ declarationNumber: generateDeclarationNumber(), ucr: generateUCR(), traderId: principalUserId, principalId: principalUserId, actingAgentId, + billOfLadingId, + billOfLadingNumber: input.billOfLadingNumber ?? null, declarationType: input.declarationType, status: "draft", hsCode: input.hsCode, diff --git a/server/routers/manifests.ts b/server/routers/manifests.ts index d011b164..a249c68b 100644 --- a/server/routers/manifests.ts +++ b/server/routers/manifests.ts @@ -46,12 +46,16 @@ export const manifestsRouter = router({ portOfLoading: z.string().min(1).max(64), portOfDischarge: z.string().min(1).max(64), eta: z.string().datetime(), + mmsi: z.string().min(1).max(16).optional(), + imo: z.string().min(1).max(16).optional(), })) .mutation(async ({ input, ctx }) => { return callManifestService("/api/manifests", "POST", { ...input, submittedBy: ctx.user.id, eta: new Date(input.eta).toISOString(), + mmsi: input.mmsi, + imo: input.imo, }); }), diff --git a/server/smoke.stakeholders.test.ts b/server/smoke.stakeholders.test.ts index a9216d78..145ba200 100644 --- a/server/smoke.stakeholders.test.ts +++ b/server/smoke.stakeholders.test.ts @@ -264,6 +264,7 @@ describe("D. Finance Officer — TigerBeetle Ledger", () => { describe("E. Port Operator — Cargo & Vessel Tracking", () => { it("E1: can view live vessels", () => expectQuery("cargoTracking.getLiveVessels")); it("E2: can view vessel route", () => expectQuery("cargoTracking.getVesselRoute")); + it("E3: can track a shipment position", () => expectQuery("cargoTracking.getShipmentPosition")); it("E4: can view port arrivals", () => expectQuery("cargoTracking.getPortArrivals")); it("E5: can view vessel stats", () => expectQuery("cargoTracking.getVesselStats")); it("E6: can log a cargo event", () => expectMutation("cargoTracking.logCargoEvent")); diff --git a/server/sprint66-68.test.ts b/server/sprint66-68.test.ts index 9e22e4a7..aa72f16e 100644 --- a/server/sprint66-68.test.ts +++ b/server/sprint66-68.test.ts @@ -184,11 +184,11 @@ describe("Sprint 68 — OpenAPI Specification", () => { }); it("cargo tracking procedures are in catalogue", () => { - const cargoProcs = ["getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats"]; + const cargoProcs = ["getShipmentPosition", "getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats"]; for (const proc of cargoProcs) { expect(proc).toBeTruthy(); } - expect(cargoProcs.length).toBe(4); + expect(cargoProcs.length).toBe(5); }); it("onboarding procedures are in catalogue", () => { From ba7c8ba5c33ffb6bd3dd603981e8892951791d9b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:40:58 +0000 Subject: [PATCH 07/17] fix(cargo): report an ambiguous bill of lading distinctly from a missing manifest Co-Authored-By: Patrick Munis --- server/cargoTracking.persisted.test.ts | 9 +++++++++ server/routers/cargoTracking.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/cargoTracking.persisted.test.ts b/server/cargoTracking.persisted.test.ts index 77c36630..eb28c812 100644 --- a/server/cargoTracking.persisted.test.ts +++ b/server/cargoTracking.persisted.test.ts @@ -76,6 +76,15 @@ describe("persisted cargo tracking", () => { await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "bill_of_lading_not_in_manifest" }); }); + it("reports an ambiguous bill of lading reference distinctly from a missing manifest", async () => { + state.shipment.declaration = { bill_of_lading_id: null, bill_of_lading_number: "BL-7" }; + state.shipment.bills = [ + { id: 7, manifest_id: 11, bl_number: "BL-7" }, + { id: 8, manifest_id: 12, bl_number: "BL-7" }, + ]; + await expect(track()).resolves.toMatchObject({ trackingStatus: "unavailable", reason: "ambiguous_bill_of_lading" }); + }); + it("reports a manifest without an identifier or unambiguous AIS name match", async () => { state.shipment.declaration = { bill_of_lading_id: 7, bill_of_lading_number: "BL-7" }; state.shipment.bills = [{ id: 7, manifest_id: 11, bl_number: "BL-7" }]; diff --git a/server/routers/cargoTracking.ts b/server/routers/cargoTracking.ts index 2fc80f20..ecfc2da9 100644 --- a/server/routers/cargoTracking.ts +++ b/server/routers/cargoTracking.ts @@ -88,6 +88,7 @@ async function persistedQuery>(query: string, params type ShipmentTrackingUnavailableReason = | "bill_of_lading_not_linked" | "bill_of_lading_not_in_manifest" + | "ambiguous_bill_of_lading" | "vessel_identifier_missing" | "ambiguous_vessel_name" | "no_ais_position"; @@ -159,7 +160,7 @@ export async function getShipmentPosition( if (billRows.length > 1) { return shipmentUnavailable( "unavailable", - "bill_of_lading_not_in_manifest", + "ambiguous_bill_of_lading", "The bill of lading reference is ambiguous and cannot be linked safely.", ); } From 2ade824d724ab05d1e019b352cef3268661ca60b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:06:50 +0000 Subject: [PATCH 08/17] feat: add excise digital tax stamps domain Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 317 ++++++- server/_core/index.ts | 3 +- server/_core/webhookSecretsValidator.ts | 15 + server/excise.test.ts | 72 ++ server/routers.ts | 2 + server/routers/excise.ts | 1090 +++++++++++++++++++++++ 6 files changed, 1496 insertions(+), 3 deletions(-) create mode 100644 server/excise.test.ts create mode 100644 server/routers/excise.ts diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 1560cd54..98ebd743 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, 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,316 @@ 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(), + 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(), + 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) => [ + unique("uq_excise_child_mark").on(t.childMarkId), + unique("uq_excise_child_aggregate").on(t.childAggregateId), + 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(), + reportedProductionQuantity: integer("reported_production_quantity").notNull(), + variance: integer("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.test.ts b/server/excise.test.ts new file mode 100644 index 00000000..56ce27d9 --- /dev/null +++ b/server/excise.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + IMPOSSIBLE_TRAVEL_SPEED_KMH, + calculateExciseLiability, + mintExciseUid, + verifyExciseUid, +} from "./routers/excise"; + +const originalKey = process.env.EXCISE_UID_HMAC_KEY; +const originalKeyId = process.env.EXCISE_UID_KEY_ID; + +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; +}); + +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"); + }); + + 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("keeps one named physical threshold for impossible-travel detection", () => { + expect(IMPOSSIBLE_TRAVEL_SPEED_KMH).toBe(120); + }); +}); 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..88ab7690 --- /dev/null +++ b/server/routers/excise.ts @@ -0,0 +1,1090 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; +import { TRPCError } from "@trpc/server"; +import { and, asc, desc, eq, inArray, isNull } 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"; + +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 }; + +// 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_not_in_manifest" + | "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"; + 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 (!isStrongExciseKey(key)) return { status: "invalid_signature", 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 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)); +} + +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 elapsedHours = (Date.now() - previous.scannedAt.getTime()) / 3_600_000; + if (elapsedHours > 0) { + const speed = distanceKm( + { latitude: previous.latitude, longitude: previous.longitude }, + { latitude, longitude }, + ) / elapsedHours; + 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, + longitude, + 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}.` }); + } +} + +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 [licence] = await db.update(exciseLicences).set({ + status: "active", + approvedBy: ctx.user.id, + approvedAt: new Date(), + 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_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), + liability: z.string().optional(), + })) + .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 }) => { + 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" }); + const { licence } = await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "payment"); + if (!order.liability) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Order must be assessed before payment." }); + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable." }); + const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amount: order.liability, + currency: order.currency, + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + }), + }); + await createLedgerEntry({ + tbTransferId: transfer.id, + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amountMinorUnits: Number(parseScaled(order.liability, 2)), + currency: order.currency, + ledger: 1, + entryType: "excise_stamp_liability", + status: "posted", + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + postedAt: new Date(), + }); + const [updated] = await db.update(exciseStampOrders).set({ status: "payment", ledgerTransferId: transfer.id, 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: transfer.id } }); + return updated; + } catch (error) { + return unavailable("Excise stamp payment is unavailable.", error); + } + }), + + 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"); + if (order.declarationId) { + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); + if (!declaration || declaration.declarationType !== "import" || !declaration.totalDue) 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 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." }); + } + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); + 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() })) + .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); + if (order.status !== "fulfilment") throw new TRPCError({ code: "BAD_REQUEST", message: "Only fulfilment orders can mint marks." }); + const [existing] = await db.select({ id: exciseStampMarks.id }).from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).limit(1); + if (existing) return db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).orderBy(asc(exciseStampMarks.id)); + 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[] = []; + for (let index = 0; index < order.quantity; index += 1) { + const signed = mintExciseUid(); + const [mark] = await tx.insert(exciseStampMarks).values({ + 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", + }).returning(); + created.push(mark); + } + 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; + } catch (error) { + return unavailable("Excise UID minting is unavailable.", error); + } + }), + + 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() })) + .query(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 [issuedRow] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).limit(1); + 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 = issuedRow ? marks.length : 0; + const activatedQuantity = marks.filter((mark) => mark.status === "active" || mark.activatedAt !== null).length; + const retiredQuantity = marks.filter((mark) => mark.status === "retired").length; + const reportedProductionQuantity = reports.reduce((sum, report) => sum + report.quantity, 0); + const variance = issuedQuantity - activatedQuantity - retiredQuantity - reportedProductionQuantity; + const [report] = await db.insert(exciseReconciliationReports).values({ + orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, reportedProductionQuantity, variance, computedBy: ctx.user.id, + }).returning(); + return report; + } catch (error) { + return unavailable("Excise reconciliation is unavailable.", error); + } + }), + + createAggregate: protectedProcedure + .input(z.object({ aggregateType: z.enum(["carton", "case", "pallet"]) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + if (!isEnforcement(ctx.user.role) && ctx.user.role !== "user") throw new TRPCError({ code: "FORBIDDEN" }); + const [aggregate] = await db.insert(exciseAggregates).values({ + aggregateUid: `EXA-${randomBytes(18).toString("hex").toUpperCase()}`, + aggregateType: input.aggregateType, + 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" }); + } 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 (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( + input.markId ? eq(exciseAggregateChildren.childMarkId, input.markId) : eq(exciseAggregateChildren.childAggregateId, input.childAggregateId!), + ).limit(1); + if (existing && existing.removedAt === null) 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 if (!isEnforcement(ctx.user.role)) { + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId!)).limit(1); + if (!aggregate || 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() })) + .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); + 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 === "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() })) + .query(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 === "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_not_in_manifest" 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 }; + } + if (!declaration.actingAgentId) { + return { available: false as const, reason: "acting_agent_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, + 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 orders = input?.orderId + ? await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)) + : await db.select().from(exciseStampOrders); + const orderIds = orders.map((order) => order.id); + const marks = orderIds.length ? await db.select().from(exciseStampMarks).where(inArray(exciseStampMarks.orderId, orderIds)) : []; + const reports = orderIds.length ? await db.select().from(exciseProductionReports).where(inArray(exciseProductionReports.orderId, orderIds)) : []; + const anomalies = marks.length ? await db.select().from(exciseAnomalies).where(inArray(exciseAnomalies.markId, marks.map((mark) => mark.id))) : []; + const reportedByOrder = new Map(); + for (const report of reports) { + reportedByOrder.set(report.orderId, (reportedByOrder.get(report.orderId) ?? 0) + report.quantity); + } + const issuedByOrder = new Map(); + const activatedByOrder = new Map(); + const retiredByOrder = new Map(); + for (const mark of marks) { + issuedByOrder.set(mark.orderId, (issuedByOrder.get(mark.orderId) ?? 0) + 1); + if (mark.status === "active") activatedByOrder.set(mark.orderId, (activatedByOrder.get(mark.orderId) ?? 0) + 1); + if (mark.status === "retired") retiredByOrder.set(mark.orderId, (retiredByOrder.get(mark.orderId) ?? 0) + 1); + } + const variance = orders.reduce((sum, order) => sum + + (issuedByOrder.get(order.id) ?? 0) - + (activatedByOrder.get(order.id) ?? 0) - + (retiredByOrder.get(order.id) ?? 0) - + (reportedByOrder.get(order.id) ?? 0), 0); + return { + orders: orders.length, + issued: marks.length, + activated: marks.filter((mark) => mark.status === "active").length, + retired: marks.filter((mark) => mark.status === "retired").length, + paid: orders.filter((order) => order.paidAt !== null).length, + reportedProduction: reports.reduce((sum, report) => sum + report.quantity, 0), + variance, + anomalies: anomalies.length, + }; + } catch (error) { + return unavailable("Excise analytics are unavailable.", error); + } + }), +}); From 80b065b206151767f4ab64dc69d0e967e601c2dd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:07:20 +0000 Subject: [PATCH 09/17] fix: coarsen anonymous excise scan locations Co-Authored-By: Patrick Munis --- server/routers/excise.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/excise.ts b/server/routers/excise.ts index 88ab7690..07521aec 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -258,8 +258,8 @@ async function recordScan( localityHash: latitude !== undefined && longitude !== undefined ? createHash("sha256").update(`${latitude.toFixed(2)}:${longitude.toFixed(2)}`).digest("hex") : null, - latitude, - longitude, + latitude: latitude === undefined ? undefined : Number(latitude.toFixed(2)), + longitude: longitude === undefined ? undefined : Number(longitude.toFixed(2)), previousScanId: previous?.id, impliedSpeedKmh, impossibleTravel, From eb37f6d384da6211edd19d05733476ecbacf576e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:18:08 +0000 Subject: [PATCH 10/17] fix: harden excise lifecycle and settlement Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 12 +- server/excise.test.ts | 24 +++- server/routers/excise.ts | 299 +++++++++++++++++++++++++++------------ 3 files changed, 238 insertions(+), 97 deletions(-) diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 98ebd743..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, check + integer, decimal, boolean, json, jsonb, bigint, index, unique, uniqueIndex, real, uuid, date, check } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -3560,6 +3560,7 @@ export const exciseStampOrders = pgTable("excise_stamp_orders", { 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"), @@ -3629,6 +3630,7 @@ 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(), @@ -3644,8 +3646,8 @@ export const exciseAggregateChildren = pgTable("excise_aggregate_children", { removedBy: integer("removed_by").references(() => users.id), removedAt: timestamp("removed_at"), }, (t) => [ - unique("uq_excise_child_mark").on(t.childMarkId), - unique("uq_excise_child_aggregate").on(t.childAggregateId), + 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)`), ]); @@ -3705,8 +3707,10 @@ export const exciseReconciliationReports = pgTable("excise_reconciliation_report 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(), - variance: integer("variance").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), }); diff --git a/server/excise.test.ts b/server/excise.test.ts index 56ce27d9..2c6893b6 100644 --- a/server/excise.test.ts +++ b/server/excise.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { IMPOSSIBLE_TRAVEL_SPEED_KMH, + calculateImpossibleTravelSpeedKmh, calculateExciseLiability, mintExciseUid, verifyExciseUid, @@ -8,12 +9,18 @@ import { 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", () => { @@ -38,6 +45,10 @@ describe("excise digital marks", () => { 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", () => { @@ -66,7 +77,16 @@ describe("excise digital marks", () => { }, { unitContent: "1", unitOfMeasure: "unit" }, 2, "100.00")).toBe("20.00"); }); - it("keeps one named physical threshold for impossible-travel detection", () => { - expect(IMPOSSIBLE_TRAVEL_SPEED_KMH).toBe(120); + 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/excise.ts b/server/routers/excise.ts index 07521aec..d7a54d06 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -1,6 +1,6 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; import { TRPCError } from "@trpc/server"; -import { and, asc, desc, eq, inArray, isNull } from "drizzle-orm"; +import { and, asc, count, desc, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { exciseAggregateChildren, @@ -34,11 +34,14 @@ 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. @@ -52,7 +55,7 @@ export type ExciseTraversalUnavailableReason = | "declaration_missing" | "bill_of_lading_not_linked" | "bill_of_lading_ambiguous" - | "bill_of_lading_not_in_manifest" + | "bill_of_lading_missing" | "manifest_missing" | "manifest_vessel_missing" | "importer_missing" @@ -161,14 +164,15 @@ export function calculateExciseLiability( } export function verifyExciseUid(uid: string): { - status: "signature_valid_pending_reconciliation" | "invalid_signature"; + 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 (!isStrongExciseKey(key)) return { status: "invalid_signature", 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)); @@ -195,6 +199,26 @@ function getExciseKey(keyId: string): string | 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"); @@ -223,6 +247,15 @@ function distanceKm( 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, @@ -240,12 +273,11 @@ async function recordScan( let impliedSpeedKmh: string | undefined; if (previous && previous.latitude !== null && previous.longitude !== null && latitude !== undefined && longitude !== undefined) { - const elapsedHours = (Date.now() - previous.scannedAt.getTime()) / 3_600_000; - if (elapsedHours > 0) { - const speed = distanceKm( - { latitude: previous.latitude, longitude: previous.longitude }, - { latitude, longitude }, - ) / elapsedHours; + 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; } @@ -287,6 +319,11 @@ function requireTransition(status: string, expected: string): void { } } +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({ @@ -342,13 +379,21 @@ export const exciseRouter = router({ 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(eq(exciseLicences.id, input.licenceId)).returning(); - if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + }).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) { @@ -535,7 +580,6 @@ export const exciseRouter = router({ quantity: z.number().int().positive(), declaredValue: z.string().regex(/^\d+(\.\d+)?$/).optional(), currency: z.string().length(3), - liability: z.string().optional(), })) .mutation(async ({ ctx, input }) => { try { @@ -592,44 +636,85 @@ export const exciseRouter = router({ payOrder: protectedProcedure .input(z.object({ orderId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { + const lock = await acquireLock(`excise:pay:${input.orderId}`, 30_000); try { + try { const db = await requireDb(); - const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + 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 transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { - method: "POST", - body: JSON.stringify({ - debitAccountId: `trader-${licence.userId}`, - creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, - amount: order.liability, - currency: order.currency, - reference: order.orderNumber, - description: `Excise stamp liability for ${order.orderNumber}`, - }), - }); + 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: transfer.id, + tbTransferId: transferId, debitAccountId: `trader-${licence.userId}`, creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, - amountMinorUnits: Number(parseScaled(order.liability, 2)), + 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: transfer.id, 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: transfer.id } }); + 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 @@ -641,20 +726,27 @@ export const exciseRouter = router({ 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) { - if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); - if (!declaration || declaration.declarationType !== "import" || !declaration.totalDue) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Customs duty liability is unavailable." }); + 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." }); } - if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); 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; @@ -681,16 +773,24 @@ export const exciseRouter = router({ }), mintMarks: protectedProcedure - .input(z.object({ orderId: z.number().int().positive(), machineId: z.number().int().positive().optional() })) + .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); 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 [existing] = await db.select({ id: exciseStampMarks.id }).from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).limit(1); - if (existing) return db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).orderBy(asc(exciseStampMarks.id)); + 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]; @@ -700,22 +800,28 @@ export const exciseRouter = router({ } const marks = await db.transaction(async (tx) => { const created: typeof exciseStampMarks.$inferSelect[] = []; - for (let index = 0; index < order.quantity; index += 1) { + const values: typeof exciseStampMarks.$inferInsert[] = []; + for (let index = 0; index < Math.min(input.batchSize, remaining); index += 1) { const signed = mintExciseUid(); - const [mark] = await tx.insert(exciseStampMarks).values({ + 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", - }).returning(); - created.push(mark); + }); + } + 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; + 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 @@ -785,22 +891,24 @@ export const exciseRouter = router({ reconcileOrder: protectedProcedure .input(z.object({ orderId: z.number().int().positive() })) - .query(async ({ ctx, input }) => { + .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 [issuedRow] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)).limit(1); 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 = issuedRow ? marks.length : 0; + 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 variance = issuedQuantity - activatedQuantity - retiredQuantity - reportedProductionQuantity; + const stampVariance = issuedQuantity - activatedQuantity - retiredQuantity - stillIssuedQuantity; + const productionVariance = activatedQuantity - reportedProductionQuantity; const [report] = await db.insert(exciseReconciliationReports).values({ - orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, reportedProductionQuantity, variance, computedBy: ctx.user.id, + orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, stillIssuedQuantity, + reportedProductionQuantity, stampVariance, productionVariance, computedBy: ctx.user.id, }).returning(); return report; } catch (error) { @@ -809,14 +917,15 @@ export const exciseRouter = router({ }), createAggregate: protectedProcedure - .input(z.object({ aggregateType: z.enum(["carton", "case", "pallet"]) })) + .input(z.object({ licenceId: z.number().int().positive(), aggregateType: z.enum(["carton", "case", "pallet"]) })) .mutation(async ({ ctx, input }) => { try { const db = await requireDb(); - if (!isEnforcement(ctx.user.role) && ctx.user.role !== "user") throw new TRPCError({ code: "FORBIDDEN" }); + 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; @@ -836,19 +945,25 @@ export const exciseRouter = router({ 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( - input.markId ? eq(exciseAggregateChildren.childMarkId, input.markId) : eq(exciseAggregateChildren.childAggregateId, input.childAggregateId!), + and( + input.markId ? eq(exciseAggregateChildren.childMarkId, input.markId) : eq(exciseAggregateChildren.childAggregateId, input.childAggregateId!), + isNull(exciseAggregateChildren.removedAt), + ), ).limit(1); - if (existing && existing.removedAt === null) throw new TRPCError({ code: "CONFLICT", message: "The child already belongs to an aggregate." }); + 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)); @@ -926,9 +1041,10 @@ export const exciseRouter = router({ 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 if (!isEnforcement(ctx.user.role)) { + } else { const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId!)).limit(1); - if (!aggregate || aggregate.createdBy !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + 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; @@ -939,7 +1055,7 @@ export const exciseRouter = router({ 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() })) - .query(async ({ ctx, input }) => { + .mutation(async ({ ctx, input }) => { if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); try { const db = await requireDb(); @@ -954,7 +1070,9 @@ export const exciseRouter = router({ 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 === "invalid_signature" || mark.status === "retired" ? "suspect" as const : "authentic" as const, + 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) { @@ -980,7 +1098,7 @@ export const exciseRouter = router({ 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() })) - .query(async ({ input }) => { + .mutation(async ({ input }) => { if (!isStrongExciseKey(process.env[EXCISE_UID_HMAC_ENV])) return { status: "unavailable" as const }; const signature = verifyExciseUid(input.uid); try { @@ -989,6 +1107,7 @@ export const exciseRouter = router({ 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 { @@ -1017,15 +1136,12 @@ export const exciseRouter = router({ 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_not_in_manifest" as const }; + 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 }; } - if (!declaration.actingAgentId) { - return { available: false as const, reason: "acting_agent_missing" as const }; - } const siblingMarks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); return { available: true as const, @@ -1035,7 +1151,7 @@ export const exciseRouter = router({ 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, + actingAgentUserId: declaration.actingAgentId ?? null, siblingMarks, }; } catch (error) { @@ -1049,39 +1165,40 @@ export const exciseRouter = router({ if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); try { const db = await requireDb(); - const orders = input?.orderId - ? await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)) - : await db.select().from(exciseStampOrders); - const orderIds = orders.map((order) => order.id); - const marks = orderIds.length ? await db.select().from(exciseStampMarks).where(inArray(exciseStampMarks.orderId, orderIds)) : []; - const reports = orderIds.length ? await db.select().from(exciseProductionReports).where(inArray(exciseProductionReports.orderId, orderIds)) : []; - const anomalies = marks.length ? await db.select().from(exciseAnomalies).where(inArray(exciseAnomalies.markId, marks.map((mark) => mark.id))) : []; - const reportedByOrder = new Map(); - for (const report of reports) { - reportedByOrder.set(report.orderId, (reportedByOrder.get(report.orderId) ?? 0) + report.quantity); - } - const issuedByOrder = new Map(); - const activatedByOrder = new Map(); - const retiredByOrder = new Map(); - for (const mark of marks) { - issuedByOrder.set(mark.orderId, (issuedByOrder.get(mark.orderId) ?? 0) + 1); - if (mark.status === "active") activatedByOrder.set(mark.orderId, (activatedByOrder.get(mark.orderId) ?? 0) + 1); - if (mark.status === "retired") retiredByOrder.set(mark.orderId, (retiredByOrder.get(mark.orderId) ?? 0) + 1); - } - const variance = orders.reduce((sum, order) => sum + - (issuedByOrder.get(order.id) ?? 0) - - (activatedByOrder.get(order.id) ?? 0) - - (retiredByOrder.get(order.id) ?? 0) - - (reportedByOrder.get(order.id) ?? 0), 0); + 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: orders.length, - issued: marks.length, - activated: marks.filter((mark) => mark.status === "active").length, - retired: marks.filter((mark) => mark.status === "retired").length, - paid: orders.filter((order) => order.paidAt !== null).length, - reportedProduction: reports.reduce((sum, report) => sum + report.quantity, 0), - variance, - anomalies: anomalies.length, + 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); From e9faa7d68e27b9362355ded94dbdd09c14e77d26 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:18:41 +0000 Subject: [PATCH 11/17] fix: fail closed without excise coordination Co-Authored-By: Patrick Munis --- server/routers/excise.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/routers/excise.ts b/server/routers/excise.ts index d7a54d06..2bc15da9 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -637,6 +637,9 @@ export const exciseRouter = router({ .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 { try { const db = await requireDb(); @@ -780,6 +783,9 @@ export const exciseRouter = router({ })) .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(); From 210eb5bf6ae1042fdc63f2191042b9a43b291b5d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:27:13 +0000 Subject: [PATCH 12/17] test: cover excise lifecycle safety Co-Authored-By: Patrick Munis --- server/excise.behavior.test.ts | 557 ++++++++++++++++++ server/routers/excise.ts | 2 - .../cmd/idempotency_test.go | 87 +++ services/go/tigerbeetle-bridge/cmd/main.go | 77 ++- 4 files changed, 697 insertions(+), 26 deletions(-) create mode 100644 server/excise.behavior.test.ts create mode 100644 services/go/tigerbeetle-bridge/cmd/idempotency_test.go 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/routers/excise.ts b/server/routers/excise.ts index 2bc15da9..6294359f 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -641,7 +641,6 @@ export const exciseRouter = router({ throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment idempotency lock is unavailable." }); } try { - 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" }); @@ -714,7 +713,6 @@ export const exciseRouter = router({ return updated; } catch (error) { return unavailable("Excise stamp payment is unavailable.", error); - } } finally { await releaseLock(lock); } 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, From 3c0d358f57f900f3ee602de4440d24f67e903f61 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:42:57 +0000 Subject: [PATCH 13/17] feat: add regulatory obligation layer Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 119 +++++++ server/declarations.test.ts | 10 + server/excise.behavior.test.ts | 30 ++ server/regulatory.behavior.test.ts | 248 ++++++++++++++ server/regulatory.ts | 501 +++++++++++++++++++++++++++++ server/routers.ts | 2 + server/routers/declarations.ts | 15 + server/routers/excise.ts | 12 +- 8 files changed, 933 insertions(+), 4 deletions(-) create mode 100644 server/regulatory.behavior.test.ts create mode 100644 server/regulatory.ts diff --git a/drizzle/schema.ts b/drizzle/schema.ts index a923cb24..38f9c8c0 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -50,6 +50,14 @@ export const permitStatusEnum = pgEnum("permit_status", [ "pending", "under_review", "approved", "rejected", "not_required" ]); +export const regulatoryRestrictionTypeEnum = pgEnum("regulatory_restriction_type", [ + "prohibition", "restriction" +]); + +export const declarationFormalityStatusEnum = pgEnum("declaration_formality_status", [ + "required", "satisfied", "blocked" +]); + export const paymentMethodEnum = pgEnum("payment_method", [ "bank_transfer", "mobile_money", "card", "bond" ]); @@ -274,6 +282,13 @@ export const ogaPermits = pgTable("oga_permits", { expiresAt: timestamp("expires_at"), slaDeadline: timestamp("sla_deadline"), respondedAt: timestamp("responded_at"), + hsCode: varchar("hs_code", { length: 12 }), + origin: varchar("origin", { length: 3 }), + destination: varchar("destination", { length: 3 }), + consigneeId: integer("consignee_id").references(() => users.id), + permittedQuantity: decimal("permitted_quantity", { precision: 18, scale: 3 }), + usedQuantity: decimal("used_quantity", { precision: 18, scale: 3 }).default("0").notNull(), + validFrom: timestamp("valid_from"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (t) => [ @@ -281,6 +296,109 @@ export const ogaPermits = pgTable("oga_permits", { index("idx_oga_status").on(t.status), ]); +// ─── REGULATORY OBLIGATIONS (SW4/SW5/SW6) ──────────────────────────────────── + +export const regulatoryFormalities = pgTable("regulatory_formalities", { + id: serial("id").primaryKey(), + hsCodePrefix: varchar("hs_code_prefix", { length: 12 }).notNull(), + origin: varchar("origin", { length: 3 }), + destination: varchar("destination", { length: 3 }), + regime: varchar("regime", { length: 32 }), + agencyCode: varchar("agency_code", { length: 32 }).notNull(), + agencyName: varchar("agency_name", { length: 128 }).notNull(), + permitType: varchar("permit_type", { length: 128 }).notNull(), + requiredQuantity: decimal("required_quantity", { precision: 18, scale: 3 }).default("1").notNull(), + quantityUnit: varchar("quantity_unit", { length: 32 }), + legalInstrument: text("legal_instrument").notNull(), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_reg_formality_match").on(t.hsCodePrefix, t.origin, t.destination, t.regime), + index("idx_reg_formality_dates").on(t.validFrom, t.validUntil), +]); + +export const regulatoryRestrictions = pgTable("regulatory_restrictions", { + id: serial("id").primaryKey(), + hsCodePrefix: varchar("hs_code_prefix", { length: 12 }).notNull(), + origin: varchar("origin", { length: 3 }), + regime: varchar("regime", { length: 32 }), + restrictionType: regulatoryRestrictionTypeEnum("restriction_type").notNull(), + description: text("description").notNull(), + legalInstrument: text("legal_instrument").notNull(), + agencyCode: varchar("agency_code", { length: 32 }), + agencyName: varchar("agency_name", { length: 128 }), + permitType: varchar("permit_type", { length: 128 }), + requiredQuantity: decimal("required_quantity", { precision: 18, scale: 3 }).default("1").notNull(), + quantityUnit: varchar("quantity_unit", { length: 32 }), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_reg_restriction_match").on(t.hsCodePrefix, t.origin, t.regime), + index("idx_reg_restriction_dates").on(t.validFrom, t.validUntil), +]); + +export const declarationFormalities = pgTable("declaration_formalities", { + id: serial("id").primaryKey(), + declarationId: integer("declaration_id").notNull().references(() => declarations.id, { onDelete: "cascade" }), + formalityId: integer("formality_id").references(() => regulatoryFormalities.id), + restrictionId: integer("restriction_id").references(() => regulatoryRestrictions.id), + agencyCode: varchar("agency_code", { length: 32 }), + agencyName: varchar("agency_name", { length: 128 }), + permitType: varchar("permit_type", { length: 128 }), + legalInstrument: text("legal_instrument").notNull(), + requiredQuantity: decimal("required_quantity", { precision: 18, scale: 3 }).notNull(), + satisfiedQuantity: decimal("satisfied_quantity", { precision: 18, scale: 3 }).default("0").notNull(), + satisfiedByPermitId: integer("satisfied_by_permit_id").references(() => ogaPermits.id), + status: declarationFormalityStatusEnum("status").default("required").notNull(), + evaluatedAt: timestamp("evaluated_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_decl_formality_declaration").on(t.declarationId), + index("idx_decl_formality_status").on(t.status), +]); + +export const tariffQuotas = pgTable("tariff_quotas", { + id: serial("id").primaryKey(), + quotaCode: varchar("quota_code", { length: 64 }).notNull().unique(), + hsCodePrefix: varchar("hs_code_prefix", { length: 12 }).notNull(), + origin: varchar("origin", { length: 3 }), + regime: varchar("regime", { length: 32 }), + periodStart: timestamp("period_start").notNull(), + periodEnd: timestamp("period_end").notNull(), + totalQuantity: decimal("total_quantity", { precision: 18, scale: 3 }).notNull(), + quantityUnit: varchar("quantity_unit", { length: 32 }).notNull(), + ledgerAccountId: varchar("ledger_account_id", { length: 128 }).notNull(), + legalInstrument: text("legal_instrument").notNull(), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_tariff_quota_match").on(t.hsCodePrefix, t.origin, t.regime), + index("idx_tariff_quota_period").on(t.periodStart, t.periodEnd), +]); + +export const tariffQuotaAllocations = pgTable("tariff_quota_allocations", { + id: serial("id").primaryKey(), + quotaId: integer("quota_id").notNull().references(() => tariffQuotas.id), + declarationId: integer("declaration_id").notNull().references(() => declarations.id), + quantity: decimal("quantity", { precision: 18, scale: 3 }).notNull(), + transferId: varchar("transfer_id", { length: 128 }).notNull().unique(), + reversedAt: timestamp("reversed_at"), + reversalTransferId: varchar("reversal_transfer_id", { length: 128 }).unique(), + allocatedAt: timestamp("allocated_at").defaultNow().notNull(), + allocatedBy: integer("allocated_by").notNull().references(() => users.id), +}, (t) => [ + uniqueIndex("uq_tariff_active_declaration").on(t.quotaId, t.declarationId) + .where(sql`${t.reversedAt} IS NULL`), + index("idx_tariff_allocation_quota").on(t.quotaId), + index("idx_tariff_allocation_declaration").on(t.declarationId), +]); + // ─── PAYMENTS ──────────────────────────────────────────────────────────────── export const payments = pgTable("payments", { @@ -3706,6 +3824,7 @@ export const exciseReconciliationReports = pgTable("excise_reconciliation_report orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), issuedQuantity: integer("issued_quantity").notNull(), activatedQuantity: integer("activated_quantity").notNull(), + everActivatedQuantity: integer("ever_activated_quantity").default(0).notNull(), retiredQuantity: integer("retired_quantity").notNull(), stillIssuedQuantity: integer("still_issued_quantity").notNull(), reportedProductionQuantity: integer("reported_production_quantity").notNull(), diff --git a/server/declarations.test.ts b/server/declarations.test.ts index 56c9ebe9..17a57619 100644 --- a/server/declarations.test.ts +++ b/server/declarations.test.ts @@ -7,6 +7,16 @@ import type { TrpcContext } from "./_core/context"; // stats is admin-only // create requires an approved trader profile vi.mock("./db", () => ({ + // The regulatory evaluator sees an available, empty register in this unit test. + getDb: vi.fn().mockResolvedValue({ + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => Promise.resolve([]), + }), + }), + }), + }), createDeclaration: vi.fn().mockResolvedValue({ id: 1, declarationNumber: "DEC-001", diff --git a/server/excise.behavior.test.ts b/server/excise.behavior.test.ts index e5809078..3d03b96d 100644 --- a/server/excise.behavior.test.ts +++ b/server/excise.behavior.test.ts @@ -425,6 +425,36 @@ describe.sequential("excise money and lifecycle behaviour", () => { expect(report.productionVariance).toBe(0); }); + it("keeps reconciliation and analytics aligned after activation then retirement", async () => { + process.env.EXCISE_UID_HMAC_KEY = "e".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); + + await caller().excise.activateMark({ uid: signed.uid }); + await caller().excise.reportProduction({ orderId: order.id, quantity: 1 }); + await caller().excise.retireMark({ uid: signed.uid, reason: "wastage", details: "Behaviour test retirement" }); + + const report = await caller().excise.reconcileOrder({ orderId: order.id }); + const analytics = await caller("customs_officer", 2).excise.analytics({ orderId: order.id }); + expect(report.stampVariance).toBe(0); + expect(report.productionVariance).toBe(0); + expect(report.activatedQuantity).toBe(0); + expect(report.everActivatedQuantity).toBe(1); + expect(analytics.stampAccountabilityVariance).toBe(report.stampVariance); + expect(analytics.productionAccountabilityVariance).toBe(report.productionVariance); + }); + 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(); diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts new file mode 100644 index 00000000..df51d1cd --- /dev/null +++ b/server/regulatory.behavior.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { appRouter } from "./routers"; +import { getDb } from "./db"; +import type { TrpcContext } from "./_core/context"; +import { evaluateDeclarationRegulations } from "./regulatory"; +import { + declarations, + declarationFormalities, + ogaPermits, + regulatoryFormalities, + regulatoryRestrictions, + tariffQuotaAllocations, + tariffQuotas, +} from "../drizzle/schema"; + +const ledgerMocks = vi.hoisted(() => ({ + available: vi.fn(async () => true), + fetch: vi.fn(async () => ({ id: `regulatory-transfer-${randomUUID()}` })), +})); + +vi.mock("./routers/ledger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tbBridgeAvailable: ledgerMocks.available, tbFetch: ledgerMocks.fetch }; +}); + +function caller(role: "user" | "admin" | "customs_officer" = "user", userId = 1) { + const context: TrpcContext = { + user: { + id: userId, + openId: `regulatory-behaviour-${userId}`, + name: "Regulatory Behaviour Test", + email: "regulatory-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 regulatory behaviour tests."); + return db; +} + +const created = { + declarations: [] as number[], + formalities: [] as number[], + restrictions: [] as number[], + quotas: [] as number[], + permits: [] as number[], +}; + +async function declaration(hsCode: string, createdAt = new Date()) { + const db = await database(); + const [row] = await db.insert(declarations).values({ + declarationNumber: `REG-${randomUUID().slice(0, 20)}`, + ucr: `REG-UCR-${randomUUID()}`, + traderId: 1, + declarationType: "import", + hsCode, + countryOfOrigin: "GH", + countryOfDestination: "NG", + numberOfPackages: 5, + createdAt, + }).returning(); + created.declarations.push(row.id); + return row; +} + +afterEach(async () => { + ledgerMocks.available.mockResolvedValue(true); + ledgerMocks.fetch.mockClear(); + const db = await database(); + if (created.declarations.length) await db.delete(declarationFormalities).where(inArray(declarationFormalities.declarationId, created.declarations)); + if (created.permits.length) await db.delete(ogaPermits).where(inArray(ogaPermits.id, created.permits)); + if (created.quotas.length) await db.delete(tariffQuotaAllocations).where(inArray(tariffQuotaAllocations.quotaId, created.quotas)); + if (created.declarations.length) await db.delete(declarations).where(inArray(declarations.id, created.declarations)); + if (created.formalities.length) await db.delete(regulatoryFormalities).where(inArray(regulatoryFormalities.id, created.formalities)); + if (created.restrictions.length) await db.delete(regulatoryRestrictions).where(inArray(regulatoryRestrictions.id, created.restrictions)); + if (created.quotas.length) await db.delete(tariffQuotas).where(inArray(tariffQuotas.id, created.quotas)); + created.declarations.length = 0; + created.formalities.length = 0; + created.restrictions.length = 0; + created.quotas.length = 0; + created.permits.length = 0; +}); + +describe.sequential("regulatory obligation behaviour", () => { + it("matches HS prefixes and requires declaration-covering permits", async () => { + const db = await database(); + const now = new Date(); + const [formality] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "1234", + origin: "GH", + destination: "NG", + regime: "import", + agencyCode: "OGA-1", + agencyName: "OGA One", + permitType: "IMPORT", + requiredQuantity: "5", + legalInstrument: "Instrument REG-1", + validFrom: new Date(now.getTime() - 60_000), + createdBy: 4, + }).returning(); + created.formalities.push(formality.id); + const matching = await declaration("123456"); + const miss = await declaration("999999"); + const required = await caller().regulatory.clearanceGraph({ + declarationId: matching.id, hsCode: matching.hsCode!, origin: "GH", destination: "NG", regime: "import", quantity: "5", + }); + expect(required.obligations).toHaveLength(1); + expect(required.obligations[0]?.blocking).toBe(true); + const noMatch = await caller().regulatory.clearanceGraph({ + declarationId: miss.id, hsCode: miss.hsCode!, origin: "GH", destination: "NG", regime: "import", quantity: "5", + }); + expect(noMatch.obligations).toHaveLength(0); + + const [wrongPermit] = await db.insert(ogaPermits).values({ + declarationId: matching.id, + agencyCode: "OGA-1", + agencyName: "OGA One", + permitType: "IMPORT", + status: "approved", + hsCode: "9999", + origin: "GH", + destination: "NG", + consigneeId: 1, + permittedQuantity: "5", + validFrom: new Date(now.getTime() - 60_000), + }).returning(); + created.permits.push(wrongPermit.id); + const stillBlocked = await caller().regulatory.clearanceGraph({ + declarationId: matching.id, hsCode: matching.hsCode!, origin: "GH", destination: "NG", regime: "import", quantity: "5", + }); + expect(stillBlocked.obligations[0]?.satisfied).toBe(false); + + const [permit] = await db.insert(ogaPermits).values({ + declarationId: matching.id, + agencyCode: "OGA-1", + agencyName: "OGA One", + permitType: "IMPORT", + status: "approved", + hsCode: "1234", + origin: "GH", + destination: "NG", + consigneeId: 1, + permittedQuantity: "5", + validFrom: new Date(now.getTime() - 60_000), + }).returning(); + created.permits.push(permit.id); + const satisfied = await caller().regulatory.clearanceGraph({ + declarationId: matching.id, hsCode: matching.hsCode!, origin: "GH", destination: "NG", regime: "import", quantity: "5", + }); + expect(satisfied.obligations[0]?.satisfied).toBe(true); + expect(satisfied.obligations[0]?.satisfiedByPermitId).toBe(permit.id); + await evaluateDeclarationRegulations({ + declarationId: matching.id, importerId: 1, hsCode: matching.hsCode!, origin: "GH", + destination: "NG", regime: "import", quantity: "5", at: now, + }); + const [consumed] = await db.select({ usedQuantity: ogaPermits.usedQuantity }) + .from(ogaPermits).where(eq(ogaPermits.id, permit.id)); + expect(consumed?.usedQuantity).toBe("5.000"); + }); + + it("cites prohibitions, converts restrictions, and evaluates historical rules", async () => { + const db = await database(); + const now = new Date(); + const oldDate = new Date(now.getTime() - 86_400_000); + const [oldRule] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "5678", agencyCode: "OLD", agencyName: "Old Agency", permitType: "OLD-PERMIT", + legalInstrument: "Instrument OLD", validFrom: new Date(oldDate.getTime() - 60_000), validUntil: new Date(oldDate.getTime() + 60_000), createdBy: 4, + }).returning(); + const [newRule] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "5678", agencyCode: "NEW", agencyName: "New Agency", permitType: "NEW-PERMIT", + legalInstrument: "Instrument NEW", validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.formalities.push(oldRule.id, newRule.id); + const historical = await caller().regulatory.clearanceGraph({ + hsCode: "567890", origin: "GH", regime: "import", asAt: oldDate, + }); + expect(historical.obligations).toHaveLength(1); + expect(historical.obligations[0]?.legalInstrument).toBe("Instrument OLD"); + + const [restriction] = await db.insert(regulatoryRestrictions).values({ + hsCodePrefix: "5678", origin: "GH", regime: "import", restrictionType: "restriction", + description: "Restricted goods", legalInstrument: "Instrument RESTRICT", + agencyCode: "RESTRICT", agencyName: "Restriction Agency", permitType: "RESTRICT-PERMIT", + validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.restrictions.push(restriction.id); + const restricted = await declaration("567890"); + await evaluateDeclarationRegulations({ + declarationId: restricted.id, importerId: 1, hsCode: restricted.hsCode!, origin: "GH", + destination: "NG", regime: "import", quantity: "1", at: now, + }); + const obligations = await db.select().from(declarationFormalities).where(eq(declarationFormalities.declarationId, restricted.id)); + expect(obligations.some((entry) => entry.restrictionId === restriction.id && entry.status === "required")).toBe(true); + + const [prohibition] = await db.insert(regulatoryRestrictions).values({ + hsCodePrefix: "9999", origin: "GH", regime: "import", restrictionType: "prohibition", + description: "Prohibited goods", legalInstrument: "Instrument PROHIBIT", + validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.restrictions.push(prohibition.id); + await expect(evaluateDeclarationRegulations({ + importerId: 1, hsCode: "999900", origin: "GH", destination: "NG", regime: "import", quantity: "1", at: now, + })).rejects.toMatchObject({ code: "FORBIDDEN", message: expect.stringContaining("Instrument PROHIBIT") }); + }); + + it("serializes ledger-backed quota drawdown and fails closed on ledger outage", async () => { + const db = await database(); + const now = new Date(); + const [quota] = await db.insert(tariffQuotas).values({ + quotaCode: `Q-${randomUUID()}`, hsCodePrefix: "7777", origin: "GH", regime: "import", + periodStart: new Date(now.getTime() - 60_000), periodEnd: new Date(now.getTime() + 60_000), + totalQuantity: "10", quantityUnit: "kg", ledgerAccountId: "quota-ledger-test", + legalInstrument: "Instrument QUOTA", validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.quotas.push(quota.id); + const first = await declaration("777700"); + const second = await declaration("777701"); + const results = await Promise.allSettled([ + caller().regulatory.allocateQuota({ quotaId: quota.id, declarationId: first.id, quantity: "6" }), + caller().regulatory.allocateQuota({ quotaId: quota.id, declarationId: second.id, quantity: "6" }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const allocations = await db.select().from(tariffQuotaAllocations).where(and( + eq(tariffQuotaAllocations.quotaId, quota.id), + isNull(tariffQuotaAllocations.reversedAt), + )); + expect(allocations).toHaveLength(1); + expect(Number(allocations[0]?.quantity)).toBe(6); + ledgerMocks.available.mockResolvedValue(false); + const outage = await declaration("777702"); + await expect(caller().regulatory.allocateQuota({ + quotaId: quota.id, declarationId: outage.id, quantity: "1", + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + }); +}); diff --git a/server/regulatory.ts b/server/regulatory.ts new file mode 100644 index 00000000..1d6134a0 --- /dev/null +++ b/server/regulatory.ts @@ -0,0 +1,501 @@ +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + gte, + isNull, + lte, + or, + sql, +} from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure, router } from "./_core/trpc"; +import { getDb, logAuditEvent } from "./db"; +import { + declarations, + declarationFormalities, + ogaPermits, + regulatoryFormalities, + regulatoryRestrictions, + tariffQuotaAllocations, + tariffQuotas, +} from "../drizzle/schema"; +import { tbBridgeAvailable, tbFetch } from "./routers/ledger"; +import { acquireLock, releaseLock } from "./_core/distributedLock"; + +type RegulatoryDb = NonNullable>>; +type RegulatoryDateInput = Date | string; + +const AUTHORING_ROLES = new Set(["admin", "customs_officer", "oga_officer"]); + +function requireAuthoringRole(role: string): void { + if (!AUTHORING_ROLES.has(role)) { + throw new TRPCError({ code: "FORBIDDEN", message: "Only authorised officers may author regulatory registers." }); + } +} + +function asDate(value: RegulatoryDateInput | undefined, fallback = new Date()): Date { + return value ? new Date(value) : fallback; +} + +function activeAt(validFrom: Date, validUntil: Date | null, at: Date): boolean { + return validFrom <= at && (validUntil === null || validUntil >= at); +} + +function matchesOptional(value: string | null, expected: string | undefined): boolean { + return value === null || value === expected; +} + +function matchesPrefix(value: string | null, prefix: string): boolean { + return value !== null && value.startsWith(prefix); +} + +async function requireRegulatoryDb(): Promise { + const db = await getDb(); + if (!db) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Regulatory registers are unavailable.", + }); + } + return db; +} + +async function matchingRegisters( + db: RegulatoryDb, + input: { + hsCode: string; + origin: string; + destination?: string; + regime: string; + at: Date; + }, +) { + const formalities = await db.select().from(regulatoryFormalities) + .where(and( + sql`${input.hsCode} LIKE ${regulatoryFormalities.hsCodePrefix} || '%'`, + or(isNull(regulatoryFormalities.origin), eq(regulatoryFormalities.origin, input.origin)), + or(isNull(regulatoryFormalities.destination), eq(regulatoryFormalities.destination, input.destination ?? "")), + or(isNull(regulatoryFormalities.regime), eq(regulatoryFormalities.regime, input.regime)), + lte(regulatoryFormalities.validFrom, input.at), + or(isNull(regulatoryFormalities.validUntil), gte(regulatoryFormalities.validUntil, input.at)), + )) + .orderBy(asc(regulatoryFormalities.id)); + const restrictions = await db.select().from(regulatoryRestrictions) + .where(and( + sql`${input.hsCode} LIKE ${regulatoryRestrictions.hsCodePrefix} || '%'`, + or(isNull(regulatoryRestrictions.origin), eq(regulatoryRestrictions.origin, input.origin)), + or(isNull(regulatoryRestrictions.regime), eq(regulatoryRestrictions.regime, input.regime)), + lte(regulatoryRestrictions.validFrom, input.at), + or(isNull(regulatoryRestrictions.validUntil), gte(regulatoryRestrictions.validUntil, input.at)), + )) + .orderBy(asc(regulatoryRestrictions.id)); + const quotas = await db.select().from(tariffQuotas) + .where(and( + sql`${input.hsCode} LIKE ${tariffQuotas.hsCodePrefix} || '%'`, + or(isNull(tariffQuotas.origin), eq(tariffQuotas.origin, input.origin)), + or(isNull(tariffQuotas.regime), eq(tariffQuotas.regime, input.regime)), + lte(tariffQuotas.periodStart, input.at), + gte(tariffQuotas.periodEnd, input.at), + lte(tariffQuotas.validFrom, input.at), + or(isNull(tariffQuotas.validUntil), gte(tariffQuotas.validUntil, input.at)), + )) + .orderBy(asc(tariffQuotas.id)); + return { formalities, restrictions, quotas }; +} + +type ObligationInput = { + declarationId?: number; + importerId: number; + hsCode: string; + origin: string; + destination?: string; + regime: string; + quantity: string; + at: Date; +}; + +async function permitSatisfies( + db: RegulatoryDb, + input: ObligationInput, + obligation: { + agencyCode: string | null; + permitType: string | null; + requiredQuantity: string; + }, + consume: boolean, +) { + if (!input.declarationId || !obligation.agencyCode || !obligation.permitType) return null; + const permits = await db.select().from(ogaPermits) + .where(and( + eq(ogaPermits.declarationId, input.declarationId), + eq(ogaPermits.agencyCode, obligation.agencyCode), + eq(ogaPermits.permitType, obligation.permitType), + eq(ogaPermits.status, "approved"), + eq(ogaPermits.consigneeId, input.importerId), + lte(ogaPermits.validFrom, input.at), + or(isNull(ogaPermits.expiresAt), gte(ogaPermits.expiresAt, input.at)), + )) + .orderBy(desc(ogaPermits.id)); + for (const permit of permits) { + if (!permit.hsCode || !matchesPrefix(input.hsCode, permit.hsCode)) continue; + if (!matchesOptional(permit.origin, input.origin)) continue; + if (!matchesOptional(permit.destination, input.destination)) continue; + if (!permit.permittedQuantity) continue; + const remaining = Number(permit.permittedQuantity) - Number(permit.usedQuantity); + if (remaining < Number(obligation.requiredQuantity)) continue; + if (consume) { + const [updated] = await db.update(ogaPermits).set({ + usedQuantity: sql`${ogaPermits.usedQuantity} + ${obligation.requiredQuantity}`, + updatedAt: new Date(), + }).where(and( + eq(ogaPermits.id, permit.id), + sql`${ogaPermits.usedQuantity} + ${obligation.requiredQuantity} <= ${ogaPermits.permittedQuantity}`, + )).returning(); + if (!updated) continue; + } + return permit; + } + return null; +} + +async function buildObligations(db: RegulatoryDb, input: ObligationInput, consumePermits: boolean) { + const { formalities, restrictions, quotas } = await matchingRegisters(db, input); + const prohibitions = restrictions.filter((entry) => entry.restrictionType === "prohibition"); + const requiredRestrictions = restrictions.filter((entry) => entry.restrictionType === "restriction"); + const obligations = [ + ...formalities.map((entry) => ({ + formalityId: entry.id, + restrictionId: null, + agencyCode: entry.agencyCode, + agencyName: entry.agencyName, + permitType: entry.permitType, + legalInstrument: entry.legalInstrument, + requiredQuantity: entry.requiredQuantity, + })), + ...requiredRestrictions.map((entry) => ({ + formalityId: null, + restrictionId: entry.id, + agencyCode: entry.agencyCode, + agencyName: entry.agencyName, + permitType: entry.permitType, + legalInstrument: entry.legalInstrument, + requiredQuantity: entry.requiredQuantity, + })), + ]; + const evaluated = []; + for (const obligation of obligations) { + const permit = await permitSatisfies(db, input, obligation, consumePermits); + evaluated.push({ ...obligation, permit }); + } + return { prohibitions, obligations: evaluated, quotas }; +} + +export async function evaluateDeclarationRegulations(input: ObligationInput): Promise { + const db = await requireRegulatoryDb(); + const preview = await buildObligations(db, input, false); + const prohibition = preview.prohibitions[0]; + if (prohibition) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, + }); + } + const result = await buildObligations(db, input, true); + if (!input.declarationId || result.obligations.length === 0) return; + await db.insert(declarationFormalities).values(result.obligations.map((obligation) => ({ + declarationId: input.declarationId!, + formalityId: obligation.formalityId, + restrictionId: obligation.restrictionId, + agencyCode: obligation.agencyCode, + agencyName: obligation.agencyName, + permitType: obligation.permitType, + legalInstrument: obligation.legalInstrument, + requiredQuantity: obligation.requiredQuantity, + satisfiedQuantity: obligation.permit ? obligation.requiredQuantity : "0", + satisfiedByPermitId: obligation.permit?.id ?? null, + status: obligation.permit ? "satisfied" as const : "required" as const, + evaluatedAt: input.at, + }))); +} + +export async function assertDeclarationFormalitiesSatisfied(declarationId: number): Promise { + const db = await requireRegulatoryDb(); + const rows = await db.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationId)); + if (rows.some((row) => row.status !== "satisfied")) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Required regulatory formalities are not satisfied.", + }); + } +} + +async function clearanceGraph(input: ObligationInput) { + const db = await requireRegulatoryDb(); + const result = await buildObligations(db, input, false); + const graph = result.obligations.map((obligation) => ({ + required: true as const, + satisfied: obligation.permit !== null, + blocking: obligation.permit === null, + agencyCode: obligation.agencyCode, + agencyName: obligation.agencyName, + permitType: obligation.permitType, + legalInstrument: obligation.legalInstrument, + requiredQuantity: obligation.requiredQuantity, + satisfiedByPermitId: obligation.permit?.id ?? null, + })); + const quotaGraph = []; + for (const quota of result.quotas) { + const allocations = input.declarationId + ? await db.select({ quantity: tariffQuotaAllocations.quantity }) + .from(tariffQuotaAllocations) + .where(and( + eq(tariffQuotaAllocations.quotaId, quota.id), + eq(tariffQuotaAllocations.declarationId, input.declarationId), + isNull(tariffQuotaAllocations.reversedAt), + )) + : []; + const allocated = allocations.reduce((sum, row) => sum + Number(row.quantity), 0); + const required = Number(input.quantity); + quotaGraph.push({ + required: true as const, + satisfied: allocated >= required, + blocking: allocated < required, + quotaCode: quota.quotaCode, + legalInstrument: quota.legalInstrument, + requiredQuantity: input.quantity, + allocatedQuantity: String(allocated), + }); + } + return { + registersAvailable: true as const, + prohibited: result.prohibitions.map((entry) => ({ + description: entry.description, + legalInstrument: entry.legalInstrument, + })), + obligations: [...graph, ...quotaGraph], + blocking: result.prohibitions.length > 0 || graph.some((entry) => entry.blocking) || quotaGraph.some((entry) => entry.blocking), + cleared: false as const, + }; +} + +export const regulatoryRouter = router({ + listFormalities: protectedProcedure + .input(z.object({ asAt: z.coerce.date().optional() }).optional()) + .query(async ({ input }) => { + const db = await requireRegulatoryDb(); + const at = input?.asAt; + return db.select().from(regulatoryFormalities) + .where(at ? and(lte(regulatoryFormalities.validFrom, at), or(isNull(regulatoryFormalities.validUntil), gte(regulatoryFormalities.validUntil, at))) : undefined) + .orderBy(asc(regulatoryFormalities.hsCodePrefix)); + }), + + listRestrictions: protectedProcedure + .input(z.object({ asAt: z.coerce.date().optional() }).optional()) + .query(async ({ input }) => { + const db = await requireRegulatoryDb(); + const at = input?.asAt; + return db.select().from(regulatoryRestrictions) + .where(at ? and(lte(regulatoryRestrictions.validFrom, at), or(isNull(regulatoryRestrictions.validUntil), gte(regulatoryRestrictions.validUntil, at))) : undefined) + .orderBy(asc(regulatoryRestrictions.hsCodePrefix)); + }), + + listQuotas: protectedProcedure + .input(z.object({ asAt: z.coerce.date().optional() }).optional()) + .query(async ({ input }) => { + const db = await requireRegulatoryDb(); + const at = input?.asAt; + return db.select().from(tariffQuotas) + .where(at ? and(lte(tariffQuotas.validFrom, at), or(isNull(tariffQuotas.validUntil), gte(tariffQuotas.validUntil, at))) : undefined) + .orderBy(asc(tariffQuotas.quotaCode)); + }), + + createFormality: protectedProcedure + .input(z.object({ + hsCodePrefix: z.string().min(2).max(12), + origin: z.string().max(3).optional(), + destination: z.string().max(3).optional(), + regime: z.string().max(32).optional(), + agencyCode: z.string().min(1).max(32), + agencyName: z.string().min(1).max(128), + permitType: z.string().min(1).max(128), + requiredQuantity: z.string().regex(/^\d+(\.\d{1,3})?$/).default("1"), + quantityUnit: z.string().max(32).optional(), + legalInstrument: z.string().min(1), + validFrom: z.coerce.date(), + validUntil: z.coerce.date().optional(), + })) + .mutation(async ({ ctx, input }) => { + requireAuthoringRole(ctx.user.role); + const db = await requireRegulatoryDb(); + const [entry] = await db.insert(regulatoryFormalities).values({ ...input, createdBy: ctx.user.id }).returning(); + await logAuditEvent({ entityType: "declaration", entityId: entry.id, action: "regulatory_formality_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: entry }); + return entry; + }), + + createRestriction: protectedProcedure + .input(z.object({ + hsCodePrefix: z.string().min(2).max(12), + origin: z.string().max(3).optional(), + regime: z.string().max(32).optional(), + restrictionType: z.enum(["prohibition", "restriction"]), + description: z.string().min(1), + legalInstrument: z.string().min(1), + agencyCode: z.string().max(32).optional(), + agencyName: z.string().max(128).optional(), + permitType: z.string().max(128).optional(), + requiredQuantity: z.string().regex(/^\d+(\.\d{1,3})?$/).default("1"), + quantityUnit: z.string().max(32).optional(), + validFrom: z.coerce.date(), + validUntil: z.coerce.date().optional(), + })) + .mutation(async ({ ctx, input }) => { + requireAuthoringRole(ctx.user.role); + const db = await requireRegulatoryDb(); + const [entry] = await db.insert(regulatoryRestrictions).values({ ...input, createdBy: ctx.user.id }).returning(); + await logAuditEvent({ entityType: "declaration", entityId: entry.id, action: "regulatory_restriction_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: entry }); + return entry; + }), + + createQuota: protectedProcedure + .input(z.object({ + quotaCode: z.string().min(1).max(64), + hsCodePrefix: z.string().min(2).max(12), + origin: z.string().max(3).optional(), + regime: z.string().max(32).optional(), + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + totalQuantity: z.string().regex(/^\d+(\.\d{1,3})?$/), + quantityUnit: z.string().min(1).max(32), + ledgerAccountId: z.string().min(1).max(128), + legalInstrument: z.string().min(1), + validFrom: z.coerce.date(), + validUntil: z.coerce.date().optional(), + })) + .mutation(async ({ ctx, input }) => { + requireAuthoringRole(ctx.user.role); + const db = await requireRegulatoryDb(); + const [entry] = await db.insert(tariffQuotas).values({ ...input, createdBy: ctx.user.id }).returning(); + await logAuditEvent({ entityType: "declaration", entityId: entry.id, action: "tariff_quota_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: entry }); + return entry; + }), + + clearanceGraph: protectedProcedure + .input(z.object({ + hsCode: z.string().min(1).max(12), + origin: z.string().min(1).max(3), + destination: z.string().max(3).optional(), + regime: z.string().min(1).max(32), + quantity: z.string().regex(/^\d+(\.\d{1,3})?$/).default("1"), + asAt: z.coerce.date().optional(), + declarationId: z.number().int().positive().optional(), + })) + .query(async ({ ctx, input }) => clearanceGraph({ + hsCode: input.hsCode, + origin: input.origin, + destination: input.destination, + regime: input.regime, + quantity: input.quantity, + importerId: ctx.user.id, + declarationId: input.declarationId, + at: asDate(input.asAt), + })), + + allocateQuota: protectedProcedure + .input(z.object({ quotaId: z.number().int().positive(), declarationId: z.number().int().positive(), quantity: z.string().regex(/^\d+(\.\d{1,3})?$/) })) + .mutation(async ({ ctx, input }) => { + const db = await requireRegulatoryDb(); + const [quota] = await db.select().from(tariffQuotas).where(eq(tariffQuotas.id, input.quotaId)).limit(1); + if (!quota) throw new TRPCError({ code: "NOT_FOUND", message: "Tariff quota not found." }); + 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 (ctx.user.role === "user" && declaration.traderId !== ctx.user.id) { + throw new TRPCError({ code: "FORBIDDEN", message: "You may only allocate quota for your own declaration." }); + } + const at = declaration.submittedAt ?? declaration.createdAt; + if (!activeAt(quota.validFrom, quota.validUntil, at) || at < quota.periodStart || at > quota.periodEnd) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Tariff quota is not active for this declaration date." }); + } + if (!declaration.hsCode?.startsWith(quota.hsCodePrefix) || + (quota.origin !== null && quota.origin !== declaration.countryOfOrigin) || + (quota.regime !== null && quota.regime !== declaration.declarationType)) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Tariff quota does not apply to this declaration." }); + } + const lock = await acquireLock(`regulatory:quota:${quota.id}`, 30_000); + if (lock.token === "no-redis") { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Quota coordination is unavailable." }); + } + try { + const [existing] = await db.select().from(tariffQuotaAllocations).where(and( + eq(tariffQuotaAllocations.quotaId, quota.id), + eq(tariffQuotaAllocations.declarationId, input.declarationId), + isNull(tariffQuotaAllocations.reversedAt), + )).limit(1); + if (existing) return existing; + if (!(await tbBridgeAvailable())) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Ledger is unavailable; quota was not allocated." }); + } + const [drawn] = await db.select({ + quantity: sql`coalesce(sum(${tariffQuotaAllocations.quantity}) filter (where ${tariffQuotaAllocations.reversedAt} is null), 0)`, + }).from(tariffQuotaAllocations).where(eq(tariffQuotaAllocations.quotaId, quota.id)); + if (Number(drawn?.quantity ?? 0) + Number(input.quantity) > Number(quota.totalQuantity)) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Tariff quota is exhausted." }); + } + const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + debitAccountId: quota.ledgerAccountId, + creditAccountId: `quota-allocation:${quota.id}:${input.declarationId}`, + amount: input.quantity, + currency: "QTY", + reference: quota.quotaCode, + description: `Tariff quota allocation for declaration ${input.declarationId}`, + idempotencyKey: `regulatory:quota:${quota.id}:${input.declarationId}`, + }), + }); + const [allocation] = await db.insert(tariffQuotaAllocations).values({ + quotaId: quota.id, + declarationId: input.declarationId, + quantity: input.quantity, + transferId: transfer.id, + allocatedBy: ctx.user.id, + }).returning(); + return allocation; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Quota allocation could not be committed." }); + } finally { + await releaseLock(lock); + } + }), + + reverseQuotaAllocation: protectedProcedure + .input(z.object({ allocationId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + requireAuthoringRole(ctx.user.role); + const db = await requireRegulatoryDb(); + const [allocation] = await db.select().from(tariffQuotaAllocations).where(eq(tariffQuotaAllocations.id, input.allocationId)).limit(1); + if (!allocation) throw new TRPCError({ code: "NOT_FOUND" }); + if (allocation.reversedAt) return allocation; + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Ledger is unavailable; quota was not restored." }); + const [quota] = await db.select().from(tariffQuotas).where(eq(tariffQuotas.id, allocation.quotaId)).limit(1); + if (!quota) throw new TRPCError({ code: "NOT_FOUND" }); + const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + debitAccountId: `quota-allocation:${allocation.quotaId}:${allocation.declarationId}`, + creditAccountId: quota.ledgerAccountId, + amount: allocation.quantity, + currency: "QTY", + reference: `reversal:${allocation.id}`, + description: `Restore tariff quota allocation ${allocation.id}`, + idempotencyKey: `regulatory:quota-reversal:${allocation.id}`, + }), + }); + const [updated] = await db.update(tariffQuotaAllocations).set({ reversedAt: new Date(), reversalTransferId: transfer.id }).where(eq(tariffQuotaAllocations.id, allocation.id)).returning(); + return updated; + }), +}); diff --git a/server/routers.ts b/server/routers.ts index c8783f6c..bc288918 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -121,9 +121,11 @@ import { tradeAnalyticsRouter } from "./routers/tradeAnalytics"; import { ncsNrsRouter } from "./routers/ncsNrs"; import { complianceReportingRouter } from "./routers/complianceReporting"; +import { regulatoryRouter } from "./regulatory"; export const appRouter = router({ complianceReporting: complianceReportingRouter, + regulatory: regulatoryRouter, // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly system: systemRouter, auth: router({ diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index 1e9dc07a..074553ec 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -19,6 +19,7 @@ import { assertValidTransition, assignRiskLane, validateHsCode, checkPermitValid import { indexDeclaration, searchDeclarations } from "../_core/opensearch"; import { scoreDeclarationRisk, validateDeclarationWithEngine, getCargoPosition } from "../_core/polyglotClients"; import { resolveActingPrincipal, requireDeclarationActor } from "../_core/mandateAuthorization"; +import { assertDeclarationFormalitiesSatisfied, evaluateDeclarationRegulations } from "../regulatory"; // Generate a unique declaration number: TG-YYYY-XXXXXXXX function generateDeclarationNumber(): string { @@ -265,6 +266,17 @@ export const declarationsRouter = router({ }); } + await evaluateDeclarationRegulations({ + declarationId: input.id, + importerId: principalUserId, + hsCode: decl.hsCode ?? "", + origin: decl.countryOfOrigin ?? "", + destination: decl.countryOfDestination ?? undefined, + regime: decl.declarationType, + quantity: String(decl.numberOfPackages ?? 1), + at: new Date(), + }); + // Run AI risk scoring — Python ML scorer (primary) with LLM fallback const risk = await computeRiskScore( { @@ -567,6 +579,9 @@ export const declarationsRouter = router({ const permifyAction = input.status === "cleared" ? "release" : input.status === "under_examination" ? "hold" : "assess"; await assertCan(String(ctx.user.id), "declaration", String(input.id), permifyAction); + if (input.status === "cleared") { + await assertDeclarationFormalitiesSatisfied(input.id); + } const updateData: Record = { status: input.status }; if (input.status === "cleared") updateData.clearedAt = new Date(); diff --git a/server/routers/excise.ts b/server/routers/excise.ts index 6294359f..54772a36 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -904,14 +904,15 @@ export const exciseRouter = router({ 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 activatedQuantity = marks.filter((mark) => mark.status === "active").length; + const everActivatedQuantity = marks.filter((mark) => 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 productionVariance = everActivatedQuantity - reportedProductionQuantity; const [report] = await db.insert(exciseReconciliationReports).values({ - orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, stillIssuedQuantity, + orderId: order.id, issuedQuantity, activatedQuantity, everActivatedQuantity, retiredQuantity, stillIssuedQuantity, reportedProductionQuantity, stampVariance, productionVariance, computedBy: ctx.user.id, }).returning(); return report; @@ -1177,6 +1178,7 @@ export const exciseRouter = router({ const [markStats] = await db.select({ issued: count(exciseStampMarks.id), activated: sql`count(*) filter (where ${exciseStampMarks.status} = 'active')`, + everActivated: sql`count(*) filter (where ${exciseStampMarks.activatedAt} is not null)`, 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); @@ -1190,6 +1192,7 @@ export const exciseRouter = router({ : await db.select({ anomalies: count(exciseAnomalies.id) }).from(exciseAnomalies); const issued = Number(markStats?.issued ?? 0); const activated = Number(markStats?.activated ?? 0); + const everActivated = Number(markStats?.everActivated ?? 0); const retired = Number(markStats?.retired ?? 0); const stillIssued = Number(markStats?.stillIssued ?? 0); const reportedProduction = Number(productionStats?.reported ?? 0); @@ -1201,7 +1204,8 @@ export const exciseRouter = router({ paid: Number(orderStats?.paid ?? 0), reportedProduction, stampAccountabilityVariance: issued - activated - retired - stillIssued, - productionAccountabilityVariance: activated - reportedProduction, + productionAccountabilityVariance: everActivated - reportedProduction, + everActivated, anomalies: Number(anomalyStats?.anomalies ?? 0), }; } catch (error) { From d45d0039647d2d472a0bfb214d86553b288546a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:51:02 +0000 Subject: [PATCH 14/17] fix: harden regulatory clearance controls Co-Authored-By: Patrick Munis --- docs/excise-tax-stamps-parity.md | 154 ++++++++++++++++++ docs/single-window-market-parity.md | 117 +++++++++++++ server/regulatory.behavior.test.ts | 78 ++++++++- server/routers.ts | 2 +- server/routers/declarations.ts | 2 +- server/{ => routers}/regulatory.ts | 150 ++++++++++++----- .../cmd/idempotency_test.go | 27 +++ services/go/tigerbeetle-bridge/cmd/main.go | 14 ++ 8 files changed, 490 insertions(+), 54 deletions(-) create mode 100644 docs/excise-tax-stamps-parity.md create mode 100644 docs/single-window-market-parity.md rename server/{ => routers}/regulatory.ts (80%) diff --git a/docs/excise-tax-stamps-parity.md b/docs/excise-tax-stamps-parity.md new file mode 100644 index 00000000..ff3767df --- /dev/null +++ b/docs/excise-tax-stamps-parity.md @@ -0,0 +1,154 @@ +# Digital tax stamps / excise traceability: market comparison and design + +## 1. What the leading platforms actually do + +Reference set (public product documentation, plus the regulatory floor those products are built to): + +| Platform | Vendor | Public source | +| --- | --- | --- | +| SICPATRACE® Evo | SICPA | https://www.sicpa.com/solutions/sicpatrace | +| TransAct™ | Authentix | https://authentix-us.com/governments/taxstamp/ | +| DirectTrace excise suite | DirectTrace | https://direct-trace.com/for-excise-tax/ | +| Regulatory floor | EU Commission Implementing Regulation (EU) 2018/574 (TPD Art. 15 traceability), implementing WHO FCTC Illicit Trade Protocol Art. 8 | https://eur-lex.europa.eu/eli/reg/2018/574/oj | + +Distilling their published capability sets, a credible excise-traceability platform has to cover +eleven capability areas. `C*` labels are used throughout this document. + +- **C1 Licensee / taxpayer administration.** Registration and licensing of manufacturers, importers, + distributors and retailers of excisable goods, with licence validity and suspension. +- **C2 Facility and production-line registry.** Each production or storage facility and each marking + machine identified. 2018/574 makes this explicit: economic operator identifier (EOID), facility + identifier (FID), machine identifier, all issued by an independent **ID issuer**. +- **C3 Product master data and taxation schemes.** Registered SKUs (brand, pack size, strength/volume) + mapped to an excise scheme — specific (per stick / per litre / per litre of pure alcohol), + ad valorem, or hybrid. +- **C4 Stamp / mark procurement.** Order → approval → fiscal liability → payment → fulfilment → + delivery, with stamp stock accounted for at every hop. +- **C5 Unique serialised identifiers.** A non-guessable unique identifier per unit packet, generated + independently of the manufacturer, resistant to duplication, and recorded with its issuance context. +- **C6 Activation and production reporting.** Marks are activated when applied; wastage, spoilage and + destruction are declared; issued stamps reconcile against activated stamps and reported production. +- **C7 Aggregation.** Unit → carton → master case → pallet, each aggregate carrying its own unique + aggregated identifier, so a pallet scan resolves every unit packet inside it (2018/574 Art. 10, + Annex II). +- **C8 Supply-chain movement events.** Dispatch, arrival, transfer of ownership, export, re-entry, + destruction — the event stream that turns marks into traceability. +- **C9 Field enforcement.** Inspector scans a mark and gets authenticity plus the mark's full history; + seizures recorded against the mark. Only authorised enforcement users see the data behind a stamp. +- **C10 Public / consumer authentication.** Anyone can verify a mark; the answer must not leak the + commercial data behind it. +- **C11 Analytics and revenue reconciliation.** Revenue realised vs. expected, illicit-trade + indicators, duplicate marks, diversion detection. + +## 2. What this platform has today + +Searching the repository for the excise domain returns exactly one thing: + +``` +server/businessRules.ts:275 exciseRate?: number; // For excisable goods +server/businessRules.ts:290 const excise = (cifValue * (input.exciseRate ?? 0)) / 100; +``` + +An ad valorem excise term inside `calculateDuty`, and nothing else. There is no stamp, mark, serial, +licensee, facility, SKU or activation concept anywhere in `drizzle/schema.ts` or in the 104 routers. + +So the honest comparison is not "which features are missing" but **C1–C11 are all absent**: this is a +customs single-window with no excise-traceability capability at all. What it does bring, and what no +tax-stamp vendor has, is the other half of the problem: declarations, valuation, duty assessment, a +double-entry ledger, payments, risk lanes, manifests and bills of lading, an audit trail, and an +enforcement/officer model. That asymmetry is what section 4 exploits. + +| Capability | Leading platforms | This platform (before) | Planned | +| --- | --- | --- | --- | +| C1 Licensee administration | yes | none | excise licences with validity + suspension | +| C2 Facility / machine registry | yes (EOID/FID/machine) | none | facility + machine identifiers, ID-issuer-owned | +| C3 Product master data + schemes | yes | none | SKU registry, specific/ad valorem/hybrid schemes | +| C4 Stamp procurement | yes | none | order → assess → pay → fulfil, ledger-posted | +| C5 Serialised UIDs | yes | none | signed, non-guessable UIDs minted server-side | +| C6 Activation + production reporting | yes | none | activation, wastage, destruction, reconciliation | +| C7 Aggregation | yes | none | unit → carton → case → pallet, resolvable both ways | +| C8 Movement events | yes | none | dispatch/receipt/export/seizure event stream | +| C9 Field enforcement | yes | none | authorised scan with full history + seizure capture | +| C10 Public authentication | yes | none | public rate-limited verify, no commercial data | +| C11 Analytics + reconciliation | yes | none | issued/activated/paid reconciliation + anomalies | + +## 3. Design rules carried over from the audit + +This module is built under the same rules the fail-closed remediation established, because an excise +system is a money system: + +1. **No fabricated authenticity.** A verification result is `authentic`, `unknown`, `suspect` or + `unavailable`. A dependency outage never renders as "authentic", and never as "counterfeit" either — + accusing a legitimate trader on the strength of a Redis timeout is the same defect in the other + direction. +2. **No fabricated reconciliation.** Unreconciled variance is reported as variance. It is never + rounded to zero and never suppressed. +3. **Fail closed on money.** Stamps are not released, and marks are not activated, when the ledger, + database or payment path is unavailable. +4. **UIDs are minted server-side and are unguessable.** A licensee cannot choose its own serials, and + a serial cannot be derived from another serial. +5. **Public endpoints disclose status only.** No brand, licensee, volume, consignee, value or route on + a public scan. +6. **Unknown is nullable.** No zero-valued or empty-string placeholders standing in for absent data. + +## 4. The six innovations + +These are deliberately *not* reimplementations of vendor features. Each one exists only because this +platform holds both halves of the data — the customs/fiscal side and the mark side — which the +standalone tax-stamp platforms do not. + +**I1 — Declaration-linked stamp issuance, gated on settled duty.** +For imported excisable goods, a stamp order is bound to the customs declaration (and through the +linkage added for shipment tracking, to its bill of lading and manifest). Stamps are released only +when the declaration's duty is *settled in the ledger* — not merely marked paid. This closes the +leak every standalone stamp platform lives with: the stamp programme and the customs programme are +different systems, so goods can clear customs and never be stamped, or be stamped and never declared. +Here the two are the same transaction. + +**I2 — Offline-verifiable marks.** +Each UID carries a truncated HMAC over the serial payload, keyed by a server-held secret with a key +identifier in the mark. An inspector's device holds a verification key and can distinguish a +well-formed mark from an invented one *with no connectivity*, then reconcile the scan on reconnect. +The offline answer is explicitly labelled `signature_valid_pending_reconciliation` — it proves the +mark was minted by the authority, and does not claim the pack is legitimate, because a genuine mark +can still be cloned onto illicit product. That distinction is the entire point, and it is the one +thing offline verification usually gets dishonestly wrong. + +**I3 — Impossible-travel detection on scans.** +The same UID scanned in two places implies a speed between them. Above a physical threshold, one of +the two marks is a clone. This is the mobile-money fraud-detection pattern applied to fiscal marks, +using the platform's existing geospatial data. It flags the *mark*, not the trader, and it records +both scans as evidence rather than deleting the "wrong" one. + +**I4 — Stamp liability on the double-entry ledger.** +Stamp orders post to the existing ledger, so at any moment `stamps issued × unit liability` is +reconcilable against `paid`, `activated` and `reported production`. Vendors report stamp counts; +posting the liability into the same ledger that carries duty and VAT means excise revenue is +auditable by the same reconciliation that covers everything else, and a variance cannot hide in a +spreadsheet between two systems. + +**I5 — Consumer scan as an enforcement sensor.** +Public verification is anonymous and discloses nothing commercial, but the scan itself is retained as +a signal feeding I3 and the risk model. Consumers become a national sensor network for illicit trade +without surrendering any personal data and without being told anything about the supply chain. + +**I6 — Seizure-to-source graph traversal.** +From a seized unit packet, resolve upward through aggregation (carton → case → pallet) to the +production or import event, the declaration, the manifest, the importer and the mandate-holding agent +who filed it — and back down to every sibling mark from the same batch that is still in the market. +Enforcement's real question is not "is this pack fake" but "where did it come from and what else came +with it", and answering that needs both the aggregation tree and the customs record. + +## 5. Also closing: two residual findings from the audit + +Both were left open in the audit's residual register as policy decisions. They are closed here as +*mechanism* — configuration replaces hardcoded constants, and absence fails closed — without inventing +Nigerian or Ghanaian rates, which remains the authority's data to load: + +- **Flat 10% duty / 15% VAT.** Replaced by a persisted tariff schedule keyed by HS code and effective + date. A declaration whose HS code has no effective rate is **rejected**, not assessed at a default. + A wrong-but-plausible assessment is worse than a refusal. +- **GHS/NGN/USD incoherence.** Replaced by an explicit jurisdiction configuration (customs accounting + currency plus permitted settlement currencies) and a persisted FX rate with a source and timestamp. + No rate on the valuation date means the assessment fails closed rather than silently mixing + currencies. diff --git a/docs/single-window-market-parity.md b/docs/single-window-market-parity.md new file mode 100644 index 00000000..48beca19 --- /dev/null +++ b/docs/single-window-market-parity.md @@ -0,0 +1,117 @@ +# Single-window market comparison: gaps and innovations + +## 1. Reference set + +| Platform | Operator | Public source | +| --- | --- | --- | +| TradeNet / Networked Trade Platform (NTP) | Singapore Customs | https://www.customs.gov.sg/doing-business/quick-links-for-traders/tradenet/what-you-need-to-know-about-tradenet/ | +| EU Single Window Environment for Customs / CSW-CERTEX | European Commission (DG TAXUD) | https://taxation-customs.ec.europa.eu/customs/customs-controls/eu-single-window-environment-customs_en | +| ASYCUDAWorld national/regional single window | UNCTAD | https://asycuda.org/ | +| Regulatory floor | Regulation (EU) 2022/2399 + Delegated Reg. (EU) 2024/2514; WTO TFA Arts. 3, 4, 7, 10.4; WCO Data Model | https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02022R2399-20241017 | + +Capability areas distilled from those sources, labelled `SW*` below. + +## 2. Comparison + +Verified against the repository, not against its own marketing components. + +| # | Capability | Reference platforms | This platform | Verdict | +| --- | --- | --- | --- | --- | +| SW1 | Single declaration serving all agencies | TradeNet: one submission, all controlling agencies | declarations + `ogaPermits` per declaration | **present** | +| SW2 | Declaring-agent model (submission on behalf of a principal) | TradeNet DA functions | `stakeholderMandates`, principal/acting-agent on declarations | **present** (added in the parity work) | +| SW3 | Amendment / cancellation / refund of a lodged declaration | TradeNet: amendment, cancellation **and** refund applications | `declarationAmendments` (request/review only); `drawback` covers duty drawback on re-export | **partial** — no cancellation, no overpayment refund | +| SW4 | Formalities catalogue: which non-customs permits a consignment actually needs | CSW-CERTEX's core purpose — automatic verification of non-customs formalities against declaration data at clearance | nothing; a grep for `requiredPermits`/`permitRequirement` across `server/` returns one unrelated type field in `server/_core/polyglotClients.ts:178` | **absent** | +| SW5 | Prohibitions & restrictions register keyed by HS code / origin / regime | standard in all three | no register; only incidental mentions in `vision.ts`, `auditEngine.ts` | **absent** | +| SW6 | Tariff quotas / quantitative restrictions with balance drawdown | ASYCUDA, EU | none | **absent** | +| SW7 | Right of appeal against a customs decision (TFA Art. 4) | all three; a treaty obligation | none — no appeals router or table | **absent** | +| SW8 | Advance rulings | TFA Art. 3 | `advanceRuling` (submit, issue decision) | **present**, but rulings are not binding on later assessment and are not published | +| SW9 | Machine-to-machine channel for approved trader front-ends | TradeNet front-end providers; NTP API/SFTP | `devPortal` (scoped API keys, rate limits, sandbox) | **present** | +| SW10 | Standards-based messaging (WCO Data Model, EDIFACT CUSDEC/CUSRES) | all three | `ncsNrs.ingestEDI` accepts EDIFACT, but the mapping lives behind an external gateway, not in this repo | **partial / unverifiable here** | +| SW11 | Cross-border exchange with partner administrations | NTP↔foreign customs; CSW-CERTEX; ASYCUDA regional | `aseanSw` adapter exists and now honestly reports unavailable (the fabricated data was removed in the audit remediation) | **surface only** | +| SW12 | AEO / trusted trader | all three | `aeo`, `aeoRenewals`, MRA partners | **present** | +| SW13 | Risk management, valuation, origin, post-clearance audit | all three | `riskModel`, `valuation`, `wtoValuation`, `rulesOfOrigin`, `postAudit` | **present, ahead** | +| SW14 | Payment, ledger, reconciliation | GIRO / banking APIs | Mojaloop + TigerBeetle double-entry, fail-closed after remediation | **present, ahead** | + +So on the classic single-window core this platform is at or ahead of the reference set. The gaps are +concentrated in the **regulatory-obligation layer** — SW4, SW5, SW6, SW7 — plus SW3's missing halves. +That is a coherent pattern: the platform automates the *customs* decision well and has almost nothing +that tells it what the *law* requires for a given consignment, or that gives a trader recourse when +the decision goes against them. + +### A confirmed defect found while comparing + +`server/businessRules.ts:517-560` presents itself as a live exchange-rate service: + +``` +// ─── 11. Live Exchange Rate Fetcher (R2 FIX) ───────────────────────────────── +// Replaces the previously hardcoded USD conversion rates with a live fetch +// from the European Central Bank (ECB) XML feed — free, no API key required. +// Falls back to a conservative in-memory cache on network failure. + +const FALLBACK_RATES_TO_EUR: Record = { + USD: 1.08, GBP: 0.86, GHS: 16.5, RWF: 1430, KES: 140, NGN: 1680, ... +``` + +The ECB daily reference feed does not publish NGN, GHS, RWF, KES, XOF or XAF. Fetched just now, the +feed carries 29 currencies: USD JPY CZK DKK GBP HUF PLN RON SEK CHF ISK NOK TRY AUD BRL CAD CNY HKD +IDR ILS INR KRW MXN MYR NZD PHP SGD THB ZAR — `grep -c NGN` returns `0`. + +So for **every** currency this platform actually operates in, the "live" fetch always misses and the +hardcoded constant is always used. A duty assessment in Nigeria is being computed at a rate hardcoded +in source in mid-2026, labelled as live, with no staleness surfaced to the officer or the trader — and +for NGN the legally correct source is the CBN rate, which the codebase already knows about +(`ncsNrs.updateCBNRate`) and does not consult here. Same family as the audit's fabricated-success +findings: the number is plausible, wrong, and presented as authoritative. + +## 3. Gaps to close + +- **SW4 formalities catalogue.** A register of non-customs formalities keyed by HS code, origin, + destination and regime, which derives the required permits at submission, routes to the right + agencies, and blocks release while a required formality is unsatisfied. Mirrors CSW-CERTEX: the + permit is *verified against the declaration data*, not merely attached to it — quantity decremented, + validity checked, consignee matched. +- **SW5 prohibitions & restrictions.** Prohibited and restricted goods keyed by classification and + origin, evaluated at submission, with the legal instrument cited on refusal. +- **SW6 tariff quotas.** Quota periods with balances, allocation on a first-come basis, and drawdown + that cannot go negative or double-spend under concurrency. +- **SW7 appeals.** A right-of-appeal workflow against a customs decision (assessment, seizure, + classification, refusal), with statutory deadlines, independent reviewer separation from the + original decision-maker, and an outcome that can actually reverse the decision it appeals. +- **SW3 completion.** Declaration cancellation, and refund of overpaid duty, distinct from drawback. +- **SW8 hardening.** Advance rulings become binding: a ruling on the same HS code/goods for the same + trader is applied to later assessment, and diverging from it requires a recorded justification. +- **FX fail-closed.** Stated in section 2. No authoritative rate for the valuation date means the + assessment refuses, using the CBN rate as the Nigerian source of truth. + +## 4. The six innovations for this track + +**J1 — Formality-aware clearance graph.** Compute, at submission, the exact set of formalities a +consignment needs (SW4/SW5/SW6 evaluated together) and expose it as a dependency graph the trader can +see: what is required, what is satisfied, what is blocking, and which legal instrument imposes it. +Reference platforms tell a trader their declaration was rejected; this tells them the specific +unsatisfied obligation before they submit. + +**J2 — Quota drawdown on the double-entry ledger.** Tariff-quota balances are held as ledger accounts +rather than a counter column, so allocation is atomic, auditable and impossible to double-spend under +concurrent submissions — the same property the platform already relies on for money. Quota fraud in +practice *is* concurrency fraud, and a `UPDATE ... SET balance = balance - n` column loses that race. + +**J3 — Binding advance rulings enforced at assessment time.** A ruling is not a document, it is a +constraint: when a declaration matches an issued ruling's scope, the assessment must follow it, and an +officer departing from it must record a justification that is itself appealable. Turns TFA Art. 3 from +a filing cabinet into a control. + +**J4 — Appeal that reverses through the ledger.** An upheld appeal against an assessment issues the +corrective ledger entries (refund, quota restoration, seizure release) as part of the appeal outcome, +rather than leaving a human to remember. Independence is enforced structurally: the reviewer cannot be +the original decision-maker, and the platform's insider-threat surface already gives us the primitives. + +**J5 — Staleness-aware valuation.** Every assessment records the exchange rate it used, its source, +and the age of that rate; an assessment computed on a rate older than its permitted window is refused +rather than silently produced. The FX defect above becomes structurally impossible instead of +individually patched. + +**J6 — Regulatory-change replay.** Formalities, P&R entries, quotas and tariff rates are all +effective-dated. That makes it possible to ask what a past declaration *would* have been assessed at +under today's rules, and — more usefully for a revenue authority — to quantify the exposure of a rule +change before enacting it, over real historical declarations rather than a projection. diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts index df51d1cd..238cfc9d 100644 --- a/server/regulatory.behavior.test.ts +++ b/server/regulatory.behavior.test.ts @@ -4,13 +4,18 @@ import { and, eq, inArray, isNull } from "drizzle-orm"; import { appRouter } from "./routers"; import { getDb } from "./db"; import type { TrpcContext } from "./_core/context"; -import { evaluateDeclarationRegulations } from "./regulatory"; +import { + assertDeclarationFormalitiesSatisfied, + evaluateDeclarationRegulations, +} from "./routers/regulatory"; import { declarations, declarationFormalities, ogaPermits, regulatoryFormalities, regulatoryRestrictions, + stakeholderRegistrations, + stakeholderMandates, tariffQuotaAllocations, tariffQuotas, } from "../drizzle/schema"; @@ -25,7 +30,10 @@ vi.mock("./routers/ledger", async (importOriginal) => { return { ...actual, tbBridgeAvailable: ledgerMocks.available, tbFetch: ledgerMocks.fetch }; }); -function caller(role: "user" | "admin" | "customs_officer" = "user", userId = 1) { +function caller( + role: "user" | "admin" | "customs_officer" | "finance" = "user", + userId = 1, +) { const context: TrpcContext = { user: { id: userId, @@ -56,14 +64,22 @@ const created = { restrictions: [] as number[], quotas: [] as number[], permits: [] as number[], + registrations: [] as number[], + mandates: [] as number[], }; -async function declaration(hsCode: string, createdAt = new Date()) { +async function declaration( + hsCode: string, + createdAt = new Date(), + options: { traderId?: number; principalId?: number; actingAgentId?: number } = {}, +) { const db = await database(); const [row] = await db.insert(declarations).values({ declarationNumber: `REG-${randomUUID().slice(0, 20)}`, ucr: `REG-UCR-${randomUUID()}`, - traderId: 1, + traderId: options.traderId ?? 1, + principalId: options.principalId, + actingAgentId: options.actingAgentId, declarationType: "import", hsCode, countryOfOrigin: "GH", @@ -86,11 +102,15 @@ afterEach(async () => { if (created.formalities.length) await db.delete(regulatoryFormalities).where(inArray(regulatoryFormalities.id, created.formalities)); if (created.restrictions.length) await db.delete(regulatoryRestrictions).where(inArray(regulatoryRestrictions.id, created.restrictions)); if (created.quotas.length) await db.delete(tariffQuotas).where(inArray(tariffQuotas.id, created.quotas)); + if (created.registrations.length) await db.delete(stakeholderRegistrations).where(inArray(stakeholderRegistrations.id, created.registrations)); + if (created.mandates.length) await db.delete(stakeholderMandates).where(inArray(stakeholderMandates.id, created.mandates)); + created.mandates.length = 0; created.declarations.length = 0; created.formalities.length = 0; created.restrictions.length = 0; created.quotas.length = 0; created.permits.length = 0; + created.registrations.length = 0; }); describe.sequential("regulatory obligation behaviour", () => { @@ -227,6 +247,33 @@ describe.sequential("regulatory obligation behaviour", () => { created.quotas.push(quota.id); const first = await declaration("777700"); const second = await declaration("777701"); + const agentDeclaration = await declaration("777702", new Date(), { actingAgentId: 2 }); + const [mandate] = await db.insert(stakeholderMandates).values({ + referenceNumber: `REG-MANDATE-${randomUUID().slice(0, 12)}`, + principalUserId: 1, + agentUserId: 2, + validFrom: new Date(now.getTime() - 60_000), + validUntil: new Date(now.getTime() + 60_000), + }).returning(); + created.mandates.push(mandate.id); + const [registration] = await db.insert(stakeholderRegistrations).values({ + referenceNumber: `REG-AGENT-${randomUUID().slice(0, 12)}`, + userId: 2, + stakeholderType: "freight_forwarder", + organizationName: "Regulatory Behaviour Agent", + country: "GH", + licenseExpiresAt: new Date(now.getTime() + 60_000), + status: "approved", + approvedBy: 4, + approvedAt: now, + }).returning(); + created.registrations.push(registration.id); + await expect(caller("finance", 2).regulatory.allocateQuota({ + quotaId: quota.id, declarationId: first.id, quantity: "1", + })).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(caller("user", 2).regulatory.allocateQuota({ + quotaId: quota.id, declarationId: agentDeclaration.id, quantity: "1", + })).resolves.toMatchObject({ declarationId: agentDeclaration.id }); const results = await Promise.allSettled([ caller().regulatory.allocateQuota({ quotaId: quota.id, declarationId: first.id, quantity: "6" }), caller().regulatory.allocateQuota({ quotaId: quota.id, declarationId: second.id, quantity: "6" }), @@ -237,12 +284,29 @@ describe.sequential("regulatory obligation behaviour", () => { eq(tariffQuotaAllocations.quotaId, quota.id), isNull(tariffQuotaAllocations.reversedAt), )); - expect(allocations).toHaveLength(1); - expect(Number(allocations[0]?.quantity)).toBe(6); + expect(allocations).toHaveLength(2); + expect(allocations.reduce((sum, allocation) => sum + Number(allocation.quantity), 0)).toBe(7); ledgerMocks.available.mockResolvedValue(false); - const outage = await declaration("777702"); + const outage = await declaration("777703"); await expect(caller().regulatory.allocateQuota({ quotaId: quota.id, declarationId: outage.id, quantity: "1", })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); + + it("re-evaluates effective regulations at clearance instead of trusting stale rows", async () => { + const db = await database(); + const declarationRow = await declaration("888800"); + const [formality] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "8888", + agencyCode: "OGA-CLEAR", + agencyName: "Clearance Agency", + permitType: "CLEARANCE", + legalInstrument: "Instrument CLEARANCE", + validFrom: new Date(Date.now() - 60_000), + createdBy: 4, + }).returning(); + created.formalities.push(formality.id); + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)) + .rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); }); diff --git a/server/routers.ts b/server/routers.ts index bc288918..3bcc6f5d 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -121,7 +121,7 @@ import { tradeAnalyticsRouter } from "./routers/tradeAnalytics"; import { ncsNrsRouter } from "./routers/ncsNrs"; import { complianceReportingRouter } from "./routers/complianceReporting"; -import { regulatoryRouter } from "./regulatory"; +import { regulatoryRouter } from "./routers/regulatory"; export const appRouter = router({ complianceReporting: complianceReportingRouter, diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index 074553ec..84e4d228 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -19,7 +19,7 @@ import { assertValidTransition, assignRiskLane, validateHsCode, checkPermitValid import { indexDeclaration, searchDeclarations } from "../_core/opensearch"; import { scoreDeclarationRisk, validateDeclarationWithEngine, getCargoPosition } from "../_core/polyglotClients"; import { resolveActingPrincipal, requireDeclarationActor } from "../_core/mandateAuthorization"; -import { assertDeclarationFormalitiesSatisfied, evaluateDeclarationRegulations } from "../regulatory"; +import { assertDeclarationFormalitiesSatisfied, evaluateDeclarationRegulations } from "./regulatory"; // Generate a unique declaration number: TG-YYYY-XXXXXXXX function generateDeclarationNumber(): string { diff --git a/server/regulatory.ts b/server/routers/regulatory.ts similarity index 80% rename from server/regulatory.ts rename to server/routers/regulatory.ts index 1d6134a0..99c2111b 100644 --- a/server/regulatory.ts +++ b/server/routers/regulatory.ts @@ -11,8 +11,8 @@ import { sql, } from "drizzle-orm"; import { z } from "zod"; -import { protectedProcedure, router } from "./_core/trpc"; -import { getDb, logAuditEvent } from "./db"; +import { protectedProcedure, router } from "../_core/trpc"; +import { getDb, logAuditEvent } from "../db"; import { declarations, declarationFormalities, @@ -21,9 +21,10 @@ import { regulatoryRestrictions, tariffQuotaAllocations, tariffQuotas, -} from "../drizzle/schema"; -import { tbBridgeAvailable, tbFetch } from "./routers/ledger"; -import { acquireLock, releaseLock } from "./_core/distributedLock"; +} from "../../drizzle/schema"; +import { tbBridgeAvailable, tbFetch } from "./ledger"; +import { acquireLock, releaseLock } from "../_core/distributedLock"; +import { requireDeclarationActor } from "../_core/mandateAuthorization"; type RegulatoryDb = NonNullable>>; type RegulatoryDateInput = Date | string; @@ -118,7 +119,7 @@ type ObligationInput = { }; async function permitSatisfies( - db: RegulatoryDb, + db: Pick, input: ObligationInput, obligation: { agencyCode: string | null; @@ -161,12 +162,21 @@ async function permitSatisfies( return null; } -async function buildObligations(db: RegulatoryDb, input: ObligationInput, consumePermits: boolean) { - const { formalities, restrictions, quotas } = await matchingRegisters(db, input); - const prohibitions = restrictions.filter((entry) => entry.restrictionType === "prohibition"); - const requiredRestrictions = restrictions.filter((entry) => entry.restrictionType === "restriction"); - const obligations = [ - ...formalities.map((entry) => ({ +type MatchingRegisters = Awaited>; +type RegisterObligation = { + formalityId: number | null; + restrictionId: number | null; + agencyCode: string | null; + agencyName: string | null; + permitType: string | null; + legalInstrument: string; + requiredQuantity: string; +}; + +function registerObligations(registers: MatchingRegisters): RegisterObligation[] { + const requiredRestrictions = registers.restrictions.filter((entry) => entry.restrictionType === "restriction"); + return [ + ...registers.formalities.map((entry) => ({ formalityId: entry.id, restrictionId: null, agencyCode: entry.agencyCode, @@ -185,57 +195,105 @@ async function buildObligations(db: RegulatoryDb, input: ObligationInput, consum requiredQuantity: entry.requiredQuantity, })), ]; +} + +async function evaluateObligations( + db: Pick, + input: ObligationInput, + registers: MatchingRegisters, + consumePermits: boolean, +) { + const obligations = registerObligations(registers); const evaluated = []; for (const obligation of obligations) { const permit = await permitSatisfies(db, input, obligation, consumePermits); evaluated.push({ ...obligation, permit }); } - return { prohibitions, obligations: evaluated, quotas }; + return { obligations: evaluated }; +} + +async function buildObligations(db: RegulatoryDb, input: ObligationInput) { + const registers = await matchingRegisters(db, input); + const evaluated = await evaluateObligations(db, input, registers, false); + return { ...evaluated, ...registers }; } export async function evaluateDeclarationRegulations(input: ObligationInput): Promise { const db = await requireRegulatoryDb(); - const preview = await buildObligations(db, input, false); - const prohibition = preview.prohibitions[0]; + const registers = await matchingRegisters(db, input); + const prohibition = registers.restrictions.find((entry) => entry.restrictionType === "prohibition"); if (prohibition) { throw new TRPCError({ code: "FORBIDDEN", message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, }); } - const result = await buildObligations(db, input, true); - if (!input.declarationId || result.obligations.length === 0) return; - await db.insert(declarationFormalities).values(result.obligations.map((obligation) => ({ - declarationId: input.declarationId!, - formalityId: obligation.formalityId, - restrictionId: obligation.restrictionId, - agencyCode: obligation.agencyCode, - agencyName: obligation.agencyName, - permitType: obligation.permitType, - legalInstrument: obligation.legalInstrument, - requiredQuantity: obligation.requiredQuantity, - satisfiedQuantity: obligation.permit ? obligation.requiredQuantity : "0", - satisfiedByPermitId: obligation.permit?.id ?? null, - status: obligation.permit ? "satisfied" as const : "required" as const, - evaluatedAt: input.at, - }))); + if (!input.declarationId || registerObligations(registers).length === 0) return; + await db.transaction(async (tx) => { + const result = await evaluateObligations(tx, input, registers, true); + await tx.insert(declarationFormalities).values(result.obligations.map((obligation) => ({ + declarationId: input.declarationId!, + formalityId: obligation.formalityId, + restrictionId: obligation.restrictionId, + agencyCode: obligation.agencyCode, + agencyName: obligation.agencyName, + permitType: obligation.permitType, + legalInstrument: obligation.legalInstrument, + requiredQuantity: obligation.requiredQuantity, + satisfiedQuantity: obligation.permit ? obligation.requiredQuantity : "0", + satisfiedByPermitId: obligation.permit?.id ?? null, + status: obligation.permit ? "satisfied" as const : "required" as const, + evaluatedAt: input.at, + }))); + }); } export async function assertDeclarationFormalitiesSatisfied(declarationId: number): Promise { const db = await requireRegulatoryDb(); - const rows = await db.select().from(declarationFormalities) - .where(eq(declarationFormalities.declarationId, declarationId)); - if (rows.some((row) => row.status !== "satisfied")) { + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, declarationId)).limit(1); + if (!declaration) { + throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found." }); + } + const input: ObligationInput = { + declarationId, + importerId: declaration.principalId ?? declaration.traderId, + hsCode: declaration.hsCode ?? "", + origin: declaration.countryOfOrigin ?? "", + destination: declaration.countryOfDestination ?? undefined, + regime: declaration.declarationType, + quantity: String(declaration.numberOfPackages ?? 1), + at: declaration.submittedAt ?? declaration.createdAt, + }; + const registers = await matchingRegisters(db, input); + const prohibition = registers.restrictions.find((entry) => entry.restrictionType === "prohibition"); + if (prohibition) { throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Required regulatory formalities are not satisfied.", + code: "FORBIDDEN", + message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, }); } + const rows = await db.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationId)); + const obligations = registerObligations(registers); + for (const obligation of obligations) { + const persisted = rows.find((row) => + (obligation.formalityId !== null && row.formalityId === obligation.formalityId) || + (obligation.restrictionId !== null && row.restrictionId === obligation.restrictionId), + ); + const satisfied = persisted?.status === "satisfied" || + (await permitSatisfies(db, input, obligation, false)) !== null; + if (!satisfied) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Required regulatory formality is unsatisfied under ${obligation.legalInstrument}.`, + }); + } + } } async function clearanceGraph(input: ObligationInput) { const db = await requireRegulatoryDb(); - const result = await buildObligations(db, input, false); + const result = await buildObligations(db, input); const graph = result.obligations.map((obligation) => ({ required: true as const, satisfied: obligation.permit !== null, @@ -272,13 +330,15 @@ async function clearanceGraph(input: ObligationInput) { } return { registersAvailable: true as const, - prohibited: result.prohibitions.map((entry) => ({ - description: entry.description, - legalInstrument: entry.legalInstrument, - })), + prohibited: result.restrictions + .filter((entry) => entry.restrictionType === "prohibition") + .map((entry) => ({ + description: entry.description, + legalInstrument: entry.legalInstrument, + })), obligations: [...graph, ...quotaGraph], - blocking: result.prohibitions.length > 0 || graph.some((entry) => entry.blocking) || quotaGraph.some((entry) => entry.blocking), - cleared: false as const, + blocking: result.restrictions.some((entry) => entry.restrictionType === "prohibition") || + graph.some((entry) => entry.blocking) || quotaGraph.some((entry) => entry.blocking), }; } @@ -412,8 +472,8 @@ export const regulatoryRouter = router({ if (!quota) throw new TRPCError({ code: "NOT_FOUND", message: "Tariff quota not found." }); 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 (ctx.user.role === "user" && declaration.traderId !== ctx.user.id) { - throw new TRPCError({ code: "FORBIDDEN", message: "You may only allocate quota for your own declaration." }); + if (ctx.user.role !== "admin" && ctx.user.role !== "customs_officer") { + await requireDeclarationActor(declaration, ctx.user); } const at = declaration.submittedAt ?? declaration.createdAt; if (!activeAt(quota.validFrom, quota.validUntil, at) || at < quota.periodStart || at > quota.periodEnd) { diff --git a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go index b49d036c..09c4730f 100644 --- a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go +++ b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go @@ -15,6 +15,7 @@ func TestPostTransferIsIdempotentByKey(t *testing.T) { if err := store.CreateAccount(&Account{ID: "revenue-test", Ledger: 1, Currency: "GHS"}); err != nil { t.Fatal(err) } + store.accounts["trader-test"].CreditsPosted = decimal.NewFromInt(100) const key = "excise:order:123" first := &Transfer{ @@ -56,6 +57,7 @@ func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { if err := store.CreateAccount(&Account{ID: "revenue-race", Ledger: 1, Currency: "GHS"}); err != nil { t.Fatal(err) } + store.accounts["trader-race"].CreditsPosted = decimal.NewFromInt(100) const key = "excise:race:123" var wg sync.WaitGroup @@ -85,3 +87,28 @@ func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { t.Fatalf("expected one stored transfer after concurrent replay, got %d", len(transfers)) } } + +func TestPostTransferRejectsDebitOverdraft(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ID: "trader-overdraft", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "revenue-overdraft", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + store.accounts["trader-overdraft"].CreditsPosted = decimal.NewFromInt(50) + + err := store.PostTransfer(&Transfer{ + ID: "transfer-overdraft", + DebitAccountID: "trader-overdraft", + CreditAccountID: "revenue-overdraft", + Amount: decimal.NewFromInt(51), + Currency: "GHS", + }) + if err == nil { + t.Fatal("expected overdraft to be rejected") + } + if transfers := store.GetTransfersByAccount("trader-overdraft", 10); len(transfers) != 0 { + t.Fatalf("expected no transfer after overdraft rejection, got %d", len(transfers)) + } +} diff --git a/services/go/tigerbeetle-bridge/cmd/main.go b/services/go/tigerbeetle-bridge/cmd/main.go index eac4379f..53d4a09e 100644 --- a/services/go/tigerbeetle-bridge/cmd/main.go +++ b/services/go/tigerbeetle-bridge/cmd/main.go @@ -215,6 +215,9 @@ func (s *Store) PostTransfer(t *Transfer) error { if !ok { return fmt.Errorf("credit account %s not found", t.CreditAccountID) } + if debit.Currency != t.Currency || credit.Currency != t.Currency { + return fmt.Errorf("transfer currency %s does not match both account currencies", t.Currency) + } now := time.Now().UTC() t.CreatedAt = now @@ -222,6 +225,10 @@ func (s *Store) PostTransfer(t *Transfer) error { switch t.Flag { case FlagPending: + available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) + if available.LessThan(t.Amount) { + return fmt.Errorf("insufficient available balance in debit account %s", debit.ID) + } debit.DebitsPending = debit.DebitsPending.Add(t.Amount) credit.CreditsPending = credit.CreditsPending.Add(t.Amount) t.Status = "PENDING" @@ -232,6 +239,9 @@ func (s *Store) PostTransfer(t *Transfer) error { if !ok { return fmt.Errorf("pending transfer %s not found", t.PendingID) } + if t.Amount.GreaterThan(pending.Amount) { + return fmt.Errorf("posted amount exceeds pending transfer %s", t.PendingID) + } // Move from pending to posted pendingDebit := s.accounts[pending.DebitAccountID] pendingCredit := s.accounts[pending.CreditAccountID] @@ -262,6 +272,10 @@ func (s *Store) PostTransfer(t *Transfer) error { default: // Immediate (non-pending) transfer + available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) + if available.LessThan(t.Amount) { + return fmt.Errorf("insufficient available balance in debit account %s", debit.ID) + } debit.DebitsPosted = debit.DebitsPosted.Add(t.Amount) credit.CreditsPosted = credit.CreditsPosted.Add(t.Amount) t.Status = "POSTED" From 8031591608f7e73385d8becb154ab8ab2e5f98d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:12:45 +0000 Subject: [PATCH 15/17] fix: wire quota ledger accounts Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 1 + server/regulatory.behavior.test.ts | 82 ++++++++++- server/routers/regulatory.ts | 96 ++++++++++++- .../cmd/idempotency_test.go | 135 +++++++++++++++++- services/go/tigerbeetle-bridge/cmd/main.go | 64 +++++---- 5 files changed, 345 insertions(+), 33 deletions(-) diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 38f9c8c0..fc8aa274 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -372,6 +372,7 @@ export const tariffQuotas = pgTable("tariff_quotas", { totalQuantity: decimal("total_quantity", { precision: 18, scale: 3 }).notNull(), quantityUnit: varchar("quantity_unit", { length: 32 }).notNull(), ledgerAccountId: varchar("ledger_account_id", { length: 128 }).notNull(), + allocatedLedgerAccountId: varchar("allocated_ledger_account_id", { length: 128 }).notNull(), legalInstrument: text("legal_instrument").notNull(), validFrom: timestamp("valid_from").notNull(), validUntil: timestamp("valid_until"), diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts index 238cfc9d..9fe39e0b 100644 --- a/server/regulatory.behavior.test.ts +++ b/server/regulatory.behavior.test.ts @@ -22,7 +22,13 @@ import { const ledgerMocks = vi.hoisted(() => ({ available: vi.fn(async () => true), - fetch: vi.fn(async () => ({ id: `regulatory-transfer-${randomUUID()}` })), + fetch: vi.fn(async (url: string, options?: RequestInit) => { + if (url === "/api/ledger/accounts") { + const body = JSON.parse(String(options?.body)) as { id: string }; + return { id: body.id }; + } + return { id: `regulatory-transfer-${randomUUID()}` }; + }), })); vi.mock("./routers/ledger", async (importOriginal) => { @@ -242,6 +248,7 @@ describe.sequential("regulatory obligation behaviour", () => { quotaCode: `Q-${randomUUID()}`, hsCodePrefix: "7777", origin: "GH", regime: "import", periodStart: new Date(now.getTime() - 60_000), periodEnd: new Date(now.getTime() + 60_000), totalQuantity: "10", quantityUnit: "kg", ledgerAccountId: "quota-ledger-test", + allocatedLedgerAccountId: "quota-allocated-test", legalInstrument: "Instrument QUOTA", validFrom: new Date(now.getTime() - 60_000), createdBy: 4, }).returning(); created.quotas.push(quota.id); @@ -293,6 +300,79 @@ describe.sequential("regulatory obligation behaviour", () => { })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); }); + it("provisions platform-owned QTY ledger accounts and fails closed if unavailable", async () => { + const db = await database(); + const now = new Date(); + const quotaCode = `Q-CREATE-${randomUUID()}`; + const createdQuota = await caller("admin", 4).regulatory.createQuota({ + quotaCode, + hsCodePrefix: "7799", + origin: "GH", + regime: "import", + periodStart: new Date(now.getTime() - 60_000), + periodEnd: new Date(now.getTime() + 60_000), + totalQuantity: "12", + quantityUnit: "kg", + legalInstrument: "Instrument QUOTA-CREATE", + validFrom: new Date(now.getTime() - 60_000), + }); + created.quotas.push(createdQuota.id); + expect(createdQuota.ledgerAccountId).toMatch(/^quota-available-/); + expect(createdQuota.allocatedLedgerAccountId).toMatch(/^quota-allocated-/); + const accountBodies = ledgerMocks.fetch.mock.calls + .filter(([url]) => url === "/api/ledger/accounts") + .map(([url, options]) => { + expect(url).toBe("/api/ledger/accounts"); + return JSON.parse(String((options as RequestInit).body)) as Record; + }); + expect(accountBodies.find((body) => body.accountType === "QUOTA_ISSUANCE")).toMatchObject({ + currency: "QTY", + initialBalance: "12", + }); + expect(accountBodies.find((body) => body.accountType === "QUOTA_AVAILABLE")).toMatchObject({ + currency: "QTY", + debitsMustNotExceedCredits: true, + }); + expect(accountBodies.find((body) => body.accountType === "QUOTA_ALLOCATED")).toMatchObject({ currency: "QTY" }); + expect(ledgerMocks.fetch.mock.calls.some(([url, options]) => + url === "/api/ledger/transfers" && + JSON.parse(String((options as RequestInit).body)).idempotencyKey === `regulatory:quota:${quotaCode}:opening`, + )).toBe(true); + const quotaDeclaration = await declaration("779900"); + const allocation = await caller().regulatory.allocateQuota({ + quotaId: createdQuota.id, + declarationId: quotaDeclaration.id, + quantity: "3", + }); + const allocationBody = [...ledgerMocks.fetch.mock.calls] + .reverse() + .find(([url]) => url === "/api/ledger/transfers"); + expect(allocationBody).toBeDefined(); + expect(JSON.parse(String((allocationBody?.[1] as RequestInit).body))).toMatchObject({ + debitAccountId: createdQuota.ledgerAccountId, + creditAccountId: createdQuota.allocatedLedgerAccountId, + amount: "3", + currency: "QTY", + }); + expect(allocation.transferId).toBeTruthy(); + + ledgerMocks.available.mockResolvedValue(false); + const unavailableCode = `Q-UNAVAILABLE-${randomUUID()}`; + await expect(caller("admin", 4).regulatory.createQuota({ + quotaCode: unavailableCode, + hsCodePrefix: "7798", + periodStart: new Date(now.getTime() - 60_000), + periodEnd: new Date(now.getTime() + 60_000), + totalQuantity: "1", + quantityUnit: "kg", + legalInstrument: "Instrument QUOTA-UNAVAILABLE", + validFrom: new Date(now.getTime() - 60_000), + })).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + const [missing] = await db.select().from(tariffQuotas) + .where(eq(tariffQuotas.quotaCode, unavailableCode)); + expect(missing).toBeUndefined(); + }); + it("re-evaluates effective regulations at clearance instead of trusting stale rows", async () => { const db = await database(); const declarationRow = await declaration("888800"); diff --git a/server/routers/regulatory.ts b/server/routers/regulatory.ts index 99c2111b..e20070d3 100644 --- a/server/routers/regulatory.ts +++ b/server/routers/regulatory.ts @@ -1,4 +1,5 @@ import { TRPCError } from "@trpc/server"; +import { randomUUID } from "node:crypto"; import { and, asc, @@ -430,7 +431,6 @@ export const regulatoryRouter = router({ periodEnd: z.coerce.date(), totalQuantity: z.string().regex(/^\d+(\.\d{1,3})?$/), quantityUnit: z.string().min(1).max(32), - ledgerAccountId: z.string().min(1).max(128), legalInstrument: z.string().min(1), validFrom: z.coerce.date(), validUntil: z.coerce.date().optional(), @@ -438,9 +438,93 @@ export const regulatoryRouter = router({ .mutation(async ({ ctx, input }) => { requireAuthoringRole(ctx.user.role); const db = await requireRegulatoryDb(); - const [entry] = await db.insert(tariffQuotas).values({ ...input, createdBy: ctx.user.id }).returning(); - await logAuditEvent({ entityType: "declaration", entityId: entry.id, action: "tariff_quota_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: entry }); - return entry; + if (!(await tbBridgeAvailable())) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Ledger is unavailable; quota was not created.", + }); + } + try { + const accountSuffix = randomUUID(); + const issuanceAccount = await tbFetch<{ id?: string }>("/api/ledger/accounts", { + method: "POST", + body: JSON.stringify({ + id: `quota-issuance-${accountSuffix}`, + ledger: 1, + accountType: "QUOTA_ISSUANCE", + description: `Issuance source for quota ${input.quotaCode}`, + currency: "QTY", + initialBalance: input.totalQuantity, + }), + }); + if (!issuanceAccount.id) { + throw new Error("Ledger did not return the quota issuance account."); + } + const availableAccount = await tbFetch<{ id?: string }>("/api/ledger/accounts", { + method: "POST", + body: JSON.stringify({ + id: `quota-available-${accountSuffix}`, + ledger: 1, + accountType: "QUOTA_AVAILABLE", + description: `Available quantity for quota ${input.quotaCode}`, + currency: "QTY", + debitsMustNotExceedCredits: true, + }), + }); + if (!availableAccount.id) { + throw new Error("Ledger did not return the available quota account."); + } + const allocatedAccount = await tbFetch<{ id?: string }>("/api/ledger/accounts", { + method: "POST", + body: JSON.stringify({ + id: `quota-allocated-${accountSuffix}`, + ledger: 1, + accountType: "QUOTA_ALLOCATED", + description: `Allocated quantity for quota ${input.quotaCode}`, + currency: "QTY", + }), + }); + if (!allocatedAccount.id) { + throw new Error("Ledger did not return the allocated quota account."); + } + const openingTransfer = await tbFetch<{ id?: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + debitAccountId: issuanceAccount.id, + creditAccountId: availableAccount.id, + amount: input.totalQuantity, + currency: "QTY", + reference: input.quotaCode, + description: `Initial quantity for quota ${input.quotaCode}`, + idempotencyKey: `regulatory:quota:${input.quotaCode}:opening`, + }), + }); + if (!openingTransfer.id) { + throw new Error("Ledger did not return the quota opening transfer."); + } + const [entry] = await db.insert(tariffQuotas).values({ + ...input, + ledgerAccountId: availableAccount.id, + allocatedLedgerAccountId: allocatedAccount.id, + createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ + entityType: "declaration", + entityId: entry.id, + action: "tariff_quota_created", + actorId: ctx.user.id, + actorType: ctx.user.role, + newState: entry, + }); + return entry; + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Quota ledger accounts could not be provisioned.", + cause: error, + }); + } }), clearanceGraph: protectedProcedure @@ -508,7 +592,7 @@ export const regulatoryRouter = router({ method: "POST", body: JSON.stringify({ debitAccountId: quota.ledgerAccountId, - creditAccountId: `quota-allocation:${quota.id}:${input.declarationId}`, + creditAccountId: quota.allocatedLedgerAccountId, amount: input.quantity, currency: "QTY", reference: quota.quotaCode, @@ -546,7 +630,7 @@ export const regulatoryRouter = router({ const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { method: "POST", body: JSON.stringify({ - debitAccountId: `quota-allocation:${allocation.quotaId}:${allocation.declarationId}`, + debitAccountId: quota.allocatedLedgerAccountId, creditAccountId: quota.ledgerAccountId, amount: allocation.quantity, currency: "QTY", diff --git a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go index 09c4730f..64af87e1 100644 --- a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go +++ b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go @@ -90,7 +90,12 @@ func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { func TestPostTransferRejectsDebitOverdraft(t *testing.T) { store := NewStore() - if err := store.CreateAccount(&Account{ID: "trader-overdraft", Ledger: 1, Currency: "GHS"}); err != nil { + if err := store.CreateAccount(&Account{ + ID: "trader-overdraft", + Ledger: 1, + Currency: "GHS", + DebitsMustNotExceedCredits: true, + }); err != nil { t.Fatal(err) } if err := store.CreateAccount(&Account{ID: "revenue-overdraft", Ledger: 1, Currency: "GHS"}); err != nil { @@ -112,3 +117,131 @@ func TestPostTransferRejectsDebitOverdraft(t *testing.T) { t.Fatalf("expected no transfer after overdraft rejection, got %d", len(transfers)) } } + +func TestPostTransferOverdraftFlagAndQuotaReversal(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ + ID: "quota-available", + Ledger: 1, + Currency: "QTY", + DebitsMustNotExceedCredits: true, + CreditsPosted: decimal.NewFromInt(10), + }); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "quota-allocated", Ledger: 1, Currency: "QTY"}); err != nil { + t.Fatal(err) + } + + if err := store.PostTransfer(&Transfer{ + ID: "quota-allocation", + DebitAccountID: "quota-available", + CreditAccountID: "quota-allocated", + Amount: decimal.NewFromInt(6), + Currency: "QTY", + }); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(&Transfer{ + ID: "quota-overallocation", + DebitAccountID: "quota-available", + CreditAccountID: "quota-allocated", + Amount: decimal.NewFromInt(5), + Currency: "QTY", + }); err == nil { + t.Fatal("expected quota overdraft to be rejected") + } + available, _ := store.GetAccount("quota-available") + if !available.Balance().Equal(decimal.NewFromInt(4)) { + t.Fatalf("expected available balance to remain 4 after rejection, got %s", available.Balance()) + } + + if err := store.PostTransfer(&Transfer{ + ID: "quota-reversal", + DebitAccountID: "quota-allocated", + CreditAccountID: "quota-available", + Amount: decimal.NewFromInt(6), + Currency: "QTY", + }); err != nil { + t.Fatal(err) + } + available, _ = store.GetAccount("quota-available") + if !available.Balance().Equal(decimal.NewFromInt(10)) { + t.Fatalf("expected reversal to restore 10 QTY, got %s", available.Balance()) + } + if err := store.PostTransfer(&Transfer{ + ID: "quota-reallocation", + DebitAccountID: "quota-available", + CreditAccountID: "quota-allocated", + Amount: decimal.NewFromInt(4), + Currency: "QTY", + }); err != nil { + t.Fatal(err) + } +} + +func TestPostTransferDefaultAccountAllowsDutyDebit(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ID: "trader-duty", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "revenue-duty", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(&Transfer{ + ID: "duty-payment", + DebitAccountID: "trader-duty", + CreditAccountID: "revenue-duty", + Amount: decimal.NewFromInt(25), + Currency: "GHS", + }); err != nil { + t.Fatalf("ordinary money account should allow duty debit: %v", err) + } +} + +func TestPostTransferAlwaysChecksCurrencyAndPendingAmount(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ID: "currency-debit", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "currency-credit", Ledger: 1, Currency: "USD"}); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(&Transfer{ + ID: "currency-mismatch", + DebitAccountID: "currency-debit", + CreditAccountID: "currency-credit", + Amount: decimal.NewFromInt(1), + Currency: "GHS", + }); err == nil { + t.Fatal("expected mismatched currency to be rejected") + } + + if err := store.CreateAccount(&Account{ID: "pending-debit", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.CreateAccount(&Account{ID: "pending-credit", Ledger: 1, Currency: "GHS"}); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(&Transfer{ + ID: "pending-transfer", + DebitAccountID: "pending-debit", + CreditAccountID: "pending-credit", + Amount: decimal.NewFromInt(10), + Currency: "GHS", + Flag: FlagPending, + }); err != nil { + t.Fatal(err) + } + if err := store.PostTransfer(&Transfer{ + ID: "pending-overpost", + DebitAccountID: "pending-credit", + CreditAccountID: "pending-debit", + Amount: decimal.NewFromInt(11), + Currency: "GHS", + Flag: FlagPostPendingTransfer, + PendingID: "pending-transfer", + }); err == nil { + t.Fatal("expected pending transfer over-post to be rejected") + } +} diff --git a/services/go/tigerbeetle-bridge/cmd/main.go b/services/go/tigerbeetle-bridge/cmd/main.go index 53d4a09e..e68ed4b6 100644 --- a/services/go/tigerbeetle-bridge/cmd/main.go +++ b/services/go/tigerbeetle-bridge/cmd/main.go @@ -89,17 +89,18 @@ const ( // Account represents a TigerBeetle account (128-bit ID stored as hex string). type Account struct { - ID string `json:"id"` - Ledger uint32 `json:"ledger"` - Code uint16 `json:"code"` - AccountType AccountType `json:"accountType"` - Description string `json:"description"` - Currency string `json:"currency"` - DebitsPosted decimal.Decimal `json:"debitsPosted"` - CreditsPosted decimal.Decimal `json:"creditsPosted"` - DebitsPending decimal.Decimal `json:"debitsPending"` - CreditsPending decimal.Decimal `json:"creditsPending"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Ledger uint32 `json:"ledger"` + Code uint16 `json:"code"` + AccountType AccountType `json:"accountType"` + Description string `json:"description"` + Currency string `json:"currency"` + DebitsMustNotExceedCredits bool `json:"debitsMustNotExceedCredits"` + DebitsPosted decimal.Decimal `json:"debitsPosted"` + CreditsPosted decimal.Decimal `json:"creditsPosted"` + DebitsPending decimal.Decimal `json:"debitsPending"` + CreditsPending decimal.Decimal `json:"creditsPending"` + CreatedAt time.Time `json:"createdAt"` } // Balance returns the net balance of an account (credits − debits). @@ -226,7 +227,7 @@ func (s *Store) PostTransfer(t *Transfer) error { switch t.Flag { case FlagPending: available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) - if available.LessThan(t.Amount) { + if debit.DebitsMustNotExceedCredits && available.LessThan(t.Amount) { return fmt.Errorf("insufficient available balance in debit account %s", debit.ID) } debit.DebitsPending = debit.DebitsPending.Add(t.Amount) @@ -273,7 +274,7 @@ func (s *Store) PostTransfer(t *Transfer) error { default: // Immediate (non-pending) transfer available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) - if available.LessThan(t.Amount) { + if debit.DebitsMustNotExceedCredits && available.LessThan(t.Amount) { return fmt.Errorf("insufficient available balance in debit account %s", debit.ID) } debit.DebitsPosted = debit.DebitsPosted.Add(t.Amount) @@ -363,12 +364,14 @@ func NewBridge(logger *zap.Logger) *TigerBeetleBridge { func (b *TigerBeetleBridge) handleCreateAccount(w http.ResponseWriter, r *http.Request) { var req struct { - ID string `json:"id"` - Ledger uint32 `json:"ledger"` - Code uint16 `json:"code"` - AccountType AccountType `json:"accountType"` - Description string `json:"description"` - Currency string `json:"currency"` + ID string `json:"id"` + Ledger uint32 `json:"ledger"` + Code uint16 `json:"code"` + AccountType AccountType `json:"accountType"` + Description string `json:"description"` + Currency string `json:"currency"` + DebitsMustNotExceedCredits bool `json:"debitsMustNotExceedCredits"` + InitialBalance string `json:"initialBalance,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) @@ -383,13 +386,24 @@ func (b *TigerBeetleBridge) handleCreateAccount(w http.ResponseWriter, r *http.R if req.Ledger == 0 { req.Ledger = 1 } + initialBalance := decimal.Zero + if req.InitialBalance != "" { + parsed, parseErr := decimal.NewFromString(req.InitialBalance) + if parseErr != nil || parsed.IsNegative() { + jsonError(w, "initialBalance must be a non-negative decimal", http.StatusBadRequest) + return + } + initialBalance = parsed + } acct := &Account{ - ID: req.ID, - Ledger: req.Ledger, - Code: req.Code, - AccountType: req.AccountType, - Description: req.Description, - Currency: req.Currency, + ID: req.ID, + Ledger: req.Ledger, + Code: req.Code, + AccountType: req.AccountType, + Description: req.Description, + Currency: req.Currency, + DebitsMustNotExceedCredits: req.DebitsMustNotExceedCredits, + CreditsPosted: initialBalance, } if err := b.store.CreateAccount(acct); err != nil { jsonError(w, err.Error(), http.StatusConflict) From f837ac66b20ed168a19503f00ecbd94140c43e68 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:14:44 +0000 Subject: [PATCH 16/17] fix: version quota allocation retries Co-Authored-By: Patrick Munis --- server/regulatory.behavior.test.ts | 55 +++++++++++++++++++++++++++++- server/routers/regulatory.ts | 11 +++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts index 9fe39e0b..ce29272b 100644 --- a/server/regulatory.behavior.test.ts +++ b/server/regulatory.behavior.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { appRouter } from "./routers"; import { getDb } from "./db"; import type { TrpcContext } from "./_core/context"; @@ -373,6 +373,59 @@ describe.sequential("regulatory obligation behaviour", () => { expect(missing).toBeUndefined(); }); + it("uses a new ledger idempotency attempt after quota reversal", async () => { + const db = await database(); + const now = new Date(); + const [quota] = await db.insert(tariffQuotas).values({ + quotaCode: `Q-RETRY-${randomUUID()}`, hsCodePrefix: "7766", origin: "GH", regime: "import", + periodStart: new Date(now.getTime() - 60_000), periodEnd: new Date(now.getTime() + 60_000), + totalQuantity: "10", quantityUnit: "kg", ledgerAccountId: "quota-retry-available", + allocatedLedgerAccountId: "quota-retry-allocated", + legalInstrument: "Instrument QUOTA-RETRY", validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.quotas.push(quota.id); + const declarationRow = await declaration("776600"); + + const first = await caller().regulatory.allocateQuota({ + quotaId: quota.id, declarationId: declarationRow.id, quantity: "3", + }); + await caller("admin", 4).regulatory.reverseQuotaAllocation({ allocationId: first.id }); + const second = await caller().regulatory.allocateQuota({ + quotaId: quota.id, declarationId: declarationRow.id, quantity: "3", + }); + + const transfers = ledgerMocks.fetch.mock.calls + .filter(([url]) => url === "/api/ledger/transfers") + .map(([, options]) => JSON.parse(String((options as RequestInit).body)) as { + debitAccountId: string; + creditAccountId: string; + amount: string; + idempotencyKey: string; + }); + const allocationTransfers = transfers.filter((transfer) => + transfer.debitAccountId === quota.ledgerAccountId && + transfer.creditAccountId === quota.allocatedLedgerAccountId, + ); + const reversalTransfers = transfers.filter((transfer) => + transfer.debitAccountId === quota.allocatedLedgerAccountId && + transfer.creditAccountId === quota.ledgerAccountId, + ); + expect(first.transferId).not.toBe(second.transferId); + expect(allocationTransfers).toHaveLength(2); + expect(new Set(allocationTransfers.map((transfer) => transfer.idempotencyKey))).toEqual(new Set([ + `regulatory:quota:${quota.id}:${declarationRow.id}:0`, + `regulatory:quota:${quota.id}:${declarationRow.id}:1`, + ])); + expect(reversalTransfers).toHaveLength(1); + + const ledgerAllocatedTotal = allocationTransfers.reduce((total, transfer) => total + Number(transfer.amount), 0) - + reversalTransfers.reduce((total, transfer) => total + Number(transfer.amount), 0); + const [activeSqlTotal] = await db.select({ + quantity: sql`coalesce(sum(${tariffQuotaAllocations.quantity}) filter (where ${tariffQuotaAllocations.reversedAt} is null), 0)`, + }).from(tariffQuotaAllocations).where(eq(tariffQuotaAllocations.quotaId, quota.id)); + expect(ledgerAllocatedTotal).toBe(Number(activeSqlTotal?.quantity ?? 0)); + }); + it("re-evaluates effective regulations at clearance instead of trusting stale rows", async () => { const db = await database(); const declarationRow = await declaration("888800"); diff --git a/server/routers/regulatory.ts b/server/routers/regulatory.ts index e20070d3..407e45d7 100644 --- a/server/routers/regulatory.ts +++ b/server/routers/regulatory.ts @@ -7,6 +7,7 @@ import { eq, gte, isNull, + isNotNull, lte, or, sql, @@ -579,6 +580,14 @@ export const regulatoryRouter = router({ isNull(tariffQuotaAllocations.reversedAt), )).limit(1); if (existing) return existing; + const [reversed] = await db.select({ + count: sql`count(*)`, + }).from(tariffQuotaAllocations).where(and( + eq(tariffQuotaAllocations.quotaId, quota.id), + eq(tariffQuotaAllocations.declarationId, input.declarationId), + isNotNull(tariffQuotaAllocations.reversedAt), + )); + const attempt = Number(reversed?.count ?? 0); if (!(await tbBridgeAvailable())) { throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Ledger is unavailable; quota was not allocated." }); } @@ -597,7 +606,7 @@ export const regulatoryRouter = router({ currency: "QTY", reference: quota.quotaCode, description: `Tariff quota allocation for declaration ${input.declarationId}`, - idempotencyKey: `regulatory:quota:${quota.id}:${input.declarationId}`, + idempotencyKey: `regulatory:quota:${quota.id}:${input.declarationId}:${attempt}`, }), }); const [allocation] = await db.insert(tariffQuotaAllocations).values({ From 6465a1bf5f673de5f7a576c16697561ed78ff211 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:43:48 +0000 Subject: [PATCH 17/17] fix: enforce regulatory clearance obligations Co-Authored-By: Patrick Munis --- drizzle/schema.ts | 4 + server/regulatory.behavior.test.ts | 104 +++++++++++++++++++ server/routers/regulatory.ts | 158 ++++++++++++++++++++--------- 3 files changed, 220 insertions(+), 46 deletions(-) diff --git a/drizzle/schema.ts b/drizzle/schema.ts index fc8aa274..0f2300d7 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -359,6 +359,10 @@ export const declarationFormalities = pgTable("declaration_formalities", { }, (t) => [ index("idx_decl_formality_declaration").on(t.declarationId), index("idx_decl_formality_status").on(t.status), + uniqueIndex("uq_decl_formality_formality").on(t.declarationId, t.formalityId) + .where(sql`${t.formalityId} IS NOT NULL`), + uniqueIndex("uq_decl_formality_restriction").on(t.declarationId, t.restrictionId) + .where(sql`${t.restrictionId} IS NOT NULL`), ]); export const tariffQuotas = pgTable("tariff_quotas", { diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts index ce29272b..831fab65 100644 --- a/server/regulatory.behavior.test.ts +++ b/server/regulatory.behavior.test.ts @@ -442,4 +442,108 @@ describe.sequential("regulatory obligation behaviour", () => { await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)) .rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); }); + + it("gates clearance on quota allocation and re-blocks after reversal", async () => { + const db = await database(); + const now = new Date(); + const [quota] = await db.insert(tariffQuotas).values({ + quotaCode: `Q-CLEAR-${randomUUID()}`, hsCodePrefix: "8899", origin: "GH", regime: "import", + periodStart: new Date(now.getTime() - 60_000), periodEnd: new Date(now.getTime() + 60_000), + totalQuantity: "5", quantityUnit: "kg", ledgerAccountId: "quota-clear-available", + allocatedLedgerAccountId: "quota-clear-allocated", + legalInstrument: "Instrument QUOTA-CLEAR", validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.quotas.push(quota.id); + const declarationRow = await declaration("889900"); + + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)) + .rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: expect.stringContaining("Instrument QUOTA-CLEAR") }); + const allocation = await caller().regulatory.allocateQuota({ + quotaId: quota.id, declarationId: declarationRow.id, quantity: "5", + }); + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)).resolves.toBeUndefined(); + await caller("admin", 4).regulatory.reverseQuotaAllocation({ allocationId: allocation.id }); + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)) + .rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: expect.stringContaining("Instrument QUOTA-CLEAR") }); + }); + + it("does not re-consume permits on resubmission and records new obligations", async () => { + const db = await database(); + const now = new Date(); + const declarationRow = await declaration("990000"); + const [firstFormality] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "9900", agencyCode: "OGA-RESUBMIT-1", agencyName: "Resubmit Agency 1", + permitType: "RESUBMIT-1", requiredQuantity: "5", legalInstrument: "Instrument RESUBMIT-1", + validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + const [permit] = await db.insert(ogaPermits).values({ + declarationId: declarationRow.id, agencyCode: "OGA-RESUBMIT-1", agencyName: "Resubmit Agency 1", + permitType: "RESUBMIT-1", status: "approved", hsCode: "9900", consigneeId: 1, + permittedQuantity: "5", validFrom: new Date(now.getTime() - 60_000), + }).returning(); + created.formalities.push(firstFormality.id); + created.permits.push(permit.id); + await evaluateDeclarationRegulations({ + declarationId: declarationRow.id, importerId: 1, hsCode: declarationRow.hsCode!, origin: "GH", + destination: "NG", regime: "import", quantity: "5", at: now, + }); + const [secondFormality] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "9900", agencyCode: "OGA-RESUBMIT-2", agencyName: "Resubmit Agency 2", + permitType: "RESUBMIT-2", requiredQuantity: "5", legalInstrument: "Instrument RESUBMIT-2", + validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.formalities.push(secondFormality.id); + await evaluateDeclarationRegulations({ + declarationId: declarationRow.id, importerId: 1, hsCode: declarationRow.hsCode!, origin: "GH", + destination: "NG", regime: "import", quantity: "5", at: now, + }); + const [permitAfter] = await db.select({ usedQuantity: ogaPermits.usedQuantity }) + .from(ogaPermits).where(eq(ogaPermits.id, permit.id)); + const rows = await db.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationRow.id)); + expect(permitAfter?.usedQuantity).toBe("5.000"); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.formalityId)).toEqual([firstFormality.id, secondFormality.id]); + }); + + it("persists and consumes a permit satisfied by the live clearance recheck", async () => { + const db = await database(); + const now = new Date(); + const declarationRow = await declaration("991100"); + const [formality] = await db.insert(regulatoryFormalities).values({ + hsCodePrefix: "9911", agencyCode: "OGA-LIVE", agencyName: "Live Agency", + permitType: "LIVE-PERMIT", requiredQuantity: "5", legalInstrument: "Instrument LIVE", + validFrom: new Date(now.getTime() - 60_000), createdBy: 4, + }).returning(); + created.formalities.push(formality.id); + await evaluateDeclarationRegulations({ + declarationId: declarationRow.id, importerId: 1, hsCode: declarationRow.hsCode!, origin: "GH", + destination: "NG", regime: "import", quantity: "5", at: now, + }); + const [before] = await db.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationRow.id)); + expect(before?.status).toBe("required"); + const [permit] = await db.insert(ogaPermits).values({ + declarationId: declarationRow.id, agencyCode: "OGA-LIVE", agencyName: "Live Agency", + permitType: "LIVE-PERMIT", status: "approved", hsCode: "9911", consigneeId: 1, + permittedQuantity: "5", validFrom: new Date(now.getTime() - 60_000), + }).returning(); + created.permits.push(permit.id); + + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)).resolves.toBeUndefined(); + const [satisfied] = await db.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationRow.id)); + const [consumed] = await db.select({ usedQuantity: ogaPermits.usedQuantity }) + .from(ogaPermits).where(eq(ogaPermits.id, permit.id)); + expect(satisfied).toMatchObject({ + status: "satisfied", + satisfiedByPermitId: permit.id, + satisfiedQuantity: "5.000", + }); + expect(consumed?.usedQuantity).toBe("5.000"); + await expect(assertDeclarationFormalitiesSatisfied(declarationRow.id)).resolves.toBeUndefined(); + const [stillConsumed] = await db.select({ usedQuantity: ogaPermits.usedQuantity }) + .from(ogaPermits).where(eq(ogaPermits.id, permit.id)); + expect(stillConsumed?.usedQuantity).toBe("5.000"); + }); }); diff --git a/server/routers/regulatory.ts b/server/routers/regulatory.ts index 407e45d7..8ea58662 100644 --- a/server/routers/regulatory.ts +++ b/server/routers/regulatory.ts @@ -67,7 +67,7 @@ async function requireRegulatoryDb(): Promise { } async function matchingRegisters( - db: RegulatoryDb, + db: Pick, input: { hsCode: string; origin: string; @@ -204,8 +204,8 @@ async function evaluateObligations( input: ObligationInput, registers: MatchingRegisters, consumePermits: boolean, + obligations = registerObligations(registers), ) { - const obligations = registerObligations(registers); const evaluated = []; for (const obligation of obligations) { const permit = await permitSatisfies(db, input, obligation, consumePermits); @@ -220,6 +220,47 @@ async function buildObligations(db: RegulatoryDb, input: ObligationInput) { return { ...evaluated, ...registers }; } +type QuotaSatisfaction = { + quota: MatchingRegisters["quotas"][number]; + allocatedQuantity: number; + requiredQuantity: number; + satisfied: boolean; +}; + +async function quotaSatisfaction( + db: Pick, + input: ObligationInput, + quotas: MatchingRegisters["quotas"], +): Promise { + const requiredQuantity = Number(input.quantity); + return Promise.all(quotas.map(async (quota) => { + const allocations = input.declarationId + ? await db.select({ quantity: tariffQuotaAllocations.quantity }) + .from(tariffQuotaAllocations) + .where(and( + eq(tariffQuotaAllocations.quotaId, quota.id), + eq(tariffQuotaAllocations.declarationId, input.declarationId), + isNull(tariffQuotaAllocations.reversedAt), + )) + : []; + const allocatedQuantity = allocations.reduce((sum, row) => sum + Number(row.quantity), 0); + return { + quota, + allocatedQuantity, + requiredQuantity, + satisfied: allocatedQuantity >= requiredQuantity, + }; + })); +} + +function matchesPersistedObligation( + obligation: RegisterObligation, + row: { formalityId: number | null; restrictionId: number | null }, +): boolean { + return (obligation.formalityId !== null && row.formalityId === obligation.formalityId) || + (obligation.restrictionId !== null && row.restrictionId === obligation.restrictionId); +} + export async function evaluateDeclarationRegulations(input: ObligationInput): Promise { const db = await requireRegulatoryDb(); const registers = await matchingRegisters(db, input); @@ -232,7 +273,16 @@ export async function evaluateDeclarationRegulations(input: ObligationInput): Pr } if (!input.declarationId || registerObligations(registers).length === 0) return; await db.transaction(async (tx) => { - const result = await evaluateObligations(tx, input, registers, true); + const existingRows = await tx.select({ + formalityId: declarationFormalities.formalityId, + restrictionId: declarationFormalities.restrictionId, + }).from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, input.declarationId!)); + const newObligations = registerObligations(registers).filter((obligation) => + !existingRows.some((row) => matchesPersistedObligation(obligation, row)), + ); + if (newObligations.length === 0) return; + const result = await evaluateObligations(tx, input, registers, true, newObligations); await tx.insert(declarationFormalities).values(result.obligations.map((obligation) => ({ declarationId: input.declarationId!, formalityId: obligation.formalityId, @@ -266,31 +316,61 @@ export async function assertDeclarationFormalitiesSatisfied(declarationId: numbe quantity: String(declaration.numberOfPackages ?? 1), at: declaration.submittedAt ?? declaration.createdAt, }; - const registers = await matchingRegisters(db, input); - const prohibition = registers.restrictions.find((entry) => entry.restrictionType === "prohibition"); - if (prohibition) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, - }); - } - const rows = await db.select().from(declarationFormalities) - .where(eq(declarationFormalities.declarationId, declarationId)); - const obligations = registerObligations(registers); - for (const obligation of obligations) { - const persisted = rows.find((row) => - (obligation.formalityId !== null && row.formalityId === obligation.formalityId) || - (obligation.restrictionId !== null && row.restrictionId === obligation.restrictionId), - ); - const satisfied = persisted?.status === "satisfied" || - (await permitSatisfies(db, input, obligation, false)) !== null; - if (!satisfied) { + await db.transaction(async (tx) => { + const registers = await matchingRegisters(tx, input); + const prohibition = registers.restrictions.find((entry) => entry.restrictionType === "prohibition"); + if (prohibition) { throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Required regulatory formality is unsatisfied under ${obligation.legalInstrument}.`, + code: "FORBIDDEN", + message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, }); } - } + const rows = await tx.select().from(declarationFormalities) + .where(eq(declarationFormalities.declarationId, declarationId)); + for (const obligation of registerObligations(registers)) { + const persisted = rows.find((row) => matchesPersistedObligation(obligation, row)); + if (persisted?.status === "satisfied") continue; + const permit = await permitSatisfies(tx, input, obligation, true); + if (!permit) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Required regulatory formality is unsatisfied under ${obligation.legalInstrument}.`, + }); + } + const satisfaction = { + status: "satisfied" as const, + satisfiedByPermitId: permit.id, + satisfiedQuantity: obligation.requiredQuantity, + evaluatedAt: input.at, + }; + if (persisted) { + await tx.update(declarationFormalities) + .set(satisfaction) + .where(eq(declarationFormalities.id, persisted.id)); + } else { + await tx.insert(declarationFormalities).values({ + declarationId, + formalityId: obligation.formalityId, + restrictionId: obligation.restrictionId, + agencyCode: obligation.agencyCode, + agencyName: obligation.agencyName, + permitType: obligation.permitType, + legalInstrument: obligation.legalInstrument, + requiredQuantity: obligation.requiredQuantity, + ...satisfaction, + }); + } + } + const quotas = await quotaSatisfaction(tx, input, registers.quotas); + for (const { quota, satisfied } of quotas) { + if (!satisfied) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Required tariff quota is unsatisfied under ${quota.legalInstrument}.`, + }); + } + } + }); } async function clearanceGraph(input: ObligationInput) { @@ -307,29 +387,15 @@ async function clearanceGraph(input: ObligationInput) { requiredQuantity: obligation.requiredQuantity, satisfiedByPermitId: obligation.permit?.id ?? null, })); - const quotaGraph = []; - for (const quota of result.quotas) { - const allocations = input.declarationId - ? await db.select({ quantity: tariffQuotaAllocations.quantity }) - .from(tariffQuotaAllocations) - .where(and( - eq(tariffQuotaAllocations.quotaId, quota.id), - eq(tariffQuotaAllocations.declarationId, input.declarationId), - isNull(tariffQuotaAllocations.reversedAt), - )) - : []; - const allocated = allocations.reduce((sum, row) => sum + Number(row.quantity), 0); - const required = Number(input.quantity); - quotaGraph.push({ + const quotaGraph = (await quotaSatisfaction(db, input, result.quotas)).map((check) => ({ required: true as const, - satisfied: allocated >= required, - blocking: allocated < required, - quotaCode: quota.quotaCode, - legalInstrument: quota.legalInstrument, + satisfied: check.satisfied, + blocking: !check.satisfied, + quotaCode: check.quota.quotaCode, + legalInstrument: check.quota.legalInstrument, requiredQuantity: input.quantity, - allocatedQuantity: String(allocated), - }); - } + allocatedQuantity: String(check.allocatedQuantity), + })); return { registersAvailable: true as const, prohibited: result.restrictions