diff --git a/client/src/App.tsx b/client/src/App.tsx index 94e5ef26..2719c145 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,12 @@ 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 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')); +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')); @@ -167,9 +173,27 @@ function Router() { {/* Sprint 80 — Public certificate verification (no auth required, QR-scannable) */} + + }> + + + }> + }> + + }> + + + }> + + + }> + + + }> + {/* Public system status page — no authentication required */} @@ -430,6 +454,17 @@ function Router() { }> + + }> + + + }> + + + + }> + + {/* Sprint 68 — OpenAPI Explorer */} 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/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/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..73630728 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -87,6 +87,27 @@ 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, + }, + { + title: "Track your Shipment", + description: "Find the latest available vessel position for a declaration or UCR.", + href: "/track-shipment", + icon: MapPin, + }, +]; + const CAPABILITIES = [ { icon: Clock, @@ -281,6 +302,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/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 && (
; +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(); + if (new Date(mandate.validUntil).getTime() <= now) return "expired"; + if (new Date(mandate.validFrom).getTime() > now) return "scheduled"; + return "active"; +} + +export default function MandateManagement() { + 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 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 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

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?: 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/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/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/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/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. 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/drizzle/schema.ts b/drizzle/schema.ts index ee4ae5dd..0f2300d7 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,6 +1,6 @@ import { pgTable, pgEnum, serial, text, timestamp, varchar, - integer, decimal, boolean, json, jsonb, bigint, index, unique, real, uuid, date + integer, decimal, boolean, json, jsonb, bigint, index, unique, uniqueIndex, real, uuid, date, check } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -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" ]); @@ -49,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" ]); @@ -137,6 +146,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 +205,10 @@ 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), + 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"), @@ -178,6 +243,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; @@ -216,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) => [ @@ -223,12 +296,121 @@ 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), + 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", { + 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(), + allocatedLedgerAccountId: varchar("allocated_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", { 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(), @@ -941,7 +1123,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", [ @@ -3204,6 +3386,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"), @@ -3220,6 +3404,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; @@ -3343,3 +3529,321 @@ export const lpcoRecords = pgTable("lpco_records", { ]); export type LPCORecord = typeof lpcoRecords.$inferSelect; export type InsertLPCORecord = typeof lpcoRecords.$inferInsert; + +// ─── EXCISE LICENSING AND DIGITAL TAX STAMPS ───────────────────────────────── + +export const exciseLicenseeTypeEnum = pgEnum("excise_licensee_type", [ + "manufacturer", "importer", "distributor", "retailer", +]); +export const exciseLicenseStatusEnum = pgEnum("excise_license_status", [ + "pending", "active", "suspended", "expired", "revoked", +]); +export const exciseApprovalStatusEnum = pgEnum("excise_approval_status", [ + "pending", "approved", "rejected", +]); +export const exciseSchemeTypeEnum = pgEnum("excise_scheme_type", [ + "specific", "ad_valorem", "hybrid", +]); +export const exciseOrderStatusEnum = pgEnum("excise_order_status", [ + "ordered", "assessed", "payment", "fulfilment", "delivery", "cancelled", +]); +export const exciseMarkStatusEnum = pgEnum("excise_mark_status", [ + "issued", "active", "retired", +]); +export const exciseRetirementReasonEnum = pgEnum("excise_retirement_reason", [ + "wastage", "spoilage", "destruction", "seizure", "other", +]); +export const exciseAggregateTypeEnum = pgEnum("excise_aggregate_type", [ + "carton", "case", "pallet", +]); +export const exciseMovementTypeEnum = pgEnum("excise_movement_type", [ + "dispatch", "receipt", "export", "re_entry", "seizure", "destruction", + "disaggregation", +]); +export const exciseScanSourceEnum = pgEnum("excise_scan_source", [ + "public", "enforcement", +]); + +export const exciseLicences = pgTable("excise_licences", { + id: serial("id").primaryKey(), + licenseNumber: varchar("license_number", { length: 128 }).notNull().unique(), + userId: integer("user_id").notNull().references(() => users.id), + licenseeType: exciseLicenseeTypeEnum("licensee_type").notNull(), + economicOperatorId: varchar("economic_operator_id", { length: 64 }).notNull().unique(), + productCategories: json("product_categories").$type().notNull(), + validFrom: timestamp("valid_from").notNull(), + validUntil: timestamp("valid_until").notNull(), + status: exciseLicenseStatusEnum("status").default("pending").notNull(), + suspendedBy: integer("suspended_by").references(() => users.id), + suspendedAt: timestamp("suspended_at"), + suspensionReason: text("suspension_reason"), + approvedBy: integer("approved_by").references(() => users.id), + approvedAt: timestamp("approved_at"), + revokedBy: integer("revoked_by").references(() => users.id), + revokedAt: timestamp("revoked_at"), + revocationReason: text("revocation_reason"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_licence_user").on(t.userId), + index("idx_excise_licence_status").on(t.status), + index("idx_excise_licence_validity").on(t.validFrom, t.validUntil), +]); +export type ExciseLicence = typeof exciseLicences.$inferSelect; +export type InsertExciseLicence = typeof exciseLicences.$inferInsert; + +export const exciseLicenceSuspensions = pgTable("excise_licence_suspensions", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + suspendedBy: integer("suspended_by").notNull().references(() => users.id), + suspendedAt: timestamp("suspended_at").defaultNow().notNull(), + reason: text("reason").notNull(), + liftedAt: timestamp("lifted_at"), + liftedBy: integer("lifted_by").references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_suspension_licence").on(t.licenceId), +]); + +export const exciseFacilities = pgTable("excise_facilities", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + facilityIdentifier: varchar("facility_identifier", { length: 64 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + address: text("address"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_facility_licence").on(t.licenceId), +]); +export type ExciseFacility = typeof exciseFacilities.$inferSelect; + +export const exciseMarkingMachines = pgTable("excise_marking_machines", { + id: serial("id").primaryKey(), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + machineIdentifier: varchar("machine_identifier", { length: 64 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_machine_facility").on(t.facilityId), +]); +export type ExciseMarkingMachine = typeof exciseMarkingMachines.$inferSelect; + +export const exciseTaxSchemes = pgTable("excise_tax_schemes", { + id: serial("id").primaryKey(), + code: varchar("code", { length: 64 }).notNull().unique(), + schemeType: exciseSchemeTypeEnum("scheme_type").notNull(), + specificAmount: decimal("specific_amount", { precision: 15, scale: 6 }), + specificUnitOfMeasure: varchar("specific_unit_of_measure", { length: 32 }), + adValoremRate: decimal("ad_valorem_rate", { precision: 9, scale: 6 }), + hybridWhicheverGreater: boolean("hybrid_whichever_greater").default(false).notNull(), + currency: varchar("currency", { length: 3 }), + active: boolean("active").default(true).notNull(), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); +export type ExciseTaxScheme = typeof exciseTaxSchemes.$inferSelect; + +export const exciseProducts = pgTable("excise_products", { + id: serial("id").primaryKey(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + sku: varchar("sku", { length: 128 }).notNull().unique(), + brand: varchar("brand", { length: 255 }).notNull(), + packSize: integer("pack_size").notNull(), + unitContent: decimal("unit_content", { precision: 15, scale: 6 }).notNull(), + unitOfMeasure: varchar("unit_of_measure", { length: 32 }).notNull(), + strength: decimal("strength", { precision: 15, scale: 6 }), + schemeId: integer("scheme_id").notNull().references(() => exciseTaxSchemes.id), + approvalStatus: exciseApprovalStatusEnum("approval_status").default("pending").notNull(), + approvedBy: integer("approved_by").references(() => users.id), + approvedAt: timestamp("approved_at"), + rejectionReason: text("rejection_reason"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_product_licence").on(t.licenceId), + index("idx_excise_product_status").on(t.approvalStatus), +]); +export type ExciseProduct = typeof exciseProducts.$inferSelect; + +export const exciseStampOrders = pgTable("excise_stamp_orders", { + id: serial("id").primaryKey(), + orderNumber: varchar("order_number", { length: 64 }).notNull().unique(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + declarationId: integer("declaration_id").references(() => declarations.id), + quantity: integer("quantity").notNull(), + declaredValue: decimal("declared_value", { precision: 15, scale: 2 }), + liability: decimal("liability", { precision: 15, scale: 2 }), + currency: varchar("currency", { length: 3 }).notNull(), + status: exciseOrderStatusEnum("status").default("ordered").notNull(), + paymentIdempotencyKey: varchar("payment_idempotency_key", { length: 128 }).unique(), + ledgerTransferId: varchar("ledger_transfer_id", { length: 128 }), + assessedAt: timestamp("assessed_at"), + paidAt: timestamp("paid_at"), + fulfilledAt: timestamp("fulfilled_at"), + deliveredAt: timestamp("delivered_at"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_order_licence").on(t.licenceId), + index("idx_excise_order_declaration").on(t.declarationId), + index("idx_excise_order_status").on(t.status), +]); +export type ExciseStampOrder = typeof exciseStampOrders.$inferSelect; + +export const exciseStampMarks = pgTable("excise_stamp_marks", { + id: serial("id").primaryKey(), + uid: varchar("uid", { length: 192 }).notNull().unique(), + payload: varchar("payload", { length: 128 }).notNull(), + signature: varchar("signature", { length: 64 }).notNull(), + keyId: varchar("key_id", { length: 32 }).notNull(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + machineId: integer("machine_id").references(() => exciseMarkingMachines.id), + status: exciseMarkStatusEnum("status").default("issued").notNull(), + issuedAt: timestamp("issued_at").defaultNow().notNull(), + activatedAt: timestamp("activated_at"), + retiredAt: timestamp("retired_at"), + retirementReason: exciseRetirementReasonEnum("retirement_reason"), + retirementDetails: text("retirement_details"), +}, (t) => [ + index("idx_excise_mark_order").on(t.orderId), + index("idx_excise_mark_status").on(t.status), +]); +export type ExciseStampMark = typeof exciseStampMarks.$inferSelect; + +export const exciseMarkActivations = pgTable("excise_mark_activations", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().unique().references(() => exciseStampMarks.id), + activatedBy: integer("activated_by").notNull().references(() => users.id), + activatedAt: timestamp("activated_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseProductionReports = pgTable("excise_production_reports", { + id: serial("id").primaryKey(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + productId: integer("product_id").notNull().references(() => exciseProducts.id), + facilityId: integer("facility_id").notNull().references(() => exciseFacilities.id), + quantity: integer("quantity").notNull(), + reportedBy: integer("reported_by").notNull().references(() => users.id), + reportedAt: timestamp("reported_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseRetirements = pgTable("excise_retirements", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().references(() => exciseStampMarks.id), + reason: exciseRetirementReasonEnum("reason").notNull(), + details: text("details"), + retiredBy: integer("retired_by").notNull().references(() => users.id), + retiredAt: timestamp("retired_at").defaultNow().notNull(), +}); + +export const exciseAggregates = pgTable("excise_aggregates", { + id: serial("id").primaryKey(), + aggregateUid: varchar("aggregate_uid", { length: 192 }).notNull().unique(), + aggregateType: exciseAggregateTypeEnum("aggregate_type").notNull(), + licenceId: integer("licence_id").notNull().references(() => exciseLicences.id), + parentAggregateId: integer("parent_aggregate_id"), + createdBy: integer("created_by").notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseAggregateChildren = pgTable("excise_aggregate_children", { + id: serial("id").primaryKey(), + aggregateId: integer("aggregate_id").notNull().references(() => exciseAggregates.id), + childMarkId: integer("child_mark_id").references(() => exciseStampMarks.id), + childAggregateId: integer("child_aggregate_id").references(() => exciseAggregates.id), + addedBy: integer("added_by").notNull().references(() => users.id), + addedAt: timestamp("added_at").defaultNow().notNull(), + removedBy: integer("removed_by").references(() => users.id), + removedAt: timestamp("removed_at"), +}, (t) => [ + uniqueIndex("uq_excise_active_child_mark").on(t.childMarkId).where(sql`${t.removedAt} IS NULL`), + uniqueIndex("uq_excise_active_child_aggregate").on(t.childAggregateId).where(sql`${t.removedAt} IS NULL`), + index("idx_excise_children_aggregate").on(t.aggregateId), + check("ck_excise_aggregate_child_exactly_one", sql`(child_mark_id IS NOT NULL) <> (child_aggregate_id IS NOT NULL)`), +]); + +export const exciseMovementEvents = pgTable("excise_movement_events", { + id: serial("id").primaryKey(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + aggregateId: integer("aggregate_id").references(() => exciseAggregates.id), + eventType: exciseMovementTypeEnum("event_type").notNull(), + actorId: integer("actor_id").notNull().references(() => users.id), + location: text("location"), + latitude: real("latitude"), + longitude: real("longitude"), + occurredAt: timestamp("occurred_at").defaultNow().notNull(), + metadata: json("metadata"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_movement_mark").on(t.markId), + index("idx_excise_movement_aggregate").on(t.aggregateId), + index("idx_excise_movement_time").on(t.occurredAt), + check("ck_excise_movement_subject_exactly_one", sql`(mark_id IS NOT NULL) <> (aggregate_id IS NOT NULL)`), +]); + +export const exciseScans = pgTable("excise_scans", { + id: serial("id").primaryKey(), + uid: varchar("uid", { length: 192 }).notNull(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + source: exciseScanSourceEnum("source").notNull(), + scannedBy: integer("scanned_by").references(() => users.id), + localityHash: varchar("locality_hash", { length: 128 }), + latitude: real("latitude"), + longitude: real("longitude"), + scannedAt: timestamp("scanned_at").defaultNow().notNull(), + previousScanId: integer("previous_scan_id"), + impliedSpeedKmh: decimal("implied_speed_kmh", { precision: 12, scale: 2 }), + impossibleTravel: boolean("impossible_travel").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (t) => [ + index("idx_excise_scan_uid").on(t.uid), + index("idx_excise_scan_mark").on(t.markId), + index("idx_excise_scan_time").on(t.scannedAt), +]); + +export const exciseSeizures = pgTable("excise_seizures", { + id: serial("id").primaryKey(), + markId: integer("mark_id").notNull().references(() => exciseStampMarks.id), + seizedBy: integer("seized_by").notNull().references(() => users.id), + location: text("location"), + reason: text("reason").notNull(), + seizedAt: timestamp("seized_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const exciseReconciliationReports = pgTable("excise_reconciliation_reports", { + id: serial("id").primaryKey(), + orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), + issuedQuantity: integer("issued_quantity").notNull(), + activatedQuantity: integer("activated_quantity").notNull(), + 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(), + stampVariance: integer("stamp_variance").notNull(), + productionVariance: integer("production_variance").notNull(), + computedAt: timestamp("computed_at").defaultNow().notNull(), + computedBy: integer("computed_by").notNull().references(() => users.id), +}); + +export const exciseAnomalies = pgTable("excise_anomalies", { + id: serial("id").primaryKey(), + markId: integer("mark_id").references(() => exciseStampMarks.id), + orderId: integer("order_id").references(() => exciseStampOrders.id), + anomalyType: varchar("anomaly_type", { length: 64 }).notNull(), + details: json("details"), + detectedAt: timestamp("detected_at").defaultNow().notNull(), +}); diff --git a/server/_core/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/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/mandateAuthorization.ts b/server/_core/mandateAuthorization.ts new file mode 100644 index 00000000..691b95df --- /dev/null +++ b/server/_core/mandateAuthorization.ts @@ -0,0 +1,72 @@ +import { TRPCError } from "@trpc/server"; +import { getActiveStakeholderMandate } from "../db"; + +const DECLARATION_CREATION_OVERRIDE_ROLES = new Set([ + "admin", + "customs_officer", +]); + +const PAYMENT_OPERATIONAL_OVERRIDE_ROLES = new Set([ + "admin", + "finance", + "customs_officer", +]); + +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 (DECLARATION_CREATION_OVERRIDE_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 (principalUserId === actor.id) { + return { principalUserId, actingAgentId: null }; + } + if (options?.allowOperationalOverride && PAYMENT_OPERATIONAL_OVERRIDE_ROLES.has(actor.role)) { + 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/_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/_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/_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 new file mode 100644 index 00000000..eb28c812 --- /dev/null +++ b/server/cargoTracking.persisted.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +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(); + return { + ...actual, + getDb: vi.fn(async () => ({})), + 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 + ? [{ 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 }; + }), + })), + }; +}); + +vi.mock("./_core/kafka", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, publishEvent: vi.fn() }; +}); + +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 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" }]; + 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 = [{ + 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, + 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 successful empty results when no persisted tracking rows exist", async () => { + const { cargoTrackingRouter } = await import("./routers/cargoTracking"); + const caller = cargoTrackingRouter.createCaller({} as any); + await expect(caller.getLiveVessels({ riskFilter: "all", statusFilter: "all" })) + .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" }); + await expect(track()).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/db.ts b/server/db.ts index b5e5336f..79206ee7 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, 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 { InsertUser, users, stakeholderProfiles, declarations, + stakeholderRegistrations, stakeholderMandates, manifests, billsOfLading, declarationDocuments, ogaPermits, payments, auditEvents, securityAlerts, sanctionsChecks, aeoApplications, notifications, kycDocuments, kycVerifications, visionAnalyses, @@ -205,6 +206,209 @@ 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 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"); + 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 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"); + 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 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, + 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) { @@ -229,6 +433,54 @@ 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"); + 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 []; @@ -366,6 +618,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/declarations.test.ts b/server/declarations.test.ts index 03aeb42c..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", @@ -70,6 +80,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 +215,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/excise.behavior.test.ts b/server/excise.behavior.test.ts new file mode 100644 index 00000000..3d03b96d --- /dev/null +++ b/server/excise.behavior.test.ts @@ -0,0 +1,587 @@ +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 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(); + const result = await caller().excise.publicVerify({ uid: signed.uid }); + expect(Object.keys(result)).toEqual(["status"]); + expect(result.status).toBe("unknown"); + + delete process.env.EXCISE_UID_HMAC_KEY; + const unavailable = await caller().excise.publicVerify({ uid: signed.uid }); + expect(unavailable).toEqual({ status: "unavailable" }); + }); + + it("retains both scans and flags the mark for impossible travel", async () => { + process.env.EXCISE_UID_HMAC_KEY = "d".repeat(64); + const signed = mintExciseUid(); + const fixture = await makeFixture(); + fixture.fixture.scanUids.push(signed.uid); + await caller().excise.publicVerify({ uid: signed.uid, latitude: 0, longitude: 0 }); + const result = await caller().excise.publicVerify({ uid: signed.uid, latitude: 0, longitude: 1 }); + expect(result.status).toBe("unknown"); + const db = await database(); + const scans = await db.select().from(exciseScans).where(eq(exciseScans.uid, signed.uid)); + expect(scans).toHaveLength(2); + expect(scans.some((scan) => scan.impossibleTravel)).toBe(true); + expect(scans[1].previousScanId).toBe(scans[0].id); + }); + + it("returns distinct source-link outcomes and permits self-filed traversal", async () => { + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: "missing-excise-mark" })).resolves.toMatchObject({ + available: false, + reason: "mark_not_found", + }); + process.env.EXCISE_UID_HMAC_KEY = "e".repeat(64); + const noDeclaration = await makeFixture(); + const noDeclarationUid = mintExciseUid(); + const [noDeclarationMark] = await noDeclaration.db.insert(exciseStampMarks).values({ + uid: noDeclarationUid.uid, + payload: noDeclarationUid.payload, + signature: noDeclarationUid.signature, + keyId: noDeclarationUid.keyId, + orderId: noDeclaration.order.id, + productId: noDeclaration.fixture.productId, + facilityId: noDeclaration.fixture.facilityId, + status: "issued", + }).returning(); + noDeclaration.fixture.markIds.push(noDeclarationMark.id); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: noDeclarationMark.uid })).resolves.toMatchObject({ + available: false, + reason: "declaration_missing", + }); + const linked = await makeFixture({ declaration: true }); + const declarationId = linked.declarationId!; + const linkedDeclaration = await linked.db.select().from(declarations).where(eq(declarations.id, declarationId)).limit(1); + expect(linkedDeclaration).toHaveLength(1); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: noDeclarationMark.uid })).resolves.toMatchObject({ + reason: "declaration_missing", + }); + const signed = mintExciseUid(); + const [mark] = await linked.db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: linked.order.id, + productId: linked.fixture.productId, + facilityId: linked.fixture.facilityId, + status: "issued", + }).returning(); + linked.fixture.markIds.push(mark.id); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_not_linked", + }); + await linked.db.update(declarations).set({ billOfLadingNumber: "BL-NOT-FILED" }).where(eq(declarations.id, declarationId)); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_missing", + }); + + const [manifestOne] = await linked.db.insert(manifests).values({ + manifestNumber: `MAN-EXC-${Date.now()}-1`, + manifestType: "IMPORT", + submittedBy: 1, + vesselName: "MV Ambiguous", + voyageNumber: "V1", + portOfLoading: "Lagos", + portOfDischarge: "Tema", + }).returning(); + const [manifestTwo] = await linked.db.insert(manifests).values({ + manifestNumber: `MAN-EXC-${Date.now()}-2`, + manifestType: "IMPORT", + submittedBy: 1, + vesselName: "MV Ambiguous", + voyageNumber: "V2", + portOfLoading: "Lagos", + portOfDischarge: "Tema", + }).returning(); + linked.fixture.manifestIds.push(manifestOne.id, manifestTwo.id); + const [billOne] = await linked.db.insert(billsOfLading).values({ + manifestId: manifestOne.id, + blNumber: "BL-AMBIGUOUS", + shipper: "Test Shipper", + consignee: "Test Consignee", + description: "Test goods", + }).returning(); + const [billTwo] = await linked.db.insert(billsOfLading).values({ + manifestId: manifestTwo.id, + blNumber: "BL-AMBIGUOUS", + shipper: "Test Shipper", + consignee: "Test Consignee", + description: "Test goods", + }).returning(); + linked.fixture.billIds.push(billOne.id, billTwo.id); + await linked.db.update(declarations).set({ billOfLadingNumber: "BL-AMBIGUOUS" }).where(eq(declarations.id, declarationId)); + await expect(caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid })).resolves.toMatchObject({ + available: false, + reason: "bill_of_lading_ambiguous", + }); + + await linked.db.update(declarations).set({ billOfLadingId: billOne.id, billOfLadingNumber: "BL-AMBIGUOUS", actingAgentId: null }).where(eq(declarations.id, declarationId)); + const traversed = await caller("customs_officer", 2).excise.traverseSource({ uid: mark.uid }); + expect(traversed).toMatchObject({ + available: true, + importerUserId: 1, + actingAgentUserId: null, + billOfLading: { id: billOne.id }, + manifest: { id: manifestOne.id }, + }); + }); +}); diff --git a/server/excise.test.ts b/server/excise.test.ts new file mode 100644 index 00000000..2c6893b6 --- /dev/null +++ b/server/excise.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + IMPOSSIBLE_TRAVEL_SPEED_KMH, + calculateImpossibleTravelSpeedKmh, + calculateExciseLiability, + mintExciseUid, + verifyExciseUid, +} from "./routers/excise"; + +const originalKey = process.env.EXCISE_UID_HMAC_KEY; +const originalKeyId = process.env.EXCISE_UID_KEY_ID; +const originalKeys = process.env.EXCISE_UID_HMAC_KEYS; +const originalIssuedKeyIds = process.env.EXCISE_UID_ISSUED_KEY_IDS; + +afterEach(() => { + if (originalKey === undefined) delete process.env.EXCISE_UID_HMAC_KEY; + else process.env.EXCISE_UID_HMAC_KEY = originalKey; + if (originalKeyId === undefined) delete process.env.EXCISE_UID_KEY_ID; + else process.env.EXCISE_UID_KEY_ID = originalKeyId; + if (originalKeys === undefined) delete process.env.EXCISE_UID_HMAC_KEYS; + else process.env.EXCISE_UID_HMAC_KEYS = originalKeys; + if (originalIssuedKeyIds === undefined) delete process.env.EXCISE_UID_ISSUED_KEY_IDS; + else process.env.EXCISE_UID_ISSUED_KEY_IDS = originalIssuedKeyIds; +}); + +describe("excise digital marks", () => { + it("mints unique, non-sequential signed UIDs", () => { + process.env.EXCISE_UID_HMAC_KEY = "a".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-1"; + const first = mintExciseUid(); + const second = mintExciseUid(); + + expect(first.uid).not.toBe(second.uid); + expect(first.uid).not.toMatch(/000001|000002/); + expect(verifyExciseUid(first.uid)).toEqual({ + status: "signature_valid_pending_reconciliation", + keyId: "rotation-1", + }); + }); + + it("refuses missing, short, and development placeholder signing keys", () => { + delete process.env.EXCISE_UID_HMAC_KEY; + expect(() => mintExciseUid()).toThrow(); + process.env.EXCISE_UID_HMAC_KEY = "dev-excise-key"; + expect(() => mintExciseUid()).toThrow(); + process.env.EXCISE_UID_HMAC_KEY = "b".repeat(64); + expect(verifyExciseUid("v1.random.invalid").status).toBe("invalid_signature"); + process.env.EXCISE_UID_KEY_ID = "issued-but-unavailable"; + process.env.EXCISE_UID_ISSUED_KEY_IDS = "issued-but-unavailable"; + delete process.env.EXCISE_UID_HMAC_KEY; + expect(verifyExciseUid("issued-but-unavailable.random.invalid").status).toBe("verification_unavailable"); + }); + + it("verifies marks signed by a retained rotated key", () => { + process.env.EXCISE_UID_HMAC_KEY = "c".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-2"; + const previous = mintExciseUid(); + process.env.EXCISE_UID_HMAC_KEY = "d".repeat(64); + process.env.EXCISE_UID_KEY_ID = "rotation-3"; + process.env.EXCISE_UID_HMAC_KEYS = JSON.stringify({ "rotation-2": "c".repeat(64) }); + expect(verifyExciseUid(previous.uid).status).toBe("signature_valid_pending_reconciliation"); + delete process.env.EXCISE_UID_HMAC_KEYS; + }); + + it("calculates fiscal liability on the server from the tax scheme", () => { + expect(calculateExciseLiability({ + schemeType: "specific", + specificAmount: "2.50", + adValoremRate: null, + hybridWhicheverGreater: false, + }, { unitContent: "1", unitOfMeasure: "unit" }, 4, undefined)).toBe("10.00"); + expect(calculateExciseLiability({ + schemeType: "hybrid", + specificAmount: "1.00", + adValoremRate: "10", + hybridWhicheverGreater: true, + }, { unitContent: "1", unitOfMeasure: "unit" }, 2, "100.00")).toBe("20.00"); + }); + + it("flags impossible travel only when implied speed exceeds the threshold", () => { + const previous = { latitude: 0, longitude: 0, scannedAt: new Date("2024-01-01T00:00:00Z") }; + const below = calculateImpossibleTravelSpeedKmh(previous, { + latitude: 0, longitude: 1, scannedAt: new Date("2024-01-01T02:00:00Z"), + }); + const above = calculateImpossibleTravelSpeedKmh(previous, { + latitude: 0, longitude: 1, scannedAt: new Date("2024-01-01T00:30:00Z"), + }); + expect(below).not.toBeNull(); + expect(below!).toBeLessThan(IMPOSSIBLE_TRAVEL_SPEED_KMH); + expect(above!).toBeGreaterThan(IMPOSSIBLE_TRAVEL_SPEED_KMH); + }); +}); diff --git a/server/mandate.enforcement.test.ts b/server/mandate.enforcement.test.ts new file mode 100644 index 00000000..716a29f8 --- /dev/null +++ b/server/mandate.enforcement.test.ts @@ -0,0 +1,189 @@ +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, + 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, role: NonNullable["role"] = "user"): TrpcContext { + return { + user: { + id, + openId: `mandate-${id}`, + name: `Mandate ${id}`, + email: `mandate-${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"], + }; +} + +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("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( + appRouter.createCaller(context(2)).declarations.create({ + ...declarationInput, + principalUserId: 1, + }), + ).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; + 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/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/openapi.ts b/server/openapi.ts index c9ef04f7..fad43319 100644 --- a/server/openapi.ts +++ b/server/openapi.ts @@ -56,9 +56,21 @@ 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" } }, + 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" } }, - 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/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts new file mode 100644 index 00000000..831fab65 --- /dev/null +++ b/server/regulatory.behavior.test.ts @@ -0,0 +1,549 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { appRouter } from "./routers"; +import { getDb } from "./db"; +import type { TrpcContext } from "./_core/context"; +import { + assertDeclarationFormalitiesSatisfied, + evaluateDeclarationRegulations, +} from "./routers/regulatory"; +import { + declarations, + declarationFormalities, + ogaPermits, + regulatoryFormalities, + regulatoryRestrictions, + stakeholderRegistrations, + stakeholderMandates, + tariffQuotaAllocations, + tariffQuotas, +} from "../drizzle/schema"; + +const ledgerMocks = vi.hoisted(() => ({ + available: vi.fn(async () => true), + 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) => { + const actual = await importOriginal(); + return { ...actual, tbBridgeAvailable: ledgerMocks.available, tbFetch: ledgerMocks.fetch }; +}); + +function caller( + role: "user" | "admin" | "customs_officer" | "finance" = "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[], + registrations: [] as number[], + mandates: [] as number[], +}; + +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: options.traderId ?? 1, + principalId: options.principalId, + actingAgentId: options.actingAgentId, + 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)); + 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", () => { + 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", + allocatedLedgerAccountId: "quota-allocated-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 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" }), + ]); + 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(2); + expect(allocations.reduce((sum, allocation) => sum + Number(allocation.quantity), 0)).toBe(7); + ledgerMocks.available.mockResolvedValue(false); + const outage = await declaration("777703"); + await expect(caller().regulatory.allocateQuota({ + quotaId: quota.id, declarationId: outage.id, quantity: "1", + })).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("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"); + 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" }); + }); + + 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.ts b/server/routers.ts index 7b20840f..3bcc6f5d 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -56,6 +56,8 @@ 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 { applicationTrackingRouter } from "./routers/applicationTracking"; import { geofencesRouter } from "./routers/geofences"; import { webhooksRouter } from "./routers/webhooks"; import { apiChangelogRouter } from "./routers/apiChangelog"; @@ -78,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"; @@ -118,9 +121,11 @@ import { tradeAnalyticsRouter } from "./routers/tradeAnalytics"; import { ncsNrsRouter } from "./routers/ncsNrs"; import { complianceReportingRouter } from "./routers/complianceReporting"; +import { regulatoryRouter } from "./routers/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({ @@ -307,6 +312,8 @@ export const appRouter = router({ traderScorecard: traderScorecardRouter, cargoTracking: cargoTrackingRouter, onboarding: onboardingRouter, + stakeholderRegistrations: stakeholderRegistrationsRouter, + applicationTracking: applicationTrackingRouter, geofences: geofencesRouter, webhooks: webhooksRouter, apiChangelog: apiChangelogRouter, @@ -329,6 +336,7 @@ export const appRouter = router({ kafkaEvents: kafkaEventsRouter, ogaPermitAudit: ogaPermitAuditRouter, temporalRuns: temporalRunsRouter, + excise: exciseRouter, openAppSec: openAppSecRouter, corazaWaf: corazaWafRouter, heartbeatAdmin: heartbeatAdminRouter, 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..ecfc2da9 100644 --- a/server/routers/cargoTracking.ts +++ b/server/routers/cargoTracking.ts @@ -1,14 +1,15 @@ /** - * 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. Persisted-source failures are + * surfaced as unavailable, while successful empty queries remain empty. */ import { z } from "zod"; -import { publicProcedure, protectedProcedure, router } from "../_core/trpc"; +import { TRPCError } from "@trpc/server"; +import { publicProcedure, publicRateLimitedProcedure, 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"; @@ -18,452 +19,445 @@ export type VesselType = "container" | "bulk" | "tanker" | "general" | "roro" | 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; // knots - heading: number; // degrees 0-359 - course: number; // degrees 0-359 - 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; // ISO timestamp - destination: string; - draught: number; // metres - length: number; // metres - lastUpdate: string; // ISO timestamp - originPort: string; - originLat: number; - originLon: number; + eta: string | null; + destination: string | null; + draught: number | null; + length: number | null; + lastUpdate: string; + originPort: string | null; + originLat: number | null; + originLon: number | null; } export interface RouteWaypoint { lat: number; lon: number; timestamp: string; - speed: number; - event?: string; + speed: number | null; } -// ─── 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() }; +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[]; +} - const speedMs = (vessel.speed * 0.514444) * tickSeconds; // knots → m/s → metres - const headingRad = (vessel.heading * Math.PI) / 180; +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, + }); + } +} - // 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)); +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"; + +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; + }; - return { - ...vessel, - lat: vessel.lat + latDelta, - lon: vessel.lon + lonDelta, - lastUpdate: new Date().toISOString(), - }; +function shipmentUnavailable( + trackingStatus: "not_linked" | "unavailable", + reason: ShipmentTrackingUnavailableReason, + message: string, +): ShipmentTrackingResult { + return { trackingStatus, reason, message }; } -// ─── ROUTE GENERATION ──────────────────────────────────────────────────────── +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.", + ); + } -function generateRoute(vessel: LiveVessel): RouteWaypoint[] { - const waypoints: RouteWaypoint[] = []; - const steps = 12; - const now = Date.now(); + 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", + "ambiguous_bill_of_lading", + "The bill of lading reference is ambiguous and cannot be linked safely.", + ); + } - 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; + 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.", + ); + } - 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, - }); + 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.", + ); } - return waypoints; + 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, + }; } -// ─── 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 = row.speed === null ? null : Number(row.speed); + return { + id: String(row.mmsi), + mmsi: String(row.mmsi), + 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: 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: null, + eta: row.eta ? new Date(row.eta).toISOString() : null, + destination: row.destination_port, + draught: null, + length: null, + lastUpdate: new Date(row.recorded_at).toISOString(), + originPort: null, + originLat: null, + originLon: null, + }; +} -// ─── 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 - */ + 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"), 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); + 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) { + return { + waypoints: [], + vessel: null, + sourceService: "vessel_tracking_events", + }; } + 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: row.speed === null ? null : Number(row.speed), + })), + 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) }; - }), + 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", + }; + } - /** - * getPortArrivals — upcoming vessel arrivals at the home port - */ - getPortArrivals: publicProcedure.query(() => { + 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 + `); + 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; + }>(` + 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 + FROM (${latestVesselsQuery}) latest + `); + const total = Number(stats?.total ?? 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: null, + amberFlag: null, + greenFlag: null, + withDeclaration: null, + 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(), @@ -482,159 +476,67 @@ export const cargoTrackingRouter = router({ lon: input.lon ?? null, loggedBy: ctx.user.id, }, - }).catch(() => {}); + }); return { success: true, eventType: input.eventType, mmsi: input.mmsi }; }), 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}%`]); + 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]); + 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", + }; }), - }); -// ─── 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/declarations.ts b/server/routers/declarations.ts index 2147f4e8..84e4d228 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"; @@ -17,6 +18,8 @@ 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"; +import { assertDeclarationFormalitiesSatisfied, evaluateDeclarationRegulations } from "./regulatory"; // Generate a unique declaration number: TG-YYYY-XXXXXXXX function generateDeclarationNumber(): string { @@ -177,6 +180,9 @@ 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(), + 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) @@ -184,14 +190,36 @@ 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." }); } + 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: ctx.user.id, + traderId: principalUserId, + principalId: principalUserId, + actingAgentId, + billOfLadingId, + billOfLadingNumber: input.billOfLadingNumber ?? null, declarationType: input.declarationType, status: "draft", hsCode: input.hsCode, @@ -210,11 +238,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 +252,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', @@ -238,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( { @@ -249,7 +288,7 @@ export const declarationsRouter = router({ }, { declarationId: String(input.id), - traderId: String(ctx.user.id), + traderId: String(principalUserId), } ); @@ -278,13 +317,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 +333,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 +347,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 +363,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, @@ -540,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 new file mode 100644 index 00000000..54772a36 --- /dev/null +++ b/server/routers/excise.ts @@ -0,0 +1,1215 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; +import { TRPCError } from "@trpc/server"; +import { and, asc, count, desc, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; +import { + exciseAggregateChildren, + exciseAggregates, + exciseAnomalies, + exciseFacilities, + exciseLicenceSuspensions, + exciseLicences, + exciseMarkActivations, + exciseMarkingMachines, + exciseMovementEvents, + exciseProducts, + exciseProductionReports, + exciseReconciliationReports, + exciseRetirements, + exciseScans, + exciseSeizures, + exciseStampMarks, + exciseStampOrders, + exciseTaxSchemes, + declarations, + billsOfLading, + manifests, + tigerBeetleLedgerEntries, +} from "../../drizzle/schema"; +import { getDb, logAuditEvent, createLedgerEntry } from "../db"; +import { protectedProcedure, publicRateLimitedProcedure, router } from "../_core/trpc"; +import { tbBridgeAvailable, tbFetch } from "./ledger"; +import { SYSTEM_ACCOUNTS } from "../_core/paymentAccountProvisioner"; +import { + EXCISE_UID_HMAC_ENV, + EXCISE_UID_KEY_ID_ENV, +} from "../_core/webhookSecretsValidator"; +import { acquireLock, releaseLock } from "../_core/distributedLock"; + +const REVIEWER_ROLES = new Set(["admin", "customs_officer", "oga_officer"]); +const ID_ISSUER_ROLES = new Set(["admin", "customs_officer"]); +const ENFORCEMENT_ROLES = new Set(["admin", "customs_officer", "oga_officer", "inspector"]); +const AGGREGATE_LEVEL: Record<"carton" | "case" | "pallet", number> = { carton: 1, case: 2, pallet: 3 }; +export const EXCISE_MINT_BATCH_CAP = 5_000; +const EXCISE_MINT_CHUNK_SIZE = 500; + +// 120 km/h is above plausible road/rail movement for a tax mark, while avoiding +// false positives from ordinary city-to-city commercial transport. +export const IMPOSSIBLE_TRAVEL_SPEED_KMH = 120; + +export type ExcisePublicStatus = "authentic" | "unknown" | "suspect" | "unavailable"; + +export type ExciseTraversalUnavailableReason = + | "mark_not_found" + | "order_missing" + | "declaration_missing" + | "bill_of_lading_not_linked" + | "bill_of_lading_ambiguous" + | "bill_of_lading_missing" + | "manifest_missing" + | "manifest_vessel_missing" + | "importer_missing" + | "acting_agent_missing"; + +function unavailable(message: string, cause?: unknown): never { + if (cause instanceof TRPCError) throw cause; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message, cause }); +} + +async function requireDb() { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise database is unavailable." }); + return db; +} + +function isOfficer(role: string): boolean { + return REVIEWER_ROLES.has(role); +} + +function isIdIssuer(role: string): boolean { + return ID_ISSUER_ROLES.has(role); +} + +function isEnforcement(role: string): boolean { + return ENFORCEMENT_ROLES.has(role); +} + +async function requireLicence( + licenceId: number, + userId: number, + role: string, + requireActive = true, +) { + const db = await requireDb(); + const [licence] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, licenceId)).limit(1); + if (!licence) throw new TRPCError({ code: "NOT_FOUND", message: "Excise licence not found." }); + if (!isOfficer(role) && licence.userId !== userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + if (requireActive) { + const now = new Date(); + if (licence.status !== "active" || licence.validFrom > now || licence.validUntil <= now) { + throw new TRPCError({ code: "FORBIDDEN", message: "The excise licence is not currently valid." }); + } + } + return { db, licence }; +} + +function authorityIdentifier(prefix: string): string { + return `TG-${prefix}-${randomBytes(12).toString("hex").toUpperCase()}`; +} + +function parseScaled(value: string, scale = 6): bigint { + const normalized = value.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid decimal amount." }); + const [whole, fraction = ""] = normalized.split("."); + if (fraction.length > scale) throw new TRPCError({ code: "BAD_REQUEST", message: "Decimal precision is too high." }); + return BigInt(whole) * (10n ** BigInt(scale)) + BigInt(fraction.padEnd(scale, "0") || "0"); +} + +function formatMoney(cents: bigint): string { + const negative = cents < 0n; + const absolute = negative ? -cents : cents; + return `${negative ? "-" : ""}${absolute / 100n}.${(absolute % 100n).toString().padStart(2, "0")}`; +} + +export function calculateExciseLiability( + scheme: { + schemeType: "specific" | "ad_valorem" | "hybrid"; + specificAmount: string | null; + specificUnitOfMeasure?: string | null; + adValoremRate: string | null; + hybridWhicheverGreater: boolean; + }, + product: { unitContent: string; unitOfMeasure?: string }, + quantity: number, + declaredValue: string | undefined, +): string { + if (scheme.specificUnitOfMeasure && product.unitOfMeasure && scheme.specificUnitOfMeasure !== product.unitOfMeasure) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The tax scheme unit does not match the product unit." }); + } + const scale = 1_000_000n; + const specific = scheme.specificAmount + ? parseScaled(scheme.specificAmount) * parseScaled(product.unitContent) * BigInt(quantity) / scale + : null; + const adValorem = scheme.adValoremRate && declaredValue + ? parseScaled(declaredValue) * parseScaled(scheme.adValoremRate) * BigInt(quantity) / (scale * 100n) + : null; + if (scheme.schemeType === "specific" && specific !== null) { + return formatMoney((specific + 5_000n) / 10_000n); + } + if (scheme.schemeType === "ad_valorem" && adValorem !== null) { + return formatMoney((adValorem + 5_000n) / 10_000n); + } + if (scheme.schemeType === "hybrid" && specific !== null && adValorem !== null) { + const chosen = scheme.hybridWhicheverGreater + ? (specific > adValorem ? specific : adValorem) + : specific + adValorem; + return formatMoney((chosen + 5_000n) / 10_000n); + } + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "The tax scheme is missing the values required for this assessment.", + }); +} + +export function verifyExciseUid(uid: string): { + status: "signature_valid_pending_reconciliation" | "invalid_signature" | "verification_unavailable"; + keyId: string | null; +} { + const parts = uid.split("."); + if (parts.length !== 3) return { status: "invalid_signature", keyId: null }; + const [keyId, nonce, signature] = parts; + const key = getExciseKey(keyId); + if (!key) return { status: isKnownExciseKeyId(keyId) ? "verification_unavailable" : "invalid_signature", keyId }; + if (!isStrongExciseKey(key)) return { status: "verification_unavailable", keyId }; + const expected = createHmac("sha256", key).update(`${keyId}.${nonce}`).digest("hex").slice(0, 32); + const valid = expected.length === signature.length && + timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + return { + status: valid ? "signature_valid_pending_reconciliation" : "invalid_signature", + keyId, + }; +} + +function getExciseKey(keyId: string): string | undefined { + const configuredKeyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (keyId === configuredKeyId) return process.env[EXCISE_UID_HMAC_ENV]; + const rotatedKey = process.env[`${EXCISE_UID_HMAC_ENV}_${keyId}`]; + if (rotatedKey) return rotatedKey; + const configuredKeys = process.env.EXCISE_UID_HMAC_KEYS; + if (!configuredKeys) return undefined; + try { + const keys: unknown = JSON.parse(configuredKeys); + if (typeof keys !== "object" || keys === null || Array.isArray(keys)) return undefined; + const candidate = (keys as Record)[keyId]; + return typeof candidate === "string" ? candidate : undefined; + } catch { + return undefined; + } +} + +function isKnownExciseKeyId(keyId: string): boolean { + const configuredKeyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (keyId === configuredKeyId) return true; + const issuedKeyIds = (process.env.EXCISE_UID_ISSUED_KEY_IDS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if (issuedKeyIds.includes(keyId)) return true; + if (Object.prototype.hasOwnProperty.call(process.env, `${EXCISE_UID_HMAC_ENV}_${keyId}`)) return true; + const configuredKeys = process.env.EXCISE_UID_HMAC_KEYS; + if (!configuredKeys) return false; + try { + const keys: unknown = JSON.parse(configuredKeys); + return typeof keys === "object" && keys !== null && !Array.isArray(keys) && + Object.prototype.hasOwnProperty.call(keys, keyId); + } catch { + return false; + } +} + +function isStrongExciseKey(value: string | undefined): value is string { + if (!value || value.length < 32) return false; + return !value.toLowerCase().includes("dev") && !value.toLowerCase().includes("secret"); +} + +export function mintExciseUid(): { uid: string; payload: string; signature: string; keyId: string } { + const key = process.env[EXCISE_UID_HMAC_ENV]; + const keyId = process.env[EXCISE_UID_KEY_ID_ENV] ?? "v1"; + if (!isStrongExciseKey(key)) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise UID signing is unavailable." }); + } + const payload = `${keyId}.${randomBytes(24).toString("hex")}`; + const signature = createHmac("sha256", key).update(payload).digest("hex").slice(0, 32); + return { uid: `${payload}.${signature}`, payload, signature, keyId }; +} + +function distanceKm( + first: { latitude: number; longitude: number }, + second: { latitude: number; longitude: number }, +): number { + const radians = (degrees: number) => degrees * Math.PI / 180; + const dLat = radians(second.latitude - first.latitude); + const dLon = radians(second.longitude - first.longitude); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(radians(first.latitude)) * Math.cos(radians(second.latitude)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export function calculateImpossibleTravelSpeedKmh( + previous: { latitude: number; longitude: number; scannedAt: Date }, + current: { latitude: number; longitude: number; scannedAt: Date }, +): number | null { + const elapsedHours = (current.scannedAt.getTime() - previous.scannedAt.getTime()) / 3_600_000; + if (elapsedHours <= 0) return null; + return distanceKm(previous, current) / elapsedHours; +} + +async function recordScan( + db: Awaited>, + uid: string, + markId: number | null, + source: "public" | "enforcement", + scannedBy: number | null, + latitude: number | undefined, + longitude: number | undefined, +) { + const [previous] = await db.select().from(exciseScans) + .where(eq(exciseScans.uid, uid)) + .orderBy(desc(exciseScans.scannedAt)) + .limit(1); + let impossibleTravel = false; + let impliedSpeedKmh: string | undefined; + if (previous && previous.latitude !== null && previous.longitude !== null && + latitude !== undefined && longitude !== undefined) { + const speed = calculateImpossibleTravelSpeedKmh( + { latitude: previous.latitude, longitude: previous.longitude, scannedAt: previous.scannedAt }, + { latitude, longitude, scannedAt: new Date() }, + ); + if (speed !== null) { + impliedSpeedKmh = speed.toFixed(2); + impossibleTravel = speed > IMPOSSIBLE_TRAVEL_SPEED_KMH; + } + } + const [scan] = await db.insert(exciseScans).values({ + uid, + markId, + source, + scannedBy, + localityHash: latitude !== undefined && longitude !== undefined + ? createHash("sha256").update(`${latitude.toFixed(2)}:${longitude.toFixed(2)}`).digest("hex") + : null, + latitude: latitude === undefined ? undefined : Number(latitude.toFixed(2)), + longitude: longitude === undefined ? undefined : Number(longitude.toFixed(2)), + previousScanId: previous?.id, + impliedSpeedKmh, + impossibleTravel, + }).returning(); + if (impossibleTravel) { + await db.insert(exciseAnomalies).values({ + markId, + anomalyType: "impossible_travel", + details: { previousScanId: previous?.id, scanId: scan.id, impliedSpeedKmh }, + }); + } + return scan; +} + +const transitionOrder = { + ordered: "assessed", + assessed: "payment", + payment: "fulfilment", + fulfilment: "delivery", +} as const; + +function requireTransition(status: string, expected: string): void { + if (!(status in transitionOrder) || transitionOrder[status as keyof typeof transitionOrder] !== expected) { + throw new TRPCError({ code: "BAD_REQUEST", message: `Order must transition from ${status} to ${expected}.` }); + } +} + +function metadataHasIdempotencyKey(metadata: unknown, key: string): boolean { + return typeof metadata === "object" && metadata !== null && + (metadata as Record).idempotencyKey === key; +} + +export const exciseRouter = router({ + registerLicence: protectedProcedure + .input(z.object({ + licenseNumber: z.string().min(2).max(128), + licenseeType: z.enum(["manufacturer", "importer", "distributor", "retailer"]), + productCategories: z.array(z.string().min(1).max(64)).min(1).max(30), + validFrom: z.string().datetime(), + validUntil: z.string().datetime(), + })) + .mutation(async ({ ctx, input }) => { + try { + if (new Date(input.validUntil) <= new Date(input.validFrom)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Licence validity window is invalid." }); + } + const db = await requireDb(); + const [licence] = await db.insert(exciseLicences).values({ + licenseNumber: input.licenseNumber, + userId: ctx.user.id, + licenseeType: input.licenseeType, + economicOperatorId: authorityIdentifier("EO"), + productCategories: input.productCategories, + validFrom: new Date(input.validFrom), + validUntil: new Date(input.validUntil), + status: "pending", + }).returning(); + await logAuditEvent({ + entityType: "user", + entityId: ctx.user.id, + action: "excise_licence_registered", + actorId: ctx.user.id, + actorType: input.licenseeType, + newState: { licenceId: licence.id, status: licence.status }, + }); + return licence; + } catch (error) { + return unavailable("Excise licence registration is unavailable.", error); + } + }), + + listLicences: protectedProcedure.query(async ({ ctx }) => { + try { + const db = await requireDb(); + if (isOfficer(ctx.user.role)) return db.select().from(exciseLicences).orderBy(desc(exciseLicences.createdAt)); + return db.select().from(exciseLicences).where(eq(exciseLicences.userId, ctx.user.id)).orderBy(desc(exciseLicences.createdAt)); + } catch (error) { + return unavailable("Excise licences are unavailable.", error); + } + }), + + approveLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [current] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, input.licenceId)).limit(1); + if (!current) throw new TRPCError({ code: "NOT_FOUND" }); + if (current.status !== "pending") { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Only pending licences can be approved." }); + } + if (current.validUntil <= new Date()) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: "An expired licence cannot be approved." }); + } + const [licence] = await db.update(exciseLicences).set({ + status: "active", + approvedBy: ctx.user.id, + approvedAt: new Date(), + updatedAt: new Date(), + }).where(and(eq(exciseLicences.id, input.licenceId), eq(exciseLicences.status, "pending"))).returning(); + if (!licence) throw new TRPCError({ code: "CONFLICT", message: "Licence changed before approval." }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_approved", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status } }); + return licence; + } catch (error) { + return unavailable("Excise licence approval is unavailable.", error); + } + }), + + suspendLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), reason: z.string().min(10).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const now = new Date(); + const [licence] = await db.update(exciseLicences).set({ + status: "suspended", suspendedBy: ctx.user.id, suspendedAt: now, suspensionReason: input.reason, updatedAt: now, + }).where(eq(exciseLicences.id, input.licenceId)).returning(); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + await db.insert(exciseLicenceSuspensions).values({ licenceId: licence.id, suspendedBy: ctx.user.id, suspendedAt: now, reason: input.reason }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_suspended", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status, reason: input.reason } }); + return licence; + } catch (error) { + return unavailable("Excise licence suspension is unavailable.", error); + } + }), + + liftSuspension: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [licence] = await db.select().from(exciseLicences).where(eq(exciseLicences.id, input.licenceId)).limit(1); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + if (licence.status !== "suspended") throw new TRPCError({ code: "BAD_REQUEST", message: "Only suspended licences can be reinstated." }); + const now = new Date(); + const status = licence.validUntil > now ? "active" : "expired"; + const [updated] = await db.update(exciseLicences).set({ + status, suspendedBy: null, suspendedAt: null, suspensionReason: null, updatedAt: now, + }).where(eq(exciseLicences.id, licence.id)).returning(); + await db.update(exciseLicenceSuspensions).set({ liftedAt: now, liftedBy: ctx.user.id }) + .where(and(eq(exciseLicenceSuspensions.licenceId, licence.id), isNull(exciseLicenceSuspensions.liftedAt))); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_suspension_lifted", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status } }); + return updated; + } catch (error) { + return unavailable("Excise licence suspension update is unavailable.", error); + } + }), + + revokeLicence: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), reason: z.string().min(10).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [licence] = await db.update(exciseLicences).set({ + status: "revoked", revokedBy: ctx.user.id, revokedAt: new Date(), revocationReason: input.reason, updatedAt: new Date(), + }).where(eq(exciseLicences.id, input.licenceId)).returning(); + if (!licence) throw new TRPCError({ code: "NOT_FOUND" }); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_licence_revoked", actorId: ctx.user.id, actorType: ctx.user.role, newState: { licenceId: licence.id, status: licence.status, reason: input.reason } }); + return licence; + } catch (error) { + return unavailable("Excise licence revocation is unavailable.", error); + } + }), + + suspensionHistory: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + try { + const { db } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role, false); + return db.select().from(exciseLicenceSuspensions).where(eq(exciseLicenceSuspensions.licenceId, input.licenceId)).orderBy(desc(exciseLicenceSuspensions.suspendedAt)); + } catch (error) { + return unavailable("Excise suspension history is unavailable.", error); + } + }), + + createFacility: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), name: z.string().min(2).max(255), address: z.string().max(1024).optional() })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + if (!isIdIssuer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN", message: "An ID issuer must create facility identifiers." }); + const [facility] = await db.insert(exciseFacilities).values({ + licenceId: licence.id, facilityIdentifier: authorityIdentifier("FI"), name: input.name, address: input.address, createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_facility_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: { facilityId: facility.id } }); + return facility; + } catch (error) { + return unavailable("Excise facility registration is unavailable.", error); + } + }), + + createMachine: protectedProcedure + .input(z.object({ facilityId: z.number().int().positive(), name: z.string().min(2).max(255) })) + .mutation(async ({ ctx, input }) => { + try { + if (!isIdIssuer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + const db = await requireDb(); + const [facility] = await db.select().from(exciseFacilities).where(eq(exciseFacilities.id, input.facilityId)).limit(1); + if (!facility) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(facility.licenceId, ctx.user.id, ctx.user.role); + const [machine] = await db.insert(exciseMarkingMachines).values({ + facilityId: facility.id, machineIdentifier: authorityIdentifier("MI"), name: input.name, createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_machine_created", actorId: ctx.user.id, actorType: ctx.user.role, newState: { machineId: machine.id } }); + return machine; + } catch (error) { + return unavailable("Excise machine registration is unavailable.", error); + } + }), + + createTaxScheme: protectedProcedure + .input(z.object({ + code: z.string().min(2).max(64), + schemeType: z.enum(["specific", "ad_valorem", "hybrid"]), + specificAmount: z.string().regex(/^\d+(\.\d+)?$/).optional(), + specificUnitOfMeasure: z.string().max(32).optional(), + adValoremRate: z.string().regex(/^\d+(\.\d+)?$/).optional(), + hybridWhicheverGreater: z.boolean().default(false), + currency: z.string().length(3).optional(), + })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [scheme] = await db.insert(exciseTaxSchemes).values({ ...input, createdBy: ctx.user.id }).returning(); + return scheme; + } catch (error) { + return unavailable("Excise tax scheme registration is unavailable.", error); + } + }), + + registerProduct: protectedProcedure + .input(z.object({ + licenceId: z.number().int().positive(), + sku: z.string().min(2).max(128), + brand: z.string().min(1).max(255), + packSize: z.number().int().positive(), + unitContent: z.string().regex(/^\d+(\.\d+)?$/), + unitOfMeasure: z.string().min(1).max(32), + strength: z.string().regex(/^\d+(\.\d+)?$/).optional(), + schemeId: z.number().int().positive(), + })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [product] = await db.insert(exciseProducts).values({ + ...input, licenceId: licence.id, createdBy: ctx.user.id, approvalStatus: "pending", + }).returning(); + await logAuditEvent({ entityType: "user", entityId: licence.userId, action: "excise_product_registered", actorId: ctx.user.id, actorType: "licensee", newState: { productId: product.id, approvalStatus: product.approvalStatus } }); + return product; + } catch (error) { + return unavailable("Excise product registration is unavailable.", error); + } + }), + + approveProduct: protectedProcedure + .input(z.object({ productId: z.number().int().positive(), approved: z.boolean(), reason: z.string().max(1024).optional() })) + .mutation(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [product] = await db.update(exciseProducts).set({ + approvalStatus: input.approved ? "approved" : "rejected", + approvedBy: input.approved ? ctx.user.id : null, + approvedAt: input.approved ? new Date() : null, + rejectionReason: input.approved ? null : input.reason, + updatedAt: new Date(), + }).where(eq(exciseProducts.id, input.productId)).returning(); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + return product; + } catch (error) { + return unavailable("Excise product approval is unavailable.", error); + } + }), + + createOrder: protectedProcedure + .input(z.object({ + licenceId: z.number().int().positive(), + productId: z.number().int().positive(), + facilityId: z.number().int().positive(), + declarationId: z.number().int().positive().optional(), + quantity: z.number().int().positive(), + declaredValue: z.string().regex(/^\d+(\.\d+)?$/).optional(), + currency: z.string().length(3), + })) + .mutation(async ({ ctx, input }) => { + try { + const { db, licence } = await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [product] = await db.select().from(exciseProducts).where(and(eq(exciseProducts.id, input.productId), eq(exciseProducts.licenceId, licence.id))).limit(1); + if (!product || product.approvalStatus !== "approved") throw new TRPCError({ code: "PRECONDITION_FAILED", message: "An approved SKU is required." }); + const [facility] = await db.select().from(exciseFacilities).where(and(eq(exciseFacilities.id, input.facilityId), eq(exciseFacilities.licenceId, licence.id))).limit(1); + if (!facility) throw new TRPCError({ code: "FORBIDDEN", message: "Facility does not belong to the licence." }); + const [scheme] = await db.select().from(exciseTaxSchemes).where(eq(exciseTaxSchemes.id, product.schemeId)).limit(1); + if (!scheme || !scheme.active) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The tax scheme is unavailable." }); + if (input.declarationId) { + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, input.declarationId)).limit(1); + if (!declaration) throw new TRPCError({ code: "NOT_FOUND", message: "Declaration not found." }); + if (!isOfficer(ctx.user.role) && (declaration.principalId ?? declaration.traderId) !== licence.userId) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + } + const liability = calculateExciseLiability(scheme, product, input.quantity, input.declaredValue); + const [order] = await db.insert(exciseStampOrders).values({ + orderNumber: `EXO-${randomBytes(10).toString("hex").toUpperCase()}`, + licenceId: licence.id, productId: product.id, facilityId: facility.id, + declarationId: input.declarationId, quantity: input.quantity, declaredValue: input.declaredValue, liability, currency: input.currency, + status: "ordered", createdBy: ctx.user.id, + }).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_created", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: order.status, liability } }); + return order; + } catch (error) { + return unavailable("Excise stamp ordering is unavailable.", error); + } + }), + + assessOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), declaredValue: z.string().regex(/^\d+(\.\d+)?$/).optional() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "assessed"); + const [product] = await db.select().from(exciseProducts).where(eq(exciseProducts.id, order.productId)).limit(1); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + const [scheme] = await db.select().from(exciseTaxSchemes).where(eq(exciseTaxSchemes.id, product.schemeId)).limit(1); + if (!scheme) throw new TRPCError({ code: "PRECONDITION_FAILED" }); + const liability = calculateExciseLiability(scheme, product, order.quantity, input.declaredValue ?? order.declaredValue ?? undefined); + const [updated] = await db.update(exciseStampOrders).set({ status: "assessed", liability, assessedAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_assessed", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, liability, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp assessment is unavailable.", error); + } + }), + + payOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + const lock = await acquireLock(`excise:pay:${input.orderId}`, 30_000); + if (lock.token === "no-redis") { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment idempotency lock is unavailable." }); + } + try { + const db = await requireDb(); + let [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + const { licence } = await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (order.status === "payment" || order.status === "fulfilment" || order.status === "delivery") return order; + requireTransition(order.status, "payment"); + if (!order.liability) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Order must be assessed before payment." }); + const liability = order.liability; + if (!(await tbBridgeAvailable())) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle bridge is unavailable." }); + const idempotencyKey = order.paymentIdempotencyKey ?? `excise:pay:${order.id}`; + if (!order.paymentIdempotencyKey) { + const [claimed] = await db.update(exciseStampOrders).set({ paymentIdempotencyKey: idempotencyKey, updatedAt: new Date() }) + .where(and(eq(exciseStampOrders.id, order.id), isNull(exciseStampOrders.paymentIdempotencyKey))).returning(); + if (!claimed) { + [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, order.id)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + } else { + order = claimed; + } + } + const existingEntries = await db.select().from(tigerBeetleLedgerEntries).where(and( + eq(tigerBeetleLedgerEntries.entryType, "excise_stamp_liability"), + eq(tigerBeetleLedgerEntries.reference, order.orderNumber), + eq(tigerBeetleLedgerEntries.status, "posted"), + )); + const existingEntry = existingEntries.find((entry) => metadataHasIdempotencyKey(entry.metadata, idempotencyKey)); + if (existingEntry) { + const [reconciled] = await db.update(exciseStampOrders).set({ + status: "payment", ledgerTransferId: existingEntry.tbTransferId, paidAt: order.paidAt ?? new Date(), updatedAt: new Date(), + }).where(eq(exciseStampOrders.id, order.id)).returning(); + return reconciled; + } + let transferId = order.ledgerTransferId; + if (transferId) { + await tbFetch>(`/api/ledger/transfers/${transferId}`); + } else { + const transfer = await tbFetch<{ id: string }>("/api/ledger/transfers", { + method: "POST", + body: JSON.stringify({ + idempotencyKey, + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amount: liability, + currency: order.currency, + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + }), + }); + transferId = transfer.id; + await db.update(exciseStampOrders).set({ ledgerTransferId: transferId, updatedAt: new Date() }) + .where(eq(exciseStampOrders.id, order.id)); + } + if (!transferId) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "TigerBeetle transfer identity is unavailable." }); + await createLedgerEntry({ + tbTransferId: transferId, + debitAccountId: `trader-${licence.userId}`, + creditAccountId: SYSTEM_ACCOUNTS.NCS_REVENUE, + amountMinorUnits: Number(parseScaled(liability, 2)), + currency: order.currency, + ledger: 1, + entryType: "excise_stamp_liability", + status: "posted", + reference: order.orderNumber, + description: `Excise stamp liability for ${order.orderNumber}`, + metadata: { idempotencyKey }, + postedAt: new Date(), + }); + const [updated] = await db.update(exciseStampOrders).set({ status: "payment", ledgerTransferId: transferId, paidAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_paid", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status, transferId } }); + return updated; + } catch (error) { + return unavailable("Excise stamp payment is unavailable.", error); + } finally { + await releaseLock(lock); + } + }), + + fulfilOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), machineId: z.number().int().positive().optional() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "fulfilment"); + const ledgerAvailable = order.declarationId ? await tbBridgeAvailable() : true; + if (!ledgerAvailable) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Settlement ledger is unavailable." }); + if (order.declarationId) { + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); + if (!declaration || declaration.declarationType !== "import" || !declaration.totalDue || !declaration.invoiceCurrency) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Customs duty liability is unavailable." }); + const entries = await db.select().from(tigerBeetleLedgerEntries).where(and( + eq(tigerBeetleLedgerEntries.declarationId, order.declarationId), + eq(tigerBeetleLedgerEntries.entryType, "duty_payment"), + eq(tigerBeetleLedgerEntries.status, "posted"), + )); + const mismatchedCurrency = entries.some((entry) => entry.currency !== declaration.invoiceCurrency); + if (mismatchedCurrency) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Customs duty settlement currency cannot be verified without an authoritative exchange rate.", + }); + } + const settled = entries.reduce((sum, entry) => sum + BigInt(entry.amountMinorUnits), 0n); + const due = parseScaled(declaration.totalDue, 2); + if (settled < due) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Customs duty is not fully settled." }); + } + const [updated] = await db.update(exciseStampOrders).set({ status: "fulfilment", fulfilledAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_fulfilled", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp fulfilment is unavailable.", error); + } + }), + + deliverOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + requireTransition(order.status, "delivery"); + const [updated] = await db.update(exciseStampOrders).set({ status: "delivery", deliveredAt: new Date(), updatedAt: new Date() }).where(eq(exciseStampOrders.id, order.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_order_delivered", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise stamp delivery is unavailable.", error); + } + }), + + mintMarks: protectedProcedure + .input(z.object({ + orderId: z.number().int().positive(), + machineId: z.number().int().positive().optional(), + batchSize: z.number().int().positive().max(EXCISE_MINT_BATCH_CAP).default(EXCISE_MINT_BATCH_CAP), + })) + .mutation(async ({ ctx, input }) => { + const lock = await acquireLock(`excise:mint:${input.orderId}`, 60_000); + if (lock.token === "no-redis") { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Minting coordination is unavailable." }); + } + try { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (order.status !== "fulfilment") throw new TRPCError({ code: "BAD_REQUEST", message: "Only fulfilment orders can mint marks." }); + const [{ minted }] = await db.select({ minted: count(exciseStampMarks.id) }).from(exciseStampMarks) + .where(eq(exciseStampMarks.orderId, order.id)); + const remaining = Math.max(0, order.quantity - Number(minted)); + if (remaining === 0) return { marks: [], mintedCount: Number(minted), remaining: 0 }; + const [product] = await db.select().from(exciseProducts).where(eq(exciseProducts.id, order.productId)).limit(1); + if (!product) throw new TRPCError({ code: "NOT_FOUND" }); + const [machine] = input.machineId ? await db.select().from(exciseMarkingMachines).where(eq(exciseMarkingMachines.id, input.machineId)).limit(1) : [undefined]; + if (machine) { + const [facility] = await db.select().from(exciseFacilities).where(eq(exciseFacilities.id, machine.facilityId)).limit(1); + if (!facility || facility.id !== order.facilityId) throw new TRPCError({ code: "FORBIDDEN" }); + } + const marks = await db.transaction(async (tx) => { + const created: typeof exciseStampMarks.$inferSelect[] = []; + const values: typeof exciseStampMarks.$inferInsert[] = []; + for (let index = 0; index < Math.min(input.batchSize, remaining); index += 1) { + const signed = mintExciseUid(); + values.push({ + uid: signed.uid, payload: signed.payload, signature: signed.signature, keyId: signed.keyId, + orderId: order.id, productId: product.id, facilityId: order.facilityId, machineId: machine?.id, + status: "issued", + }); + } + for (let index = 0; index < values.length; index += EXCISE_MINT_CHUNK_SIZE) { + created.push(...await tx.insert(exciseStampMarks).values(values.slice(index, index + EXCISE_MINT_CHUNK_SIZE)).returning()); + } + return created; + }); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_marks_minted", actorId: ctx.user.id, actorType: "licensee", newState: { orderId: order.id, quantity: marks.length } }); + return { marks, mintedCount: Number(minted) + marks.length, remaining: remaining - marks.length }; + } catch (error) { + return unavailable("Excise UID minting is unavailable.", error); + } + } finally { + await releaseLock(lock); + } + }), + + offlineVerify: publicRateLimitedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(({ input }) => verifyExciseUid(input.uid)), + + activateMark: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (mark.status === "active") return mark; + if (mark.status !== "issued") throw new TRPCError({ code: "BAD_REQUEST", message: "Retired marks cannot be activated." }); + const [activation] = await db.insert(exciseMarkActivations).values({ markId: mark.id, activatedBy: ctx.user.id }).onConflictDoNothing().returning(); + if (!activation) { + const [current] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, mark.id)).limit(1); + return current ?? mark; + } + const [updated] = await db.update(exciseStampMarks).set({ status: "active", activatedAt: activation.activatedAt }).where(eq(exciseStampMarks.id, mark.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_mark_activated", actorId: ctx.user.id, actorType: "licensee", newState: { markId: mark.id, status: updated.status } }); + return updated; + } catch (error) { + return unavailable("Excise mark activation is unavailable.", error); + } + }), + + retireMark: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), reason: z.enum(["wastage", "spoilage", "destruction", "seizure", "other"]), details: z.string().min(2).max(1024) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + if (mark.status === "retired") return mark; + const now = new Date(); + await db.insert(exciseRetirements).values({ markId: mark.id, reason: input.reason, details: input.details, retiredBy: ctx.user.id, retiredAt: now }); + const [updated] = await db.update(exciseStampMarks).set({ status: "retired", retiredAt: now, retirementReason: input.reason, retirementDetails: input.details }).where(eq(exciseStampMarks.id, mark.id)).returning(); + await logAuditEvent({ entityType: "user", entityId: ctx.user.id, action: "excise_mark_retired", actorId: ctx.user.id, actorType: "licensee", newState: { markId: mark.id, status: updated.status, reason: input.reason } }); + return updated; + } catch (error) { + return unavailable("Excise mark retirement is unavailable.", error); + } + }), + + reportProduction: protectedProcedure + .input(z.object({ orderId: z.number().int().positive(), quantity: z.number().int().nonnegative() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + const [report] = await db.insert(exciseProductionReports).values({ orderId: order.id, productId: order.productId, facilityId: order.facilityId, quantity: input.quantity, reportedBy: ctx.user.id }).returning(); + return report; + } catch (error) { + return unavailable("Excise production reporting is unavailable.", error); + } + }), + + reconcileOrder: protectedProcedure + .input(z.object({ orderId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, input.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role, false); + const marks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + const reports = await db.select().from(exciseProductionReports).where(eq(exciseProductionReports.orderId, order.id)); + const issuedQuantity = marks.length; + const activatedQuantity = marks.filter((mark) => mark.status === "active").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 = everActivatedQuantity - reportedProductionQuantity; + const [report] = await db.insert(exciseReconciliationReports).values({ + orderId: order.id, issuedQuantity, activatedQuantity, everActivatedQuantity, retiredQuantity, stillIssuedQuantity, + reportedProductionQuantity, stampVariance, productionVariance, computedBy: ctx.user.id, + }).returning(); + return report; + } catch (error) { + return unavailable("Excise reconciliation is unavailable.", error); + } + }), + + createAggregate: protectedProcedure + .input(z.object({ licenceId: z.number().int().positive(), aggregateType: z.enum(["carton", "case", "pallet"]) })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + await requireLicence(input.licenceId, ctx.user.id, ctx.user.role); + const [aggregate] = await db.insert(exciseAggregates).values({ + aggregateUid: `EXA-${randomBytes(18).toString("hex").toUpperCase()}`, + aggregateType: input.aggregateType, + licenceId: input.licenceId, + createdBy: ctx.user.id, + }).returning(); + return aggregate; + } catch (error) { + return unavailable("Excise aggregation is unavailable.", error); + } + }), + + addToAggregate: protectedProcedure + .input(z.object({ aggregateId: z.number().int().positive(), markId: z.number().int().positive().optional(), childAggregateId: z.number().int().positive().optional() }).refine((input) => Boolean(input.markId) !== Boolean(input.childAggregateId), "Exactly one child is required.")) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [parent] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId)).limit(1); + if (!parent) throw new TRPCError({ code: "NOT_FOUND" }); + if (!isEnforcement(ctx.user.role) && parent.createdBy !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + if (input.markId) { + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, input.markId)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order || order.licenceId !== parent.licenceId) throw new TRPCError({ code: "FORBIDDEN" }); + } else if (input.childAggregateId === parent.id) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } else { + const [childAggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.childAggregateId!)).limit(1); + if (!childAggregate) throw new TRPCError({ code: "NOT_FOUND" }); + if (childAggregate.licenceId !== parent.licenceId) throw new TRPCError({ code: "FORBIDDEN" }); + if (AGGREGATE_LEVEL[childAggregate.aggregateType] >= AGGREGATE_LEVEL[parent.aggregateType]) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Aggregate hierarchy cannot skip levels." }); + } + } + const [existing] = await db.select().from(exciseAggregateChildren).where( + and( + input.markId ? eq(exciseAggregateChildren.childMarkId, input.markId) : eq(exciseAggregateChildren.childAggregateId, input.childAggregateId!), + isNull(exciseAggregateChildren.removedAt), + ), + ).limit(1); + if (existing) throw new TRPCError({ code: "CONFLICT", message: "The child already belongs to an aggregate." }); + const [child] = await db.insert(exciseAggregateChildren).values({ aggregateId: parent.id, childMarkId: input.markId, childAggregateId: input.childAggregateId, addedBy: ctx.user.id }).returning(); + if (input.childAggregateId) { + await db.update(exciseAggregates).set({ parentAggregateId: parent.id }).where(eq(exciseAggregates.id, input.childAggregateId)); + } + return child; + } catch (error) { + return unavailable("Excise aggregation is unavailable.", error); + } + }), + + disaggregate: protectedProcedure + .input(z.object({ childId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + const [child] = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.id, input.childId), isNull(exciseAggregateChildren.removedAt))).limit(1); + if (!child) throw new TRPCError({ code: "NOT_FOUND" }); + const now = new Date(); + await db.update(exciseAggregateChildren).set({ removedAt: now, removedBy: ctx.user.id }).where(eq(exciseAggregateChildren.id, child.id)); + await db.insert(exciseMovementEvents).values({ aggregateId: child.aggregateId, eventType: "disaggregation", actorId: ctx.user.id, metadata: { childId: child.id } }); + if (child.childAggregateId) await db.update(exciseAggregates).set({ parentAggregateId: null }).where(eq(exciseAggregates.id, child.childAggregateId)); + return { ...child, removedAt: now, removedBy: ctx.user.id }; + } catch (error) { + return unavailable("Excise disaggregation is unavailable.", error); + } + }), + + aggregateContents: protectedProcedure + .input(z.object({ aggregateUid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.aggregateUid, input.aggregateUid)).limit(1); + if (!aggregate) throw new TRPCError({ code: "NOT_FOUND" }); + const children = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.aggregateId, aggregate.id), isNull(exciseAggregateChildren.removedAt))); + return { aggregate, children }; + } catch (error) { + return unavailable("Excise aggregate contents are unavailable.", error); + } + }), + + markAggregate: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [child] = await db.select().from(exciseAggregateChildren).where(and(eq(exciseAggregateChildren.childMarkId, mark.id), isNull(exciseAggregateChildren.removedAt))).limit(1); + if (!child) return { aggregate: null }; + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, child.aggregateId)).limit(1); + return { aggregate: aggregate ?? null }; + } catch (error) { + return unavailable("Excise mark aggregation is unavailable.", error); + } + }), + + recordMovement: protectedProcedure + .input(z.object({ + markId: z.number().int().positive().optional(), + aggregateId: z.number().int().positive().optional(), + eventType: z.enum(["dispatch", "receipt", "export", "re_entry", "seizure", "destruction"]), + location: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + }).refine((input) => Boolean(input.markId) !== Boolean(input.aggregateId), "Exactly one movement subject is required.")) + .mutation(async ({ ctx, input }) => { + try { + const db = await requireDb(); + if (input.markId) { + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.id, input.markId)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) throw new TRPCError({ code: "NOT_FOUND" }); + await requireLicence(order.licenceId, ctx.user.id, ctx.user.role); + } else { + const [aggregate] = await db.select().from(exciseAggregates).where(eq(exciseAggregates.id, input.aggregateId!)).limit(1); + if (!aggregate) throw new TRPCError({ code: "NOT_FOUND" }); + if (!isEnforcement(ctx.user.role) && aggregate.createdBy !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); + } + const [event] = await db.insert(exciseMovementEvents).values({ ...input, actorId: ctx.user.id }).returning(); + return event; + } catch (error) { + return unavailable("Excise movement recording is unavailable.", error); + } + }), + + enforcementScan: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), latitude: z.number().min(-90).max(90).optional(), longitude: z.number().min(-180).max(180).optional() })) + .mutation(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + const scan = await recordScan(db, input.uid, mark?.id ?? null, "enforcement", ctx.user.id, input.latitude, input.longitude); + if (!mark) return { status: "unknown" as const, scan, history: [] }; + if (!isStrongExciseKey(getExciseKey(mark.keyId))) { + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Excise UID verification is unavailable." }); + } + const [activation] = await db.select().from(exciseMarkActivations).where(eq(exciseMarkActivations.markId, mark.id)).limit(1); + const movements = await db.select().from(exciseMovementEvents).where(eq(exciseMovementEvents.markId, mark.id)).orderBy(asc(exciseMovementEvents.occurredAt)); + const scans = await db.select().from(exciseScans).where(eq(exciseScans.uid, input.uid)).orderBy(asc(exciseScans.scannedAt)); + const signature = verifyExciseUid(mark.uid); + return { + status: signature.status === "verification_unavailable" + ? "unavailable" as const + : signature.status === "invalid_signature" || mark.status === "retired" ? "suspect" as const : "authentic" as const, + mark, activation: activation ?? null, movements, scans, scan, + }; + } catch (error) { + return unavailable("Excise enforcement scan is unavailable.", error); + } + }), + + seize: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192), location: z.string().max(255).optional(), reason: z.string().min(5).max(1024) })) + .mutation(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) throw new TRPCError({ code: "NOT_FOUND" }); + const [seizure] = await db.insert(exciseSeizures).values({ markId: mark.id, seizedBy: ctx.user.id, location: input.location, reason: input.reason }).returning(); + await db.insert(exciseMovementEvents).values({ markId: mark.id, eventType: "seizure", actorId: ctx.user.id, location: input.location, metadata: { seizureId: seizure.id } }); + return seizure; + } catch (error) { + return unavailable("Excise seizure recording is unavailable.", error); + } + }), + + publicVerify: publicRateLimitedProcedure + .input(z.object({ uid: z.string().min(8).max(192), latitude: z.number().min(-90).max(90).optional(), longitude: z.number().min(-180).max(180).optional() })) + .mutation(async ({ input }) => { + if (!isStrongExciseKey(process.env[EXCISE_UID_HMAC_ENV])) return { status: "unavailable" as const }; + const signature = verifyExciseUid(input.uid); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (mark && !isStrongExciseKey(getExciseKey(mark.keyId))) return { status: "unavailable" as const }; + await recordScan(db, input.uid, mark?.id ?? null, "public", null, input.latitude, input.longitude); + if (!mark) return { status: signature.status === "invalid_signature" ? "suspect" as const : "unknown" as const }; + if (signature.status === "verification_unavailable") return { status: "unavailable" as const }; + if (signature.status === "invalid_signature" || mark.status === "retired") return { status: "suspect" as const }; + return { status: "authentic" as const }; + } catch { + return { status: "unavailable" as const }; + } + }), + + traverseSource: protectedProcedure + .input(z.object({ uid: z.string().min(8).max(192) })) + .query(async ({ ctx, input }) => { + if (!isEnforcement(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const [mark] = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.uid, input.uid)).limit(1); + if (!mark) return { available: false as const, reason: "mark_not_found" as const }; + const [order] = await db.select().from(exciseStampOrders).where(eq(exciseStampOrders.id, mark.orderId)).limit(1); + if (!order) return { available: false as const, reason: "order_missing" as const }; + if (!order.declarationId) return { available: false as const, reason: "declaration_missing" as const }; + const [declaration] = await db.select().from(declarations).where(eq(declarations.id, order.declarationId)).limit(1); + if (!declaration) return { available: false as const, reason: "declaration_missing" as const }; + if (!declaration.billOfLadingId && !declaration.billOfLadingNumber) return { available: false as const, reason: "bill_of_lading_not_linked" as const }; + const bills = declaration.billOfLadingId + ? await db.select().from(billsOfLading).where(eq(billsOfLading.id, declaration.billOfLadingId)).limit(1) + : await db.select().from(billsOfLading).where(eq(billsOfLading.blNumber, declaration.billOfLadingNumber!)); + if (!declaration.billOfLadingId && bills.length > 1) { + return { available: false as const, reason: "bill_of_lading_ambiguous" as const }; + } + const [bl] = bills; + if (!bl) return { available: false as const, reason: "bill_of_lading_missing" as const }; + const [manifest] = await db.select().from(manifests).where(eq(manifests.id, bl.manifestId)).limit(1); + if (!manifest) return { available: false as const, reason: "manifest_missing" as const }; + if (!declaration.principalId && !declaration.traderId) { + return { available: false as const, reason: "importer_missing" as const }; + } + const siblingMarks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); + return { + available: true as const, + mark, + order, + declaration: { id: declaration.id, declarationNumber: declaration.declarationNumber, ucr: declaration.ucr }, + billOfLading: { id: bl.id, blNumber: bl.blNumber }, + manifest: { id: manifest.id, manifestNumber: manifest.manifestNumber, vesselName: manifest.vesselName, mmsi: manifest.mmsi, imo: manifest.imo }, + importerUserId: declaration.principalId ?? declaration.traderId, + actingAgentUserId: declaration.actingAgentId ?? null, + siblingMarks, + }; + } catch (error) { + return unavailable("Excise source traversal is unavailable.", error); + } + }), + + analytics: protectedProcedure + .input(z.object({ orderId: z.number().int().positive().optional() }).optional()) + .query(async ({ ctx, input }) => { + if (!isOfficer(ctx.user.role)) throw new TRPCError({ code: "FORBIDDEN" }); + try { + const db = await requireDb(); + const orderFilter = input?.orderId ? eq(exciseStampOrders.id, input.orderId) : undefined; + const [orderStats] = await db.select({ + orders: count(exciseStampOrders.id), + paid: sql`count(*) filter (where ${exciseStampOrders.paidAt} is not null)`, + }).from(exciseStampOrders).where(orderFilter); + const [markStats] = await db.select({ + issued: count(exciseStampMarks.id), + activated: sql`count(*) filter (where ${exciseStampMarks.status} = 'active')`, + 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); + 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 everActivated = Number(markStats?.everActivated ?? 0); + const retired = Number(markStats?.retired ?? 0); + const stillIssued = Number(markStats?.stillIssued ?? 0); + const reportedProduction = Number(productionStats?.reported ?? 0); + return { + orders: Number(orderStats?.orders ?? 0), + issued, + activated, + retired, + paid: Number(orderStats?.paid ?? 0), + reportedProduction, + stampAccountabilityVariance: issued - activated - retired - stillIssued, + productionAccountabilityVariance: everActivated - reportedProduction, + everActivated, + anomalies: Number(anomalyStats?.anomalies ?? 0), + }; + } catch (error) { + return unavailable("Excise analytics are unavailable.", error); + } + }), +}); 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/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/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/regulatory.ts b/server/routers/regulatory.ts new file mode 100644 index 00000000..8ea58662 --- /dev/null +++ b/server/routers/regulatory.ts @@ -0,0 +1,720 @@ +import { TRPCError } from "@trpc/server"; +import { randomUUID } from "node:crypto"; +import { + and, + asc, + desc, + eq, + gte, + isNull, + isNotNull, + 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 "./ledger"; +import { acquireLock, releaseLock } from "../_core/distributedLock"; +import { requireDeclarationActor } from "../_core/mandateAuthorization"; + +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: Pick, + 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: Pick, + 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; +} + +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, + 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, + })), + ]; +} + +async function evaluateObligations( + db: Pick, + input: ObligationInput, + registers: MatchingRegisters, + consumePermits: boolean, + obligations = registerObligations(registers), +) { + const evaluated = []; + for (const obligation of obligations) { + const permit = await permitSatisfies(db, input, obligation, consumePermits); + evaluated.push({ ...obligation, permit }); + } + 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 }; +} + +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); + const prohibition = registers.restrictions.find((entry) => entry.restrictionType === "prohibition"); + if (prohibition) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Declaration refused under ${prohibition.legalInstrument}: ${prohibition.description}`, + }); + } + if (!input.declarationId || registerObligations(registers).length === 0) return; + await db.transaction(async (tx) => { + 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, + 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 [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, + }; + 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: "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) { + const db = await requireRegulatoryDb(); + const result = await buildObligations(db, input); + 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 = (await quotaSatisfaction(db, input, result.quotas)).map((check) => ({ + required: true as const, + satisfied: check.satisfied, + blocking: !check.satisfied, + quotaCode: check.quota.quotaCode, + legalInstrument: check.quota.legalInstrument, + requiredQuantity: input.quantity, + allocatedQuantity: String(check.allocatedQuantity), + })); + return { + registersAvailable: true as const, + prohibited: result.restrictions + .filter((entry) => entry.restrictionType === "prohibition") + .map((entry) => ({ + description: entry.description, + legalInstrument: entry.legalInstrument, + })), + obligations: [...graph, ...quotaGraph], + blocking: result.restrictions.some((entry) => entry.restrictionType === "prohibition") || + graph.some((entry) => entry.blocking) || quotaGraph.some((entry) => entry.blocking), + }; +} + +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), + 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(); + 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 + .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 !== "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) { + 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; + 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." }); + } + 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.allocatedLedgerAccountId, + amount: input.quantity, + currency: "QTY", + reference: quota.quotaCode, + description: `Tariff quota allocation for declaration ${input.declarationId}`, + idempotencyKey: `regulatory:quota:${quota.id}:${input.declarationId}:${attempt}`, + }), + }); + 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.allocatedLedgerAccountId, + 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/stakeholderRegistrations.ts b/server/routers/stakeholderRegistrations.ts new file mode 100644 index 00000000..2beddf3d --- /dev/null +++ b/server/routers/stakeholderRegistrations.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { protectedProcedure, publicRateLimitedProcedure, router } from "../_core/trpc"; +import { + createStakeholderRegistration, + getStakeholderRegistrationById, + getPendingStakeholderRegistrationForUser, + getStakeholderRegistrationsByUser, + getPendingStakeholderRegistrations, + updateStakeholderRegistration, + createStakeholderMandate, + getStakeholderMandateById, + getStakeholderMandateByReference, + revokeStakeholderMandate, + getStakeholderMandatesByPrincipal, + getStakeholderMandatesByAgent, + getApprovedAgentRegistration, + getApprovedAgentRegistrations, + getApprovedTraderProfile, + logAuditEvent, +} from "../db"; +import { resolveActingPrincipal } from "../_core/mandateAuthorization"; +import { lookupPublicApplication } from "../_core/applicationTracking"; +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 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, + 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: publicRateLimitedProcedure + .input(z.object({ referenceNumber: z.string().min(8).max(32) })) + .query(async ({ input }) => { + try { + return await lookupPublicApplication(input.referenceNumber); + } 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); + } + }), + + 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); + } + }), + + 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 }) => { + 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/smoke.stakeholders.test.ts b/server/smoke.stakeholders.test.ts index 1bd24945..145ba200 100644 --- a/server/smoke.stakeholders.test.ts +++ b/server/smoke.stakeholders.test.ts @@ -264,7 +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 view shipment position", () => expectQuery("cargoTracking.getShipmentPosition")); + 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 d8dfde7a..aa72f16e 100644 --- a/server/sprint66-68.test.ts +++ b/server/sprint66-68.test.ts @@ -184,7 +184,7 @@ describe("Sprint 68 — OpenAPI Specification", () => { }); it("cargo tracking procedures are in catalogue", () => { - const cargoProcs = ["getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats", "getShipmentPosition"]; + const cargoProcs = ["getShipmentPosition", "getLiveVessels", "getVesselRoute", "getPortArrivals", "getVesselStats"]; for (const proc of cargoProcs) { expect(proc).toBeTruthy(); } 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 new file mode 100644 index 00000000..a305eb97 --- /dev/null +++ b/server/stakeholderRegistrations.test.ts @@ -0,0 +1,248 @@ +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, + pendingDuplicate: undefined as unknown, +})); + +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; + }), + 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]; + }), + 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", + }; + }), + 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 }; + }), + 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(), + 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("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({ + 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("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("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( + 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" }); + await expect( + appRouter.createCaller(context(2)).stakeholderRegistrations.approvedAgents(), + ).rejects.toMatchObject({ code: "SERVICE_UNAVAILABLE" }); + dbState.unavailable = false; + }); +}); 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", () => { 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..64af87e1 --- /dev/null +++ b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go @@ -0,0 +1,247 @@ +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) + } + store.accounts["trader-test"].CreditsPosted = decimal.NewFromInt(100) + + 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) + } + store.accounts["trader-race"].CreditsPosted = decimal.NewFromInt(100) + + 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)) + } +} + +func TestPostTransferRejectsDebitOverdraft(t *testing.T) { + store := NewStore() + 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 { + 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)) + } +} + +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 459e5ed1..e68ed4b6 100644 --- a/services/go/tigerbeetle-bridge/cmd/main.go +++ b/services/go/tigerbeetle-bridge/cmd/main.go @@ -81,25 +81,26 @@ 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" ) // 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). @@ -121,12 +122,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 +199,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) @@ -205,6 +216,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 @@ -212,6 +226,10 @@ func (s *Store) PostTransfer(t *Transfer) error { switch t.Flag { case FlagPending: + available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) + 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) credit.CreditsPending = credit.CreditsPending.Add(t.Amount) t.Status = "PENDING" @@ -222,6 +240,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] @@ -252,6 +273,10 @@ func (s *Store) PostTransfer(t *Transfer) error { default: // Immediate (non-pending) transfer + available := debit.CreditsPosted.Sub(debit.DebitsPosted).Sub(debit.DebitsPending) + 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) credit.CreditsPosted = credit.CreditsPosted.Add(t.Amount) t.Status = "POSTED" @@ -270,6 +295,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() @@ -328,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) @@ -348,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) @@ -395,17 +444,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 +465,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 +499,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 +517,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 +568,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,