Payments — QA Plan
Money is paranoid. Each scenario below is a must-pass before any release that touches apps/api/src/payments/. Critical scenarios marked [C] are FIT-133 / FIT-134 / FIT-136 surface area.
Smoke
| # | Scenario | Steps | Expected |
|---|---|---|---|
| S1 | Configure provider | Owner: POST /organizations/:orgId/payment-config with Cardcom test creds. | GET returns config with credentials redacted. Row in payment_provider_configs with encrypted blob. |
| S2 | Purchase a paid plan | Member: POST /plans/:id/purchase → follow paymentPageUrl → pay with Cardcom test card. | Webhook fires; sub pending → active; payment_transactions.status='completed'; card saved as member_payment_methods row; receipt email sent. |
| S3 | Purchase a free plan | Member purchases plan with priceInCents=0. | Sub created with status='active' directly; no paymentPageUrl; no txn row. |
| S4 | Cancel a paid plan (admin) | Owner: POST /subscriptions/:id/cancel. | Sub active → cancelled. Membership payment_status unchanged. No refund. |
| S5 | Staff record a member’s notice | Owner: POST /subscriptions/:id/cancel { initiator: 'member_request' }. | cancelAtPeriodEnd=true; sub stays active; cancellation_review task created (priority=low). Email sent. |
| S6 | Member tries the old self-serve routes | Member: POST /subscriptions/my/:id/cancel-at-period-end or POST /subscriptions/my/:id/resume. | 404 — no member-facing cancellation route exists. |
| S7 | Owner downgrades from Pro to Lite | PATCH /tier { tier: 'lite' }. | Routes through cancelSubscription; sub scheduled to cancel at period end; org keeps Pro tier until then. |
Critical money flows
[C] Refund — automatic capability
Today every provider is manual. When flipping any provider to automatic (FIT-134), run this.
| # | Scenario | Expected |
|---|---|---|
| R-A1 | Full refund on completed charge | Original txn → refunded; new refund type txn inserted; member receives refund email; observability emits payment.refund_completed. |
| R-A2 | Partial refund | New refund txn with amount_in_cents < original.amount_in_cents. Original stays completed (gap — see below). |
| R-A3 | Refund a refunded txn | Second call to refund(): adapter call may succeed; original status already refunded; no schema-level constraint prevents over-refunding (FIT-134). |
| R-A4 | Refund failed | Adapter success=false. Original stays completed; emits payment.refund_failed; a failed refund-type txn is recorded. |
[C] Refund — manual capability
| # | Scenario | Expected |
|---|---|---|
| R-M1 | Open manual refund | POST /payments/:txnId/refund. Original txn → refund_pending, refund_task_id set. tasks row created (type='manual_refund', priority='high', 3-day due date). Observability payment.refund_task_opened. |
| R-M2 | Re-open same txn | Refuses with 400 Refund already in progress for this transaction (payment.service.ts:508). |
| R-M3 | Close task with externalReference | POST /payments/refund-tasks/:taskId/complete { externalReference }. Task → completed; txn → refunded; refundExternalReference stored; member email sent. |
| R-M4 | Close already-closed task (idempotency) | Same POST again. Returns { alreadyCompleted: true }. No additional observability event, no duplicate email. |
| R-M5 | Close task missing externalReference | Returns 400. |
| R-M6 | Cross-org task close attempt | Task belongs to org A; user from org B closes it. Service guards task.organizationId !== orgId → 400 Task not found. |
[C] Subscription renewal failure (FIT-136)
| # | Scenario | Expected |
|---|---|---|
| RF1 | First renewal fails | failed_charge_attempts=1, status active → past_due, nextChargeDate = now + 3d, payment_status='past_due', “payment failed” email. |
| RF2 | Second renewal fails | attempts=2, nextChargeDate = now + 7d. |
| RF3 | Third renewal fails | attempts=3, status → debt, debt_amount_in_cents += plan.price_in_cents, debt_since stamped, nextChargeDate=NULL, “debt warning” email, payment_status='debt'. |
| RF4 | Retry succeeds after past_due | attempts → 0, status past_due → active, period advanced, credits refilled. |
| RF5 | Pending sub gets failed first charge | Sub pending → cancelled immediately. No retry. (webhook-processing.service.ts:379) |
| RF6 | Concurrent cron worker collision | Two workers run the daily cron simultaneously. FOR UPDATE SKIP LOCKED ensures each sub is processed exactly once. |
[C] Mid-cycle cancellation + refund
| # | Scenario | Expected |
|---|---|---|
| MC1 | Member requests immediate cancel with refund | cancellation_requests row created (status='pending', refund_requested=true). Owner email + member email + cancellation_review task (priority=urgent). |
| MC2 | Owner approves with refund | Sub canceled immediately. Most-recent completed charge txn is refunded via capability-aware flow. Request → approved; refund_task_id filled if manual. |
| MC3 | Owner rejects | Request → rejected; sub stays active. Member email. |
| MC4 | Approve when no completed charge exists | Sub canceled; refund step is skipped (cancellation-requests.service.ts:282); request still moves to approved. |
| MC5 | Duplicate pending request | Second POST to /cancellation-requests for same sub: 400 A cancellation request is already pending. |
| MC6 | Member cancels someone else’s sub | 403 You can only cancel your own subscription. |
[C] Webhook idempotency (FIT-133)
| # | Scenario | Expected |
|---|---|---|
| W1 | Same webhook fired twice | Second arrival hits status === 'completed' short-circuit; no duplicate sub mutation. |
| W2 | verify-return + webhook race | Both call processWebhookEvent for the same processId. Outcomes converge: sub active, txn completed, period advanced exactly once. Today this is best-effort — no row lock between the two reads (webhook-processing.service.ts:121). |
| W3 | Webhook with cross-org metadata | Org A’s webhook URL carries metadata referencing entitlement from org B. Service logs Cross-org webhook abuse blocked, captures Sentry, does not mutate (webhook-processing.service.ts:215). |
| W4 | Webhook arrives before transaction row exists | findByProviderTransactionId returns null; falls through to completePendingBySubscriptionId using metadata.subscriptionId; if no metadata, no-op + warning. |
| W5 | Webhook signature invalid (Meshulam, iCredit) | Controller throws 400; processor never called. |
| W6 | Webhook signature missing (Cardcom) | Controller passes (Cardcom returns true unconditionally). Defence comes from URL-secrecy + re-fetch on verify-return. |
| W7 | Ignored event type | Adapter sets metadata.ignored; controller emits payment.webhook_ignored and returns 200 without processing. |
Charge succeeded but DB write failed
| # | Scenario | Expected |
|---|---|---|
| PS1 | Provider returns success → DB transaction throws after adapter.createCharge (recurring-charge.service.ts:153) | Pending txn row stays pending; subscription untouched. Card was debited. Recovery: next cron tick will attempt to re-charge (double-charge risk — see below). |
| PS2 | Pending row exists but next cron tick fires | Today no reconciler for per-org payments (gap; platform-billing has one). Risk: double-charge unless the provider dedupes on the same idempotency key. |
Mitigation today: upsertPending (payment-transaction.service.ts:154) reuses the pending row by subscriptionId, so the second cron sees the same row and skips the insert. But the adapter.createCharge call has no idempotency key — duplication risk lives at the provider boundary.
Network failures
| # | Scenario | Expected |
|---|---|---|
| NF1 | Webhook controller times out mid-processing | Provider retries (per provider). Idempotency guards handle the replay (W1). |
| NF2 | Adapter HTTP fails | createCharge returns success=false, errorMessage. Txn → failed. Membership flags accordingly. |
| NF3 | DB advisory-lock contention on platform billing checkout | Two parallel createCheckoutSession calls serialize on pg_advisory_xact_lock(hashtext('platform-billing:checkout:<orgId>')) (platform-billing.service.ts:155). Second waits for the first; both end with one Cardcom session. |
[C] Taikan-managed terminal provisioning (FIT-286)
Nothing here has been run against Cardcom yet — dealer credentials are outstanding. Run the whole block before the first real gym applies.
| # | Scenario | Expected |
|---|---|---|
| T1 | Owner picks Taikan-managed | Settings → Payments opens on the mode choice; managed path shows the explanation step with no fields and no credential inputs anywhere. |
| T2 | Submit a valid application | POST …/provision → row written with status='pending_kyc', isActive=true. Provider dashboard login shown once; dialog cannot be dismissed until acknowledged. |
| T3 | Member view while pending | Member sees a gym with no shop — identical to a gym with no provider at all. |
| T4 | Staff view while pending | Plans page shows the pending alert; owner can still create and price plans. Settings card shows the pending badge and no Configure button. |
| T5 | Charge attempt while pending | Every charging path (purchase, recurring cron, desk charge, debt collection, card registration, platform-billing) fails closed — getDecryptedCredentials filters on status='active'. |
| T6 | Second application while pending | 409 Conflict. Verify no second company was created at Cardcom — this is the expensive failure mode. |
| T7 | KYC approved | Platform admin POST …/managed-status {outcome:'active'} → status flips, activation email arrives, shop appears for members, a real card charges successfully. |
| T7a | Status check activates | With a terminal pending, call POST …/managed-status/refresh (or run the sweep). Once Cardcom answers IsDone: true: status flips, the stored credentials match APIUserName/APISecret from the status response, activation email arrives exactly once, and a repeat check is a no-op. |
| T7b | Owner checks status early | Pending card shows Check status. Before approval it reports still-under-review and lists any outstanding requirements from Cardcom’s error arrays; after approval it activates. Works with managed-terminal-kyc-poll OFF — the button is never flag-gated. |
| T7c | Status query fails | GetCompanyStatus returns a non-zero ResponseCode → terminal stays pending and nothing is written. Unreachable → the on-demand check surfaces the error to the owner rather than reading back as “still under review”; the sweep logs it and moves to the next org. |
| T7d | Company number is the right field | After NewCompany, the stored config carries companyNumber = the response’s CompanyNumber (e.g. 21351), NOT CompanyInternalID (e.g. 21348, a UUID in practice). Confirm the outbound GetCompanyStatus URL carries that value and that Cardcom answers ResponseCode: 0 — this is the assertion the whole sweep rests on and it has never been seen pass against Cardcom. |
| T7e | Sweep flag rollout | managed-terminal-kyc-poll OFF (or PostHog unreachable) → the tick contacts Cardcom for zero orgs; only the button activates. ON for one org with two pending → exactly that org is polled. Verify against the sweep’s own log line (Checked N …, skipped N …). |
| T7f | Terminal with no company number | A pending_kyc row whose config has no companyNumber (provisioned before the fix, or a Cardcom response without the field) is never sent to GetCompanyStatus, even with the flag ON — it counts as skipped and waits for Check status. This is the regression guard for FITKIT-BACKEND-3W’s hourly error loop. |
| T8 | KYC rejected | {outcome:'rejected', reason} → rejected badge with the reason; owner can submit a new application. |
| T9 | Missing dealer env | Any CARDCOM_SUPPLIER_* unset → 503, no partial row written. |
| T10 | Module check | Cardcom opens fewer modules than requested → warning logged. Confirm tokenisation is among them — without it recurring billing fails at the first renewal a month later, not at setup. |
| T11 | Derived KYC estimates | Provision for an org with plans → payload’s min/avg/max match the catalogue; org with no plans → non-zero fallbacks. |
| T12 | Non-Israeli owner | Validation rejects with the support message before any Cardcom call. |
Permission tests
| # | Scenario | Expected |
|---|---|---|
| P1 | Coach attempts refund | 403 — only owner/admin |
| P2 | Member attempts admin cancel | 403 |
| P3 | Member cancels another member’s sub | 403 |
| P4 | Cross-org access to refund task | 400 Task not found |
| P5 | Owner of org A reads txns of org B | Filtered out by organization_id clause; returns empty list (no error). |
| P6 | Update tier by admin (not owner) | 403 (platform-tiers.controller.ts:64). |
| P7 | Coach attempts terminal provisioning | 403 — owner/admin only. |
| P8 | Owner calls managed-status | 403 — @PlatformAdmin only; a gym must not mark its own terminal live. |
| P9 | Coach calls managed-status/refresh | 403 — owner/admin only. The refresh asks Cardcom whether it approved, so it cannot be used to self-activate. |
E2E (Playwright)
apps/web/e2e/specs/payments-*.spec.ts— owner refund flow.apps/web/e2e/specs/buy-course.spec.ts— full course-checkout journey (Clerk email-code → Cardcom test card → entitlement live).
Open gaps requiring manual QA today
- No unique index on
payment_transactions.(organization_id, provider_transaction_id)— duplicate insert is technically allowed (FIT-133). - Manual refund external reference is not validated against the provider’s actual credit document — wrong number lands in the DB. Audit by reconciling Morning’s portal monthly.
- Tranzila + Morning webhook signatures are stubs — only deploy these providers behind an IP allowlist at the proxy until validation is real.
- Partial-refund sum check is application-only and not enforced (R-A2 / R-A3). The closing manual task can over-refund the original charge if the gym owner enters wrong amounts.
Manual charges & debt collection (FIT-254 §3.3/§3.4)
| # | Scenario | Expected |
|---|---|---|
| MCH1 | Flag off | Any org without admin-card-on-file evaluated true → 403 before any DB/provider call. UI hides the charge button/dialog entirely. |
| MCH2 | Non-owner/admin charges | 403 — payments/manage required. |
| MCH3 | No provider configured | 400 "No payment provider configured for this organization" — not the FIT-254 spec’s originally-proposed 409 no_payment_provider. Known delta, see ADR-0016. |
| MCH4 | Provider not in CHARGE_VERIFIED_PROVIDERS (e.g. Morning) | 409 provider_charge_unverified. |
| MCH5 | Membership suspended/cancelled | 409 membership_not_active. |
| MCH6 | No active card on membership | 409 no_active_payment_method; UI shows Register-card CTA. |
| MCH7 | [C] Double-charge via retry | No idempotency key on the charge path — two rapid POSTs (double-click, or a client retry after a timeout) each insert their own pending row and each get charged. The confirm dialog’s in-flight button-disable is the only client-side guard; there is no server-side dedupe. Must be covered by an explicit “click twice fast” e2e/manual test before rollout, and called out to org owners as “one click, wait for the result.” |
| MCH8 | Crash between adapter call and status update | Pending row stays pending — card may have been debited with no completed record. No reconciler exists for this table (same gap as FIT-133/PS1); manual reconciliation only. |
| MCH9 | Successful charge | Txn completed; receipt email sent with description as a line item; payment.manual_charge event emitted (amount/ids/actor only — description never reaches PostHog); no SUBSCRIPTION_RENEWED emitted. |
| MCH10 | Provider decline | Txn failed with errorMessage; no receipt; retry allowed (produces a new txn, see MCH7). |
| MCH11 | Clear debt success | Full debtAmountInCents charged; sub → active; nextChargeDate = max(now, currentPeriodEnd) and failedChargeAttempts=0 (A6 — previously nextChargeDate stayed NULL forever); membership payment_status='current'. Pending-row-first: the txn row exists (status pending → completed) before the adapter call, never after (A6). |
| MCH12 | Clear debt failure | Sub stays debt; inline error; no partial collection possible (v1 scope). |
| MCH12b | Clear debt double-collection race (A6) | Two concurrent clear-debt calls on the same debt sub both pass the pre-charge checks and both charge the provider; only the first’s atomic post-charge claim (WHERE status='debt' AND debt_amount_in_cents=<charged amount>) succeeds. The second’s txn flips to refund_pending and a manual_refund task opens (via PaymentService.flagTransactionForManualReview) instead of throwing to that caller — the debt genuinely is cleared (by the other request), so this surfaces as a reconciliation item, not a client-facing error. |
| MCH13 | Allowlist expansion procedure | Adding a provider to CHARGE_VERIFIED_PROVIDERS (manual-charge.service.ts:34) is a one-line code change, but must only happen after: (1) the provider’s createCharge (token MIT) has been sandbox-verified end-to-end, (2) tax-document (חשבונית/קבלה) issuance on that call path is confirmed for the IL market. Treat as a release-gated change, not a config flip — it is compiled into the binary, shared between manual-charge and plan-change. |
| MCH14 | Cross-org membership id | 404 (membership lookup scoped to orgId). |
| MCH15 | Garbage (non-UUID) :membershipId/:subscriptionId/:orgId on charge/clear-debt/payment-methods-list (B, Wave B) | 400 via ParseUUIDPipe, never a Postgres-level 500. |
| MCH16 | Plan-change charge succeeds but the swap fails (B1/B2, Wave B) | Same reconciliation pattern as MCH12b — flagTransactionForManualReview flips the txn to refund_pending and opens a manual_refund task; see subscriptions-plans/qa-plan.md PC34/PC35. |
| MCH17 | Clear-debt with no active card on the membership (C6, Wave C) | 409 no_active_payment_method (structured code, same as MCH6’s manual-charge check) — previously a plain, uncoded 400 from DebtService.clearDebt itself. The collect-debt dialog now maps this to localized copy instead of the raw English message. |
Checkout decision — cancelled / failed return (unflagged)
Covered by apps/api/src/subscriptions/checkout-decision.int.spec.ts,
apps/api/src/payments/checkout-urls.unit.spec.ts,
cardcom.provider.unit.spec.ts and webhook-failure-races.int.spec.ts.
| # | Scenario | Expected |
|---|---|---|
| CD1 | Member closes the hosted Cardcom form | Lands on the return page with status=cancelled. Subscription stays pending. POST …/checkout-return emits payment.checkout_cancel_returned + payment.return_landed; nothing is mutated (re-firing it on every mount is safe). |
| CD2 | Card is DECLINED on the hosted form | Cardcom fires FailedRedirectUrl, which now carries status=failed — distinct copy, not “you cancelled”. The transaction is failed, the subscription stays pending, and payment.activation_failed { reason: 'first_charge_failed' } is emitted. Regression watch: it used to flip to cancelled with no marker, which killed both retry and revival. |
| CD3 | Declined on mobile | status=failed survives the payments/app-return deep-link bridge into taikan://shop/payment-return (the bridge forwards every query param). |
| CD4 | Member taps Resume payment | POST …/resume-checkout returns { subscription, paymentPageUrl, resuming: true } for the SAME subscription id. payment.checkout_resumed { source: 'resume_endpoint' }. |
| CD5 | Resume after the plan sold out / was retired / gained a signing requirement | The same refusal a fresh purchase would give (409 form_signature_required, seat/cap error, “Plan is not available”) — never a payment page for something unbuyable. |
| CD6 | Resume after the 24h sweep already released the row | 409 checkout_not_resumable, body carries status: 'cancelled'. No new subscription is created. |
| CD7 | Resume, withdraw, or report a return against somebody else’s subscription | 404 on all three — never a 403, which confirms the id exists and hands out an enumeration oracle. (The pre-existing cancel-pending route keeps its 403 so deployed clients don’t shift.) |
| CD8 | Resume an externally-billed plan (ADR-0017) | paymentPageUrl is the plan’s own paymentLinkUrl; no provider API call. |
| CD8b | Resume a checkout whose payment already settled, or whose charge is in flight | 409 checkout_not_resumable — issuing a second payment page would invite the member to pay twice. Mirror image of voidPendingSubscription’s refusal to release the same row. |
| CD8c | Payment-page creation fails while RESUMING | The pending subscription is left intact (only a row that call created is rolled back). Regression watch: deleting it would destroy the very checkout the member asked to resume, and orphan its transactions. |
| CD8d | Return URLs | successUrl, cancelUrl and failedUrl all carry sub=<id>; a client that already appended one does not get a second. |
| CD9 | Member taps Cancel purchase | POST …/cancel-pending { intent: 'regret', source: 'return_page' }. Row → cancelled + abandoned_checkout + cancellation_requested_by = member, cancellation_requested_at stays NULL. Disappears from GET …/subscriptions/my; still listed for staff; the plan is purchasable again in the shop. |
| CD10 | Cancel purchase when a payment already settled | 409 cancel_pending_unsafe (“contact the gym”); the row is untouched. |
| CD11 | Cancel purchase while a charge is in flight (charge_started_at) | 409 cancel_pending_unsafe; the row is untouched. |
| CD12 | A late payment.completed arrives for a rolled-back checkout | The subscription is REVIVED (revivingAbandonedCheckout) and reappears in the member’s list. This is why the rollback must keep the abandoned_checkout reason. |
| CD13 | Row released by the 24h sweep, or dismissed by staff | Stays VISIBLE to the member with the checkout_abandoned chip. Only the member’s own rollback hides. checkoutReleasedBy says which one it was: sweep / staff / member. |
| CD13b | Declined checkout still pending after an hour | Swept (seat released), payment.checkout_abandoned with reason: 'declined charge, idle > 1h'. Within the hour, or once a new attempt is open, it is left alone. |
| CD14 | Cardcom payment.failed with NO TranzactionId (the payment page expired, ~25 min after creation) | The attempt keyed on that lowProfileId is marked cancelled — never failed; the subscription stays pending; payment.webhook_ignored with reason: 'checkout_page_expired'; no activation_failed, no Sentry. |
| CD14b | Same expiry against a LIVE subscription, another org’s checkout, or a page already closed | Nothing touched. |
| CD14c | Expiry for an OLD page while a newer attempt is open on the same subscription | Only the old page’s attempt is closed. |
| CD14d | Genuine decline: payment.failed WITH a TranzactionId we never stored | Resolved through lowProfileId to the pending attempt (not transaction_not_on_file); attempt failed with the gateway’s errorMessage; subscription stays pending. |
| CD15 | detectStuckPending over a declined checkout | outcome: 'ignored', reason names the decline. A checkout stuck with no decline behind it still emits failure. |
| CD16 | Join-link sweep releases a declined vs an abandoned checkout | cause: 'declined' / 'abandoned'; the CRM note names the decline and says to offer another payment method. Membership is released either way. |
| CD17 | checkout-recovery-emails OFF | Seat still released, no mail. Flag ON: the declined member gets the decline letter with the bank’s own reason; the abandoner never sees the word “declined”. |
| CD18 | Recovery mail with no live join link anywhere | Mail still sends, button dropped — never a link to a page that 404s. |
| CD19 | Card declined (a failed attempt exists), checkout-recovery-emails ON | The release sweep sends the decline letter with the bank’s own reason at the release. Nothing is sent at the refusal — Cardcom gives us no event for it. |
| CD20 | Expired page, checkout-recovery-emails ON | The release sweep sends the ABANDON letter; the word “declined” never appears. |
| CD22 | Released join-link registrant signs in | ”Membership inactive” screen offers a working way back; the contact-your-gym line is replaced, not stacked. |
| CD23 | Suspended member, or one removed by staff, signs in | No rejoin button — the screen keeps today’s copy. |
| CD13c | Two-day-old checkout resumed minutes ago | NOT swept — idleness is measured from the newest open attempt. |
| CD14 | Staff dismiss from the member page | POST /organizations/:orgId/subscriptions/:id/void-pending, owner/admin only (member → 403, coach → 403). Same money-safety refusals as the member path. |
| CD15 | Deployed mobile build posts cancel-pending with no body, in an org that never had the flag | Identical to today: row cancelled, checkoutCancel.intent null. No client is broken by the new field. |
| CD16 | deriveFailedUrl on a cancel URL with no status=cancelled | Returns undefined; the adapter falls back to cancelUrl, i.e. exactly today’s behavior. Never a guessed URL. |
| CD17 | Morning-backed org | Unchanged — Morning has a single failureUrl for both legs, so no failed/cancelled split is attempted there. Verify the return page still reads as “cancelled” and Resume still works. |
Card issuer validation (card-issuer-validation)
Covered by cardcom.provider.unit.spec.ts (operation + classifier),
payment.service.unit.spec.ts (policy → request) and
card-validation-policy.service.unit.spec.ts (flag → policy). The live-terminal
rows need a Cardcom test terminal with the tokens module enabled.
| # | Scenario | Expected |
|---|---|---|
| CV1 | Flag OFF / unset / PostHog unreachable, presale checkout | LowProfile/Create carries Operation: CreateTokenOnly and no AdvancedDefinition — byte-for-byte today’s request. |
| CV2 | Flag ON, presale checkout | Operation: SuspendedDeal, AdvancedDefinition.JValidateType: 5, Amount = the plan price. Ledger row still recorded at 0 with metadata.tokenOnly. |
| CV3 | Flag ON, card registration | Same operation, Amount: 1 — the member’s statement shows a ₪1 hold, not the plan price. |
| CV4 | Flag ON, ordinary (charging) checkout | Untouched: ChargeAndCreateToken, policy never consulted. |
| CV5 | Live terminal: valid card on a suspended deal | GetLpResult answers ResponseCode: 0 and TranzactionInfo.ResponseCode 700 or 701; classified completed; token + expiry stored; subscription lands scheduled; the member’s bank shows a pending authorization for the plan price that lapses without a charge. |
| CV6 | Live terminal: wrong CVV / expiry / no credit | Cardcom refuses on the page. Whatever reaches us is a payment.failed, filed as card_validation_failed like today’s presale decline; the checkout stays the member’s to retry. |
| CV7 | A 700/701 on a ChargeAndCreateToken deal | Classified failed — the J-codes are trusted only on CreateTokenOnly / SuspendedDeal. |
| CV8 | Suspended deal whose token sits in TranzactionInfo only | Token, CardMonth and CardYear still land in the event metadata and on the payment method. |
| CV9 | Opening day after a suspended deal | The renewal sweep charges the stored token with Transactions/Transaction as before. The hold is never captured (ApprovalNumber unused); if it is still live the bank shows both for a few days. |