Migration import¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/migration-import.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What migration-import is for¶
The invariant you must not break: never model card-data portability as a
runtime token-attach call across processors or accounts. Processor tokens
(Stripe PaymentMethod, Braintree token) are not portable; only the
underlying PAN migrates, via an out-of-band PCI process, producing a new
destination-account instrument ID. Building against a same-shaped "attach the
old token" endpoint means building against a platform capability that does
not exist — the exact failure class ADR-0078
corrected after the original US-3.2 spec assumed it. (Verified against
Stripe Data Migrations.)
Migration-import lets a merchant bring existing subscribers over from a competitor platform without re-collecting payment data or losing their place in a billing cycle. The domain breaks into five reader-facing features, each anchored to its build story:
- Bulk subscriber import from a CSV/NDJSON row set — a Merchant Admin (or
their script) posts existing subscriptions, one row per subscriber, and
gets a per-row succeeded/failed/skipped outcome back, with re-runs
deduplicating on
external_id(US-3.1, reachable path) - Migrating stored cards without re-asking subscribers — a Merchant Admin moving off Recharge/Bold/PayWhirl gets their subscribers' authorized cards carried forward via an out-of-band, PCI-compliant PAN migration rather than re-collecting card data at cutover (US-3.2)
- Cycle dates survive the move — a subscriber mid-cycle at migration
time keeps their exact
next_charge_at; nothing doubles or skips a charge because of the migration itself (US-3.3) - Per-vendor field mapping (built, not wired) — dedicated extract/normalize logic exists for Recharge, Bold (v1 six-state + v2), and PayWhirl (CSV and, per US-3.7, authoritative API-mode) so a merchant's vendor-specific export shape doesn't need hand-translation (US-3.1/US-3.4/US-3.5/US-3.7 — mechanism present, no route calls it)
- Dry-run impact preview before committing (spec only) — the BRD promises an executive-summary report (subs to create, PMs matched, estimated MRR migrated, revenue at risk from unmapped PANs) before a merchant commits a migration (US-3.6) — not built
The load-bearing decisions, most cited to ADR-0078:
- Card data migrates out-of-band via the processor's own PCI process; there is no runtime token-attach API — only the PAN migrates, producing new destination-account instrument IDs the import consumes (ADR-0078).
- Shared pipeline, per-vendor adapter — normalize → validate →
idempotent write → rollback → audit lives once in
MigrationSourceAdapterBase; only extract + field-map + edge-case detection are per-source. This is a spike recommendation adopted into the codebase, not a ratified ADR (docs/spikes/epic-3-migration-importers.md, Alex Vela, 2026-05-07) — no ADR formally ratifies the adapter-framework architecture, and none of it is wired to a route. - Idempotency key is the store-scoped external reference, not a
per-attempt token — both import surfaces dedup on
(store_hash, external_ref)before doing anything else, including in dry-run mode, so a merchant can safely re-run a partially-failed batch (migration/base.tswrite();routes/admin/subscriptions-import.tsimportRow()). - Unmapped payment instruments pause the subscription, they never
fabricate one — a row with no resolved
payment_method_refimports without an active charge path rather than an active subscription with no way to charge (ADR-0078; enforced in both write paths).
Canonical-framing attestation (operator-ratified 2026-07-02). Two distinct, non-interoperable import surfaces exist and neither is the wizard the BRD describes:
- The reachable HTTP path is a flat, plan-must-already-exist CSV/NDJSON row
importer —
POST /api/v1/admin/subscriptions/import(worker.ts:1024, spec #1345). No field-mapping UI, no dry-run report, no per-source vendor logic. This is the canonical reachable mechanism today. - The
MigrationSourceAdapterframework (apps/api/src/migration/{base,recharge,bold,paywhirl}.ts, ~2,700 lines of implementation + tests) is a fully-built, per-vendor extract/fieldMap/validate/write/rollback pipeline for Recharge, Bold v1/v2, and PayWhirl — but it is imported by nothing outside its own test file. Zero HTTP route, cron, or UI reaches it. Real code, unreachable code.
The BRD's Migration Wizard (Source Select / Field Mapping / PM Matching /
Dry Run) and every /api/v1/migrations/* endpoint it would call do not
exist in any form — every US-3.x UI-states block in
docs/audits/derived/brd-epics/epic-03-migration-from-legacy-subscription-apps.md
is explicitly frontmattered "NOT YET BUILT — forward-looking contract." The
adapter framework is the mechanism a future wizard would call; today
nothing calls it. Traced: grepped apps/api/src for importers of
migration/index, migration/recharge, migration/bold,
migration/paywhirl, RechargeAdapter, BoldV1Adapter, BoldV2Adapter,
PayWhirlAdapter, createBoldAdapter outside apps/api/src/migration/
itself — zero results; grepped the whole repo for api/v1/migrations —
zero route registrations, only BRD/epic-view prose references the path.
docs/spikes/epic-3-migration-importers.md (canonical: true) recommends
the adapter pattern but is a spike, not a ratified ADR — no decision record
ratifies the adapter-framework architecture itself.
How it actually works¶
The reachable path. handleAdminSubscriptionsImport
(routes/admin/subscriptions-import.ts)
is dispatched from worker.ts:1024 on POST
/api/v1/admin/subscriptions/import. It accepts either a JSON body ({
rows, dry_run? }) or NDJSON text (?dry_run=true query param for that
form). Each row is { bc_customer_id? | email, plan_id, payment_token?,
next_charge_at, migrated_from?, external_id? }, capped at 5,000 rows per
call (MAX_IMPORT_ROWS).
Read the per-row flow as: check idempotency first → validate the row →
resolve plan_id (must already exist in the local plans table, no
fallback) → resolve the customer by BC customer ID or email → resolve an
optional payment token to a payment_methods row → in dry-run, return the
outcome without writing → otherwise resolve an active processor connection
and insert the subscription with external_ref, imported_from, and the
supplied next_charge_at copied to both current_period_end and
next_charge_at (importRow(), subscriptions-import.ts:166-291).
Failures per row (plan_not_found, customer_not_found,
payment_token_not_found, no_active_processor_connection, a raw DB error)
do not abort the batch — each row reports independently
(handleAdminSubscriptionsImport, subscriptions-import.ts:321-324).
The unreached path. MigrationSourceAdapterBase
(migration/base.ts) defines the
shared pipeline every concrete adapter inherits: validate() checks the
normalized-record shape (externalRef, customerEmail, planId,
nextChargeAt, status enum, priceCents); write() checks
(external_ref, store_hash) for idempotency first — same pattern as the
reachable path — then, outside dry-run, resolves customer and payment
method and inserts a subscription row with legacy_cancellation_reason
also carried, which the reachable path does not collect (base.ts:82-165);
rollback() deletes still-active, zero-charges rows written by a given
source for a store (base.ts:169-192); audit() emits typed events
(import.row_written, import.edge_case_flagged, import.row_skipped,
import.rollback_complete) into the shared audit-log pipeline via
emitImportAuditEvent (migration/audit.ts).
Three adapters extend that base, each with per-vendor authenticate /
extract / fieldMap:
RechargeAdapter(migration/recharge.ts) — a three-phase extract strategy (bulk CSV, REST delta, webhook tail, per the spike). Recharge has no explicit "paused" status, sodetectPausedapproximates it fromnext_charge_scheduled_atvs. twice the billing interval and flags an edge case rather than silently reclassifying;detectDunningsimilarly flags any row withcharge_failure_count > 0for manual review instead of importing it in a failed state.BoldV1Adapter/BoldV2Adapter(migration/bold.ts) — Bold v1's six-state dunning machine collapses two states intocancelledwith an audit event each:paused_indefinitelywith noresume_date(indefinite_pause_imported_as_cancelled) andpending_review(pending_review_collapsed_to_cancelled) — both require merchant follow-up, traced atbold.ts:365-455.PayWhirlAdapter(migration/paywhirl.ts) — CSV mode (US-3.4) is the default: the merchant exports from PayWhirl admin and the HTTP handler injects parsed rows. API mode (US-3.7, spec #1699) is "authoritative" — whencredentials.apiKeyis present, it walksGET /customersinstead, sobilling_amount/interval/frequency come from live PayWhirl data rather than a CSV snapshot (paywhirl.ts:81-234).
Read the mechanism gap as: the reachable route and the adapter framework are two independent implementations of "import a subscription row," not one pipeline with two entry points. They share a design pattern (idempotency-by-external-ref, pause-on-missing-PM) but not code, and the adapter framework's richer per-vendor logic — Bold's state collapsing, Recharge's paused/dunning heuristics, PayWhirl's API-authoritative mode — is unreachable from any HTTP request today.
The data model. Migration 0036 added three columns to subscriptions
that both import paths write:
erDiagram
subscriptions {
TEXT id PK
TEXT external_ref
TEXT imported_from
TEXT legacy_cancellation_reason
}
Diagram provenance. Focused excerpt of the canonical, code-sourced
docs/architecture/data-model-erd.md(@generatedfromapps/api/src/schema.sql,as_of_commit: 80fc35f4,canonical: false,staleness_threshold_days: infinite— this artifact carries nosign_offfield at all, so its own staleness marker is the honesty signal instead). Excerpted from ~40 columns onsubscriptionsto the 3 this domain added (plus the PK) — every other column and every other one of ~85 tables is omitted. Boundary note: the ERD'ssubscription_import_jobstable (id,status,csv_payload,preview_json,result_json, …) is a same-named-but-unrelated capability — Epic-22 US-22.4 CS bulk-CSV-create (apps/api/src/routes/admin/cs/bulk-csv-create.ts), for internal CS tooling creating new subscriptions for existing BC customers. Confirmed it carries no FK relationship tosubscriptionsin the ERD's relationship block (greppedsubscriptions ||--o{— nosubscription_import_jobsentry). Do not conflate the two "import job" concepts.
Where intent and reality diverge¶
The derived coverage matrix
(_coverage-matrix.json) reports
all seven of Epic-3's stories at g4_status: pass (US-3.1 through
US-3.7). That is true, and it is not the whole truth — the pass is split
across two disjoint code paths under one epic label. Six typed deltas:
- Superseded-framing residue — the original US-3.2 spec ("attach prior-processor tokens to imported subscriptions at runtime") described a platform capability that does not exist; ADR-0078 corrected it to the batch out-of-band PAN-migration model now reflected in the BRD. A reader of the pre-2026-06-27 spec would build against the fiction. (ADR-0078)
- Built-but-untrodden — the entire
MigrationSourceAdapterframework (apps/api/src/migration/{base,recharge,bold,paywhirl}.ts—RechargeAdapter,BoldV1Adapter,BoldV2Adapter,PayWhirlAdapter, real and tested code) is imported by nothing outside its own test suite. No route, cron, or UI reaches it; it is dead weight until a caller is wired. (Traced via grep, zero external importers — same grep as the canonical-framing attestation above.) - Named-deferred — the spike that founded this domain scoped three
source platforms: Recharge, Stay AI, Bold
(
docs/spikes/epic-3-migration-importers.md§Context, §F2). Stay AI was never implemented; PayWhirl was built instead — not in the original spike scope — without a documented decision to swap. TheMigrationSourcetype union still carries'stayai'as a phantom member with no adapter class (apps/api/src/migration/types.ts:21). - Contract-verified, not live-verified — all seven US-3.x ACs show
g4_status: pass, but the pass is split across two disjoint code paths under one epic label: the adapter framework is G4-verified by callingadapter.write()directly inside the scenario file — bypassingworker.tsentirely, a route-orphan pass — while US-3.6's "dry-run" AC is verified againsthandleAdminSubscriptionsImport'sdry_runflag, not the BRD's promised MRR/revenue-at-risk report. Neither path has ever executed via HTTP against live data. (apps/api/test/scenarios/epic-3-migration.scenario.tsheader, which self-documents this split; epic-3 rows of_coverage-matrix.json) - Verified-but-incomplete — the reachable endpoint (
POST /api/v1/admin/subscriptions/import) requires the targetplan_idto already exist in the localplanstable; there is no plan-creation or plan-matching step in this path, so a merchant must pre-create plans before importing rows against them. The BRD's "plan-to-product mapping surfaced for confirmation" affordance (US-3.4) exists only in the unbuilt wizard. (apps/api/src/routes/admin/subscriptions-import.tsimportRow(), plan lookup with no fallback,subscriptions-import.ts:190-197.) - Dependency-gated — activation of any real migration is gated on a third-party, out-of-band PAN-migration process (Stripe Data Migrations team, multi-business-day SLA, or self-serve PAN-copy for Stripe→Stripe) that this codebase has no way to trigger, poll, or verify completion of; the import can only consume a mapping file after that external process finishes. (ADR-0078)
Live-state attestation (operator-ratified 2026-07-02). Nothing in this
domain has run against live/production data or through a real
merchant-facing UI. The one HTTP-reachable endpoint has zero callers in
apps/admin, apps/portal, apps/storefront-svelte, or e2e/ — it is a
script-only surface (curl/SDK), consistent with
docs/migration/README.md's own guidance to
"script around /create per row." The adapter pipeline is exercised only by
calling adapter.write() directly inside the scenario file against the
miniflare D1 harness — never through worker.ts. No admin UI directory
contains any migration-wizard files. docs/migration/README.md
(canonical: true) itself states import options are POST
/api/v1/admin/subscriptions/create per row or the SDK, with the bulk
endpoint "queued under Hive #1345" — that issue closed 2026-06-23, so the
doc is stale against the code it describes, but the substance of its claim
(no wizard, script-driven import only) still matches current reality.
docs/attestations/documentation/migration-guides.md is itself status:
pending, last_attested: null, human_owner: TBD — the guide corpus is
unratified.
How to operate & extend¶
- Add a migration source vendor: extend
MigrationSourceAdapterBase(migration/base.ts) and implementauthenticate/extract/fieldMap;validate,write,rollback,auditcome free from the base class. FollowRechargeAdapter,BoldV1Adapter/BoldV2Adapter, orPayWhirlAdapteras the pattern — but note none of them are wired to a route yet, so a new adapter alone changes nothing reachable. - Wire the adapter framework to something reachable: today's gap is a
caller. A route, cron, or admin UI action that instantiates one of the
three adapters and drives
extract → fieldMap → validate → writewould be the first thing to touch this framework in production; nothing in the worker dispatch table does this yet (worker.ts:1024only routes tohandleAdminSubscriptionsImport, the separate simpler path). - The invariant you must not break: never build a runtime token-attach endpoint across processors — only the batch, out-of-band PAN migration model is real (ADR-0078, restated above).
- The idempotency key:
(store_hash, external_ref)on both paths. Re-running the same file/batch is safe — already-imported rows reportskipped/already_imported, even in dry-run. - Extension seams:
MigrationSourceinmigration/types.tsis the discriminated union for adding a source;ALLOWED_MIGRATED_FROMinsubscriptions-import.tsis the separate allowlist the reachable path validatesmigrated_fromagainst — the two lists currently diverge (the reachable path's set includeswcsubsandcustom, which have no adapter class; the adapter framework'sMigrationSourceunion includesstayai, which the reachable path's allowlist doesn't recognize at all).
Confidence notes¶
- Line-count figure for the adapter framework is an estimate. Input-B
cited "~3,076 lines incl. tests"; a direct
wc -lonapps/api/src/migration/{base,recharge,bold,paywhirl}.{ts,test.ts}(the four adapter/base source files plus their.test.tscompanions, but nottypes.ts,index.ts, oraudit.ts) totals 2,733 in this checkout. I used the figure I traced directly (~2,700) rather than repeating Input-B's number verbatim; the discrepancy is likely file-set scope (which files were included in each count), not a moving target — I did not attempt to reconcile the exact file list Input-B counted. docs/migration/README.md's staleness is real but narrow. I traced Hive #1345 as closed 2026-06-23 against the doc's "queued" framing of the bulk endpoint — that specific sentence is stale. I did not do a line-by-line audit of the rest of that guide for other staleness beyond what Input-B's live-state attestation already scoped (the "no wizard, script-driven only" substance, which still holds).- PayWhirl's CSV-mode field defaults are self-documented as provisional
in the source file (
paywhirl.ts:19-22: "the exact CSV headers a merchant's export uses aren't confirmed... To finalize: diff against one real PayWhirl subscription export CSV"). This is a code-comment admission, not something I independently verified against a real export — flagging per trace-don't-recall discipline, since a comment states intent/caveat, not proof of correctness either way. - I did not exhaustively check every file under
e2e/for a migration-import spec beyond a targeted grep for import/migration route callers acrossapps/admin,apps/portal,apps/storefront-svelte, ande2e/(zero hits) — consistent with Input-B's live-state trace, which used the same grep scope.