Guide 08 · Developer docs

Commerce

A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.

L2–L3
Unit 04 · Taking payment

Payment methods and how a payment runs

The four payment methods a site can offer and what each needs, then the path every payment takes: the provider's script in the browser, the purchase call, the invoice, the currency it is charged in, and each answer your page acts on.

Who this is for. Developers building the payment step of a checkout. After this page you can set up each payment method, list the ones a shopper may choose, write the script that hands a payment to the platform, and act on every answer the purchase call gives.

The four payment methods

A payment method is a record under Invoices › Payment methods. There are four kinds, and every method has the same core fields: a name, a position, a Test Mode switch, an Active switch and an optional site. The rest is the provider's own configuration, entered on the record and stored with it.

MethodpaymentServiceProviderTypeConfiguration (admin labels)
StripestripeThe connected Stripe account, through Connect Stripe Account
PayPalpaypalClient ID and Secret Key
PayPal Donatepaypal-donateBusiness Email or Hosted Button ID, exactly one of the two
Mock Paymock-payFailure Rate, Authorization, Partial Capture, Refunds

Stripe

Stripe takes cards and the other payment methods your Stripe account switches on. It works through Stripe Connect: connect the site's Stripe account with Connect Stripe Account on the method, then save the record to finish onboarding (until you do, the admin shows "Save payment method to complete on boarding"). The method stores only the connected account's id. There are no API keys to enter: the platform's own Stripe keys are used, and Test Mode chooses the test or the live set. On an environment where Stripe Connect has not been set up, the method shows "Stripe Connect is not configured for this environment" in place of the button.

PayPal

PayPal takes the Client ID and Secret Key of a PayPal REST app. Only the client ID ever reaches the browser. It supports full and partial refunds, and its payment is captured during the purchase call itself.

PayPal Donate

PayPal Donate shows PayPal's donate button. It needs a Business Email or a Hosted Button ID, and exactly one of them: a record with both, or with neither, is not saved. It takes a donation in one step, with no separate authorisation, and refunds are not made through it. For donations taken on a form, see Taking payments.

Mock Pay

Mock Pay is the test provider: it reports payments as successful and moves no money, and it must never be active on a live site. Its settings, and the check that proves it is off, are on The mock provider.

What a method leaves to your page

The Active switch decides whether a method is offered, and your page decides when, for example leaving out a method whose provider account does not take the checkout's currency (The currency a payment is taken in). Each payment is collected in the provider's script at the time it is made.

Listing the methods a shopper can choose

The payment step reads the active methods with GET /cdna-api/invoices/payment-methods?active=1, which answers with data.paymentMethods. The list covers every site on the account, so keep, in your script, the entries whose site is the current site or empty. Then sort them by position, draw one option per entry labelled with its name, and choose the script that handles it by paymentServiceProviderType.

EntryFields
Every methodid, name, position, testMode, active, created, modified, site, paymentServiceProviderName, paymentServiceProviderType
StripepublishableKey and accountId, which Stripe's script needs in the browser
PayPalclientId, which PayPal's script needs; the secret key stays on the server
PayPal DonatebusinessEmail, hostedButtonId, isAuthorizationSupported
Mock PayfailureRate, isAuthorizationSupported, isPartialCaptureSupported, isRefundsSupported

The anatomy of a payment method

Every provider has the same four parts. Knowing which part does what tells you where to look when a payment does not behave as you expect, and how a provider not covered here would fit.

PartWhere it livesWhat it does
AvailabilityThe Active switch, and your page's filterDecides whether the shopper is offered the method
ConfigurationThe payment method recordHolds what the provider needs: the connected account, the app's credentials, the button
Client-side handlerYour script on the checkout pageLoads the provider's own script, collects the payment and returns the provider's token
Server-side confirmationThe platform, in the purchase callCreates the invoice, asks the provider to take the money, records the result and makes the order

How a payment runs

  1. The browser collects the payment with the provider's own script: Stripe's Payment Element, PayPal's buttons. The platform ships no checkout script, so this part is yours.
  2. The browser calls purchase, POST /cdna-api/checkout/<uuid>/purchase, with _paymentMethodId, the provider's token (confirmationToken for Stripe, paypalOrderId for PayPal) and _recaptchaToken when the captcha is on.
  3. The platform creates an invoice, or reuses the open one, and asks the provider to take the money, recording the answer as a transaction on the invoice.
  4. The order is created once the invoice is fully paid: during the purchase call for PayPal and Mock Pay, and from the provider's webhook for Stripe and PayPal Donate (Webhooks).

