← All epicsBRD.md §9 · lines 1050–1612

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 3 — Migration from legacy subscription apps (derived view)

Read-only per-epic slice of BRD.md §9, lines 1050–1612. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-3.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (7): US-3.1, US-3.2, US-3.3, US-3.4, US-3.5, US-3.6, US-3.7
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 3 — Migration from legacy subscription apps

<!-- traceability:start:BRD:Epic-3 -->

Prototype: Source Select · Field Mapping · PM Matching · Dry Run

<!-- traceability:end:BRD:Epic-3 -->

Value: Merchants on Recharge, PayWhirl, Bold, or MINIBC can move to our platform without re-asking subscribers for payment methods.

Epic context. Migration is Phase 2 but is the single biggest wedge against Recharge/PayWhirl install-base. Correctness is critical: a botched migration destroys subscriber trust and merchant willingness to commit.

US-3.1: Recharge export import

<!-- traceability:start:US-3.1 -->

Prototype: Source Select · Field Mapping

<!-- traceability:end:US-3.1 -->

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin leaving Recharge, I want to import my subscriptions, plans, and customer data from a Recharge CSV export, so that I continue serving subscribers without interruption.

Acceptance criteria:

  • Given I upload a Recharge CSV export, When I map fields in the wizard, Then the system validates row-by-row and shows pass/fail counts.
  • Given validation passes, When I click "Import," Then subscriptions are created in a "Paused pending PM migration" state until payment methods are re-associated.
  • Given any rows fail validation, When I view the import result, Then I see downloadable error rows with specific reason codes.

UI states.

<!-- ui-states US-3.1 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard — Recharge CSV import:
  three steps (Upload → Field Mapping → Validation Results). Persona: Merchant Admin.'
idle:
  render: 'A stepped wizard UI (step indicators at top: 1 Upload · 2 Map Fields · 3 Review). Step 1 shows a BigDesign file-drop
    zone accepting .csv files with a ''Download Recharge export guide'' secondary link. Step 2 (after upload) renders a mapping
    table: each detected source column on the left, a BigDesign Select on the right offering our canonical field options (customer_email,
    status, next_charge_at, plan_id, interval, interval_count, currency, plus a ''Skip this column'' option), with Recharge-specific
    defaults pre-populated. Step 3 renders a validation summary: pass count, fail count, and a ''Download error rows (.csv)''
    link with per-row reason codes if any rows fail.'
  primary_action: Step 1 — 'Upload file' (or drag-and-drop). Step 2 — 'Validate' triggers row-by-row validation with the current
    mapping. Step 3 — 'Import' enabled only when fail count is 0 or the merchant explicitly acknowledges a partial import;
    'Back' returns to mapping for adjustments.
loading:
  trigger: 'Step 2 — ''Validate'': POST /api/v1/migrations/validate. Step 3 — ''Import'': POST /api/v1/migrations/{id}/commit.'
  render: 'During validation: ''Validating rows…'' inline progress bar with a running row count; Validate and Back are disabled.
    During import commit: Import button shows isLoading spinner with label ''Importing…''; all wizard controls disabled to
    prevent double-submit.'
error:
  surfaced_at: 'Inline BigDesign Message type=error: below the file-drop zone (Step 1) for upload/parse errors; at the top
    of the mapping table (Step 2) for schema-detection failures; at the top of the validation summary (Step 3) for server-side
    validation-run failures. Never a vanishing toast.'
  recovery: Upload/parse errors — re-upload a corrected file. Mapping failures — adjust the column Select dropdowns and re-validate.
    Import commit failures — the wizard returns to Step 3 with the error displayed and re-enables the Import button so the
    merchant can retry.
empty:
  render: 'If the uploaded CSV has zero data rows (headers only), the validation summary shows a BigDesign Message type=warning:
    ''No data rows found in this file. Upload a CSV with at least one subscription row.'' The Import button is disabled.'
inputs:
- field: source_file
  control: file
  label: Recharge CSV export — BigDesign file-input (accept=.csv, max 50 MB)
- field: field_mapping_columns
  control: select
  label: Target field for each detected source column — BigDesign Select per column; one Select per detected source column
    in the mapping table
  allowed_values: customer_email, status, next_charge_at, plan_id, interval, interval_count, currency, (skip)
edge_status:
- status: Validation completes with zero errors — all rows pass
  affordance: Import button enabled; merchant proceeds to commit.
- status: Validation completes with some failing rows — partial import possible
  affordance: '''Import anyway (N rows will be skipped)'' secondary action shown alongside a ''Download error rows (.csv)''
    link with per-row reason codes so the merchant can correct and re-import the failing subset separately.'
- status: Validation run fails server-side (timeout or worker crash)
  affordance: '''Retry validation'' button re-fires the same mapping payload against the stored file.'
