/** * PayMe Hosted Fields (JSAPI) — the browser half of tokenization. Vanilla ES * module, no framework. Port the calls into your own frontend; keep the rules. * * 1. browser: PayMe.create(sellerPublicKey) -> instance * instance.hostedFields() -> create + mount * instance.tokenize(saleData) -> { token, card, ... } * 2. server: generate-sale with buyer_key = token -> charge * * Card data goes browser -> PayMe's vault inside cross-origin iframes. It never * touches your server, which keeps you out of PCI DSS scope. * * Guide: https://docs.payme.io/accepting-payments/checkout-and-in-person/hosted-fields-jsapi-guide * * RULES (each one has cost someone an afternoon): * - PayMe.create takes the seller's PUBLIC key (a uuid). Never a partner key * and never an MPL: both are server-side credentials. A partner gets the key * from create-seller or GET /sellers/{mpl}/public-keys, which is partner-only. * A direct seller takes it from the dashboard: Admin -> API & Integration, * section Hosted Fields JSAPI Credentials. * - testMode must match the environment you charge in: true for sandbox, * false for production. A sandbox token cannot pay a live sale. * - One instance, one tokenize(). A second call fails with "Bad session — * Session expired or deleted". After any failure, clear the containers and * start again from a NEW instance. * - mount() appends; it does not replace. Empty the containers before * remounting or the buyer sees two card fields. * - Containers need an explicit height (e.g. 44px). A cross-origin iframe * has no intrinsic height and collapses to nothing. * - total.amount.value is a DECIMAL STRING ("50.75"), not minor units. It is * display-only; the amount charged is the one on the server-side sale, so * keep them equal. * - Any payer field you do not mount must be passed as a value in tokenize(). * Card number, expiry and CVV must always be mounted, never passed. * - Styles: only `base`, `invalid`, `valid` groups, with `::placeholder` * nested inside `base`. Only color, font-size, text-align, letter-spacing, * text-decoration, text-shadow, text-transform are honoured. Anything else * is dropped silently. * - CSP: script-src https://cdn.payme.io, frame-src https://hf.payme.io. */ const SCRIPT_URL = 'https://cdn.payme.io/hf/v1/hostedfields.js'; const DEFAULT_STYLES = { base: { color: '#1a1a1a', 'font-size': '15px', '::placeholder': { color: '#8a8a8a' } }, invalid: { color: '#b00020' }, valid: { color: '#1a1a1a' }, }; let loader = null; /** Load hostedfields.js once. */ export function loadPayMeSdk() { if (window.PayMe) return Promise.resolve(window.PayMe); if (loader) return loader; loader = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = SCRIPT_URL; script.async = true; script.onload = () => window.PayMe ? resolve(window.PayMe) : reject(new Error('hostedfields.js loaded but window.PayMe is undefined')); script.onerror = () => reject( new Error( `Could not load ${SCRIPT_URL}. An ad blocker or a CSP without cdn.payme.io in script-src will block it.`, ), ); document.head.appendChild(script); }).catch((error) => { loader = null; // allow a retry throw error; }); return loader; } /** * Mount card fields into three empty containers and return a one-shot * `pay()` function. * * @param {object} opts * @param {string} opts.sellerPublicKey the seller's PUBLIC key (uuid), never an MPL * @param {boolean} opts.testMode true in sandbox, false in production * @param {{number: string, expiry: string, cvv: string}} opts.containers CSS selectors * @param {(event: object) => void} [opts.onValidity] per-field validity updates * @param {string} [opts.language] 'en' | 'he' * @param {object} [opts.styles] the card fields' look, under the rules above * @param {boolean} [opts.tokenIsPermanent] false for a single-use token * @param {{number?: object, expiry?: object, cvv?: object}} [opts.fieldOptions] * each field's own `placeholder` and `messages: { required, invalid }` * @param {Record void>} [opts.listeners] * field events by name, such as focus, blur and keyup, on all three fields */ export async function mountHostedFields({ sellerPublicKey, testMode, containers, onValidity, language = 'en', styles = DEFAULT_STYLES, tokenIsPermanent = true, fieldOptions = {}, listeners = {}, }) { const PayMe = await loadPayMeSdk(); const instance = await PayMe.create(sellerPublicKey, { testMode, language, tokenIsPermanent, }); const fields = instance.hostedFields(); const number = fields.create('cardNumber', { styles, placeholder: 'Card number', ...fieldOptions.number }); const expiry = fields.create('cardExpiration', { styles, placeholder: 'MM/YY', ...fieldOptions.expiry }); const cvv = fields.create('cvc', { styles, placeholder: 'CVV', ...fieldOptions.cvv }); for (const field of [number, expiry, cvv]) { if (onValidity) field.on('validity-changed', onValidity); for (const [event, listener] of Object.entries(listeners)) field.on(event, listener); } // mount() appends — clear first so a remount does not duplicate fields. for (const selector of Object.values(containers)) { document.querySelector(selector).replaceChildren(); } await Promise.all([ number.mount(containers.number), expiry.mount(containers.expiry), cvv.mount(containers.cvv), ]); let used = false; /** * Tokenize once. Resolves with the token to send to YOUR server, which then * calls generate-sale with buyer_key = token. Never call PayMe's server API from * the browser. * * @param {object} payer payerFirstName, payerLastName, payerEmail, payerPhone, payerSocialId * @param {{label: string, currency: string, amountMinor: number}} total */ async function tokenize(payer, total) { if (used) { throw new Error('This Hosted Fields session was already used. Remount to start again.'); } used = true; const result = await instance.tokenize({ ...payer, total: { label: total.label, // Decimal string for display only; must equal the server-side sale price. amount: { currency: total.currency, value: (total.amountMinor / 100).toFixed(2) }, }, }); return { token: result.token, cardMask: result.card?.cardMask }; } /** Remove the fields and end this instance, before mounting a new one. */ function teardown() { instance.teardown(); } return { tokenize, teardown }; }