Migrations¶
QuickPay credit cards to Frisbii¶
Migrates stored cards from QuickPay to Frisbii using the mapping file Frisbii returns after importing the cards on their side.
wp payment-hub frisbii import-quickpay-cards \
--file=/srv/private/cards-import.csv \
--expect-handle-prefix=prod- \
--dry-run
The mapping file¶
Semicolon separated, no header row, three columns:
479600290;10748-rqM;ct_1521aa11bb22cc33dd44ee55ff66aa77
│ │ └── Frisbii one-time token
│ └───────────── subscription ID, with an optional suffix
└─────────────────────── QuickPay transaction ID
Column 2 is the order ID QuickPay was given. For a subscription that is the subscription's WooCommerce ID, with -<3 random characters> appended when the checkout was a request to change payment method (OrderPaymentsUtils::get_order_number_for_api() in the QuickPay plugin). The subscription ID is therefore everything before the first hyphen.
What gets migrated¶
A row is migrated only when all of the following hold:
| Requirement | Why |
|---|---|
| Column 2 resolves to a WooCommerce subscription | Single orders must never be touched |
OrderUtils::get_transaction_id() equals column 1 | Proves the row and the subscription belong together, and picks the active card when the file holds several transactions for one subscription |
The subscription's payment method is exactly quickpay | Anything already migrated or on another gateway is left alone |
| The subscription has a registered customer | A guest has no Frisbii customer handle |
The next renewal is more than --min-hours-to-next-payment away | A brand new card should not be charged minutes after it is attached |
| No active Frisbii agreement exists yet | Avoids a second card on the same subscription |
Everything else is written to the skipped report with a reason. not_active_transaction is the expected outcome for the extra rows of a subscription with several transactions, and is counted separately from real failures.
On success¶
- A
paymenthub_agreementsrow:provider=frisbii,gateway=credit_card,status=ACTIVE,is_default=1,ext_agreement_id=ca_…, and the card's expiry inexpires_at. - Agreement meta recording where the card came from:
migrated_from,legacy_quickpay_transaction_id,legacy_quickpay_order_reference,card_last4,card_brand,card_exp_month,card_exp_year. - The subscription's payment method switched to
frisbii_credit_card, plus an order note.
The QuickPay meta on the subscription is left untouched, so a row can always be re-verified and rolled back.
What a dry run does¶
A dry run never contacts Frisbii. Not the token exchange, and not the harmless GET /v1/customer either. A ct_ token can only be exchanged once, so an analysis that spent one would destroy the very card it was meant to report on.
A dry run only:
- reads subscriptions and existing agreements from the database,
- reads the ledger file,
- writes its reports to
dry-run.-prefixed files, so a real report is never overwritten.
Two independent things enforce this: the command returns after the read-only pass, and the import service refuses to go past preflight when it is in dry-run mode. Both are covered by tests (test_the_analysis_pass_never_calls_frisbii, test_a_dry_run_import_never_calls_frisbii).
Options¶
| Option | Default | |
|---|---|---|
--file | required | Absolute path. The file holds card tokens, so keep it outside the web root |
--expect-handle-prefix | required | The PAYMENTHUB_HANDLE_PREFIX this environment must have. A mismatch aborts before anything is sent |
--dry-run | off | Analysis only. No Frisbii calls at all, no database writes. Reports get a dry-run. prefix |
--limit, --offset | – | Applied to file line numbers, so an offset means the same across runs |
--sleep | 250 | Milliseconds between API calls |
--min-hours-to-next-payment | 6 | 0 disables the check |
--stop-on-error | 10 | Aborts after this many consecutive real failures |
--send-emails | off | Keeps WooCommerce emails enabled during the run |
--verify | – | Re-checks every row in the success report against the database. Read-only |
--rollback | – | Reverts the subscriptions in the run's success report back to QuickPay |
Reports¶
Written next to the input file:
<file>.success.csv— one row per migrated subscription<file>.skipped.csv— everything not migrated, with a reason<file>.ledger.csv— every token exchanged, written the moment the exchange succeeds
Tokens appear in the reports only as a truncated, hashed label, never in full.
Validating a batch¶
Take a handful at a time and check them before moving on.
Automated, and the one to trust:
Read-only. It re-reads every row in the success report through the same accessors production uses — not the report itself — and prints PASS/FAIL per subscription with the specific problems named. It exits non-zero if anything failed, so it also works in a script. Each row is checked for:
| Check | |
|---|---|
payment_method_is_* | The subscription is on frisbii_credit_card |
quickpay_transaction_id_changed | The original QuickPay transaction is still on the subscription |
agreement_missing | The ca_ reference from the report exists as an agreement |
agreement_status_is_* | That agreement is ACTIVE |
wrong_provider, wrong_gateway | It is frisbii / credit_card |
not_default | It is the subscription's default |
session_id_set | session_id is null, keeping it out of the webhook handler's fallback lookup |
active_frisbii_agreements_N | Exactly one active Frisbii agreement, not two |
card_expired | The stored expiry is in the future |
agreement_meta_transaction_id_mismatch | The agreement records the transaction it was migrated from |
customer_changed | The subscription still belongs to the same customer |
By hand: docs/sql/verify-quickpay-frisbii-migration.sql
Sections 1 and 2 list every migrated card next to its subscription so you can eyeball a batch — migrated_from_txn and sub_quickpay_txn must match on every row. Sections 3 to 7 are the red flags and should all return zero rows; the one to care most about is section 4, a subscription set to Frisbii with no agreement to charge, which would fail at its next renewal. Sections 8 and 9 show progress and let you confirm no orders were touched.
Pick the HPOS or the legacy variant depending on the shop:
wp eval 'var_dump( Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() );'
Beyond the data, one thing only a real renewal proves: let a migrated subscription renew (or bring its next payment date forward) and confirm the charge goes through Frisbii. Do that once, on the first small batch, before running the rest.
When a row fails¶
The skipped report names the reason and, for an API failure, what Frisbii said:
reason what it means
token_exchange_rejected Frisbii gave a definitive 4xx. The token was not accepted and no
card was created, so the row can be retried once the cause is fixed.
token_exchange_indeterminate 429, 5xx, a timeout, or an unexpected error. We do not know whether
the exchange happened. Do not blindly retry - check the customer's
payment methods at Frisbii first.
customer_provisioning_failed The customer could not be created or looked up. The token was never
sent, so nothing is lost. The detail names the cause - including the
one case that needs Frisbii support: a customer that was deleted at
Frisbii still answers the lookup and still occupies its handle, so it
can neither be used nor replaced, and there is no API to restore one.
Frisbii documents these errors for POST /v1/payment_method, and the reason usually tells you straight away whether the token is still usable:
| Frisbii code | HTTP | Meaning |
|---|---|---|
| 9 | 404 | Customer not found |
| 34 | 400 | Invalid card token provided as source |
| 103 | 400 | Card token already used |
| 104 | 400 | Card token has expired |
A 422 is not in that list. It means the request body itself did not validate, which is a bug on our side rather than a problem with the token.
To check whether a card was actually created for a customer before retrying:
wp eval '$c = ygd_get_container()->get( Subscribed\PaymentsHub\Core\Gateways\Frisbii\Clients\FrisbiiClient::class );
echo $c->get( "https://api.frisbii.com/v1/customer/PREFIX+CUSTOMERID/payment_method", [] )->getBody();'
An empty list means the token was not consumed and the row is safe to retry.
Re-running is safe¶
The Frisbii token is single use. The ledger is written before any database write, so a token that has already been exchanged is never sent to Frisbii again — re-running the same file resumes rather than repeats. ext_agreement_id also has a unique index as a second line of defence.
Three outcomes mean a card exists at Frisbii but our own data does not reflect it, and always need a human: token_exchange_indeterminate, agreement_persist_failed and payment_method_switch_failed. The command warns about them and the details are in the skipped report and the WooCommerce log.
Suggested order¶
# 1. Confirm the container can build the repository under WP-CLI
wp eval 'ygd_get_container()->get( \Subscribed\PaymentsHub\Core\Contracts\Repositories\AgreementRepositoryInterface::class );'
# 2. Analyse without touching anything
wp payment-hub frisbii import-quickpay-cards --file=… --expect-handle-prefix=prod- --dry-run
# 3. Canary batch, then verify one real renewal before going further
wp payment-hub frisbii import-quickpay-cards --file=… --expect-handle-prefix=prod- --limit=5
# 4. Verify the batch against the database
wp payment-hub frisbii import-quickpay-cards --file=… --verify
# 5. Re-run the same batch: all five should be skipped as already_migrated
wp payment-hub frisbii import-quickpay-cards --file=… --expect-handle-prefix=prod- --limit=5
# 6. Let one migrated subscription renew and confirm the charge goes through Frisbii
# 7. The rest, in batches, running --verify between them
Delete the mapping file once the migration is done.