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
Taking paymentPage 14 of 31Updated
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.
Method
paymentServiceProviderType
Configuration (admin labels)
Stripe
stripe
The connected Stripe account, through Connect Stripe Account
PayPal
paypal
Client ID and Secret Key
PayPal Donate
paypal-donate
Business Email or Hosted Button ID, exactly one of the two
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.
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.
Part
Where it lives
What it does
Availability
The Active switch, and your page's filter
Decides whether the shopper is offered the method
Configuration
The payment method record
Holds what the provider needs: the connected account, the app's credentials, the button
Client-side handler
Your script on the checkout page
Loads the provider's own script, collects the payment and returns the provider's token
Server-side confirmation
The platform, in the purchase call
Creates the invoice, asks the provider to take the money, records the result and makes the order
How a payment runs
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.
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.
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.
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:
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.
JavaScriptjs/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.
A payment the provider accepted, or still has to finish, answers with data.code and data.action. The action is a redirect (typeredirect, with methodGET and a url) or a challenge (typeauthenticate, with data).
Code
What it means
Action
What your page does
PAYMENT_SUCCESSFUL
Paid in full, and the order exists
Redirect to /checkout/complete/<order uuid>: the order's uuid, never its id
Follows it
PAYMENT_PENDING
The 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 webhook
Redirect to /checkout/payment-processing
Follows it
PAYMENT_AUTHENTICATION_REQUIRED
Stripe needs the shopper to complete an action in Stripe's script, such as a 3-D Secure challenge
Challenge; data holds paymentIntentId, clientSecret, nextActionType and hostedInstructionsUrl
Passes it to the handler's authenticate(), then goes to /checkout/payment-processing
PAYMENT_REDIRECT
The provider needs its own page
Redirect to the provider's url
Follows it
PAYMENT_SUCCESSFUL_BUT_NOT_FULLY_PAID
Part of the total is paid and a balance remains; no order yet
None (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:
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).
The purchase call creates the invoice in the checkout's currency.
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.