Commerce gateways and hooks
Register a payment gateway, react to what was sold, sell at a price you worked out, and add an e-invoicing channel.
On this page
Commerce knows nothing about what's sold or who pays for it. A plugin extends it in four places, and none needs a change to Commerce.
#React to a sale
Commerce doesn't know what a purchase means. It keeps an entitlement for each paid line and tells you when one changes:
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);
});| Hook | Kind | Arguments | When |
|---|---|---|---|
commerce.entitlement.granted | action | $event | Paid, renewed, or in grace after a failed renewal (reason: grace, periodEnd is when it ends). |
commerce.entitlement.revoked | action | $event | reason is refunded, withdrawal, charged_back, canceled, ended or another a plugin gave. |
commerce.entitlement.restored | action | $event | A lost chargeback was reversed. |
commerce.order.paid | action | $order | Inside the transaction. Throwing undoes the sale, so keep to the entitlement hooks unless you mean that. |
commerce.order.refunded | action | $order, $refund | Inside the transaction too. |
commerce.refund.refusal | filter | $refusal, $order | Return a sentence to stop a voluntary refund. The consumer's statutory 14 days don't pass through here. |
commerce.alert | action | $code, $severity, $message, $subject | Something needs a person. |
commerce.line.adjust | filter | $line, $product, $buyer | Change a line before it's priced into a quote. |
commerce.can_purchase | filter | $refusal, $product, $buyer | Return a sentence to refuse a sale. |
commerce.vat_rates | filter | $rates | Add or change rates, per country and date. |
Events arrive after the sale has committed, at least once, and a listener that throws is tried again later without touching the sale. So dedupe on $event->id, which stays the same across retries. For a product that only opens gated content, you need none of this: name the access on the product. A product with licence terms issues its licence the same way, with no code.
#Sell at your own price
A browser can't set a price. A signed cart can, because only your server can make one:
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.
#Register a gateway
Implement Cms\Commerce\Gateways\PaymentGateway and register it. It's then a choice under Settings → Commerce.
cms()->whenAvailable(Gateways::class, fn (Gateways $g) => $g->register('acme', fn () => new AcmeGateway));Four rules keep the contract swappable:
- A webhook is a hint.
verifyWebhook()authenticates and says which resources it's about. It changes nothing. Commerce then callsfetch(), and only that answer changes an order. A gateway with unsigned, id-only webhooks returnsauthenticated: 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, andcapabilities()->checkoutHostslists its hosts for the page's content security policy.
startCheckout(), refund() and the subscription calls get a key that's the same on a retry, with the same body. Throw GatewayUnknownOutcome when a call may have worked (a timeout, a 5xx), GatewayUnavailable when it surely didn't go through, and GatewayRejected when the gateway refused. Commerce looks up an unknown outcome with findByLocalId(), or retries with the same key, and never with a new one.
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.
#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 aTaxRequest: 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 wheninclusiveis set, as for a renewal already charged. It answers with aTaxCalculation: 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. ThrowGatewayRejectedfor an address or number it can't place, and the buyer is asked to check it. ThrowGatewayUnavailableorGatewayUnknownOutcomewhen 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. Go Live 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 mail, whatever the channel. To use another Peppol provider, bind your own Cms\Commerce\Transports\Peppol\AccessPoint, and 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.