The client-side handler

Split the payment step's script by provider. A small dispatcher draws a radio option per method and, when the shopper picks one, loads the module for its paymentServiceProviderType. Each module answers the same six calls, so the dispatcher never needs to know which provider it is talking to:

  • init(id): load the provider's script and draw its fields for the method with that id;
  • getId(): the method's id, sent as _paymentMethodId;
  • preparePurchase(): collect the provider's token, such as Stripe's confirmation token;
  • getPaymentMethodData(): the token payload to merge into the purchase body;
  • authenticate(data): complete a challenge the provider asks for, such as 3-D Secure;
  • cleanup(): remove the provider's fields when the shopper picks another method.

The script needs the checkout's uuid for the purchase call. Hand it over from the checkout template in an inline script that carries the page's nonce:

Template code checkouts/single-page.html
<script nonce="{$_inlineScriptNonce}">
  window.paymentConfig = {$paymentConfig = ['id' => $checkout->getUuid()]}{$paymentConfig|json_encode};
</script>
<script nonce="{{ _inlineScriptNonce }}">
  window.paymentConfig = {{ {'id': checkout.getUuid()}|json_encode }};
</script>

The dispatcher below lists the methods, loads a module per provider type, and acts on the purchase call's answer. purchase stands for your own API layer's call (One API layer). A provider type with no module is left out of the list, which is also how Mock Pay stays out of it here.

JavaScript js/checkout/payment-method-handler.js
// The dispatcher: one option per active method, one module per provider type.
const modules = {
  stripe: () => import('./payment-methods/stripe.js'),
  paypal: () => import('./payment-methods/paypal.js'),
};

let handler = null;

export async function listMethods(isCurrentSite) {
  const res = await fetch('/cdna-api/invoices/payment-methods?active=1', {
    headers: { Accept: 'application/json' },
  });
  const body = await res.json();
  return body.data.paymentMethods
    .filter((method) => !method.site || isCurrentSite(method.site))
    .filter((method) => method.paymentServiceProviderType in modules)
    .sort((a, b) => a.position - b.position);
}

export async function select(method) {
  if (handler) handler.cleanup();
  const { default: Handler } = await modules[method.paymentServiceProviderType]();
  handler = new Handler(method);
  await handler.init(method.id);
}

export async function pay(checkoutId, purchase) {
  await handler.preparePurchase();
  const body = await purchase(checkoutId, {
    _paymentMethodId: handler.getId(),
    ...handler.getPaymentMethodData(),
  });
  if (body.result === 'error') return body; // a decline: show its message

  const { action } = body.data;
  if (action.type === 'authenticate') {
    await handler.authenticate(action.data);
    location.href = '/checkout/payment-processing';
  } else if (action.type === 'redirect') {
    location.href = action.url;
  }
  return body; // PAYMENT_SUCCESSFUL_BUT_NOT_FULLY_PAID has no action
}

What the purchase call does

On the server, the purchase call is the confirmation half of every method:

  • A checkout with nothing to pay skips the provider. When the total is zero, the checkout is marked paid without asking a provider.
  • The method is checked. _paymentMethodId must name an active payment method. A missing or inactive one is refused with HTTP 400 and "Payment method was not provided or is invalid".
  • The invoice is created, or the open one reused. An open invoice for a different payment method is voided, because the shopper has changed method. An open invoice for the same method is reused only while its grand total, person, totals, items and discounts all still match the checkout; otherwise it is voided and a new one is created, so the invoice always describes what the shopper is paying for now.
  • The provider is asked to take the money, and its answer is recorded as a transaction on the invoice.
  • The order is made when the invoice is fully paid, and the call answers with a code.

The full sequence, with the captcha and what each answer does to the checkout, is on The checkout and when it can be paid.

The purchase response

A payment the provider accepted, or still has to finish, answers with data.code and data.action. The action is a redirect (type redirect, with method GET and a url) or a challenge (type authenticate, with data).

