Payment Gateways and Purchase Sources
Developer GuideHow 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
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#
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/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#
| 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.