- status: Import committed — subscriptions created in paused_pending_pm state
  affordance: Success summary with count of imported subscriptions; CTA 'Go to migration dashboard' to proceed to the PM-mapping
    step (US-3.2).
disabled_focus:
  keyboard: 'All wizard controls are real BigDesign components (file-input, Select, Button) wrapping native focusable elements
    — no div-onClick dead-ends. Tab order: file input → Upload button (Step 1); column-mapping Selects in DOM order → Validate
    → Back (Step 2); Import → Back (Step 3). Disabled state during loading uses native disabled attribute, removing controls
    from tab order. The ''Download error rows'' affordance is a real <a> with an href.'

US-3.2: Payment method migration via Stripe/Braintree dataset import

<!-- traceability:start:US-3.2 -->

Prototype: PM Matching

<!-- traceability:end:US-3.2 -->

Phase: P2 · Priority: P0 · Effort: XL · Persona: Merchant Admin / Developer

As a Merchant Admin, I want my subscribers' stored cards transferred from my prior processor into our charge rail via a PCI-compliant migration, so that renewal charges run against the cards my subscribers already authorized — without re-collecting card details.

Respec note (research red team S1.2/A2, synthesis #1821). The original mechanism — "attach prior-processor Stripe PaymentMethods / Braintree tokens to imported subscriptions at runtime" — does not exist. Processor tokens are not portable; only the underlying card data (PAN) migrates, via an out-of-band, PCI-compliant process. Verified against Stripe Data Migrations: transferring payment information "to or from another payment processor, or even between Stripe accounts" is run by Stripe's Data Migrations team (you receive a JSON mapping file); even Stripe→Stripe across accounts is a distinct PAN-copy process, not token attachment. Epic 3 is therefore modeled as a batch PCI-migration workstream with a third-party dependency, not a code-time token-attach call. See ADR-0078.

Acceptance criteria:

  • Given I am migrating subscribers from a prior processor, When I request the card-data migration, Then the PAN is transferred out-of-band via the processor's PCI migration process — Stripe's Data Migrations team for cross-processor moves, or self-serve PAN-copy for Stripe→Stripe across accounts — not by attaching prior-processor tokens at runtime; the process returns a mapping from prior customer/instrument identifiers to new destination-account instruments.
  • Given the migration mapping has been received, When I import the subscription dataset, Then each subscription's payment_method_ref is set to the new destination-account instrument id from the mapping; any row with no mapped instrument imports as paused_pending_pm and triggers a subscriber email with a PM-update link.
  • Given the prior processor is Braintree, When I read stored payment methods to reconcile the dataset, Then I use the Braintree server SDK (Customer.find(id).paymentMethods) or the GraphQL Customer.paymentMethods connection — Braintree exposes no Stripe-style GET /customers/{id}/payment_methods REST endpoint.
  • Given the migration changes the merchant-of-record, When a renewal charges off-session (MIT), Then cardholder consent does not automatically carry across the MoR change — re-consent / re-authorization is a migration prerequisite, not an automatic inherit.

UX notes.

  • Surface: Migration Wizard, step 3 (after file upload + mapping)
  • Detail: interactive table: each imported subscription row, PAN-migration mapping result (mapped, unmapped, ambiguous)
  • Bulk action: "Email unmapped subscribers to update PM"
  • Warning banners: "N subscribers have no mapped instrument and will be paused at migration"; "Card-data migration is an out-of-band step run by the processor — allow for its lead time before activation"

Data contract.

  • Inputs: Recharge (or similar) subscription CSV export with columns including customer_id, stripe_customer_id (if known), braintree_customer_id
  • PAN migration (Stripe): requested out-of-band via Stripe Data Migrations (cross-processor) or self-serve PAN-copy (Stripe→Stripe); returns a JSON mapping of prior identifiers → new destination-account pm_/card_ ids. There is no runtime token-attach API.
  • Braintree (reconcile/read path): server SDK Customer.find(id).paymentMethods or GraphQL Customer.paymentMethods connection — no Stripe-shaped REST endpoint
  • Our DB: subscriptions.payment_method_ref = the new destination-account instrument id from the migration mapping; subscriptions.status = paused_pending_pm if no mapped instrument
  • Events: migration.pm_mapped / migration.pm_unmapped per row

Success metrics.

  • Functional: PAN-mapping coverage target ≥ 95% for Recharge → Stripe migrations (calibrate with pilot merchants)
  • Product (target): 90% of migrations reach "ready to activate" state without merchant intervention (after the out-of-band PAN migration completes)
  • Operational (target): unmapped-subscriber email → PM-update → reactivation conversion ≥ 60% within 7 days

Dependencies.

  • US-3.1 (import machinery)
  • US-3.6 (dry-run / impact report — must surface the migration lead-time dependency)
  • US-19.1 (PM update flow, for the unmapped-subscriber email link)
  • External, third-party: the processor's PAN-migration process (Stripe Data Migrations / PAN-copy) — a batch out-of-band dependency with its own lead time, on the critical path to activation

Non-functional.

  • The PAN migration is an out-of-band, multi-business-day step run by the processor's migration process — confirm the current SLA with Stripe Migrations at request time; sequence activation after it completes
  • Dataset reconciliation runs as a background workflow — large imports can take minutes
  • Rate-limit reconciliation calls (Braintree SDK / Stripe) at 50 req/s to stay under provider limits
  • All migration-mapping decisions logged for audit

Risks / open questions.

  • Merchant-of-record change does not inherit cardholder consent — re-consent handling must be resolved before charging migrated instruments off-session
  • Braintree → Stripe is not directly migratable as tokens; only the PAN migrates — confirm the per-processor migration route during onboarding
  • If the merchant's export doesn't include prior-processor customer IDs, the migration mapping can't be keyed — offer a manual-match UI or email-based re-authorization flow

UI states.

<!-- ui-states US-3.2 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard Step 3 — PAN-migration
  payment method mapping table. Persona: Merchant Admin / Developer.'
idle:
  render: 'Migration Wizard step header: ''Map payment methods''. Two persistent BigDesign Message type=warning banners at
    top (non-dismissible): ''N subscribers have no mapped instrument and will be paused at migration'' and ''Card-data migration
    is an out-of-band step run by the processor — allow for its lead time before activation.'' Below, a BigDesign Table: one
    row per imported subscription with columns (Subscriber email · Prior processor identifier · Mapping result badge · New
    instrument id). Mapping result badge is one of: mapped (success/green) / unmapped (danger/red) / ambiguous (warning/amber).
    A sticky footer shows totals: N mapped, N unmapped, N ambiguous.'
  primary_action: Bulk action 'Email unmapped subscribers' (enabled when unmapped count > 0) — queues PM-update emails to
    all subscribers with no mapped instrument. 'Continue to dry run' CTA proceeds to the US-3.6 Review step.
loading:
  trigger: POST /api/v1/migrations/{id}/reconcile — background workflow applying the processor's PAN-migration JSON mapping
    against the imported subscription dataset.
  render: 'While reconciliation runs, the table is replaced by a progress indicator: ''Reconciling payment methods… (N of
    M rows processed)'' with an indeterminate BigDesign ProgressBar. ''Email unmapped'' and ''Continue'' CTAs are disabled.
    The progress label updates via polling every 5 seconds; reconciliation may take minutes for large datasets.'
