'use strict'; /** * Apple Pay on the Web — the browser half. * * The two handlers below are the whole integration. Everything else here is plumbing to * make the sample readable. * * onvalidatemerchant -> your backend -> TruePay -> Apple -> completeMerchantValidation * onpaymentauthorized -> your backend -> TruePay -> completePayment * * The first one is what in-app Apple Pay does not have, and skipping it is why a sheet * opens and then closes with no token. * * Nothing secret is here, and nothing secret can be: this file is public. The amount is * read from the server and the server charges its own figure — a page that posts its own * amount can be edited in the console. */ (function () { const $ = (id) => document.getElementById(id); const statusEl = () => $('status'); function say(kind, text) { const el = statusEl(); el.className = `status ${kind}`; el.textContent = text; el.hidden = false; } /** * Apple Pay is available only when ALL of this holds, and the reasons are worth * distinguishing because each has a different fix. */ function availability() { if (!window.ApplePaySession) { return { ok: false, why: 'This browser does not support Apple Pay. Open the page in Safari on an Apple device.' }; } if (!ApplePaySession.supportsVersion(3)) { return { ok: false, why: 'This device has an Apple Pay version older than 3.' }; } if (!ApplePaySession.canMakePayments()) { return { ok: false, why: 'Apple Pay is not set up on this device — add a card to Wallet.' }; } if (location.protocol !== 'https:') { // Apple Pay JS refuses to run on an insecure origin, and localhost is not an // exception the way it is for service workers. return { ok: false, why: 'Apple Pay needs HTTPS on a real domain. localhost will not work.' }; } return { ok: true }; } async function post(path, body) { const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await r.json().catch(() => null); if (!r.ok) { const err = new Error((data && (data.message || data.error)) || `Request failed (${r.status})`); err.payload = data; throw err; } return data; } function startPayment(cfg) { const request = { countryCode: cfg.countryCode, currencyCode: cfg.currency, merchantCapabilities: ['supports3DS'], // required; 3DS is the wallet's own attestation supportedNetworks: ['visa', 'masterCard'], total: { label: cfg.displayName, amount: cfg.total.toFixed(3), type: 'final' }, lineItems: cfg.lines.map((l) => ({ label: l.label, amount: l.amount.toFixed(3) })), }; const session = new ApplePaySession(3, request); // ── STEP 1 — merchant validation ─────────────────────────────────────── // Safari gives us a one-time validationURL. It cannot be called from here: the call // needs a client certificate. Hand it to your own backend and pass the answer back // UNCHANGED — it is opaque and short-lived. session.onvalidatemerchant = async (event) => { try { const merchantSession = await post('/apple-pay/validate', { validationURL: event.validationURL }); session.completeMerchantValidation(merchantSession); } catch (e) { // Abort rather than leave the sheet spinning. Without a session the sheet can // never produce a token, so there is nothing to wait for. console.error('[apple-pay] merchant validation failed', e, e.payload); say('error', `Could not start Apple Pay: ${e.message}`); session.abort(); } }; // ── STEP 2 — the payment ─────────────────────────────────────────────── // event.payment.token is the same PKPaymentToken an iOS app produces, and it goes to // the same TruePay endpoint. Send the WHOLE object. session.onpaymentauthorized = async (event) => { try { const result = await post('/apple-pay/pay', { token: event.payment.token }); // Tell the sheet before you tell the customer — it is waiting on this, and a slow // page here leaves them looking at a spinner on their own device. session.completePayment(result.success ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE); if (result.success) { say('ok', `Paid. Reference ${result.reference_number}.`); } else { say('error', result.message || 'The payment was not completed.'); } } catch (e) { session.completePayment(ApplePaySession.STATUS_FAILURE); console.error('[apple-pay] payment failed', e, e.payload); say('error', e.message); } }; session.oncancel = () => { say('muted', 'Payment cancelled.'); }; // Must be called from the user gesture that created the session, which is why this // whole function runs inside the button's click handler. session.begin(); } async function init() { let cfg; try { cfg = await (await fetch('/checkout/config')).json(); } catch (e) { say('error', 'Could not load the checkout.'); return; } $('total').textContent = cfg.total.toFixed(3); $('items').innerHTML = cfg.lines .map((l) => `
  • ${l.label}${l.amount.toFixed(3)}
  • `) .join(''); if (!cfg.ready) { say('error', `The server is not configured yet — set ${cfg.missing.join(', ')} in .env.`); return; } const avail = availability(); if (!avail.ok) { say('muted', avail.why); return; } const btn = $('apple-pay-button'); btn.hidden = false; btn.addEventListener('click', () => { statusEl().hidden = true; startPayment(cfg); }); } document.addEventListener('DOMContentLoaded', init); })();