Skip to content

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.

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 back

Rules 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 tax is 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.

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:

<?php
use 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/acme to process_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.

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.

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.

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.

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.php
wp eval-file wp-content/plugins/learnomy/tests/payments/cross-adapter-conformance.php strict

It 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.