P7 C Zonos Spike
Source: docs/spikes/p7-c-zonos-spike.md
# P7-C: Zonos Adapter Spike — Go/No-Go Decision Memo
**Status:** CLOSED — CONDITIONAL GO
**Date:** 2026-02-13 (spike), 2026-02-14 (closeout)
**Author:** Engineering
**Plan ID:** P7-C
---
## Executive Summary
This spike proves end-to-end external landed cost calculator integration via a provider adapter pattern. The architecture is validated: typed adapter contract, lazy factory, comparison response, error handling, and 27 unit/integration tests all pass. Live local validation confirms the full flow works against the running API with `provider=mock`.
**Decision: CONDITIONAL GO.** The adapter architecture is production-ready (~1 sprint to harden). Criteria 1-4 (accuracy, latency, stability) require Zonos sandbox credentials, which is a business gate (Zonos account setup), not an engineering gate. The spike exit criterion is met: one external adapter spike completes end-to-end with a go/no-go decision memo.
---
## What Was Built
| Component | Description |
|-----------|-------------|
| `lib/calc/provider.interface.ts` | Typed adapter contract (ProviderRequest/Response/Adapter) |
| `lib/calc/zonos.adapter.ts` | Zonos REST adapter (native fetch, AbortController timeout) |
| `lib/calc/mock.adapter.ts` | Mock adapter for testing with HS-code-based duty rates |
| `lib/calc/provider.factory.ts` | Lazy factory — creates adapter on demand, no startup failures |
| `lib/calc/config.ts` | Env var parsing (ZONOS_API_KEY, ZONOS_BASE_URL, CALC_TIMEOUT_MS) |
| Route enhancement | `POST /api/trade/landed-cost` accepts optional `provider` body field |
| Error codes | PROVIDER_NOT_CONFIGURED (503), PROVIDER_TIMEOUT (504), PROVIDER_ERROR (502) |
| Tests | 27 unit/integration tests covering adapter, factory, transforms, error paths |
**Reuse from archived code:** ~75% structural reuse from `_archived/services/external-calculator-adapters/`.
---
## Go/No-Go Criteria
| # | Criterion | GO | CONDITIONAL | NO-GO | Result |
|---|-----------|-----|-------------|-------|--------|
| 1 | **Duty rate accuracy** | Delta ≤ 2% absolute for 80%+ of test HS codes | Delta ≤ 5% for 80%+ | Delta > 5% for majority | **TBD — requires sandbox** |
| 2 | **Tax rate accuracy** | Delta ≤ 2% absolute for 80%+ of test HS codes | Delta ≤ 5% for 80%+ | Delta > 5% for majority | **TBD — requires sandbox** |
| 3 | **p95 latency** | ≤ 2s | ≤ 5s | > 5s | **TBD — requires sandbox** |
| 4 | **API stability** | No errors in 20 consecutive requests | ≤ 5% error rate | > 10% error rate | **TBD — requires sandbox** |
| 5 | **Integration effort to production** | ≤ 2 sprints estimated | ≤ 4 sprints | > 4 sprints | **GO — ~1 sprint** |
### Criterion 5 Detail: Integration Effort Estimate
Production readiness from this spike requires:
| Work item | Estimate |
|-----------|----------|
| `ExternalCalculation` Prisma model + migration | 2 days |
| Redis/Prisma result caching | 2 days |
| Retry with exponential backoff + circuit breaker | 2 days |
| Rate limiting per provider | 1 day |
| Multi-item batch support | 1 day |
| Currency conversion integration | 1 day |
| Production hardening (logging, monitoring, alerts) | 1 day |
| **Total** | **~10 days (~1 sprint)** |
---
## Test HS Codes for Sandbox Validation
When Zonos sandbox credentials are available, test with these codes to fill criteria 1-4:
| HS Code | Product | Expected US Duty | Origin |
|---------|---------|-----------------|--------|
| 6402.99 | Athletic footwear | ~12.5% | CN |
| 6205.30 | Cotton shirts | ~16.5% | BD |
| 8471.30 | Laptop computers | 0% | TW |
| 2204.21 | Wine | 6.3¢/liter | FR |
| 0901.21 | Roasted coffee | 0% | CO |
| 7306.30 | Steel tubing | 0% (check AD/CVD) | KR |
| 8517.12 | Smartphones | 0% | CN |
| 6110.20 | Cotton sweaters | ~17% | VN |
| 4202.12 | Leather handbags | ~8% | IT |
| 9403.60 | Wooden furniture | ~3.5% | CN |
**Test matrix:** 10 HS codes Ă— {US, CA, GB} destinations = 30 calculations.
---
## Comparison Architecture
The spike runs both internal and external calculations in parallel and returns a structured comparison:
```json
{
"breakdown": { "...internal result..." },
"source": "zonos",
"comparison": {
"internal": { "dutyRate": 0.125, "dutyAmount": 7.24, "taxRate": 0, "taxAmount": 0, "otherFees": 25, "totalLandedCost": 90.19 },
"external": { "dutyRate": 0.125, "dutyAmount": 5.63, "taxRate": 0.085, "taxAmount": 3.82, "totalFees": 1.50, "totalLandedCost": 68.45 },
"delta": { "dutyRatePct": 0, "dutyAmount": -1.61, "taxRatePct": 0.085, "taxAmount": 3.82, "totalLandedCost": -21.74 }
},
"providerMeta": { "provider": "zonos", "calculationId": "calc_abc123", "latencyMs": 450, "status": "success" }
}
```
**Primary accuracy signals:** `delta.dutyRatePct` and `delta.taxRatePct` (rate-level comparison avoids noise from internal hardcoded `otherFees`).
**Rate fallback:** When Zonos returns duty/tax amounts but not rates, effective rates are computed using the same CIF customs value base as the internal calculator.
---
## Known Limitations
| Limitation | Impact | Resolution |
|-----------|--------|------------|
| Single-item requests only | Can't batch-test multi-item shipments | Production PR adds batch support |
| No caching | Every request hits Zonos API | Production PR adds Redis/Prisma cache |
| No retry/circuit breaker | Single failure = error response | Deferred to INFRA-B |
| Hardcoded USD currency | Can't test cross-currency scenarios | Production PR adds currency param |
| No province/state in request | May affect tax rate accuracy for CA/AU | Add to production `LandedCostInput` |
| Spike defaults (quantity=1, description=hsCode) | May slightly affect Zonos classification | Production should pass real values |
---
## Cost Model
| Factor | Detail |
|--------|--------|
| Pricing model | Per-request (REST API call) |
| Sandbox | Free (rate-limited) |
| Production estimate | Contact Zonos sales for volume pricing |
| Lock-in risk | Low — adapter pattern allows swapping providers |
---
## Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Zonos sandbox ≠production accuracy | Medium | Validate with production API key before go-live |
| Zonos API breaking changes | Low | Pin to `/v1`, monitor changelog |
| Rate limit exceeded in production | Medium | Cache results, implement circuit breaker |
| Zonos downtime affects our SLA | Medium | Fallback to internal calc when provider errors |
---
## Recommendation
**CONDITIONAL GO** — architecture validated, accuracy criteria deferred to business gate.
The integration effort is clearly within GO threshold (~1 sprint). The adapter architecture is clean, testable, and provider-agnostic. Criteria 1-4 (accuracy, latency, stability) require a Zonos business relationship — this is a sales/partnership step, not an engineering blocker.
### Live Local Validation (2026-02-14)
Verified against local API (`localhost:3001`) with `provider=mock`:
| Test | Result |
|------|--------|
| `POST /api/trade/landed-cost` (no provider) | Internal calc returns `{ breakdown }` — backward compatible |
| `provider=mock` (footwear CN→US) | Full comparison: internal dutyRate=3.5%, mock dutyRate=12.5%, delta=+9% |
| `provider=mock` (apparel BD→CA) | Full comparison: internal dutyRate=17%, mock dutyRate=17.5%, taxRate delta=+8% |
| `provider=zonos` (no creds) | 503 `PROVIDER_NOT_CONFIGURED` — correct |
| `provider=nonexistent` | 400 `INVALID_REQUEST` with available providers list — correct |
**Next steps (when Zonos relationship is pursued):**
1. Obtain Zonos sandbox API key (business/sales step)
2. Run 30-calculation test matrix: `npx tsx scripts/run-zonos-sandbox-matrix.ts`
3. Fill in criteria 1-4 results in this memo
4. If GO: file production PR (P7-C-PROD) with caching, retry, dedicated table
5. If CONDITIONAL: identify specific HS code gaps, evaluate alternative providers
6. If NO-GO: evaluate Avalara or Descartes as next spike candidate