Skip to main content

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​

FieldWhat it is
providerthe driver the account belongs to, mollie or stripe
keyyour 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
credentialthe API key, or for Mollie an OAuth access token
testmodewhether calls go to the provider's test mode
profileIdthe Mollie profile payments are created on. Mollie requires it with an OAuth access token and ignores it with an API key
webhookSecretthe 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'),
),
);
OptionMollieStripe
returnUrlthe redirectUrl, in place of the install'sthe return_url, in place of billing.stripe.return_url
cancelUrlwhere a customer who cancels goesnot used
webhookUrlin place of the account's addressnot used, Stripe posts to the endpoints of the account
localethe language of the checkoutnot used, Stripe.js picks it in the browser
methodsthe methods offered, empty for all of the account'snot used, the payment method decides
metadatakept with the paymentkept with the payment intent
lines, billingAddress, shippingAddresssent with the paymentnot used
cardTokenthe token from Mollie Components, with the creditcard methodnot used
applePayTokenthe token Apple Pay handed the page, with the creditcard methodnot used, Stripe's wallets create a payment method in the browser
captureModemanual authorizes a card payment and leaves the capture to younot 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:

  1. The account is looked up through your PaymentAccounts, before anything in the request is trusted. An address that names no account you know answers 404, so the endpoint tells a caller nothing about which shops exist.
  2. 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.
  3. It is recorded under the account's provider, with the account's key as its reference, reported as an AccountPaymentChanged and 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.

FieldWhat it is
provider, accountthe account the payment went into
paymentReferencethe provider's id of the payment, as the charge's result reported it
statusan AccountPaymentStatus
amountwhat the payment was for
amountRefundedwhat went back to the customer so far, failed refunds not counted
amountChargedBackwhat the customer's bank took back and still holds
billingReferencethe idempotency key the charge carried, such as the order's id
metadatathe metadata of the charge's options
refundReferencewith 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.