error:
  surfaced_at: 'Inline BigDesign Message type=error at the top of the mapping table — not a toast. Covers: mapping file format
    unrecognized, reconciliation timeout, processor reconciliation API error.'
  recovery: '''Retry reconciliation'' button re-submits the same mapping file. ''Re-upload mapping file'' lets the merchant
    replace the processor-supplied JSON mapping and re-run.'
empty:
  render: 'If the PAN-migration JSON mapping file has not yet been uploaded, the table body is empty and a BigDesign Message
    type=info explains: ''Upload the mapping file provided by your processor (Stripe Data Migrations / PAN-copy export) to
    begin reconciliation.'' A ''Upload mapping file'' Button is the single CTA.'
inputs:
- field: pan_mapping_file
  control: file
  label: Processor PAN-migration JSON mapping file — BigDesign file-input (accept=.json)
edge_status:
- status: All subscriptions mapped (unmapped count = 0)
  affordance: '''Continue to dry run'' CTA enabled; the unmapped-subscriber warning banner is not shown.'
- status: N subscriptions unmapped — will be paused_pending_pm on activation
  affordance: '''Email unmapped subscribers'' bulk action queues PM-update emails; badge on the button shows the count of
    emails to be sent.'
- status: Ambiguous mapping — multiple new instruments matched to one prior identifier
  affordance: Per-row 'Resolve' button opens an inline BigDesign Select to choose the correct new instrument; ambiguous rows
    block the 'Continue' CTA until all are resolved.
- status: Prior processor is Braintree — reconciliation uses SDK path, not a Stripe-shaped REST endpoint
  affordance: 'Contextual BigDesign Message type=info: ''Braintree reconciliation uses the server SDK / GraphQL path. Confirm
    your mapping file was generated via the Braintree SDK.'' No different UI path — informational only.'
disabled_focus:
  keyboard: '''Email unmapped subscribers'' Button, ''Continue to dry run'' Button, ''Retry reconciliation'' Button, ''Re-upload
    mapping file'' Button, per-row ''Resolve'' buttons, and the per-row Resolve Select are real BigDesign Button and Select
    elements reachable via Tab — no div-onClick. All CTAs disabled (native disabled) while reconciliation is running.'

US-3.3: Cycle-date preservation

<!-- traceability:start:US-3.3 -->

Prototype: Dry Run

