Skip to content
Plugins

Commerce gateways and hooks

React to what was sold, sell at a price you worked out, or add a payment gateway, a tax service or an e-invoicing channel.

On this page

Commerce takes payments, issues invoices and handles VAT, but it knows nothing about what you sell. Your plugin fills that in: it can give buyers what they paid for, sell at a price it works out itself, or add a new way to pay. None of it needs a change to Commerce.

#Give buyers what they paid for

Commerce keeps an entitlement for each paid line: a record that this buyer has this product. It tells you when one is granted or taken back, and you decide what that means:

PHP
cms()->hooks()->on('commerce.entitlement.granted', function (EntitlementEvent $e) {
    MyThing::give($e->email, $e->sku, until: $e->periodEnd, because: $e->context);
});

cms()->hooks()->on('commerce.entitlement.revoked', function (EntitlementEvent $e) {
    MyThing::takeBack($e->sku, $e->orderId, reason: $e->reason);
});

Events arrive after the sale has committed, at least once. A listener that throws is tried again later, without touching the sale. So dedupe on $event->id, which stays the same across retries.

Cms\Commerce\Orders\EntitlementEvent carries id, type, entitlementId, sku, orderId, lineId, userId, email, mode, reason, periodEnd and context (what the checkout carried, for example from a signed cart).

#Every Commerce hook

HookKindArgumentsWhen
commerce.entitlement.grantedaction$eventPaid, renewed, or in grace after a failed renewal (reason: grace, periodEnd is when it ends).
commerce.entitlement.revokedaction$eventreason is refunded, withdrawal, charged_back, canceled, ended or another a plugin gave.
commerce.entitlement.restoredaction$eventA lost chargeback was reversed.
commerce.order.paidaction$orderInside the transaction. Throwing undoes the sale, so keep to the entitlement hooks unless you mean that.
commerce.order.refundedaction$order, $refundInside the transaction too.
commerce.refund.refusalfilter$refusal, $orderReturn a sentence to stop a voluntary refund. The consumer's statutory 14 days don't pass through here.
commerce.alertaction$code, $severity, $message, $subjectSomething needs a person.
commerce.line.adjustfilter$line, $product, $buyerChange a line before it's priced into a quote.
commerce.can_purchasefilter$refusal, $product, $buyerReturn a sentence to refuse a sale.
commerce.vat_ratesfilter$ratesAdd or change rates, per country and date.

#Sell at your own price

A browser can't set a price. A signed cart can, because only your server can make one. Use it for an upgrade, a quote or anything priced on the fly:

PHP
use Cms\Commerce\Checkout\Carts;

$url = Carts::url(Carts::sign([[
    'sku' => 'pro',
    'name' => 'Upgrade to Pro',
    'unit_minor' => 12000,                 // cents
    'context' => ['upgrade_from' => $id],  // comes back on the event
]], validForSeconds: 86_400));

The link goes to the normal checkout, so VAT and the invoice work as usual.

#Add a payment gateway

Implement Cms\Commerce\Gateways\PaymentGateway and register it. It's then a choice of Payment Gateway on Commerce's Settings tab.

PHP
cms()->whenAvailable(Gateways::class, fn (Gateways $g) => $g->register('acme', fn () => new AcmeGateway));

#Four rules

These keep every gateway swappable:

  • A webhook is a hint. verifyWebhook() authenticates and says which resources it's about. It changes nothing. Commerce then calls fetch(), and only that answer changes an order. A gateway with unsigned, id-only webhooks returns authenticated: false, and Commerce only acts on references it already knows.
  • It sees only a gross amount. A payment carries no tax details, and returns none. A gateway that also works out tax does it apart from the payment.
  • It never issues the legal document. Commerce does, the same for every gateway.
  • It's always a redirect. startCheckout() returns a hosted page's address, and capabilities()->checkoutHosts lists its hosts for the page's content security policy.

#Retries and errors

startCheckout(), refund() and the subscription calls get a key that's the same on a retry, with the same body. Throw:

ExceptionWhen
GatewayUnknownOutcomeThe call may have worked (a timeout, a 5xx). Commerce looks it up with findByLocalId(), or retries with the same key, never a new one.
GatewayUnavailableIt surely didn't go through.
GatewayRejectedThe gateway refused.

#Webhooks and going live

Webhooks reach you at POST /api/commerce/webhooks/{gateway}/{mode}, each mode with its own address. changesSince() is the catch-up for missed ones.

Return liveAllowed: false from capabilities() for anything that takes no real money: going live refuses it.

#The interface

MethodDoes
id(), label()Who it is.
capabilities($mode), diagnose($mode)What it can do, and whether its keys are in place and it answers (checked before going live).
startCheckout(), cancelCheckout()Open and cancel a hosted checkout.
verifyWebhook(), fetch(), findByLocalId(), changesSince()Learn what happened, as above.
updateSubscriptionAmount(), cancelSubscription(), resumeSubscription()Subscriptions.
refund()Refund a payment.
customerActionUrl(), dashboardUrl()A hosted page where a customer changes their payment method, and a link to a resource in the gateway's dashboard for admins. Either may be null.

#Work out tax outside the EU

A gateway that also offers a tax service implements Cms\Commerce\Tax\TaxService. When Buyers Outside the EU is set to the gateway's tax, Commerce prices every sale to a buyer outside the EU through it. EU VAT never does.

  • calculate() gets a TaxRequest: the buyer's country, postal code, city and address line, whether they're a business and their tax number, and the lines in minor units. Lines are before tax, or include it when inclusive is set, as for a renewal already charged. It answers with a TaxCalculation: the exact tax, the rate in basis points, the service's own rate and the tax's type when there's one, and whether it's a reverse charge. Throw GatewayRejected for an address or number it can't place, and the buyer is asked to check it. Throw GatewayUnavailable or GatewayUnknownOutcome when it can't be asked. The same request is asked once per 30 minutes.
  • record() is called once the sale is paid, with the calculation and the order number. reverse() is called for each refund, with the amount refunded, tax included. Both run in the background and are tried again, so they must be safe to repeat under the same reference.
  • taxProblems() lists what stops it working with real money. The Go Live page shows it.

Stripe's driver does this with Stripe Tax's calculations and transactions.

#Add an e-invoicing channel

Implement Cms\Commerce\Transports\InvoiceTransport, and call app(Transports::class)->register($transport). It says which buyers it covers and whether it can reach them, and sends the frozen document. Everyone also gets the PDF by email, whatever the channel.

To use another Peppol provider, bind your own Cms\Commerce\Transports\Peppol\AccessPoint; UBL and the flow stay as they are.

#Another tax system

Bind your own Cms\Commerce\Tax\TaxResolver. It takes a TaxContext and answers with a TaxDecision: a rate, a scheme, a category and what the invoice must say.