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"}
setSelectedVessel(null)}>
@@ -452,12 +466,12 @@ export default function CargoTrackingMap() {
- {selectedVessel.status.toUpperCase()}
+ {selectedVessel.status?.toUpperCase() ?? "STATUS UNAVAILABLE"}
- {selectedVessel.cargoStatus.replace("-", " ")}
+ {selectedVessel.cargoStatus ? selectedVessel.cargoStatus.replace("-", " ") : "Cargo status 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.
+
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" && onRevoke?.(mandate.id)}>Revoke }
; })}
: 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.
+
+
+
+
;
+}
+
+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() : "—"})
}
approve.mutate({ registrationId: item.id })} disabled={approve.isPending}>Approve { const reason = window.prompt("Rejection reason (at least 10 characters):"); if (reason && reason.length >= 10) reject.mutate({ registrationId: item.id, reason }); }} disabled={reject.isPending}>Reject
)}
: 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 (
+
+
+
+
+ Application reference
+
+
+
+
+ {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 number
+
+
+ {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 (
+
+
+
+
+ Declaration or UCR reference
+
+
+
+
+ {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,