Payment Gateways and Purchase Sources
Learnomy overviewFree + ProGet Learnomy →
How money becomes access in Learnomy, and how to plug in a gateway (Mollie, Razorpay, a local bank), a new kind of product, or an outside system that sells for you (Make, Zapier, Gumroad). Each one is a class or a listener in your own plugin; none needs an edit to Learnomy’s files.
The flow
Section titled “The flow”Every sale, whichever way it arrives, goes through the same two services:
Your gateway (Payment_Adapter) An outside sale (Inbound_Purchase_Source) checkout -> create_purchase_session() POST /webhooks/enrollment, signed provider webhook -> process_webhook() verify() + normalise() \ / v v Purchase_Provisioner::provision( $args ) one place a payment becomes access | v Purchase_Recorder::record() one place a payment becomes a transaction |-- do_action( 'learnomy_purchase_completed', $txn_id, $user_id, $type, $item_id, $ext, $gateway ) |-- do_action( 'learnomy_payment_completed', $txn_id, $user_id, $gateway ) -> commissions v enrolment / subscription (course, membership, recurring course) or your listener (any other product type)
Refunds: Refund_Service::apply( $sale, $amount, $gateway, $provider_refund_id ) ledger refund row, sale marked refunded, a full refund takes the access backRules the flow enforces for every gateway:
- Idempotency is keyed on the provider id you pass as
provider_id. A webhook delivered twice records once. - Tax passed as
taxis recorded and kept out of the commission base. - Currency is the site currency. Learnomy sells in one currency per site.
- Refunds are made where the sale was taken. Learnomy shows its own Refund button only for its built-in gateways (Stripe, PayPal, Test). A third-party gateway reports a refund through its webhook, and you record it with
Refund_Service::apply(), which is deduplicated on the provider’s refund id.
Adding a gateway
Section titled “Adding a gateway”A gateway is one class that extends Learnomy\Adapters\Abstract_Payment_Adapter, registered on learnomy_register_payment_adapters. The base class implements the interface’s optional capabilities with safe defaults, so you write only what your gateway does.
Put the class in its own file, in a subfolder next to the main file, and load it inside the registration hook, as below. Learnomy is loaded by the time that hook runs, so Abstract_Payment_Adapter exists.
The subfolder is what makes this work as a must-use plugin too. WordPress loads every .php file in the root of wp-content/mu-plugins at startup, before Learnomy, so a class file left there stops the whole site with “Class Learnomy\Adapters\Abstract_Payment_Adapter not found”. Files in a subfolder are only loaded when you require them.
wp-content/plugins/acme-pay-learnomy/ wp-content/mu-plugins/├── acme-pay-learnomy.php (main file) ├── acme-pay-learnomy.php (main file)└── acme-pay/ └── acme-pay/ └── class-acme-pay-adapter.php └── class-acme-pay-adapter.php<?php/** * Plugin Name: Acme Pay for Learnomy */
add_action( 'learnomy_register_payment_adapters', function () { require_once __DIR__ . '/acme-pay/class-acme-pay-adapter.php'; // Learnomy is loaded by now. \Learnomy\Adapters\Adapter_Registry::register( new Acme_Pay_Adapter() );} );acme-pay/class-acme-pay-adapter.php:
<?phpuse Learnomy\Adapters\Abstract_Payment_Adapter;use Learnomy\Adapters\Adapter_Registry;use Learnomy\Services\Purchase_Provisioner;
class Acme_Pay_Adapter extends Abstract_Payment_Adapter {
public function get_id(): string { return 'acme'; } // a-z, 0-9, _ and - public function get_label(): string { return 'Acme Pay'; } // shown at checkout public function is_available(): bool { return '' !== (string) get_option( 'acme_api_key', '' ); }
// Checkout: create a payment page at Acme and send the buyer there. public function create_purchase_session( array $args ): array { $args = Adapter_Registry::normalize_purchase_args( $args, $this ); // amount incl. tax, item, buyer $order = acme_api_create_order( array( 'amount' => $args['amount'], 'currency' => $args['currency'], 'reference' => wp_json_encode( array( 'user' => $args['user_id'], 'type' => $args['purchase_type'], 'item' => $args['item_id'] ) ), 'return_url' => $args['return_url'], 'notify_url' => rest_url( 'learnomy/v1/webhooks/acme' ), ) );
return $order ? array( 'redirect_url' => $order['pay_url'], 'session_id' => $order['id'] ) : array(); } public function create_checkout( array $args ): array { return $this->create_purchase_session( $args ); } public function create_subscription( array $args ): array { return array(); } // one-time only
// Acme calls POST /wp-json/learnomy/v1/webhooks/acme when the payment is captured. public function process_webhook( \WP_REST_Request $request ): void { $event = acme_api_verify_signature( $request->get_body(), $request->get_header( 'X-Acme-Signature' ) ); if ( ! $event ) { throw new \InvalidArgumentException( 'bad_signature' ); // answered 400: the provider stops retrying } if ( 'refund' === $event['type'] ) { // refunded in Acme's dashboard $sale = \Learnomy\Models\Transaction::find_by_provider_id( $event['payment_id'] ); if ( $sale ) { ( new \Learnomy\Services\Refund_Service() )->apply( $sale, (float) $event['amount'], 'acme', $event['refund_id'] ); } return; } $ref = json_decode( $event['reference'], true ); Purchase_Provisioner::provision( array( 'user_id' => (int) $ref['user'], 'gateway' => 'acme', 'provider_id' => $event['payment_id'], // the idempotency key 'purchase_type' => $ref['type'], 'course_id' => 'course' === $ref['type'] ? (int) $ref['item'] : 0, 'plan_id' => 'membership' === $ref['type'] ? (int) $ref['item'] : 0, 'item_id' => (int) $ref['item'], 'amount' => (float) $event['amount'], 'currency' => $event['currency'], 'tax' => (float) $event['tax'], ) ); }
// Refunds are made in Acme's dashboard (see process_webhook), so Learnomy // shows no Refund button for Acme sales and never calls this. public function refund( string $transaction_id, float $amount ): string { return ''; } public function cancel_subscription( string $provider_sub_id ): bool { return false; } public function verify_payment_status( string $session_id ): string { return acme_api_order_status( $session_id ); // 'paid' | 'pending' | 'failed' | 'unknown' }}The acme_api_*() calls stand for your gateway’s own SDK or HTTP calls; replace them. Until you do, the webhook route answers 500 “Webhook processing failed”, because those functions do not exist.
With that, Learnomy:
- offers Acme Pay at checkout (“Continue to Acme Pay”) and redirects to the URL
create_purchase_session()returns; - routes
POST /wp-json/learnomy/v1/webhooks/acmetoprocess_webhook(); - records refunds that Acme reports through the same webhook (see
process_webhook()below); - asks
verify_payment_status()on the thank-you page when the buyer arrives before your webhook.
When WooCommerce is the site’s payment route, it owns checkout for every purchase type: the owner chooses WooCommerce or Learnomy’s own payment methods, one flow for the whole site. A registered gateway is then not offered at checkout until WooCommerce is switched off as the payment method. Surfaces that show a single Pay button pick the first active gateway from learnomy_default_gateway_order (default woocommerce, stripe, paypal, test, then any other registered gateway); filter it to put yours first.
What each method must return
Section titled “What each method must return”| Method | Contract |
|---|---|
create_purchase_session( $args ) |
redirect_url (where to send the buyer) and session_id, or an empty array on failure. $args: purchase_type, item_id, item_label, amount, currency, mode (one_time or subscription), user_id (or guest_email), return_url, cancel_url, optional coupon_id, extra. Run it through Adapter_Registry::normalize_purchase_args() first. |
process_webhook( $request ) |
Verify the provider’s signature over the raw body, then call Purchase_Provisioner::provision(). Throw InvalidArgumentException for a request the provider should not retry (answered 400). |
refund( $id, $amount ) |
Called by Learnomy’s Refund button, which only built-in gateways show. A third-party adapter returns ''. |
verify_payment_status( $id ) |
paid, pending, failed or unknown. |
cancel_subscription( $id ) |
True when the provider stopped billing. |
Optional capabilities (override only when the default is wrong)
Section titled “Optional capabilities (override only when the default is wrong)”| Method | Default | Override when |
|---|---|---|
supports_refund() |
false | Only Learnomy’s built-in gateways return true: they get a Refund button in Transactions that calls refund(). A third-party gateway is refunded in its own dashboard and reports it to process_webhook(). |
is_configured() |
is_available() |
Acting on an old order needs less than taking a new one. |
supports_purchase( $args ) |
true | The gateway refuses a product (for example recurring). Hides it at checkout for that product. |
supports_first_payment_discount() |
false | The gateway can bill a discounted first payment, then the full price. |
handles_tax() |
false | The gateway taxes the sale itself; it is then given the net price. |
manage_subscription_link( $sub ) |
Learnomy’s billing page | The gateway hosts its own subscription management page. |
reconcile_order( $order_id ) |
false | The thank-you page can capture an approved order whose webhook never came. |
has_unguessable_session_ids() |
false | Return-URL ids cannot be guessed, so a signed-out buyer may see their receipt. |
renewal_terms( $payload ) |
the payload’s own period | Your renewal payload names the period and the amount charged. |
Proration for membership plan changes is added through Pro’s learnomy_pro_proration_provider filter.
Adding a product type
Section titled “Adding a product type”A product type is something other than a course or a membership plan: a gift, a seat pack, a bundle. Declare it on learnomy_checkout_purchase_types; Learnomy’s checkout then handles coupons, tax, billing details, zero-total orders and every gateway for it. You fulfil it in a listener.
add_filter( 'learnomy_checkout_purchase_types', function ( array $types ) { $types['bundle'] = array( 'label' => __( 'Bundle', 'acme' ), // What this request buys: amount, currency, item_id, optional extra. 'resolve' => function ( \WP_REST_Request $request, int $user_id ) { $bundle = acme_get_bundle( (int) $request->get_param( 'item_id' ) ); return $bundle ? array( 'amount' => $bundle->price, 'currency' => \Learnomy\charge_currency(), 'item_id' => $bundle->id, 'extra' => array( 'courses' => implode( ',', $bundle->course_ids ) ) ) : new \WP_Error( 'acme_no_bundle', 'Bundle not found', array( 'status' => 404 ) ); }, // What a full refund takes back. 'revoke' => function ( object $transaction ) { acme_revoke_bundle( (int) $transaction->user_id, (int) $transaction->item_id ); return array( 'revoked' => true, 'notice' => '' ); }, ); return $types;} );
// Fulfil it when the payment is recorded, whichever gateway took it.add_action( 'learnomy_purchase_completed', function ( $txn_id, $user_id, $type, $item_id, $ext, $gateway ) { if ( 'bundle' === $type ) { acme_grant_bundle( (int) $user_id, (int) $item_id, $ext ); }}, 10, 6 );A type with a revoke handler that deliberately takes nothing back must still register one that returns a notice, so “nothing to undo” and “nobody implemented this” stay distinguishable.
Adding an inbound purchase source
Section titled “Adding an inbound purchase source”When a sale happens in another system and that system notifies Learnomy, it posts to the signed endpoint POST /wp-json/learnomy/v1/webhooks/enrollment. The built-in source reads a JSON or form body signed with HMAC-SHA256; the Incoming Sales Webhook page documents the fields and the Make and Zapier recipes.
For a system with its own wire format, implement Learnomy\Adapters\Inbound_Purchase_Source:
class Gumroad_Source implements \Learnomy\Adapters\Inbound_Purchase_Source { public function get_id(): string { return 'gumroad'; } public function matches( \WP_REST_Request $r ): bool { return 'gumroad' === $r->get_param( 'source' ); } // Gumroad's ping is not signed, so the ping URL carries a long random token: // .../webhooks/enrollment?source=gumroad&token=<secret saved in your settings> public function verify( \WP_REST_Request $r, string $raw_body ) { $secret = (string) get_option( 'acme_gumroad_token', '' ); return '' !== $secret && hash_equals( $secret, (string) $r->get_param( 'token' ) ) ? true : new \WP_Error( 'gumroad_bad_token', 'Unknown sender', array( 'status' => 403 ) ); } public function normalise( \WP_REST_Request $r ) { return array( 'op' => $r->get_param( 'refunded' ) ? 'refund' : 'purchase', 'user_email' => (string) $r->get_param( 'email' ), 'full_name' => (string) $r->get_param( 'full_name' ), 'purchase_type'=> 'course', 'item_id' => (int) acme_course_for_product( (string) $r->get_param( 'product_id' ) ), 'external_ref' => (string) $r->get_param( 'sale_id' ), 'amount' => (float) $r->get_param( 'price' ) / 100, 'tax' => 0, 'currency' => strtoupper( (string) $r->get_param( 'currency' ) ), 'access_days' => 0, 'coupon' => '', 'refund_ref' => '', 'provider' => 'gumroad', ); }}
add_filter( 'learnomy_incoming_purchase_sources', function ( array $sources ) { $sources['gumroad'] = new Gumroad_Source(); return $sources;} );The source only verifies and maps field names. Learnomy validates the result the same way for every source (Incoming_Purchase_Service::validate(): email, a positive course id, external_ref for purchases and refunds, amounts of 0 or more, the site currency), then fulfils it through the same provisioner and refund funnel, logs the call in Payment Log > Incoming sales, and answers the sender.
Proving it works: the conformance gate
Section titled “Proving it works: the conformance gate”tests/payments/cross-adapter-conformance.php runs the same purchase scenarios through every inlet (Test, Stripe, PayPal, WooCommerce and the incoming sales endpoint) and asserts the same database outcome: the transaction, the fired actions, commission rows, enrolment, refunds, and that nothing is left behind afterwards. Scenarios cover one-time and recurring courses, memberships, coupons, tax, access days, multi-course orders, guest buyers, and the Pro types (learning path, Space, seats, gift). Run it after any change to payment code:
wp eval-file wp-content/plugins/learnomy/tests/payments/cross-adapter-conformance.phpwp eval-file wp-content/plugins/learnomy/tests/payments/cross-adapter-conformance.php strictIt prints a matrix per scenario. A cell is PASS, FAIL, N/A with a written reason (for example, the Test gateway records a free sale, so it has no commission), or SKIP with an owner and an expiry date. Any FAIL exits 1. An expired skip counts as a failure. In strict mode (or with LEARNOMY_CONFORMANCE_STRICT=1), any remaining skip exits 2.
tests/payments/gateway-capability-contract.php registers a sample third-party adapter (tests/payments/fixtures/qa-sample-adapter.php) and proves it gets its configured state, a manage link and proration without any edit outside its own file, and that no Learnomy Refund button is offered for it. Use that fixture as a second worked example.
| Hook | Type | Use |
|---|---|---|
learnomy_register_payment_adapters |
action | Register a gateway with Adapter_Registry::register(). |
learnomy_checkout_purchase_types |
filter | Declare a product type (label, resolve, validate, supports, revoke). |
learnomy_purchase_completed |
action | ( $txn_id, $user_id, $type, $item_id, $ext, $gateway ) after any sale is recorded. Fulfil your product type here. |
learnomy_payment_completed |
action | ( $txn_id, $user_id, $gateway ) after money is recorded; commissions run here. |
learnomy_incoming_purchase_sources |
filter | Add an inbound source. |
learnomy_incoming_webhook_payload |
filter | Change a normalised incoming operation before it runs. |
learnomy_pro_proration_provider |
filter | Pro: give your gateway proration for plan changes. |
Every hook with its full argument list is on the Hooks Reference.

