diff --git a/docs/excise-tax-stamps-parity.md b/docs/excise-tax-stamps-parity.md new file mode 100644 index 00000000..ff3767df --- /dev/null +++ b/docs/excise-tax-stamps-parity.md @@ -0,0 +1,154 @@ +# Digital tax stamps / excise traceability: market comparison and design + +## 1. What the leading platforms actually do + +Reference set (public product documentation, plus the regulatory floor those products are built to): + +| Platform | Vendor | Public source | +| --- | --- | --- | +| SICPATRACE® Evo | SICPA | https://www.sicpa.com/solutions/sicpatrace | +| TransAct™ | Authentix | https://authentix-us.com/governments/taxstamp/ | +| DirectTrace excise suite | DirectTrace | https://direct-trace.com/for-excise-tax/ | +| Regulatory floor | EU Commission Implementing Regulation (EU) 2018/574 (TPD Art. 15 traceability), implementing WHO FCTC Illicit Trade Protocol Art. 8 | https://eur-lex.europa.eu/eli/reg/2018/574/oj | + +Distilling their published capability sets, a credible excise-traceability platform has to cover +eleven capability areas. `C*` labels are used throughout this document. + +- **C1 Licensee / taxpayer administration.** Registration and licensing of manufacturers, importers, + distributors and retailers of excisable goods, with licence validity and suspension. +- **C2 Facility and production-line registry.** Each production or storage facility and each marking + machine identified. 2018/574 makes this explicit: economic operator identifier (EOID), facility + identifier (FID), machine identifier, all issued by an independent **ID issuer**. +- **C3 Product master data and taxation schemes.** Registered SKUs (brand, pack size, strength/volume) + mapped to an excise scheme — specific (per stick / per litre / per litre of pure alcohol), + ad valorem, or hybrid. +- **C4 Stamp / mark procurement.** Order → approval → fiscal liability → payment → fulfilment → + delivery, with stamp stock accounted for at every hop. +- **C5 Unique serialised identifiers.** A non-guessable unique identifier per unit packet, generated + independently of the manufacturer, resistant to duplication, and recorded with its issuance context. +- **C6 Activation and production reporting.** Marks are activated when applied; wastage, spoilage and + destruction are declared; issued stamps reconcile against activated stamps and reported production. +- **C7 Aggregation.** Unit → carton → master case → pallet, each aggregate carrying its own unique + aggregated identifier, so a pallet scan resolves every unit packet inside it (2018/574 Art. 10, + Annex II). +- **C8 Supply-chain movement events.** Dispatch, arrival, transfer of ownership, export, re-entry, + destruction — the event stream that turns marks into traceability. +- **C9 Field enforcement.** Inspector scans a mark and gets authenticity plus the mark's full history; + seizures recorded against the mark. Only authorised enforcement users see the data behind a stamp. +- **C10 Public / consumer authentication.** Anyone can verify a mark; the answer must not leak the + commercial data behind it. +- **C11 Analytics and revenue reconciliation.** Revenue realised vs. expected, illicit-trade + indicators, duplicate marks, diversion detection. + +## 2. What this platform has today + +Searching the repository for the excise domain returns exactly one thing: + +``` +server/businessRules.ts:275 exciseRate?: number; // For excisable goods +server/businessRules.ts:290 const excise = (cifValue * (input.exciseRate ?? 0)) / 100; +``` + +An ad valorem excise term inside `calculateDuty`, and nothing else. There is no stamp, mark, serial, +licensee, facility, SKU or activation concept anywhere in `drizzle/schema.ts` or in the 104 routers. + +So the honest comparison is not "which features are missing" but **C1–C11 are all absent**: this is a +customs single-window with no excise-traceability capability at all. What it does bring, and what no +tax-stamp vendor has, is the other half of the problem: declarations, valuation, duty assessment, a +double-entry ledger, payments, risk lanes, manifests and bills of lading, an audit trail, and an +enforcement/officer model. That asymmetry is what section 4 exploits. + +| Capability | Leading platforms | This platform (before) | Planned | +| --- | --- | --- | --- | +| C1 Licensee administration | yes | none | excise licences with validity + suspension | +| C2 Facility / machine registry | yes (EOID/FID/machine) | none | facility + machine identifiers, ID-issuer-owned | +| C3 Product master data + schemes | yes | none | SKU registry, specific/ad valorem/hybrid schemes | +| C4 Stamp procurement | yes | none | order → assess → pay → fulfil, ledger-posted | +| C5 Serialised UIDs | yes | none | signed, non-guessable UIDs minted server-side | +| C6 Activation + production reporting | yes | none | activation, wastage, destruction, reconciliation | +| C7 Aggregation | yes | none | unit → carton → case → pallet, resolvable both ways | +| C8 Movement events | yes | none | dispatch/receipt/export/seizure event stream | +| C9 Field enforcement | yes | none | authorised scan with full history + seizure capture | +| C10 Public authentication | yes | none | public rate-limited verify, no commercial data | +| C11 Analytics + reconciliation | yes | none | issued/activated/paid reconciliation + anomalies | + +## 3. Design rules carried over from the audit + +This module is built under the same rules the fail-closed remediation established, because an excise +system is a money system: + +1. **No fabricated authenticity.** A verification result is `authentic`, `unknown`, `suspect` or + `unavailable`. A dependency outage never renders as "authentic", and never as "counterfeit" either — + accusing a legitimate trader on the strength of a Redis timeout is the same defect in the other + direction. +2. **No fabricated reconciliation.** Unreconciled variance is reported as variance. It is never + rounded to zero and never suppressed. +3. **Fail closed on money.** Stamps are not released, and marks are not activated, when the ledger, + database or payment path is unavailable. +4. **UIDs are minted server-side and are unguessable.** A licensee cannot choose its own serials, and + a serial cannot be derived from another serial. +5. **Public endpoints disclose status only.** No brand, licensee, volume, consignee, value or route on + a public scan. +6. **Unknown is nullable.** No zero-valued or empty-string placeholders standing in for absent data. + +## 4. The six innovations + +These are deliberately *not* reimplementations of vendor features. Each one exists only because this +platform holds both halves of the data — the customs/fiscal side and the mark side — which the +standalone tax-stamp platforms do not. + +**I1 — Declaration-linked stamp issuance, gated on settled duty.** +For imported excisable goods, a stamp order is bound to the customs declaration (and through the +linkage added for shipment tracking, to its bill of lading and manifest). Stamps are released only +when the declaration's duty is *settled in the ledger* — not merely marked paid. This closes the +leak every standalone stamp platform lives with: the stamp programme and the customs programme are +different systems, so goods can clear customs and never be stamped, or be stamped and never declared. +Here the two are the same transaction. + +**I2 — Offline-verifiable marks.** +Each UID carries a truncated HMAC over the serial payload, keyed by a server-held secret with a key +identifier in the mark. An inspector's device holds a verification key and can distinguish a +well-formed mark from an invented one *with no connectivity*, then reconcile the scan on reconnect. +The offline answer is explicitly labelled `signature_valid_pending_reconciliation` — it proves the +mark was minted by the authority, and does not claim the pack is legitimate, because a genuine mark +can still be cloned onto illicit product. That distinction is the entire point, and it is the one +thing offline verification usually gets dishonestly wrong. + +**I3 — Impossible-travel detection on scans.** +The same UID scanned in two places implies a speed between them. Above a physical threshold, one of +the two marks is a clone. This is the mobile-money fraud-detection pattern applied to fiscal marks, +using the platform's existing geospatial data. It flags the *mark*, not the trader, and it records +both scans as evidence rather than deleting the "wrong" one. + +**I4 — Stamp liability on the double-entry ledger.** +Stamp orders post to the existing ledger, so at any moment `stamps issued × unit liability` is +reconcilable against `paid`, `activated` and `reported production`. Vendors report stamp counts; +posting the liability into the same ledger that carries duty and VAT means excise revenue is +auditable by the same reconciliation that covers everything else, and a variance cannot hide in a +spreadsheet between two systems. + +**I5 — Consumer scan as an enforcement sensor.** +Public verification is anonymous and discloses nothing commercial, but the scan itself is retained as +a signal feeding I3 and the risk model. Consumers become a national sensor network for illicit trade +without surrendering any personal data and without being told anything about the supply chain. + +**I6 — Seizure-to-source graph traversal.** +From a seized unit packet, resolve upward through aggregation (carton → case → pallet) to the +production or import event, the declaration, the manifest, the importer and the mandate-holding agent +who filed it — and back down to every sibling mark from the same batch that is still in the market. +Enforcement's real question is not "is this pack fake" but "where did it come from and what else came +with it", and answering that needs both the aggregation tree and the customs record. + +## 5. Also closing: two residual findings from the audit + +Both were left open in the audit's residual register as policy decisions. They are closed here as +*mechanism* — configuration replaces hardcoded constants, and absence fails closed — without inventing +Nigerian or Ghanaian rates, which remains the authority's data to load: + +- **Flat 10% duty / 15% VAT.** Replaced by a persisted tariff schedule keyed by HS code and effective + date. A declaration whose HS code has no effective rate is **rejected**, not assessed at a default. + A wrong-but-plausible assessment is worse than a refusal. +- **GHS/NGN/USD incoherence.** Replaced by an explicit jurisdiction configuration (customs accounting + currency plus permitted settlement currencies) and a persisted FX rate with a source and timestamp. + No rate on the valuation date means the assessment fails closed rather than silently mixing + currencies. diff --git a/docs/single-window-market-parity.md b/docs/single-window-market-parity.md new file mode 100644 index 00000000..48beca19 --- /dev/null +++ b/docs/single-window-market-parity.md @@ -0,0 +1,117 @@ +# Single-window market comparison: gaps and innovations + +## 1. Reference set + +| Platform | Operator | Public source | +| --- | --- | --- | +| TradeNet / Networked Trade Platform (NTP) | Singapore Customs | https://www.customs.gov.sg/doing-business/quick-links-for-traders/tradenet/what-you-need-to-know-about-tradenet/ | +| EU Single Window Environment for Customs / CSW-CERTEX | European Commission (DG TAXUD) | https://taxation-customs.ec.europa.eu/customs/customs-controls/eu-single-window-environment-customs_en | +| ASYCUDAWorld national/regional single window | UNCTAD | https://asycuda.org/ | +| Regulatory floor | Regulation (EU) 2022/2399 + Delegated Reg. (EU) 2024/2514; WTO TFA Arts. 3, 4, 7, 10.4; WCO Data Model | https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02022R2399-20241017 | + +Capability areas distilled from those sources, labelled `SW*` below. + +## 2. Comparison + +Verified against the repository, not against its own marketing components. + +| # | Capability | Reference platforms | This platform | Verdict | +| --- | --- | --- | --- | --- | +| SW1 | Single declaration serving all agencies | TradeNet: one submission, all controlling agencies | declarations + `ogaPermits` per declaration | **present** | +| SW2 | Declaring-agent model (submission on behalf of a principal) | TradeNet DA functions | `stakeholderMandates`, principal/acting-agent on declarations | **present** (added in the parity work) | +| SW3 | Amendment / cancellation / refund of a lodged declaration | TradeNet: amendment, cancellation **and** refund applications | `declarationAmendments` (request/review only); `drawback` covers duty drawback on re-export | **partial** — no cancellation, no overpayment refund | +| SW4 | Formalities catalogue: which non-customs permits a consignment actually needs | CSW-CERTEX's core purpose — automatic verification of non-customs formalities against declaration data at clearance | nothing; a grep for `requiredPermits`/`permitRequirement` across `server/` returns one unrelated type field in `server/_core/polyglotClients.ts:178` | **absent** | +| SW5 | Prohibitions & restrictions register keyed by HS code / origin / regime | standard in all three | no register; only incidental mentions in `vision.ts`, `auditEngine.ts` | **absent** | +| SW6 | Tariff quotas / quantitative restrictions with balance drawdown | ASYCUDA, EU | none | **absent** | +| SW7 | Right of appeal against a customs decision (TFA Art. 4) | all three; a treaty obligation | none — no appeals router or table | **absent** | +| SW8 | Advance rulings | TFA Art. 3 | `advanceRuling` (submit, issue decision) | **present**, but rulings are not binding on later assessment and are not published | +| SW9 | Machine-to-machine channel for approved trader front-ends | TradeNet front-end providers; NTP API/SFTP | `devPortal` (scoped API keys, rate limits, sandbox) | **present** | +| SW10 | Standards-based messaging (WCO Data Model, EDIFACT CUSDEC/CUSRES) | all three | `ncsNrs.ingestEDI` accepts EDIFACT, but the mapping lives behind an external gateway, not in this repo | **partial / unverifiable here** | +| SW11 | Cross-border exchange with partner administrations | NTP↔foreign customs; CSW-CERTEX; ASYCUDA regional | `aseanSw` adapter exists and now honestly reports unavailable (the fabricated data was removed in the audit remediation) | **surface only** | +| SW12 | AEO / trusted trader | all three | `aeo`, `aeoRenewals`, MRA partners | **present** | +| SW13 | Risk management, valuation, origin, post-clearance audit | all three | `riskModel`, `valuation`, `wtoValuation`, `rulesOfOrigin`, `postAudit` | **present, ahead** | +| SW14 | Payment, ledger, reconciliation | GIRO / banking APIs | Mojaloop + TigerBeetle double-entry, fail-closed after remediation | **present, ahead** | + +So on the classic single-window core this platform is at or ahead of the reference set. The gaps are +concentrated in the **regulatory-obligation layer** — SW4, SW5, SW6, SW7 — plus SW3's missing halves. +That is a coherent pattern: the platform automates the *customs* decision well and has almost nothing +that tells it what the *law* requires for a given consignment, or that gives a trader recourse when +the decision goes against them. + +### A confirmed defect found while comparing + +`server/businessRules.ts:517-560` presents itself as a live exchange-rate service: + +``` +// ─── 11. Live Exchange Rate Fetcher (R2 FIX) ───────────────────────────────── +// Replaces the previously hardcoded USD conversion rates with a live fetch +// from the European Central Bank (ECB) XML feed — free, no API key required. +// Falls back to a conservative in-memory cache on network failure. + +const FALLBACK_RATES_TO_EUR: Record = { + USD: 1.08, GBP: 0.86, GHS: 16.5, RWF: 1430, KES: 140, NGN: 1680, ... +``` + +The ECB daily reference feed does not publish NGN, GHS, RWF, KES, XOF or XAF. Fetched just now, the +feed carries 29 currencies: USD JPY CZK DKK GBP HUF PLN RON SEK CHF ISK NOK TRY AUD BRL CAD CNY HKD +IDR ILS INR KRW MXN MYR NZD PHP SGD THB ZAR — `grep -c NGN` returns `0`. + +So for **every** currency this platform actually operates in, the "live" fetch always misses and the +hardcoded constant is always used. A duty assessment in Nigeria is being computed at a rate hardcoded +in source in mid-2026, labelled as live, with no staleness surfaced to the officer or the trader — and +for NGN the legally correct source is the CBN rate, which the codebase already knows about +(`ncsNrs.updateCBNRate`) and does not consult here. Same family as the audit's fabricated-success +findings: the number is plausible, wrong, and presented as authoritative. + +## 3. Gaps to close + +- **SW4 formalities catalogue.** A register of non-customs formalities keyed by HS code, origin, + destination and regime, which derives the required permits at submission, routes to the right + agencies, and blocks release while a required formality is unsatisfied. Mirrors CSW-CERTEX: the + permit is *verified against the declaration data*, not merely attached to it — quantity decremented, + validity checked, consignee matched. +- **SW5 prohibitions & restrictions.** Prohibited and restricted goods keyed by classification and + origin, evaluated at submission, with the legal instrument cited on refusal. +- **SW6 tariff quotas.** Quota periods with balances, allocation on a first-come basis, and drawdown + that cannot go negative or double-spend under concurrency. +- **SW7 appeals.** A right-of-appeal workflow against a customs decision (assessment, seizure, + classification, refusal), with statutory deadlines, independent reviewer separation from the + original decision-maker, and an outcome that can actually reverse the decision it appeals. +- **SW3 completion.** Declaration cancellation, and refund of overpaid duty, distinct from drawback. +- **SW8 hardening.** Advance rulings become binding: a ruling on the same HS code/goods for the same + trader is applied to later assessment, and diverging from it requires a recorded justification. +- **FX fail-closed.** Stated in section 2. No authoritative rate for the valuation date means the + assessment refuses, using the CBN rate as the Nigerian source of truth. + +## 4. The six innovations for this track + +**J1 — Formality-aware clearance graph.** Compute, at submission, the exact set of formalities a +consignment needs (SW4/SW5/SW6 evaluated together) and expose it as a dependency graph the trader can +see: what is required, what is satisfied, what is blocking, and which legal instrument imposes it. +Reference platforms tell a trader their declaration was rejected; this tells them the specific +unsatisfied obligation before they submit. + +**J2 — Quota drawdown on the double-entry ledger.** Tariff-quota balances are held as ledger accounts +rather than a counter column, so allocation is atomic, auditable and impossible to double-spend under +concurrent submissions — the same property the platform already relies on for money. Quota fraud in +practice *is* concurrency fraud, and a `UPDATE ... SET balance = balance - n` column loses that race. + +**J3 — Binding advance rulings enforced at assessment time.** A ruling is not a document, it is a +constraint: when a declaration matches an issued ruling's scope, the assessment must follow it, and an +officer departing from it must record a justification that is itself appealable. Turns TFA Art. 3 from +a filing cabinet into a control. + +**J4 — Appeal that reverses through the ledger.** An upheld appeal against an assessment issues the +corrective ledger entries (refund, quota restoration, seizure release) as part of the appeal outcome, +rather than leaving a human to remember. Independence is enforced structurally: the reviewer cannot be +the original decision-maker, and the platform's insider-threat surface already gives us the primitives. + +**J5 — Staleness-aware valuation.** Every assessment records the exchange rate it used, its source, +and the age of that rate; an assessment computed on a rate older than its permitted window is refused +rather than silently produced. The FX defect above becomes structurally impossible instead of +individually patched. + +**J6 — Regulatory-change replay.** Formalities, P&R entries, quotas and tariff rates are all +effective-dated. That makes it possible to ask what a past declaration *would* have been assessed at +under today's rules, and — more usefully for a revenue authority — to quantify the exposure of a rule +change before enacting it, over real historical declarations rather than a projection. diff --git a/drizzle/schema.ts b/drizzle/schema.ts index a923cb24..0f2300d7 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -50,6 +50,14 @@ export const permitStatusEnum = pgEnum("permit_status", [ "pending", "under_review", "approved", "rejected", "not_required" ]); +export const regulatoryRestrictionTypeEnum = pgEnum("regulatory_restriction_type", [ + "prohibition", "restriction" +]); + +export const declarationFormalityStatusEnum = pgEnum("declaration_formality_status", [ + "required", "satisfied", "blocked" +]); + export const paymentMethodEnum = pgEnum("payment_method", [ "bank_transfer", "mobile_money", "card", "bond" ]); @@ -274,6 +282,13 @@ export const ogaPermits = pgTable("oga_permits", { expiresAt: timestamp("expires_at"), slaDeadline: timestamp("sla_deadline"), respondedAt: timestamp("responded_at"), + hsCode: varchar("hs_code", { length: 12 }), + origin: varchar("origin", { length: 3 }), + destination: varchar("destination", { length: 3 }), + consigneeId: integer("consignee_id").references(() => users.id), + permittedQuantity: decimal("permitted_quantity", { precision: 18, scale: 3 }), + usedQuantity: decimal("used_quantity", { precision: 18, scale: 3 }).default("0").notNull(), + validFrom: timestamp("valid_from"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (t) => [ @@ -281,6 +296,114 @@ 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", { @@ -3706,6 +3829,7 @@ export const exciseReconciliationReports = pgTable("excise_reconciliation_report orderId: integer("order_id").notNull().references(() => exciseStampOrders.id), issuedQuantity: integer("issued_quantity").notNull(), activatedQuantity: integer("activated_quantity").notNull(), + everActivatedQuantity: integer("ever_activated_quantity").default(0).notNull(), retiredQuantity: integer("retired_quantity").notNull(), stillIssuedQuantity: integer("still_issued_quantity").notNull(), reportedProductionQuantity: integer("reported_production_quantity").notNull(), diff --git a/server/declarations.test.ts b/server/declarations.test.ts index 56c9ebe9..17a57619 100644 --- a/server/declarations.test.ts +++ b/server/declarations.test.ts @@ -7,6 +7,16 @@ import type { TrpcContext } from "./_core/context"; // stats is admin-only // create requires an approved trader profile vi.mock("./db", () => ({ + // The regulatory evaluator sees an available, empty register in this unit test. + getDb: vi.fn().mockResolvedValue({ + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => Promise.resolve([]), + }), + }), + }), + }), createDeclaration: vi.fn().mockResolvedValue({ id: 1, declarationNumber: "DEC-001", diff --git a/server/excise.behavior.test.ts b/server/excise.behavior.test.ts index e5809078..3d03b96d 100644 --- a/server/excise.behavior.test.ts +++ b/server/excise.behavior.test.ts @@ -425,6 +425,36 @@ describe.sequential("excise money and lifecycle behaviour", () => { expect(report.productionVariance).toBe(0); }); + it("keeps reconciliation and analytics aligned after activation then retirement", async () => { + process.env.EXCISE_UID_HMAC_KEY = "e".repeat(64); + const { db, fixture, order } = await makeFixture({ quantity: 1 }); + const signed = mintExciseUid(); + const [mark] = await db.insert(exciseStampMarks).values({ + uid: signed.uid, + payload: signed.payload, + signature: signed.signature, + keyId: signed.keyId, + orderId: order.id, + productId: fixture.productId, + facilityId: fixture.facilityId, + status: "issued", + }).returning(); + fixture.markIds.push(mark.id); + + await caller().excise.activateMark({ uid: signed.uid }); + await caller().excise.reportProduction({ orderId: order.id, quantity: 1 }); + await caller().excise.retireMark({ uid: signed.uid, reason: "wastage", details: "Behaviour test retirement" }); + + const report = await caller().excise.reconcileOrder({ orderId: order.id }); + const analytics = await caller("customs_officer", 2).excise.analytics({ orderId: order.id }); + expect(report.stampVariance).toBe(0); + expect(report.productionVariance).toBe(0); + expect(report.activatedQuantity).toBe(0); + expect(report.everActivatedQuantity).toBe(1); + expect(analytics.stampAccountabilityVariance).toBe(report.stampVariance); + expect(analytics.productionAccountabilityVariance).toBe(report.productionVariance); + }); + it("keeps public verification status-only and fails closed when signing is unavailable", async () => { process.env.EXCISE_UID_HMAC_KEY = "c".repeat(64); const signed = mintExciseUid(); diff --git a/server/regulatory.behavior.test.ts b/server/regulatory.behavior.test.ts new file mode 100644 index 00000000..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 c8783f6c..3bcc6f5d 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -121,9 +121,11 @@ import { tradeAnalyticsRouter } from "./routers/tradeAnalytics"; import { ncsNrsRouter } from "./routers/ncsNrs"; import { complianceReportingRouter } from "./routers/complianceReporting"; +import { regulatoryRouter } from "./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({ diff --git a/server/routers/declarations.ts b/server/routers/declarations.ts index 1e9dc07a..84e4d228 100644 --- a/server/routers/declarations.ts +++ b/server/routers/declarations.ts @@ -19,6 +19,7 @@ import { assertValidTransition, assignRiskLane, validateHsCode, checkPermitValid import { indexDeclaration, searchDeclarations } from "../_core/opensearch"; import { scoreDeclarationRisk, validateDeclarationWithEngine, getCargoPosition } from "../_core/polyglotClients"; import { resolveActingPrincipal, requireDeclarationActor } from "../_core/mandateAuthorization"; +import { assertDeclarationFormalitiesSatisfied, evaluateDeclarationRegulations } from "./regulatory"; // Generate a unique declaration number: TG-YYYY-XXXXXXXX function generateDeclarationNumber(): string { @@ -265,6 +266,17 @@ export const declarationsRouter = router({ }); } + await evaluateDeclarationRegulations({ + declarationId: input.id, + importerId: principalUserId, + hsCode: decl.hsCode ?? "", + origin: decl.countryOfOrigin ?? "", + destination: decl.countryOfDestination ?? undefined, + regime: decl.declarationType, + quantity: String(decl.numberOfPackages ?? 1), + at: new Date(), + }); + // Run AI risk scoring — Python ML scorer (primary) with LLM fallback const risk = await computeRiskScore( { @@ -567,6 +579,9 @@ export const declarationsRouter = router({ const permifyAction = input.status === "cleared" ? "release" : input.status === "under_examination" ? "hold" : "assess"; await assertCan(String(ctx.user.id), "declaration", String(input.id), permifyAction); + if (input.status === "cleared") { + await assertDeclarationFormalitiesSatisfied(input.id); + } const updateData: Record = { status: input.status }; if (input.status === "cleared") updateData.clearedAt = new Date(); diff --git a/server/routers/excise.ts b/server/routers/excise.ts index 6294359f..54772a36 100644 --- a/server/routers/excise.ts +++ b/server/routers/excise.ts @@ -904,14 +904,15 @@ export const exciseRouter = router({ const marks = await db.select().from(exciseStampMarks).where(eq(exciseStampMarks.orderId, order.id)); const reports = await db.select().from(exciseProductionReports).where(eq(exciseProductionReports.orderId, order.id)); const issuedQuantity = marks.length; - const activatedQuantity = marks.filter((mark) => mark.status === "active" || mark.activatedAt !== null).length; + const activatedQuantity = marks.filter((mark) => mark.status === "active").length; + const everActivatedQuantity = marks.filter((mark) => mark.activatedAt !== null).length; const retiredQuantity = marks.filter((mark) => mark.status === "retired").length; const stillIssuedQuantity = marks.filter((mark) => mark.status === "issued").length; const reportedProductionQuantity = reports.reduce((sum, report) => sum + report.quantity, 0); const stampVariance = issuedQuantity - activatedQuantity - retiredQuantity - stillIssuedQuantity; - const productionVariance = activatedQuantity - reportedProductionQuantity; + const productionVariance = everActivatedQuantity - reportedProductionQuantity; const [report] = await db.insert(exciseReconciliationReports).values({ - orderId: order.id, issuedQuantity, activatedQuantity, retiredQuantity, stillIssuedQuantity, + orderId: order.id, issuedQuantity, activatedQuantity, everActivatedQuantity, retiredQuantity, stillIssuedQuantity, reportedProductionQuantity, stampVariance, productionVariance, computedBy: ctx.user.id, }).returning(); return report; @@ -1177,6 +1178,7 @@ export const exciseRouter = router({ const [markStats] = await db.select({ issued: count(exciseStampMarks.id), activated: sql`count(*) filter (where ${exciseStampMarks.status} = 'active')`, + everActivated: sql`count(*) filter (where ${exciseStampMarks.activatedAt} is not null)`, retired: sql`count(*) filter (where ${exciseStampMarks.status} = 'retired')`, stillIssued: sql`count(*) filter (where ${exciseStampMarks.status} = 'issued')`, }).from(exciseStampMarks).where(input?.orderId ? eq(exciseStampMarks.orderId, input.orderId) : undefined); @@ -1190,6 +1192,7 @@ export const exciseRouter = router({ : await db.select({ anomalies: count(exciseAnomalies.id) }).from(exciseAnomalies); const issued = Number(markStats?.issued ?? 0); const activated = Number(markStats?.activated ?? 0); + const everActivated = Number(markStats?.everActivated ?? 0); const retired = Number(markStats?.retired ?? 0); const stillIssued = Number(markStats?.stillIssued ?? 0); const reportedProduction = Number(productionStats?.reported ?? 0); @@ -1201,7 +1204,8 @@ export const exciseRouter = router({ paid: Number(orderStats?.paid ?? 0), reportedProduction, stampAccountabilityVariance: issued - activated - retired - stillIssued, - productionAccountabilityVariance: activated - reportedProduction, + productionAccountabilityVariance: everActivated - reportedProduction, + everActivated, anomalies: Number(anomalyStats?.anomalies ?? 0), }; } catch (error) { 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/services/go/tigerbeetle-bridge/cmd/idempotency_test.go b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go index b49d036c..64af87e1 100644 --- a/services/go/tigerbeetle-bridge/cmd/idempotency_test.go +++ b/services/go/tigerbeetle-bridge/cmd/idempotency_test.go @@ -15,6 +15,7 @@ func TestPostTransferIsIdempotentByKey(t *testing.T) { if err := store.CreateAccount(&Account{ID: "revenue-test", Ledger: 1, Currency: "GHS"}); err != nil { t.Fatal(err) } + store.accounts["trader-test"].CreditsPosted = decimal.NewFromInt(100) const key = "excise:order:123" first := &Transfer{ @@ -56,6 +57,7 @@ func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { if err := store.CreateAccount(&Account{ID: "revenue-race", Ledger: 1, Currency: "GHS"}); err != nil { t.Fatal(err) } + store.accounts["trader-race"].CreditsPosted = decimal.NewFromInt(100) const key = "excise:race:123" var wg sync.WaitGroup @@ -85,3 +87,161 @@ func TestPostTransferIdempotencyIsConcurrent(t *testing.T) { t.Fatalf("expected one stored transfer after concurrent replay, got %d", len(transfers)) } } + +func TestPostTransferRejectsDebitOverdraft(t *testing.T) { + store := NewStore() + if err := store.CreateAccount(&Account{ + ID: "trader-overdraft", + Ledger: 1, + Currency: "GHS", + 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 eac4379f..e68ed4b6 100644 --- a/services/go/tigerbeetle-bridge/cmd/main.go +++ b/services/go/tigerbeetle-bridge/cmd/main.go @@ -89,17 +89,18 @@ const ( // Account represents a TigerBeetle account (128-bit ID stored as hex string). type Account struct { - ID string `json:"id"` - Ledger uint32 `json:"ledger"` - Code uint16 `json:"code"` - AccountType AccountType `json:"accountType"` - Description string `json:"description"` - Currency string `json:"currency"` - DebitsPosted decimal.Decimal `json:"debitsPosted"` - CreditsPosted decimal.Decimal `json:"creditsPosted"` - DebitsPending decimal.Decimal `json:"debitsPending"` - CreditsPending decimal.Decimal `json:"creditsPending"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Ledger uint32 `json:"ledger"` + Code uint16 `json:"code"` + AccountType AccountType `json:"accountType"` + Description string `json:"description"` + Currency string `json:"currency"` + DebitsMustNotExceedCredits bool `json:"debitsMustNotExceedCredits"` + DebitsPosted decimal.Decimal `json:"debitsPosted"` + CreditsPosted decimal.Decimal `json:"creditsPosted"` + DebitsPending decimal.Decimal `json:"debitsPending"` + CreditsPending decimal.Decimal `json:"creditsPending"` + CreatedAt time.Time `json:"createdAt"` } // Balance returns the net balance of an account (credits − debits). @@ -215,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 @@ -222,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" @@ -232,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] @@ -262,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" @@ -349,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) @@ -369,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)