<!-- traceability:end:US-3.3 -->

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin migrating mid-cycle, I want next-charge dates preserved exactly, so that subscribers don't get double-charged or skipped.

Acceptance criteria:

  • Given the import includes next_charge_at for each subscription, When I activate post-migration, Then no charge is scheduled earlier than the imported date.
  • Given an imported subscription has a next_charge_at in the past, When I activate, Then the system surfaces the anomaly and lets me choose reschedule or immediate-retry.

UI states.

<!-- ui-states US-3.3 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard — past-due next_charge_at
  anomaly resolution screen shown before the merchant can activate the migration. Persona: Merchant Admin.'
idle:
  render: 'A ''Resolve charge-date anomalies'' step displayed before ''Activate migration'' is available. A BigDesign Message
    type=warning at top: ''N subscriptions have a next_charge_at date in the past. Activating without resolution will cause
    missed or double charges.'' Below, a BigDesign Table listing each anomalous subscription: Subscriber email · Original
    next_charge_at (past date) · Suggested reschedule date (today + 1 interval) · Action. Each row carries two BigDesign Radio
    buttons: ''Reschedule to [suggested date]'' and ''Retry immediately on activation.'' A BigDesign Input type=date in the
    Reschedule row lets the merchant override the suggested date (min=today).'
  primary_action: '''Apply to all'' BigDesign Select offers ''Reschedule all to today + 1 interval'' or ''Retry all immediately''
    for bulk resolution. ''Confirm and activate'' Button at the bottom enabled only after every anomalous row has a selection.
    ''Back'' returns to the Review / dry-run step.'
loading:
  trigger: 'On ''Confirm and activate'': POST /api/v1/migrations/{id}/activate with per-row resolution decisions.'
  render: The Activate button shows isLoading spinner with label 'Activating…'; all Table controls (Radios, date Inputs, Apply-to-all
    Select) and Back are disabled. A progress note 'Scheduling N subscriptions…' renders beneath the button.
error:
  surfaced_at: Inline BigDesign Message type=error at the top of the anomaly table, scoped to the activation attempt. Per-row
    inline validation messages appear below the date Input when an invalid date is entered (e.g. a past date override).
  recovery: Activation failure — 'Retry activation' re-submits the current resolution selections. Per-row date validation
    failure — the offending date Input is highlighted with BigDesign error state and the Confirm button remains disabled until
    corrected.
empty:
  render: 'If no imported subscriptions have a past next_charge_at, this step is skipped entirely and the wizard proceeds
    directly from the Review step to the Activate confirmation. If the step is reached with an empty anomaly set, a BigDesign
    Message type=success states: ''No charge-date anomalies found — all subscriptions have future-dated charge dates.'''
inputs:
- field: resolution_action
  control: select
  label: 'Resolution action per anomalous subscription — BigDesign Radio group per row: ''Reschedule'' or ''Retry immediately'''
  allowed_values: reschedule, retry_immediately
- field: reschedule_date
  control: date
  label: Override reschedule date — BigDesign Input type=date, min=today, shown only when Reschedule is selected for that
    row
edge_status:
- status: All anomalies resolved — Reschedule or Retry selection made for every row
  affordance: '''Confirm and activate'' Button enabled; merchant proceeds to activation.'
- status: Some rows still unresolved — no selection made
  affordance: '''Confirm and activate'' disabled; unresolved rows are visually highlighted (missing-selection state) and a
    tooltip on the button explains: ''Resolve all anomalies to continue''.'
- status: Activation partially fails — some subscriptions error, some succeed
  affordance: Post-activation summary shows succeeded / failed counts; a 'Download failed rows (.csv)' link and 'Retry failed
    rows' CTA address the errored subset.
- status: Reschedule date override is before today (invalid)
  affordance: 'Per-row BigDesign Input error state: ''Reschedule date must be today or later''; Confirm button stays disabled
    until corrected.'
disabled_focus:
  keyboard: 'Radio buttons, date Inputs, Apply-to-all Select, Back, and Confirm are all real BigDesign focusable elements
    — no div-onClick. Tab order: Apply-to-all Select → per-row Radio pairs in DOM order (Reschedule first in each row) → date
    Input (visible only when Reschedule is selected for that row) → Back → Confirm. Native disabled removes all controls from
    tab order during activation loading.'

US-3.4: PayWhirl export import

<!-- traceability:start:US-3.4 -->

Prototype: Source Select

<!-- traceability:end:US-3.4 -->

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin leaving PayWhirl, I want to import via PayWhirl's export format, so that I can migrate without manual reformatting.

Acceptance criteria:

  • Given I pick "PayWhirl" as the source in the import wizard, When I upload their CSV, Then field mapping defaults match PayWhirl's schema.
  • Given the import completes, When I review the diff, Then plan-to-product mappings are surfaced for confirmation.

UI states.