CodeWhat it meansActionWhat your page does
PAYMENT_SUCCESSFULPaid in full, and the order existsRedirect to /checkout/complete/<order uuid>: the order's uuid, never its idFollows it
PAYMENT_PENDINGThe provider has still to confirm. This is the normal answer for Stripe, even when the card went through, because a Stripe order is made from the webhookRedirect to /checkout/payment-processingFollows it
PAYMENT_AUTHENTICATION_REQUIREDStripe needs the shopper to complete an action in Stripe's script, such as a 3-D Secure challengeChallenge; data holds paymentIntentId, clientSecret, nextActionType and hostedInstructionsUrlPasses it to the handler's authenticate(), then goes to /checkout/payment-processing
PAYMENT_REDIRECTThe provider needs its own pageRedirect to the provider's urlFollows it
PAYMENT_SUCCESSFUL_BUT_NOT_FULLY_PAIDPart of the total is paid and a balance remains; no order yetNone (an empty list)Stays on the checkout and shows what is still to pay

A payment that does not go through answers with result: error and a message in place of a code. A provider's decline arrives with HTTP status 500 and the provider's message; a request refused before any provider is asked, such as one without a valid payment method or confirmation token, arrives with 400. Tell the cases apart by the body rather than the status. What to show for each is on 3-D Secure, declines and retries.

The currency a payment is taken in

A payment is taken in the checkout's currency, which carries through unchanged from the cart to the provider:

  1. The cart takes its currency when its first item is added (the currency sent with that request, or else the default currency) and keeps it until it is emptied, and the checkout copies it (Its currency, and the language of its lines).
  2. The purchase call creates the invoice in the checkout's currency.
  3. The provider is sent the invoice's currency code as it is: as the payment intent's currency for Stripe, as currency_code on the PayPal order, and in PayPal Donate's button configuration.

No payment method has a currency setting, so an active method can be chosen whatever the checkout's currency. Before launch, check that each provider account accepts every currency the site sells in: a currency an account does not take comes back as that provider's decline. Where a provider account takes only some of the site's currencies, leave that method out of your list for the others.

Amounts in the currency's minor unit

Providers take amounts in a currency's minor unit, such as cents. The platform converts with the decimal places on the site's Currency record for that currency, not a fixed 100, so a currency with no minor unit, such as the Japanese yen, is sent as a whole number when its record has 0 decimal places. Set each Currency record's decimal places to the currency's own (Prices and currencies) and your code needs no special case. Where your own script gives Stripe an amount, as stripe.elements({ mode: 'payment', amount, currency }) does, work it out with the same decimal places and pass the checkout's currency code.

The language of the payment step

Stripe's and PayPal's own fields and buttons take their language from the shopper's browser, or from the locale your script passes when it loads them. On a multilingual site, pass the page's language, so the payment fields match the rest of the checkout.

A receipt the provider emails itself, when the provider account is set to send one, is in the language that account's settings give it. The order confirmation the site sends is in the language its template is written in, like every other customer email; a multilingual store records the shopper's language on the checkout so the template can follow it: see The language of an email.

Content Security Policy

The site's default policy allows scripts from the site itself, from cdnjs, and inline scripts that carry the page's nonce: script-src 'self' https://cdnjs.cloudflare.com 'nonce-%nonce%'. Stripe's and PayPal's scripts load once you add their hosts to the CSP policy setting, Globals.cspPolicy under Globals › Security, keeping the %nonce% placeholder, which the platform fills with the page's nonce. Take the host list from the provider's own documentation. The browser reports a script the policy refuses in its console, so open the console on the payment step of every environment before launch. Every inline script you add carries nonce="{$_inlineScriptNonce}" (Twig {{ _inlineScriptNonce }}), as in the snippet above.

The rest of a payment

Express payments. Apple Pay and Google Pay through Stripe are on Express payments.

The waiting page and webhooks. What the shopper sees while a payment is confirmed, and how a provider's webhook turns a confirmed payment into an order, are on The payment-processing page and webhooks.

3-D Secure, declines and retries. The card challenge, the decline messages and a retry policy that stays clear of fraud rules have their own page: 3-D Secure, declines and retries.

The mock provider. Mock Pay, the test provider that must never be active on a live site, and the check that proves it is not, have their own page: The mock provider.

Go deeper

Was this page helpful?

Your answer goes to the docs team.