Skip to content

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

  1. A paymenthub_agreements row: provider=frisbii, gateway=credit_card, status=ACTIVE, is_default=1, ext_agreement_id=ca_…, and the card's expiry in expires_at.
  2. 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.
  3. 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:

wp payment-hub frisbii import-quickpay-cards --file= --verify

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

wp db query < 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.