<!-- ui-states US-3.4 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard — PayWhirl CSV upload
  and field-mapping step, activated when merchant selects ''PayWhirl'' as source. Persona: Merchant Admin.'
idle:
  render: 'The wizard''s Source Select step shows a BigDesign Radio group of supported providers (Recharge / PayWhirl / Bold
    / MINIBC / Other). Selecting ''PayWhirl'' advances to a CSV-upload step with PayWhirl-specific field-mapping defaults
    pre-populated (auto-matched based on PayWhirl''s known export schema; unmatched columns flagged for manual mapping via
    a Select). After upload and auto-mapping, a diff panel shows: ''Plan-to-product mapping'' — each distinct source plan
    name matched to a BC product/plan (or flagged as unmatched). Unmatched plan names display a ''Map to product'' BigDesign
    Select.'
  primary_action: '''Validate'' runs row-by-row validation with the current field map; enabled only after all unmatched plan
    names have a selection or are explicitly skipped. ''Import'' on the validation-result step.'
loading:
  trigger: CSV parse + auto-mapping client-side on upload; plan-to-product matching POST to /api/v1/migrations/plans/match.
  render: 'During file parse: ''Reading file…'' inline indicator; file input and Next are disabled. During plan matching:
    ''Matching plans…'' banner; the diff panel renders skeleton rows while the response is pending.'
error:
  surfaced_at: 'Inline BigDesign Message type=error: CSV parse errors below the file-drop zone; plan-match API errors at the
    top of the diff panel. Never a toast.'
  recovery: CSV parse error — re-upload a corrected PayWhirl CSV. Plan-match failure — 'Retry plan matching' button re-fires
    the request; the merchant can also manually map unmatched plans via the diff-panel Selects.
empty:
  render: 'If the uploaded PayWhirl CSV has no distinct plan names, the plan-mapping diff panel shows a BigDesign Message
    type=info: ''No plans detected in this export. All subscriptions will need a plan assigned manually before import.'' Manual-map
    Selects are shown for each subscription.'
inputs:
- field: source_file
  control: file
  label: PayWhirl CSV export — BigDesign file-input (accept=.csv, max 50 MB)
- field: plan_mapping
  control: select
  label: BC product/plan to map this PayWhirl plan to — BigDesign Select per distinct plan name in the export
  allowed_values: active BC plans returned by the plans API, plus '(skip / exclude from import)'
edge_status:
- status: All PayWhirl plan names auto-matched to BC plans
  affordance: '''Validate'' enabled immediately; diff panel shows all rows green with matched plan names.'
- status: Some PayWhirl plan names unmatched
  affordance: Per-plan 'Map to product' Select with allowed_values = active BC plans; Validate is blocked until all unmatched
    plans have a selection or are explicitly skipped.
- status: PayWhirl payment tokens present in export — tokens are not portable to BC Payments
  affordance: 'Info banner: ''PayWhirl payment tokens cannot be transferred to BC Payments. Subscribers will require payment
    method re-collection after migration.'' pm_revault_required is emitted per subscription; no blocking action required at
    this step.'
disabled_focus:
  keyboard: 'Source Radio group, file-input, per-plan-name Selects in the diff panel, Validate, Import, and Back are real
    BigDesign focusable elements — no div-onClick. Tab order: source Radio group → Next (Step 1); file input → Next (Step
    2); plan-mapping Selects in DOM order → Validate → Back (Step 3). Disabled via native disabled during loading.'

US-3.5: Bold / MINIBC import

<!-- traceability:start:US-3.5 -->

Prototype: Source Select

<!-- traceability:end:US-3.5 -->

Phase: P3 · Persona: Merchant Admin

As a Merchant Admin leaving Bold Subscriptions or MINIBC, I want a supported import path, so that I am not locked in.

Acceptance criteria:

  • Given I pick the respective source, When I upload the export, Then vendor-specific field mappings are applied.

UI states.

<!-- ui-states US-3.5 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard — Bold Subscriptions
  / MINIBC source selection and CSV upload step, with vendor-specific field mappings applied automatically. Persona: Merchant
  Admin.'
