Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core-components/src/payment_sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type {
AdyenSession,
KnownPaymentSessionStatus,
KnownPaymentTransactionStatus,
PaymentInstrument,
PaymentSessionStatus,
PaymentTransactionStatus,
PlaceabilityError,
Expand All @@ -52,6 +53,7 @@ export {
MONEY_RETURNED_SESSION_STATUSES,
PAYMENT_TAKEN_SESSION_STATUSES,
readAdyenSession,
readPaymentInstrument,
readStripeClientSecret,
STRIPE_SETTING_TYPE,
TERMINAL_FAILURE_TRANSACTION_STATUSES,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
52 changes: 52 additions & 0 deletions packages/core-components/src/payment_sessions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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<Order> {
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<Order>
}

function renderInstrument(currentOrder: Partial<Order>, children?: ReactNode) {
return render(
<CommerceLayerContext.Provider value={{ accessToken: "token" } as never}>
<OrderContext.Provider
value={
{
...defaultOrderContext,
order: currentOrder,
include: ["payment_sessions.payment_setting", "payment_sessions.payment_authorization"],
includeLoaded: {
"payment_sessions.payment_setting": true,
"payment_sessions.payment_authorization": true,
},
addResourceToInclude: vi.fn(),
getOrder: vi.fn(),
} as never
}
>
<PaymentSetting readonly>
{children ?? <PaymentSettingInstrument data-testid="instrument" />}
</PaymentSetting>
</OrderContext.Provider>
</CommerceLayerContext.Provider>
)
}

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({}),
<PaymentSettingInstrument fallback={<span data-testid="fallback">Bank transfer</span>} />
)
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" }),
<PaymentSettingInstrument>
{({ isCard, issuerName, iconUrl }) => (
<span data-testid="custom">{`${isCard}|${issuerName}|${iconUrl}`}</span>
)}
</PaymentSettingInstrument>
)
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" }),
<PaymentSettingInstrument>
{({ label, iconUrl }) => <span data-testid="custom">{`${label}|${iconUrl}`}</span>}
</PaymentSettingInstrument>
)
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" }),
<PaymentSettingInstrument>
{({ brandName, cardLastDigits, cardExpiryMonth, cardExpiryYear, iconUrl }) => (
<span data-testid="custom">
{`${brandName}|${cardLastDigits}|${cardExpiryMonth}/${cardExpiryYear}|${iconUrl}`}
</span>
)}
</PaymentSettingInstrument>
)
expect(screen.getByTestId("custom").textContent).toBe(
"American express|4242|1/2028|//data.commercelayer.app/assets/images/icons/credit-cards/color/american_express.svg"
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading