Skip to content
Learnomy
Get Learnomy Pro

Payment Gateways and Purchase Sources

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#

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.

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:

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

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)#

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#

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#

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#

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.

Hooks#

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.