idle:
  render: 'The wizard''s Source Select step (BigDesign Radio group: Recharge / PayWhirl / Bold / MINIBC / Other). Selecting
    ''Bold Subscriptions'' or ''MINIBC'' advances to a vendor-specific upload step labeled with the selected vendor''s name.
    A file-drop zone accepts .csv files. After upload, a mapping table renders: each detected source column on the left, a
    BigDesign Select on the right offering our canonical field options (customer_email, status, next_charge_at, plan_id, bc_product_id,
    interval, interval_count, external_id, currency, and a ''Skip this column'' option). Vendor-matched defaults are pre-selected
    for columns that match the vendor''s known export schema; unmatched columns are flagged with an amber ''Unmatched'' indicator.'
  primary_action: '''Validate'' triggers row-by-row validation with the current mapping. ''Import'' on the validation-result
    step.'
loading:
  trigger: File upload + CSV parse; row-by-row validation POST to /api/v1/migrations/validate.
  render: 'During parse: ''Reading file…'' with file input and Next disabled. During validation: ''Validating rows…'' progress
    bar; Validate and Back disabled.'
error:
  surfaced_at: Inline BigDesign Message type=error below the file-drop zone for parse failures; at the top of the mapping
    table if the uploaded file's column headers do not match any known vendor schema (e.g. wrong vendor selected). Never a
    toast.
  recovery: Parse error — re-upload a corrected file; the source Radio group remains accessible to switch the vendor selection
    if the wrong export format was uploaded. Schema mismatch — the merchant adjusts column-to-field Selects manually and retries
    validation.
empty:
  render: 'If the uploaded Bold/MINIBC CSV contains zero data rows, the validation summary shows BigDesign Message type=warning:
    ''No data rows found in this file. Upload a CSV with at least one subscription row.'' Import button disabled.'
inputs:
- field: source_vendor
  control: select
  label: Source vendor — BigDesign Radio group on the Source Select step
  allowed_values: recharge, paywhirl, bold, minibc, other
- field: source_file
  control: file
  label: Vendor CSV export — BigDesign file-input (accept=.csv, max 50 MB)
- field: field_mapping_columns
  control: select
  label: Target field for each detected vendor CSV column — BigDesign Select per column
  allowed_values: customer_email, status, next_charge_at, plan_id, bc_product_id, interval, interval_count, external_id, currency,
    (skip)
edge_status:
- status: Uploaded file column headers do not match the selected vendor's known export schema
  affordance: BigDesign Message type=warning listing the mismatch; merchant can either switch to 'Other' (manual mapping)
    or re-upload the correct vendor export.
- status: Validation passes with zero errors
  affordance: '''Import'' CTA enabled.'
- status: Validation passes with some failing rows
  affordance: '''Import anyway (N rows will be skipped)'' secondary action + ''Download error rows (.csv)'' link with per-row
    reason codes.'
disabled_focus:
  keyboard: Source Radio group, file-input, column-mapping Selects, Validate, Import, and Back are all real BigDesign focusable
    elements — no div-onClick. Tab order follows DOM source order across all wizard steps. Native disabled removes controls
    from tab order during loading.

US-3.6: Dry-run migration with impact report

<!-- traceability:start:US-3.6 -->

Prototype: Dry Run

<!-- traceability:end:US-3.6 -->

Phase: P2 · Priority: P1 · Effort: M · Persona: Merchant Admin

As a Merchant Admin, I want to dry-run a migration before committing, so that I see what will change, what will fail, and what revenue is at stake.

Acceptance criteria:

  • Given I upload an export, When I click "Dry run," Then the system simulates the import and produces a report: subscriptions created, instruments mapped, estimated MRR migrated, revenue at risk from PAN-mapping failures.
  • Given the dry-run report is produced, When I review it, Then I can adjust mappings and re-run before final commit.
  • Given migration depends on the out-of-band PAN transfer (US-3.2), When the dry-run report is produced, Then it surfaces the card-data migration lead-time dependency as an explicit prerequisite (the processor's PAN-migration step runs before activation, on its own SLA) and quantifies the revenue at risk during that window — so "Migrate now" is never presented as instantaneous when a third-party migration is on the critical path.

UX notes.

  • Surface: Migration Wizard's "Review" step
  • Visual: executive summary card (subs to be created, PMs matched, MRR migrated) + detail tabs (warnings, errors, field-mapping diffs, plan-mapping diffs)
  • CTA disparity: "Dry run" vs. "Migrate now" — the latter is greyed until dry-run passes with zero critical errors

Data contract.

  • Our API: POST /api/v1/migrations/{id}/dry-run
  • Workflow: simulate all row-level operations, accumulate outcomes to an in-memory report, serialize to Postgres for the UI
  • Output entity: migration_report with rollup + detail rows

Success metrics.

  • Functional (target): dry-run report predicts actual migration outcomes with ≥ 98% accuracy
  • Product (target): merchants who run a dry-run before committing experience ≥ 50% fewer post-migration corrections

UI states.

<!-- ui-states US-3.6 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard Review step — dry-run
  impact report with executive summary card and detail tabs. Persona: Merchant Admin.'
idle:
  render: 'Migration Wizard ''Review'' step (final step before ''Migrate now''). Top: a BigDesign Panel acting as an executive
    summary card with four stat tiles — ''Subscriptions to create (N)'', ''Payment methods matched (N of M)'', ''Estimated
    MRR migrated ($X)'', ''Revenue at risk from PAN-mapping gap ($Y)''. Below, a BigDesign Tabs component with four tab panels:
    Warnings (amber badges per warning), Errors (red badges per error), Field-mapping diffs (table: source column → target
    field → sample mapped value), Plan-mapping diffs (table: source plan name → matched BC plan). A persistent BigDesign Message
    type=warning (non-dismissible) beneath the summary card: ''Card-data migration runs out-of-band via your processor''s
    PAN migration service. Activation requires that step to complete first — contact Stripe Data Migrations for current lead-time
    SLA.'' ''Migrate now'' primary Button is disabled until the dry-run passes with zero entries in the Errors tab.'
  primary_action: '''Dry run'' Button triggers simulation. ''Migrate now'' Button is enabled only after a clean dry-run (Errors
    tab count = 0). ''Adjust mappings'' secondary link returns to the field-mapping step.'
