diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts index ed1272ff..acce729e 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -34,6 +34,7 @@ export type { AdyenSession, KnownPaymentSessionStatus, KnownPaymentTransactionStatus, + PaymentInstrument, PaymentSessionStatus, PaymentTransactionStatus, PlaceabilityError, @@ -52,6 +53,7 @@ export { MONEY_RETURNED_SESSION_STATUSES, PAYMENT_TAKEN_SESSION_STATUSES, readAdyenSession, + readPaymentInstrument, readStripeClientSecret, STRIPE_SETTING_TYPE, TERMINAL_FAILURE_TRANSACTION_STATUSES, diff --git a/packages/core-components/src/payment_sessions/paymentInstrument.spec.ts b/packages/core-components/src/payment_sessions/paymentInstrument.spec.ts new file mode 100644 index 00000000..d4d2130d --- /dev/null +++ b/packages/core-components/src/payment_sessions/paymentInstrument.spec.ts @@ -0,0 +1,44 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { readPaymentInstrument } from "./types" + +function session(paymentInstrument?: unknown): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "authorized", + payment_instrument: paymentInstrument, + } as unknown as PaymentSession +} + +describe("readPaymentInstrument", () => { + it("reads a card instrument as the API sends it", () => { + const instrument = { + card_type: "visa", + payment_id: "pm_1ULltUDtxYOB6MQVpTbaSy8A", + issuer_type: "card", + card_expiry_year: 2028, + card_fingerprint: "FnbDgA9WDd203xvY", + card_last_digits: "4242", + card_expiry_month: 1, + } + expect(readPaymentInstrument(session(instrument))).toEqual(instrument) + }) + + it("reads an empty object as nothing known yet", () => { + // The column defaults to `{}` until the payment is authorized. + expect(readPaymentInstrument(session({}))).toBeUndefined() + }) + + it("is undefined when the attribute is missing or not an object", () => { + expect(readPaymentInstrument(session())).toBeUndefined() + expect(readPaymentInstrument(session(null))).toBeUndefined() + expect(readPaymentInstrument(session("visa"))).toBeUndefined() + expect(readPaymentInstrument(session([]))).toBeUndefined() + }) + + it("is undefined for a missing session", () => { + expect(readPaymentInstrument(undefined)).toBeUndefined() + expect(readPaymentInstrument(null)).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts index abad0327..1660c56e 100644 --- a/packages/core-components/src/payment_sessions/types.ts +++ b/packages/core-components/src/payment_sessions/types.ts @@ -310,3 +310,55 @@ export function readStripeClientSecret(session?: PaymentSession | null): string if (typeof clientSecret !== "string" || clientSecret === "") return undefined return clientSecret } + +/** + * What the shopper paid with, as the API describes it. + * + * `payment_instrument` is one shape for every gateway: each + * `Payment::Instrument::*` class in `core-api` maps its gateway's own response + * onto these keys (`app/models/payment/instrument/`), so a Visa reads the same + * whether Stripe, Adyen or Checkout.com took it. Card instruments fill the + * `card_*` keys, account-based ones (PayPal, SEPA, an external gateway's + * wallet) the `account_*` keys; any key may be absent. + * + * Transcribed by hand because the SDK does not type it on `PaymentSession`. + * **Re-check against `core-api` whenever the SDK is upgraded.** + */ +export interface PaymentInstrument { + /** e.g. `card`, a wallet such as `apple_pay`, or the issuer's name on Adyen. */ + issuer_type?: string + issuer?: string + /** The gateway's id for the payment method, e.g. Stripe's `pm_…`. */ + payment_id?: string + /** The card brand as the gateway names it, e.g. `visa`. */ + card_type?: string + card_last_digits?: string + card_expiry_month?: number | string + card_expiry_year?: number | string + card_holder_name?: string + card_fingerprint?: string + account_id?: string + account_email?: string + account_status?: string + account_holder_type?: string + account_last_digits?: string + account_fingerprint?: string +} + +/** + * Read the payment instrument out of a Payment Session. + * + * The API fills it once the payment is authorized (`fill_payment_instrument!`, + * called from `PaymentAuthorization`), or from a stored wallet when one is + * attached, and defaults the column to `{}` until then. So an empty object, as + * much as a missing one, means "nothing known yet" and reads as `undefined` — + * which is also what a consumer whose `fields` allowlist omits it gets. + */ +export function readPaymentInstrument( + session?: PaymentSession | null +): PaymentInstrument | undefined { + const data = (session as { payment_instrument?: unknown } | null | undefined)?.payment_instrument + if (data == null || typeof data !== "object" || Array.isArray(data)) return undefined + if (Object.keys(data).length === 0) return undefined + return data as PaymentInstrument +} diff --git a/packages/react-components/specs/payment_settings/PaymentSettingInstrument.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSettingInstrument.spec.tsx new file mode 100644 index 00000000..910e637b --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSettingInstrument.spec.tsx @@ -0,0 +1,156 @@ +import type { Order } from "@commercelayer/sdk" +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it, vi } from "vitest" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import { PaymentSettingInstrument } from "#components/payment_settings/PaymentSettingInstrument" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +// `public_key` because a Stripe setting without one is skipped as unusable. +const STRIPE = { + id: "ps-stripe", + type: "payment_setting_stripes", + name: "Stripe", + public_key: "pk_test_123", +} + +// The instrument Stripe reports for a test Visa, as the API stores it. +const VISA = { + card_type: "visa", + payment_id: "pm_1ULltUDtxYOB6MQVpTbaSy8A", + issuer_type: "card", + card_expiry_year: 2028, + card_fingerprint: "FnbDgA9WDd203xvY", + card_last_digits: "4242", + card_expiry_month: 1, +} + +function order(paymentInstrument: unknown): Partial { + return { + id: "order-1", + available_payment_settings: [STRIPE], + payment_sessions: [ + { + id: "session-1", + type: "payment_sessions", + status: "authorized", + payment_setting: STRIPE, + payment_instrument: paymentInstrument, + }, + ], + } as unknown as Partial +} + +function renderInstrument(currentOrder: Partial, children?: ReactNode) { + return render( + + + + {children ?? } + + + + ) +} + +describe("PaymentSettingInstrument", () => { + it("renders the card brand and last digits by default", () => { + renderInstrument(order(VISA)) + expect(screen.getByTestId("instrument").textContent).toBe("Visa •••• 4242") + }) + + it("renders nothing before the API knows the instrument", () => { + // `{}` is the column's default until the payment is authorized. + renderInstrument(order({})) + expect(screen.queryByTestId("instrument")).toBeNull() + }) + + it("renders the fallback when there is no instrument", () => { + renderInstrument( + order({}), + Bank transfer} /> + ) + expect(screen.getByTestId("fallback").textContent).toBe("Bank transfer") + }) + + it("names the method rather than the account for PayPal", () => { + // What Stripe reports for a PayPal payment. The email is the shopper's own + // data and is left out of the default rendering on purpose. + renderInstrument( + order({ + account_id: "DBORPRPPK23GA", + payment_id: "pm_1ULnJlDtxYOB6MQVup6uMpY9", + issuer_type: "paypal", + account_email: "fake_payer@personal.example.com", + }) + ) + expect(screen.getByTestId("instrument").textContent).toBe("PayPal") + }) + + it("folds each gateway's spelling of a method into one name and icon", () => { + // Adyen's type for Klarna's pay-later; Stripe calls the same thing `klarna`. + renderInstrument( + order({ issuer_type: "klarna_account" }), + + {({ isCard, issuerName, iconUrl }) => ( + {`${isCard}|${issuerName}|${iconUrl}`} + )} + + ) + expect(screen.getByTestId("custom").textContent).toBe( + "false|Klarna|//data.commercelayer.app/assets/images/icons/credit-cards/color/klarna.svg" + ) + }) + + it("gives no icon for a method the icon set has no artwork for", () => { + renderInstrument( + order({ issuer_type: "amazon_pay" }), + + {({ label, iconUrl }) => {`${label}|${iconUrl}`}} + + ) + expect(screen.getByTestId("custom").textContent).toBe("Amazon Pay|undefined") + }) + + it("names an unknown method from its issuer type", () => { + renderInstrument(order({ issuer_type: "us_bank_account", account_email: "a@b.c" })) + expect(screen.getByTestId("instrument").textContent).toBe("Us bank account") + }) + + it("treats a card paid through a wallet as the card", () => { + // Stripe puts the wallet in `issuer_type` and still fills the card fields. + renderInstrument(order({ ...VISA, issuer_type: "apple_pay" })) + expect(screen.getByTestId("instrument").textContent).toBe("Visa •••• 4242") + }) + + it("hands the mapped fields and the brand icon to function children", () => { + renderInstrument( + order({ ...VISA, card_type: "american_express" }), + + {({ brandName, cardLastDigits, cardExpiryMonth, cardExpiryYear, iconUrl }) => ( + + {`${brandName}|${cardLastDigits}|${cardExpiryMonth}/${cardExpiryYear}|${iconUrl}`} + + )} + + ) + expect(screen.getByTestId("custom").textContent).toBe( + "American express|4242|1/2028|//data.commercelayer.app/assets/images/icons/credit-cards/color/american_express.svg" + ) + }) +}) diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index ca34e1b7..3899ea57 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -168,6 +168,13 @@ export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { }) if (result.placed) { + // The context still holds the order from before the money moved: the + // sessions read as unpaid and carry no `payment_instrument`, which the + // API fills only as it authorizes them. Whatever renders the recap next + // reads that context, so it is brought up to date first. `result.order` + // cannot stand in for it — `_place` returns it without the includes the + // recap needs. + await refetch() onClick?.({ placed: true, order: result.order }) return } diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingInstrument.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingInstrument.tsx new file mode 100644 index 00000000..ce34b1b8 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingInstrument.tsx @@ -0,0 +1,143 @@ +import { type PaymentInstrument, readPaymentInstrument } from "@commercelayer/core-components" +import type { JSX, ReactNode } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +export interface PaymentSettingInstrumentChildrenProps + extends Omit { + /** The instrument as the API sends it, for anything not mapped below. */ + paymentInstrument: PaymentInstrument + /** True when the brand and last digits of a card are known. */ + isCard: boolean + /** The payment method as the gateway names it, e.g. `paypal` or `klarna_account`. */ + issuerType?: string + /** + * `issuerType` made readable, with each gateway's spelling folded into one: + * `PayPal`, `Klarna`, `Apple Pay`. Undefined for a plain card. + */ + issuerName?: string + /** The card brand as the gateway names it, e.g. `visa`. */ + cardType?: string + /** `cardType` made readable, e.g. `American express` for `american_express`. */ + brandName?: string + cardLastDigits?: string + cardExpiryMonth?: number | string + cardExpiryYear?: number | string + cardHolderName?: string + accountEmail?: string + /** + * The card brand's or the payment method's icon, from the same set + * `` uses. Undefined for a method the set has no + * artwork for, rather than a URL that fails to load. + */ + iconUrl?: string + /** What the component renders when it has no children. */ + label: string +} + +export interface PaymentSettingInstrumentProps + extends Omit { + children?: ChildrenFunction + /** + * Rendered instead when there is no instrument to show: a setting with no + * gateway behind it, such as a manual payment, never gets one. Typically + * ``. + */ + fallback?: ReactNode +} + +const ICONS_URL = "//data.commercelayer.app/assets/images/icons/credit-cards/color" + +/** + * Non-card methods, keyed by every spelling a gateway uses for them. + * + * `issuer_type` is the gateway's own name for the payment method — Adyen's + * `paymentMethod.type`, Stripe's payment method `type`, or the wallet a Stripe + * card was paid through — so one method arrives under several names. `icon` + * is set only where the icon set has artwork; the rest fall back to text. + */ +const ISSUERS: Record = { + paypal: { name: "PayPal", icon: "paypal" }, + klarna: { name: "Klarna", icon: "klarna" }, + klarna_account: { name: "Klarna", icon: "klarna" }, + klarna_paynow: { name: "Klarna", icon: "klarna" }, + klarna_b2b: { name: "Klarna", icon: "klarna" }, + applepay: { name: "Apple Pay", icon: "apple_pay" }, + apple_pay: { name: "Apple Pay", icon: "apple_pay" }, + googlepay: { name: "Google Pay", icon: "google_pay" }, + paywithgoogle: { name: "Google Pay", icon: "google_pay" }, + google_pay: { name: "Google Pay", icon: "google_pay" }, + link: { name: "Link", icon: "link" }, + ideal: { name: "iDEAL", icon: "ideal" }, + amazon_pay: { name: "Amazon Pay" }, + amazonpay: { name: "Amazon Pay" }, +} + +/** Same rule as ``, so both models name a brand alike. */ +function readableBrand(brand?: string): string | undefined { + if (brand == null || brand === "") return undefined + const spaced = brand.replace(/_|-/gm, " ") + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase() +} + +/** + * What the shopper paid with on the selected setting: the card brand and last + * digits, or the payment method — PayPal, Klarna, a wallet. + * + * Renders `fallback`, or nothing, until the API knows: `payment_instrument` + * is filled once the payment is authorized, so this belongs on a recap — a + * thank-you page, or `` after placing — rather than on + * the selector, where it would always be empty. + * + * Without children it renders `Visa •••• 4242` for a card and the method's + * name otherwise. Never the account email: that is the shopper's personal + * data, and the method's name is what tells them how they paid. + */ +export function PaymentSettingInstrument({ + children, + fallback, + ...props +}: PaymentSettingInstrumentProps): JSX.Element | null { + const { currentPaymentSession } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingInstrument", + key: "setting", + }) + const instrument = readPaymentInstrument(currentPaymentSession) + if (instrument == null) return fallback != null ? <>{fallback} : null + + const cardType = instrument.card_type + const brandName = readableBrand(cardType) + const cardLastDigits = instrument.card_last_digits + // A card wins over its wallet: a card paid through Apple Pay is still + // recognised by the shopper as their Visa ending in 4242. + const isCard = brandName != null && cardLastDigits != null + const issuer = ISSUERS[instrument.issuer_type ?? ""] + const issuerName = isCard ? undefined : (issuer?.name ?? readableBrand(instrument.issuer_type)) + const label = isCard ? `${brandName} •••• ${cardLastDigits}` : (issuerName ?? brandName ?? "") + const icon = isCard ? cardType : issuer?.icon + + const childrenProps: PaymentSettingInstrumentChildrenProps = { + ...props, + paymentInstrument: instrument, + isCard, + issuerType: instrument.issuer_type, + issuerName, + cardType, + brandName, + cardLastDigits, + cardExpiryMonth: instrument.card_expiry_month, + cardExpiryYear: instrument.card_expiry_year, + cardHolderName: instrument.card_holder_name, + accountEmail: instrument.account_email, + iconUrl: icon != null ? `${ICONS_URL}/${icon}.svg` : undefined, + label, + } + + return children ? {children} : {label} +} + +export default PaymentSettingInstrument diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index d5615c3c..b041f5f5 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -98,6 +98,7 @@ export * from "#components/payment_settings/PaymentSettingGiftCardList" export * from "#components/payment_settings/PaymentSettingGiftCardListItem" export * from "#components/payment_settings/PaymentSettingGiftCardRemoveButton" export * from "#components/payment_settings/PaymentSettingGiftCardSubmitButton" +export * from "#components/payment_settings/PaymentSettingInstrument" export * from "#components/payment_settings/PaymentSettingManualPayment" export * from "#components/payment_settings/PaymentSettingName" export * from "#components/payment_settings/PaymentSettingRadioButton"