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.
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 isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-3.x), not a line number. Regenerates on everydev → mainsync viaderive-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 --><!-- traceability:end:BRD:Epic-3 -->Prototype: Source Select · Field Mapping · PM Matching · Dry Run
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 --><!-- traceability:end:US-3.1 -->Prototype: Source Select · Field Mapping
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 --><!-- traceability:end:US-3.2 -->Prototype: PM Matching
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_refis set to the new destination-account instrument id from the mapping; any row with no mapped instrument imports aspaused_pending_pmand 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 GraphQLCustomer.paymentMethodsconnection — Braintree exposes no Stripe-styleGET /customers/{id}/payment_methodsREST 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).paymentMethodsor GraphQLCustomer.paymentMethodsconnection — 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_pmif no mapped instrument - Events:
migration.pm_mapped/migration.pm_unmappedper 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 --><!-- traceability:end:US-3.3 -->Prototype: Dry Run
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_atfor 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_atin 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 --><!-- traceability:end:US-3.4 -->Prototype: Source Select
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 --><!-- traceability:end:US-3.5 -->Prototype: Source Select
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 --><!-- traceability:end:US-3.6 -->Prototype: Dry Run
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_reportwith 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 customerafter_idcursor. - Given PayWhirl payment tokens are not portable to BC Payments, When a subscription is written, Then a
pm_revault_requirededge-case event is emitted (same as US-3.4).
Data contract.
- PayWhirl API (verified 2026-06-25, api.paywhirl.com): auth =
api-key+api-secretheaders;GET /customers(cursorbefore_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 thefieldMap→NormalizedRecordpath.
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''.'