loading:
  trigger: POST /api/v1/migrations/{id}/dry-run.
  render: '''Running dry run…'' with a BigDesign ProgressBar (indeterminate); all four tab panels show skeleton rows; both
    Dry-run and Migrate-now Buttons are disabled. The progress label remains visible throughout — dry runs on large datasets
    may take several seconds.'
error:
  surfaced_at: 'If the dry-run API call itself fails (server crash or timeout — not content errors), a BigDesign Message type=error
    appears above the summary card: ''Dry run failed — could not complete simulation.'' Content errors (row-level issues)
    render inside the Errors tab panel, never as a page-level banner.'
  recovery: Dry-run API failure — 'Retry dry run' Button re-fires POST /api/v1/migrations/{id}/dry-run. Content errors in
    the Errors tab — 'Adjust mappings' link returns to the mapping step so the merchant can correct field or plan mappings
    and re-run.
empty:
  render: 'Before the first dry run is executed, the summary card shows zero-state placeholder dashes for all four stat tiles,
    and each tab panel shows: ''Run a dry run to see results.'' The Migrate-now Button is disabled.'
edge_status:
- status: Dry-run passes with zero critical errors (Errors tab count = 0)
  affordance: '''Migrate now'' Button enabled; BigDesign Message type=success: ''Dry run passed — ready to migrate.'''
- status: Dry-run completes with critical errors (Errors tab count > 0)
  affordance: '''Migrate now'' stays disabled; Errors tab is auto-selected; ''Adjust mappings'' CTA is visible. Merchant must
    fix errors and re-run before proceeding.'
- status: Dry-run completes with warnings but no errors (Warnings count > 0, Errors = 0)
  affordance: '''Migrate now'' enabled with a caution Message type=warning: ''N warnings — review before proceeding.'' Merchant
    can proceed or return to adjust.'
- status: PAN-migration lead-time dependency not yet resolved — processor migration step not confirmed complete
  affordance: 'Persistent lead-time warning banner (non-dismissible) remains visible regardless of dry-run state. ''Migrate
    now'' tooltip: ''Confirm the card-data migration is complete before activating.'' The button is not hard-blocked by the
    UI — the external confirmation is advisory, surfaced by the warning banner.'
disabled_focus:
  keyboard: 'BigDesign Tabs tab triggers are real <button> elements reachable by Tab (or arrow keys within the tab list per
    ARIA Tabs pattern). ''Dry run'', ''Migrate now'', and ''Adjust mappings'' are real BigDesign Button and anchor elements.
    ''Migrate now'' uses native disabled attribute — not aria-disabled on a div — so it is correctly removed from tab order
    while disabled. Tab order: Dry-run → Migrate-now → Adjust-mappings → Tab group triggers → active tab panel content.'

US-3.7: PayWhirl API import (authoritative extraction)

Phase: P2 · Priority: P2 · Effort: M · Persona: Merchant Admin

As a Merchant Admin leaving PayWhirl, I want to import via PayWhirl's REST API (api key + secret), so that migration uses authoritative data without exporting and re-formatting a CSV. Complements US-3.4 (CSV upload) — the merchant picks whichever path they have.

Acceptance criteria:

  • Given valid PayWhirl API credentials (api-key + api-secret), When I run the import, Then all subscriptions are extracted by paginating customers (cursor-based) → per-customer subscriptions, across all pages.
  • Given PayWhirl embeds the plan in the subscription response, When records are normalized, Then plan_id, billing amount, interval (count), frequency (unit), and customer email map from authoritative API fields — no field-mapping wizard step is required in API mode.
  • Given PayWhirl's 360 requests/minute limit, When extraction runs, Then it throttles and backs off on HTTP 429 (honoring x-ratelimit-reset) with no dropped or duplicated rows, resumable by the customer after_id cursor.
  • Given PayWhirl payment tokens are not portable to BC Payments, When a subscription is written, Then a pm_revault_required edge-case event is emitted (same as US-3.4).

Data contract.

  • PayWhirl API (verified 2026-06-25, api.paywhirl.com): auth = api-key + api-secret headers; GET /customers (cursor before_id/after_id, limit ≤100); GET /subscriptions/{customer_id} (plan embedded); 360 req/min, x-ratelimit-* + HTTP 429.
  • Our adapter: dual-mode PayWhirlAdapter — API mode when credentials are supplied, CSV mode (US-3.4) otherwise; both share the fieldMapNormalizedRecord path.

Verification ceiling. G4 = behavior verified via a scenario mocking the api-key'd API responses (mirrors the BC-Payments adapter pattern). G5/live requires a real PayWhirl account + key (operator/external).

Spec: [Spec] #1699.


UI states.

<!-- ui-states US-3.7 -->
surface: 'NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) Migration Wizard — PayWhirl API credentials
  entry step, presented when merchant selects PayWhirl and chooses ''Connect via API'' over the CSV upload path. Persona:
  Merchant Admin.'
idle:
  render: 'A wizard step titled ''Connect your PayWhirl account'' with a one-sentence explanation: ''Enter your PayWhirl API
    credentials to extract subscriptions directly — no CSV export needed.'' Two BigDesign Input fields: API Key (type=text)
    and API Secret (type=password with a show/hide toggle Button). A ''Test connection'' secondary Button verifies credentials
    before extraction begins. A contextual note beneath: ''Extraction paginates all customers and subscriptions automatically.
    PayWhirl rate limit: 360 requests/min — extraction may take several minutes for large accounts.'''
  primary_action: '''Test connection'' validates credentials and shows an inline success or error indicator. ''Start extraction''
    primary Button is enabled only after a successful connection test and begins the paginated extraction.'
loading:
  trigger: POST /api/v1/migrations/paywhirl/test-connection for credential verification; POST /api/v1/migrations/paywhirl/extract
    for paginated extraction.
  render: 'During connection test: ''Testing…'' spinner next to the Test-connection Button; both Inputs and the Start-extraction
    Button are disabled. During extraction: full-step progress view — ''Extracting subscriptions… (customers processed: N,
    subscriptions found: M)'' with a BigDesign ProgressBar; progress label updates via polling every 5 seconds; all controls
    disabled; a note: ''Extraction is resumable — if interrupted, return to this step to continue from the last cursor position.'''
