Payment accounts
An install normally runs one provider, configured once: billing.default, billing.mollie.api_key or
cashier.secret. An application that takes payments for many shops, each with its own account at Mollie or
Stripe, needs a second way: the credential belongs to the shop of the request, not to the install, and it
comes from wherever the application keeps it.
That way is a PaymentAccount, handed to BillingManager::forAccount():
use Pushery\Billing\Support\BillingManager;
use Pushery\Billing\ValueObjects\Money;
use Pushery\Billing\ValueObjects\PaymentAccount;
$account = new PaymentAccount(
provider: 'mollie',
key: $shop->uuid,
credential: $shop->mollieAccessToken(),
testmode: $shop->isInTestMode(),
profileId: $shop->mollieProfileId(),
webhookSecret: $shop->mollieWebhookSecret(),
);
$result = app(BillingManager::class)
->forAccount($account)
->rails()
->charge(Money::of(1900, 'EUR'), 'ideal', idempotencyKey: $order->uuid);
The driver that comes back charges with that account and no other: its client carries the account's credential and test mode, every payment it creates at Mollie carries the account's profile, and Mollie posts the status pings to an address that names the account. A second account gets a client of its own.
What an account holds
| Field | What it is |
|---|---|
provider | the driver the account belongs to, mollie or stripe |
key | your stable name for the account. It becomes part of the account's webhook address, so lowercase letters, digits and inner hyphens, at most 64 characters |
credential | the API key, or for Mollie an OAuth access token |
testmode | whether calls go to the provider's test mode |
profileId | the Mollie profile payments are created on. Mollie requires it with an OAuth access token and ignores it with an API key |
webhookSecret | the secret the provider signs this account's webhooks with |
A Mollie or Stripe API key picks its mode by its prefix (test_ or live_, sk_test_ or sk_live_). An
account whose testmode disagrees with its key is refused when its driver is built, before anything reaches
the provider, because the key would win and the account would charge in the mode nobody asked for. An OAuth
access token carries no mode, so the client is told the account's.
Paying for an order
charge() takes ChargeOptions as its last argument, for what the shop's checkout decided about one payment:
use Pushery\Billing\ValueObjects\ChargeOptions;
use Pushery\Billing\ValueObjects\PaymentAddress;
$result = app(BillingManager::class)->forAccount($account)->rails()->charge(
Money::of(5990, 'EUR'),
'',
idempotencyKey: $order->uuid,
options: new ChargeOptions(
returnUrl: route('shop.orders.show', $order),
cancelUrl: route('shop.cart'),
locale: 'de_DE',
methods: ['klarna', 'in3'],
metadata: ['shop' => $shop->uuid, 'order' => $order->number],
lines: $order->paymentLines(),
billingAddress: new PaymentAddress(givenName: 'Ada', familyName: 'Lovelace', streetAndNumber: 'Keizersgracht 1', postalCode: '1015 CJ', city: 'Amsterdam', country: 'NL'),
),
);
| Option | Mollie | Stripe |
|---|---|---|
returnUrl | the redirectUrl, in place of the install's | the return_url, in place of billing.stripe.return_url |
cancelUrl | where a customer who cancels goes | not used |
webhookUrl | in place of the account's address | not used, Stripe posts to the endpoints of the account |
locale | the language of the checkout | not used, Stripe.js picks it in the browser |
methods | the methods offered, empty for all of the account's | not used, the payment method decides |
metadata | kept with the payment | kept with the payment intent |
lines, billingAddress, shippingAddress | sent with the payment | not used |
cardToken | the token from Mollie Components, with the creditcard method | not used |
applePayToken | the token Apple Pay handed the page, with the creditcard method | not used, Stripe's wallets create a payment method in the browser |
captureMode | manual authorizes a card payment and leaves the capture to you | not used |
Some Mollie methods refuse a payment without lines or a billing address, Klarna, in3, Billie and Riverty among
them. Each PaymentLine states its amounts VAT included, and the lines add up to the amount charged; a charge whose
lines disagree with it is refused before anything reaches Mollie. At Mollie the token names one method or stays
empty, and a charge that names a method as its token and in its options is refused as well. The metadata holds
text and whole numbers, and billing_reference is the key the rails write themselves, so no option can carry it.
Klarna, in3, Billie and Riverty
These methods refuse a payment without its lines, and most of them without a billing address, so the charge carries
both in its options. Their payments end in authorized, which AccountPaymentChanged reports, and the money moves
once the payment is captured, usually when the order ships. A card payment created with the capture mode manual
waits the same way. Capture it in full, or in parts as the parcels go out:
use Pushery\Billing\Drivers\Mollie\MollieCaptures;
$capture = app(MollieCaptures::class)->capture($account, $paymentReference, Money::of(2000, 'EUR'), 'First parcel');
A capture Mollie refuses comes back as a failed CaptureResult with Mollie's status and words, and an error that
says nothing about the capture is thrown. Once the capture succeeds, Mollie pings the payment again and it is
reported as paid.
Refunding a payment
The rails of an account refund one of its payments, in full or in part, under your idempotency key:
$result = app(BillingManager::class)->forAccount($account)->rails()
->refund($paymentReference, Money::of(1000, 'EUR'), idempotencyKey: $return->uuid);
if ($result->failed()) {
$result->failureCode; // why the provider refused, in its own terms
$result->failureMessage; // the provider's words, where it gave any
}
A refusal of the refund itself comes back as such a result: at Stripe with its error code or the refund's
failure_reason, at Mollie with the HTTP status or the refund's status, because Mollie names no error codes. An
error that says nothing about the refund, such as a rejected credential, a rate limit or an outage, is thrown, so
the refund can be tried again under the same key. A refund that fails after the provider accepted it is reported
later, as an AccountPaymentChanged with the status RefundFailed.
Apple Pay, Google Pay and PayPal on the shop's domain
At Mollie, Apple Pay runs on the shop's own page. When Apple Pay JS asks the page to validate the merchant, an endpoint of your application asks Mollie for the merchant session and answers Apple Pay with it, unchanged:
use Pushery\Billing\Drivers\Mollie\MollieApplePay;
return response()->json(
app(MollieApplePay::class)->session($account, $request->input('validationUrl'), $request->getHost()),
);
The domain serves Mollie's validation file at /.well-known/apple-developer-merchantid-domain-association. Once the
customer has authorized the payment, the page sends Apple Pay's token to your application, and the charge carries it
as applePayToken, JSON encoded. Mollie creates such a payment with the creditcard method, which the charge picks
when it names no other. Google Pay and PayPal run on Mollie's checkout: offer googlepay or paypal as a method and
the customer completes the payment there. Mollie's API and SDK name no Google Pay token for a page of your own.
At Stripe, register each shop domain with the merchant's account, with the merchant's own key:
use Pushery\Billing\Drivers\Stripe\StripePaymentMethodDomains;
$domain = app(StripePaymentMethodDomains::class)->register($account, 'shop.example');
$domain->wallets; // ['apple_pay' => true, 'google_pay' => true, 'link' => true, 'paypal' => false]
A domain the account has already is found rather than registered twice, and wallets says per wallet whether
Stripe reports it active there. The Express Checkout Element then creates the payment method in the browser, and
charge() confirms it with the payment method's id and the order's returnUrl, which a wallet or PayPal needs
for its redirect.
Connecting a merchant's Mollie account
A merchant who already has a Mollie account connects it to your platform's OAuth app, and stays the merchant at
Mollie: no money is routed through the platform. Pushery\Billing\Drivers\Mollie\Connect\MollieConnect has the
steps, and MollieOAuthApp holds the app's client id, client secret and the address Mollie sends the merchant
back to.
use Pushery\Billing\Drivers\Mollie\Connect\MollieConnect;
use Pushery\Billing\Drivers\Mollie\Connect\MollieOAuthApp;
$app = new MollieOAuthApp($clientId, $clientSecret, 'https://lane.example/mollie/callback');
$connect = app(MollieConnect::class);
// 1. Send the merchant to Mollie, with a state you remember for this attempt.
return redirect($connect->authorizeUrl($app, ['payments.write', 'refunds.write', 'profiles.read', 'methods.read'], $state));
// 2. On the way back, after checking the state, exchange the code for tokens and store both, encrypted.
$tokens = $connect->exchangeCode($app, $request->query('code'));
// 3. Let the merchant pick the profile payments are created on.
$profiles = $connect->profiles(new PaymentAccount('mollie', $shop->uuid, $tokens->accessToken, testmode: false));
An access token expires after an hour, and a refresh token does not expire on its own. Refresh before
$tokens->expiresWithin(300) says the access token runs out; Mollie may issue a new refresh token, and
refresh() hands back the one it was given where it does not. revoke() ends the connection from your side.
A merchant without a Mollie account goes through clientLink() instead, with the organization access token of
your partner account. Mollie creates the account, links it to your partner account, and connects it to the app
once it exists.
MollieTokens and MollieOAuthApp keep their secrets out of debuggers and refuse to be serialized, like an
account. A refused code or refresh throws MollieConnectFailed with Mollie's error.
The credential stays out of logs and queues
An account never shows its two secrets: var_dump, print_r and a debugger see them masked, and the
refusals above name the account by its key, never by its credential. An account also refuses to be
serialized, so it cannot reach a queue payload, a cache or a session by accident.
A job therefore carries the provider and the key, and finds the account again. Bind your own
Pushery\Billing\Contracts\PaymentAccounts for that:
use Pushery\Billing\Contracts\PaymentAccounts;
use Pushery\Billing\ValueObjects\PaymentAccount;
final class ShopPaymentAccounts implements PaymentAccounts
{
public function find(string $provider, string $key): ?PaymentAccount
{
return Shop::query()->where('uuid', $key)->first()?->paymentAccount($provider);
}
}
$this->app->bind(PaymentAccounts::class, ShopPaymentAccounts::class);
The default knows no account and answers null for every pair.
Webhooks
Each account has its own webhook address: the install's billing.webhook_path, followed by the provider and the
key.
https://shop.example/billing/webhook/mollie/0b7c1e4a-2f63-4b8e-9a51-6d2f0c9e7a11
At Mollie, the account's driver sets this address on every payment it creates, so the classic status pings
arrive there without any setup. At Stripe, add a webhook endpoint with this address in the account's dashboard,
or through the Stripe API, and keep the signing secret it gives you as the account's webhookSecret.
A delivery to the address goes through these steps:
- The account is looked up through your
PaymentAccounts, before anything in the request is trusted. An address that names no account you know answers404, so the endpoint tells a caller nothing about which shops exist. - The delivery is verified with the account's
webhookSecret. Stripe signs every event, so a Stripe account without a secret has none of its webhooks accepted. Mollie's classic ping is not signed: it is read back from Mollie with the account's own credential, which a payment of another account does not answer to. - It is recorded under the account's provider, with the account's key as its reference, reported as an
AccountPaymentChangedand queued to the effects registered for it. A redelivery to the same account is recognized as one; the same event id at another account is a different delivery.
billing:webhooks:replay reads a stored delivery of an account again with that account's mapper.
What happened to a payment
Every webhook about a payment of an account is reported as Pushery\Billing\Events\AccountPaymentChanged, and only
as that: an account's payment raises no add-on, subscription or marketplace event, so nothing of the install reads it
as its own. The event says where the payment stands now, never what changed, so a listener that stores the latest
one is right after a redelivery as well.
| Field | What it is |
|---|---|
provider, account | the account the payment went into |
paymentReference | the provider's id of the payment, as the charge's result reported it |
status | an AccountPaymentStatus |
amount | what the payment was for |
amountRefunded | what went back to the customer so far, failed refunds not counted |
amountChargedBack | what the customer's bank took back and still holds |
billingReference | the idempotency key the charge carried, such as the order's id |
metadata | the metadata of the charge's options |
refundReference | with RefundFailed, the refund that failed |
The statuses are Pending, Authorized, Paid, PartiallyRefunded, Refunded, RefundFailed, ChargedBack,
Failed, Canceled and Expired. A chargeback outranks the refunds, because the bank holds the money whatever
happened to them.
At Mollie the ping only names the payment, so it is read back with the account's client, together with its refunds and chargebacks where it has any. Mollie pings when a payment is paid, authorized, expired, failed or canceled, when a refund reaches processing, refunded or failed, and when a chargeback arrives.
At Stripe the signed event says what happened. Add a webhook endpoint for the events
payment_intent.processing, payment_intent.amount_capturable_updated, payment_intent.succeeded,
payment_intent.payment_failed, payment_intent.canceled, charge.refunded, charge.dispute.created and
refund.failed (charge.refund.updated and refund.updated work as well). For a refund, a failed refund or a
dispute, the payment intent is read back with the account's key, so a restricted key needs read access to payment
intents.
A refused delivery dispatches WebhookDeliveryRefused with the surface account.
The payment methods an account has turned on
accountPaymentMethods() lists the methods an account accepts, read from its provider with the account's own
credential, for a settings screen that shows them and a checkout that offers them:
$methods = app(BillingManager::class)
->accountPaymentMethods($account)
->all(amount: Money::of(1900, 'EUR'), country: 'NL', locale: 'nl_NL');
Each EnabledPaymentMethod carries the provider's id, a name to show, the address of its logo as image,
its minimumAmount and maximumAmount where the provider states them, and the issuers a customer picks from,
such as the banks of KBC/CBC. The ids are the provider's own, creditcard at Mollie and card at Stripe,
because a shop that runs both activates and orders the methods of each.
At Mollie the list comes from the Methods API, on the account's profile and in its test mode, and all three filters apply: the amount returns the methods that take it in its currency (without one, Mollie answers for the euro), the country the methods offered there, and the locale translates the names. In test mode Mollie also lists the methods whose activation is still pending.
At Stripe the list is what the account's default payment method configuration shows and Stripe makes available to the account. That configuration states no amounts or countries and carries no names or logos, so the filters do not narrow it, the names are Stripe's product names in English, and Stripe applies the limits of a method when it creates the payment. A restricted key needs read access to payment method configurations.
Content Security Policy
A page that mounts a provider's element for an account, or sends the customer to its checkout, needs that
provider's sources in its Content-Security-Policy. accountCsp() hands them over by directive:
$directives = app(BillingManager::class)->accountCsp($account)->directives();
// ['script-src' => ['https://js.mollie.com'], 'frame-src' => [...], 'connect-src' => [...]]
Add them to the policy of the pages that take payments, and nowhere else. The account hub keeps scoping the sources of the install's own driver to its routes, as before.
A shop with a Mollie and a Stripe account needs the sources of both, and accountsCsp() hands them over together,
each source once:
$directives = app(BillingManager::class)->accountsCsp($mollieAccount, $stripeAccount)->directives();
A page that offers Link through Stripe adds its sources with
new MergedPaymentCsp($csp, new StripePaymentCsp(link: true)). Apple Pay, Google Pay, PayPal and Klarna inside
Stripe's elements need no source beyond those of Stripe.js, according to Stripe's security guide: they run in its
frames or redirect through hooks.stripe.com, which is among them.
Payments only
An application that only takes payments into the accounts of its shops, and bills nobody for a subscription, sets
BILLING_MODE=payments. Payment accounts then work as described here, and the billing of a SaaS application stays
off:
- no account hub and no admin console, so no billing screen is reachable on the domains the application serves
- no Cashier routes
- no default effects, so no receipt, dunning notice or credit note goes out, and no billing cycle runs
- of the scheduled runs only
billing:prune, because the webhook records of the accounts carry personal data and fall under the same retention as every other delivery
The migrations stay: the webhook records of the accounts live in billing_webhook_events, and several migrations
shape that table together with others. BillingServiceProvider::ignoreMigrations() hands them to the application
as before. A payment into an account creates no customer at the provider, so no customer column is needed for it.
What stays the install's
An account's driver charges what it is handed. Its engine is the null engine, because the subscriptions of
the install keep running on the install's own driver, BillingManager::driver(). The seams the install binds
at boot, such as the webhook verifier, the payment methods and the invoices, stay those of the install's
driver, and an override you bound for them keeps working.
With billing switched off, forAccount() returns the null driver, as driver() does. An account of a
provider that has no account driver is refused with UnsupportedDriver, which names the registration that is
missing.