You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #466 / PR #478 (implements PayPal in full + Binance Pay). That PR shipped with code + build + unit-test verification only — no live sandbox run, because no PayPal sandbox or Binance Pay merchant credentials were available in the dev environment. This issue tracks the credentialed end-to-end QA that must happen after the PR merges, before either provider is enabled for a real school.
Prerequisites (setup)
Apply the migration (supabase db push) — adds binance to the products/plans payment_provider CHECK constraints (20260719180000_add_binance_payment_provider.sql).
PayPal sandbox app (developer.paypal.com): set PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_ENVIRONMENT=sandbox.
PayPal webhook: register https://<host>/api/payments/webhook/paypal with events PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, BILLING.SUBSCRIPTION.ACTIVATED, BILLING.SUBSCRIPTION.CANCELLED, BILLING.SUBSCRIPTION.EXPIRED, BILLING.SUBSCRIPTION.SUSPENDED, BILLING.SUBSCRIPTION.PAYMENT.FAILED, PAYMENT.SALE.COMPLETED; set the webhook id as PAYPAL_WEBHOOK_ID. Local testing needs a public tunnel (ngrok/cloudflared) since PayPal must reach the endpoint.
Binance Pay merchant account: set BINANCE_PAY_API_KEY, BINANCE_PAY_API_SECRET; configure the merchant webhook URL to https://<host>/api/payments/webhook/binance.
Admin → Settings → Payment: enable PayPal and Binance Pay for the test tenant.
PayPal — one-time (product)
Create a paid product with provider PayPal → a Catalog Product id (PROD-…) is stored in provider_product_id and a synthetic PAYPAL-ONETIME:… id in provider_price_id.
Student checkout → redirected to PayPal sandbox approve page → after approval, returned via /api/payments/paypal/capture to the success page.
Transaction is successful, enrollment exists (enroll_user ran), provider_subscription_id holds the PayPal order id.
Refresh the capture return URL → no double processing (transaction stays successful, single enrollment).
PAYMENT.CAPTURE.COMPLETED webhook delivery shows 200 in the PayPal dashboard; webhook_events row exists with processed_at set.
Refund the capture from the PayPal sandbox dashboard → PAYMENT.CAPTURE.REFUNDED flips the transaction to refunded and revokes the product entitlements.
PayPal — subscription (plan)
Create a monthly plan with provider PayPal → Billing Plan id (P-…) stored in provider_price_id.
Student subscribes → PayPal approve → BILLING.SUBSCRIPTION.ACTIVATED webhook flips the pending transaction, creates the subscription row with provider_subscription_id = I-… and period end = PayPal's next_billing_time.
Owner-binding: confirm the webhook only activates when the custom_id userId/tenantId match the transaction (tamper test below covers the negative).
Renewal (sandbox billing cycles are slow — optionally use PayPal's webhook simulator for PAYMENT.SALE.COMPLETED with a real billing_agreement_id) → extend_subscription_period extends current_period_end.
Student self-cancel (billing page) → PayPal subscription cancelled; CANCELLED webhook marks the row canceled and enrollments are disabled.
Create a paid product with provider Binance Pay (no provider ids required) → student checkout redirects to Binance checkout (checkoutUrl), pay in USDT.
PAY_SUCCESS webhook enrolls the student; delivery log shows our ack {"returnCode":"SUCCESS"} and Binance marks the notification delivered (no retry storm).
Create a monthly plan with provider Binance Pay → after payment, subscription row exists with the plan duration as period and the Binance order id as provider_subscription_id.
Re-purchase the same plan before expiry → period extends (renewal path); after expiry with no payment, the expiry cron lapses the subscription (selfManagedPeriod).
Abandon a checkout (close the Binance page) → transaction stays pending; PAY_CLOSED (order expiry) arrives → normalized payment.failed, no state corruption, retry purchase works (partial unique index allows it).
Refund via merchant tooling → PAY_REFUND/REFUND_SUCCESS flips the transaction to refunded and revokes entitlements.
Webhook security (both providers)
Replay a captured webhook body with a tampered signature → 400, no state change.
Replay a valid delivery unchanged → duplicate: true ack, no double processing (webhook_events idempotency).
Send a valid-signature event whose custom_id/passThroughInfo references another user's pending transaction id → dispatcher throws owner-mismatch, transaction untouched (fail-closed guard).
POST to /api/payments/webhook/binance with missing signature headers → 400.
UI polish check
Admin payment settings shows the Binance Pay toggle (en + es); product/plan forms and the product-creation wizard offer PayPal/Binance only when enabled.
Checkout page for a PayPal/Binance product shows the hosted-redirect flow (no dead "card unavailable" state); PayPal label no longer says "Coming Soon".
Follow-up to #466 / PR #478 (implements PayPal in full + Binance Pay). That PR shipped with code + build + unit-test verification only — no live sandbox run, because no PayPal sandbox or Binance Pay merchant credentials were available in the dev environment. This issue tracks the credentialed end-to-end QA that must happen after the PR merges, before either provider is enabled for a real school.
Prerequisites (setup)
supabase db push) — addsbinanceto the products/planspayment_providerCHECK constraints (20260719180000_add_binance_payment_provider.sql).PAYPAL_CLIENT_ID,PAYPAL_CLIENT_SECRET,PAYPAL_ENVIRONMENT=sandbox.https://<host>/api/payments/webhook/paypalwith eventsPAYMENT.CAPTURE.COMPLETED,PAYMENT.CAPTURE.REFUNDED,BILLING.SUBSCRIPTION.ACTIVATED,BILLING.SUBSCRIPTION.CANCELLED,BILLING.SUBSCRIPTION.EXPIRED,BILLING.SUBSCRIPTION.SUSPENDED,BILLING.SUBSCRIPTION.PAYMENT.FAILED,PAYMENT.SALE.COMPLETED; set the webhook id asPAYPAL_WEBHOOK_ID. Local testing needs a public tunnel (ngrok/cloudflared) since PayPal must reach the endpoint.BINANCE_PAY_API_KEY,BINANCE_PAY_API_SECRET; configure the merchant webhook URL tohttps://<host>/api/payments/webhook/binance.PayPal — one-time (product)
PROD-…) is stored inprovider_product_idand a syntheticPAYPAL-ONETIME:…id inprovider_price_id./api/payments/paypal/captureto the success page.successful, enrollment exists (enroll_userran),provider_subscription_idholds the PayPal order id.successful, single enrollment).PAYMENT.CAPTURE.COMPLETEDwebhook delivery shows 200 in the PayPal dashboard;webhook_eventsrow exists withprocessed_atset.PAYMENT.CAPTURE.REFUNDEDflips the transaction torefundedand revokes the product entitlements.PayPal — subscription (plan)
P-…) stored inprovider_price_id.BILLING.SUBSCRIPTION.ACTIVATEDwebhook flips the pending transaction, creates the subscription row withprovider_subscription_id = I-…and period end = PayPal'snext_billing_time.custom_iduserId/tenantId match the transaction (tamper test below covers the negative).PAYMENT.SALE.COMPLETEDwith a realbilling_agreement_id) →extend_subscription_periodextendscurrent_period_end.CANCELLEDwebhook marks the row canceled and enrollments are disabled.Binance Pay
checkoutUrl), pay in USDT.PAY_SUCCESSwebhook enrolls the student; delivery log shows our ack{"returnCode":"SUCCESS"}and Binance marks the notification delivered (no retry storm).provider_subscription_id.selfManagedPeriod).pending;PAY_CLOSED(order expiry) arrives → normalizedpayment.failed, no state corruption, retry purchase works (partial unique index allows it).PAY_REFUND/REFUND_SUCCESSflips the transaction torefundedand revokes entitlements.Webhook security (both providers)
duplicate: trueack, no double processing (webhook_eventsidempotency).custom_id/passThroughInforeferences another user's pending transaction id → dispatcher throws owner-mismatch, transaction untouched (fail-closed guard)./api/payments/webhook/binancewith missing signature headers → 400.UI polish check
Acceptance