Skip to content

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.ts write(); routes/admin/subscriptions-import.ts importRow()).
  • Unmapped payment instruments pause the subscription, they never fabricate one — a row with no resolved payment_method_ref imports 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:

  1. 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.
  2. The MigrationSourceAdapter framework (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, so detectPaused approximates it from next_charge_scheduled_at vs. twice the billing interval and flags an edge case rather than silently reclassifying; detectDunning similarly flags any row with charge_failure_count > 0 for 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 into cancelled with an audit event each: paused_indefinitely with no resume_date (indefinite_pause_imported_as_cancelled) and pending_review (pending_review_collapsed_to_cancelled) — both require merchant follow-up, traced at bold.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" — when credentials.apiKey is present, it walks GET /customers instead, so billing_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 (@generated from apps/api/src/schema.sql, as_of_commit: 80fc35f4, canonical: false, staleness_threshold_days: infinite — this artifact carries no sign_off field at all, so its own staleness marker is the honesty signal instead). Excerpted from ~40 columns on subscriptions to the 3 this domain added (plus the PK) — every other column and every other one of ~85 tables is omitted. Boundary note: the ERD's subscription_import_jobs table (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 to subscriptions in the ERD's relationship block (grepped subscriptions ||--o{ — no subscription_import_jobs entry). 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 MigrationSourceAdapter framework (apps/api/src/migration/{base,recharge,bold,paywhirl}.tsRechargeAdapter, 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. The MigrationSource type 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 calling adapter.write() directly inside the scenario file — bypassing worker.ts entirely, a route-orphan pass — while US-3.6's "dry-run" AC is verified against handleAdminSubscriptionsImport's dry_run flag, 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.ts header, 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 target plan_id to already exist in the local plans table; 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.ts importRow(), 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 implement authenticate / extract / fieldMap; validate, write, rollback, audit come free from the base class. Follow RechargeAdapter, BoldV1Adapter/BoldV2Adapter, or PayWhirlAdapter as 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 → write would be the first thing to touch this framework in production; nothing in the worker dispatch table does this yet (worker.ts:1024 only routes to handleAdminSubscriptionsImport, 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 report skipped / already_imported, even in dry-run.
  • Extension seams: MigrationSource in migration/types.ts is the discriminated union for adding a source; ALLOWED_MIGRATED_FROM in subscriptions-import.ts is the separate allowlist the reachable path validates migrated_from against — the two lists currently diverge (the reachable path's set includes wcsubs and custom, which have no adapter class; the adapter framework's MigrationSource union includes stayai, 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 -l on apps/api/src/migration/{base,recharge,bold,paywhirl}.{ts,test.ts} (the four adapter/base source files plus their .test.ts companions, but not types.ts, index.ts, or audit.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 across apps/admin, apps/portal, apps/storefront-svelte, and e2e/ (zero hits) — consistent with Input-B's live-state trace, which used the same grep scope.