error:
  surfaced_at: 'Inline BigDesign Message type=error directly below the credential Inputs for authentication failures (401
    from PayWhirl API). Inline BigDesign Message type=warning for rate-limit pauses (HTTP 429): ''Rate limit reached — pausing
    and retrying after X seconds (per x-ratelimit-reset header).'' Never a toast.'
  recovery: Auth failure — re-enter correct API key + secret and re-test. Rate-limit warning — extraction auto-resumes after
    the x-ratelimit-reset window; no merchant action needed. If extraction is interrupted mid-run, 'Resume extraction' Button
    restarts from the last successful after_id cursor.
empty:
  render: 'If extraction completes but the PayWhirl account has zero subscriptions, a BigDesign Message type=info is shown:
    ''No subscriptions found in this PayWhirl account. Verify you are using the correct API credentials, or use the CSV import
    path instead.'' A ''Switch to CSV import'' link returns to the source-upload step.'
inputs:
- field: api_key
  control: text
  label: '''PayWhirl API Key'' — BigDesign Input, required'
- field: api_secret
  control: password
  label: '''PayWhirl API Secret'' — BigDesign Input type=password with a show/hide toggle Button'
edge_status:
- status: Connection test passes — credentials valid
  affordance: 'Inline success indicator next to Test-connection Button: ''Connected''; Start-extraction Button enabled.'
- status: Connection test fails — 401 from PayWhirl API
  affordance: 'Inline BigDesign Message type=error: ''Invalid API credentials. Check your PayWhirl API key and secret.'' Inputs
    re-enabled to correct and re-test.'
- status: Extraction hits rate limit (HTTP 429) — honoring x-ratelimit-reset
  affordance: 'BigDesign Message type=warning: ''Rate limit reached — resuming after N seconds.'' Auto-retries; no merchant
    action needed.'
- status: Extraction interrupted (worker crash or network drop) — resumable by after_id cursor
  affordance: '''Resume extraction'' Button appears; status line shows last-successful cursor position (''Resuming from customer
    N of approximately M'').'
- status: PayWhirl payment tokens present in extracted data — tokens not portable to BC Payments
  affordance: 'Info banner after extraction completes: ''PayWhirl payment tokens cannot be transferred to BC Payments. Subscribers
    will require payment method re-collection after migration.'' pm_revault_required is emitted per subscription.'
disabled_focus:
  keyboard: 'API Key Input, API Secret Input, show/hide toggle Button, Test-connection Button, and Start-extraction Button
    are all real BigDesign Input and Button elements — no div-onClick. Tab order: API Key → API Secret → show/hide toggle
    → Test connection → Start extraction. Native disabled removes all controls from tab order during credential-test and extraction
    loading. The show/hide toggle is a real <button> with aria-label=''Show API secret'' / ''Hide API secret''.'