Checkout flow

Updated

How an EDD cart becomes a Paddle checkout, for one-time purchases and subscriptions, on both platforms.

Turning an EDD cart into Paddle lines

Driver::one_time_lines( $order, $purchase_data, $subscription ):

  1. Every cart item except the subscription item becomes a one-time line with its EDD line total (after discounts, including EDD tax if enabled). Zero-amount lines are skipped. Each line keeps its cart_index.
  2. The subscription item contributes what is due now: its recurring_amount, or 0 during a trial.
  3. Reconciliation: adjustment = order total − (sum of 1 and 2). Positive: an extra one-time line (named “Sign-up fee” when the subscription has one, “Fees” otherwise, or the product name for license upgrades and renewals). Negative: a non-recurring flat discount (first-payment-only discounts, proration).

This guarantees Paddle charges exactly $order->total, whatever mix of EDD fees, sign-up fees, discounts and Software Licensing proration produced it. The order total (not $purchase_data['price']) is used because EDD Recurring zeroes trial items after the order is inserted.

One-time purchase (inline or overlay)

BROWSER (paddle-checkout.js)          SERVER
EDD validates the form
form.onsubmit intercepted  ────────▶  admin-ajax: wpinterface_paddle_checkout
                                       · verify edd-process-checkout-nonce
                                       · wp_redirect filter → JSON {redirect}
                                       · edd_process_purchase_form()
                                         → Gateway::process_purchase()
                                         → edd_insert_payment (pending)
                                         → driver->start_checkout()
        ◀────────────────────────────  JSON { transaction_id | pay_link, payment_key,
                                         email, success_url, checkout_nonce }
Paddle.Checkout.open() inline/overlay
checkout.completed / successCallback
poll wpinterface_paddle_order_status ▶ empty cart (first poll); Billing:
                                       sync_order() via API (max 1 per 4 s)
redirect to success_url               Gateway::confirm_payment() shows EDD's
                                       "processing" page while still pending
  • The AJAX handler reuses EDD’s normal purchase processing. A temporary wp_redirect filter turns any redirect EDD would make (validation errors, free orders, gateway errors) into JSON { redirect }, which the script follows.
  • Every response includes a fresh checkout_nonce, because EDD may register and log in the buyer during the request, which invalidates the guest nonce on the page.
  • The cart is emptied only after Paddle reports success (first status poll), so abandoning the payment loses nothing.
  • Without JavaScript the form posts normally and respond() redirects to redirect_url (the Classic pay link, or the Billing transaction’s checkout.url). On any page with ?_ptxn=, Paddle.js v2 is enqueued and opens the checkout automatically.

Subscription purchase

EDD Recurring hooks edd_gateway_paddle at priority 0 (before Gateway::process_purchase at 10) whenever the cart contains a recurring item:

  1. Recurring_Gateway::validate_fields() blocks: more than one subscription per cart; unsupported periods; Classic in a currency other than USD, EUR or GBP; missing credentials.
  2. create_payment_profiles() sets a placeholder profile_id (paddle-{purchase_key}-{n}), forces status = pending (trials included), and applies edd_recurring_create_subscription_args so Recurring’s Software Licensing integration can record manual-renewal expirations.
  3. EDD Recurring’s record_signup() inserts the pending order and pending subscription ($offsite = true).
  4. complete_signup() calls align_with_license() and then Gateway::respond().

License upgrades and renewals: EDD Recurring has already set the pending subscription’s expiration to the license expiration. align_with_license() turns the days until then into a Paddle trial, so the prorated amount is charged now and the recurring price starts at the license expiration. This mirrors EDD Recurring’s own approach for Stripe.

Billing transaction body

Billing_Driver::start_checkout() sends POST /transactions:

POST /transactions

{
  "items": [
    { "quantity": 1, "price": {
        "name": "Product — Option", "description": "…",
        "unit_price": { "amount": "999", "currency_code": "USD" },
        "tax_mode": "account_setting",
        "quantity": { "minimum": 1, "maximum": 1 },
        "product": { "name": "…", "tax_category": "standard", "image_url": "https://…" },
        "billing_cycle": { "interval": "month", "frequency": 1 },
        "trial_period": { "interval": "day", "frequency": 7 }
    } }
  ],
  "currency_code": "USD",
  "custom_data": { "edd_order_id": 123, "edd_site": "1a2b3c4d5e" },
  "discount": { "type": "flat", "amount": "500", "recur": false, "description": "Discount" }
}
  • Amounts are strings in the lowest denomination (Orders::to_minor(); JPY, KRW, VND and CLP have no minor unit).
  • Period mapping: day → day/1, week → week/1, month → month/1, quarter → month/3, semi-year → month/6, year → year/1.
  • image_url is sent only for HTTPS images (Paddle rejects others).
  • The response’s items[].price.id values are stored in order meta _wpinterface_paddle_line_map with each line’s cart_index (or fee), so later refunds can target the right lines.
  • The browser opens the transaction with Paddle.Checkout.open({ transactionId, customer: { email }, settings: { … showAddDiscounts: false, allowDiscountRemoval: false } }). Paddle discount codes are turned off because EDD already applied its own.

Classic_Driver::start_checkout() calls product/generate_pay_link with:

FieldValue
passthroughJSON {"edd_order_id":123,"edd_site":"…","ref":"<16 random chars>"}
prices[]CUR:<order total>
title, webhook_urlOne-time orders only (custom pay link with its own fulfilment webhook)
product_id, recurring_prices[], trial_daysSubscriptions: an auto-created plan plus EDD’s recurring price and trial
customer_email, return_url, image_urlPrefill, success page (payment-confirmation=paddle), image
discountable=0, quantity_variable=0The buyer cannot change price or quantity
expiresTwo days from now

Plans are created once per environment, download, price option, period and currency with subscription/plans_create, and cached in the option wpinterface_paddle_classic_plans. Pay links override the plan’s prices, so one plan serves every price.

Each pay link gets a random ref. The order stores the current one (_wpinterface_paddle_checkout_ref) and a fingerprint of each recent link’s terms (_wpinterface_paddle_checkout_refs). A payment through an older link is accepted only if its terms are identical to the current link’s (for example the same checkout open in two tabs); otherwise the order gets a note and is not changed. Because Paddle signs the passthrough and the ref is unguessable, a buyer cannot point a hand-made checkout at someone else’s order.

Was this page helpful?