@revenexx/cover 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,6 +70,38 @@ const showPoNumber = computed(() => form.value.paymentMethod === "po_number");
70
70
  const showCostCenter = computed(() =>
71
71
  form.value.paymentMethod === "cost_center"
72
72
  && (profile.value?.costCenters.length ?? 0) > 0);
73
+
74
+ // PSP card entry (Stripe Elements, …). The provider's secure field is mounted
75
+ // when a PSP card method is selected; the registry decides which provider needs
76
+ // one, so this is generic (Novalnet/Mollie become drop-ins).
77
+ const psp = usePspCheckout();
78
+ const selectedOption = computed<PaymentOption | null>(() =>
79
+ paymentOptions.value.find(o => o.method === form.value.paymentMethod) ?? null);
80
+ const needsCardField = computed(() => psp.requiresInstrument(selectedOption.value));
81
+ const cardEl = ref<HTMLElement | null>(null);
82
+ const cardError = ref<string | null>(null);
83
+
84
+ async function syncCardField(): Promise<void> {
85
+ cardError.value = null;
86
+ await nextTick();
87
+ if (needsCardField.value && cardEl.value && selectedOption.value) {
88
+ try {
89
+ await psp.mount(cardEl.value, selectedOption.value);
90
+ }
91
+ catch (err) {
92
+ cardError.value = err instanceof Error ? err.message : String(err);
93
+ }
94
+ }
95
+ else {
96
+ psp.destroy();
97
+ }
98
+ }
99
+
100
+ if (import.meta.client) {
101
+ watch([needsCardField, () => form.value.paymentMethod], () => void syncCardField());
102
+ onMounted(() => void syncCardField());
103
+ onBeforeUnmount(() => psp.destroy());
104
+ }
73
105
  </script>
74
106
 
75
107
  <template>
@@ -177,5 +209,12 @@ const showCostCenter = computed(() =>
177
209
  :items="costCenterOptions"
178
210
  />
179
211
  </div>
212
+
213
+ <!-- PSP secure card field (mounted client-side by the provider) -->
214
+ <div v-if="needsCardField" class="space-y-1.5">
215
+ <label class="block text-sm font-medium text-muted">{{ t('onepage.payment.cardDetails') }}</label>
216
+ <div ref="cardEl" class="w-full sm:max-w-sm p-3 rounded-lg border border-(--ui-border) bg-(--ui-bg)" />
217
+ <p v-if="cardError" class="text-sm text-error-500">{{ cardError }}</p>
218
+ </div>
180
219
  </div>
181
220
  </template>
@@ -55,6 +55,7 @@ export function useCheckoutOnePage() {
55
55
  const { t } = useI18n();
56
56
  const cart = useCartStore();
57
57
  const { settings } = useB2BContext();
58
+ const psp = usePspCheckout();
58
59
  const { data: profile, refresh: refreshProfile } = useCheckoutProfileSource();
59
60
  const profileLoading = computed(() => profile.value === null);
60
61
 
@@ -315,12 +316,32 @@ export function useCheckoutOnePage() {
315
316
  try {
316
317
  await fetchSessionToken();
317
318
 
319
+ const payload = buildOrderPayload() as Record<string, unknown>;
320
+ // PSP card methods (Stripe Elements, …) tokenize client-side; only
321
+ // the single-use token reaches the BFF (PCI-safe). Self-managed and
322
+ // redirect methods carry no instrument here.
323
+ if (psp.hasActive()) {
324
+ const instrument = await psp.tokenize();
325
+ if (instrument) {
326
+ payload.paymentInstrument = instrument;
327
+ }
328
+ }
329
+
318
330
  const order = await $fetch(checkoutApi.orders(), {
319
331
  method: "POST",
320
- body: buildOrderPayload(),
321
- }) as { orderId: string; approvalRequired?: boolean; approvers?: string[] };
332
+ body: payload,
333
+ }) as { orderId: string; approvalRequired?: boolean; approvers?: string[]; requiresAction?: boolean; redirectUrl?: string };
334
+
335
+ // Redirect-based PSPs (3DS step-up, wallets, …) — one generic
336
+ // handler for every provider: hand the buyer to the PSP, return later.
337
+ if (order.requiresAction && order.redirectUrl) {
338
+ cart.clearCart();
339
+ window.location.href = order.redirectUrl;
340
+ return null;
341
+ }
322
342
 
323
343
  cart.clearCart();
344
+ psp.destroy();
324
345
  return {
325
346
  orderId: order.orderId,
326
347
  approvalRequired: order.approvalRequired ?? false,
@@ -0,0 +1,71 @@
1
+ import { pspTokenizerFor } from "../payments/psp/registry";
2
+ import type { PspTokenizer } from "../payments/psp/types";
3
+ import type { PaymentOption } from "../interfaces/payment";
4
+
5
+ /**
6
+ * Drives the per-PSP client-side tokenization in the checkout. The active
7
+ * tokenizer is a module-level singleton so the payment section (which mounts
8
+ * the secure card field) and the submit flow (which tokenizes on place-order)
9
+ * share one instance. Provider-agnostic: which PSP needs a field comes from
10
+ * the registry + /api/payment/psp-config.
11
+ */
12
+
13
+ interface PspConfig {
14
+ providers: Record<string, { publishableKey: string; testMode: boolean }>;
15
+ }
16
+
17
+ // Client-only imperative handle; never used during SSR.
18
+ let active: PspTokenizer | null = null;
19
+
20
+ export function usePspCheckout() {
21
+ const { locale } = useI18n();
22
+ const { data: config } = useFetch<PspConfig>("/api/payment/psp-config", {
23
+ key: "psp-config",
24
+ default: () => ({ providers: {} }),
25
+ });
26
+
27
+ /** True when this method needs inline tokenization (PSP + registered + has a public key). */
28
+ function requiresInstrument(option?: PaymentOption | null): boolean {
29
+ if (!option || option.kind !== "psp" || !option.provider) {
30
+ return false;
31
+ }
32
+ if (!pspTokenizerFor(option.provider)) {
33
+ return false;
34
+ }
35
+ return Boolean(config.value?.providers[option.provider]?.publishableKey);
36
+ }
37
+
38
+ /** Mount the PSP's secure field into `el` for the chosen method. */
39
+ async function mount(el: HTMLElement, option: PaymentOption): Promise<void> {
40
+ destroy();
41
+ const factory = pspTokenizerFor(option.provider);
42
+ const pcfg = option.provider ? config.value?.providers[option.provider] : undefined;
43
+ if (!factory || !pcfg) {
44
+ return;
45
+ }
46
+ active = factory();
47
+ await active.mount({
48
+ publishableKey: pcfg.publishableKey,
49
+ testMode: pcfg.testMode,
50
+ el,
51
+ locale: locale.value,
52
+ });
53
+ }
54
+
55
+ /** True when a secure field is mounted and a token must be produced on submit. */
56
+ function hasActive(): boolean {
57
+ return active !== null;
58
+ }
59
+
60
+ /** Produce the single-use token from the mounted field (or null if none). */
61
+ async function tokenize(): Promise<{ token: string } | null> {
62
+ return active ? active.tokenize() : null;
63
+ }
64
+
65
+ function destroy(): void {
66
+ active?.destroy();
67
+ active = null;
68
+ }
69
+
70
+ return { config, requiresInstrument, mount, hasActive, tokenize, destroy };
71
+ }
@@ -16,4 +16,6 @@ export interface PaymentOption {
16
16
  feeType?: "none" | "fixed" | "percent";
17
17
  /** self_managed | psp — informational. */
18
18
  kind?: string;
19
+ /** PSP provider code (e.g. "stripe") for psp methods — drives the FE tokenizer. */
20
+ provider?: string;
19
21
  }
@@ -0,0 +1,19 @@
1
+ import { createStripeTokenizer } from "./stripe";
2
+ import type { PspTokenizerFactory } from "./types";
3
+
4
+ /**
5
+ * PSP tokenizer registry — the single extension point. A PSP that needs inline
6
+ * card collection gets a factory here; everything else (BFF token threading,
7
+ * the generic redirect/next_action handler, the order flow) is provider-
8
+ * agnostic. To add Novalnet/Mollie later: implement its tokenizer and add one
9
+ * line here.
10
+ */
11
+ export const pspTokenizers: Record<string, PspTokenizerFactory> = {
12
+ stripe: createStripeTokenizer,
13
+ // novalnet: createNovalnetTokenizer,
14
+ // mollie: createMollieTokenizer,
15
+ };
16
+
17
+ export function pspTokenizerFor(provider: string | undefined): PspTokenizerFactory | undefined {
18
+ return provider ? pspTokenizers[provider] : undefined;
19
+ }
@@ -0,0 +1,69 @@
1
+ import type { PspMountContext, PspTokenizer } from "./types";
2
+
3
+ /**
4
+ * Stripe tokenizer — loads Stripe.js (no npm dep; the CDN script is the
5
+ * supported integration) and mounts a Card Element. `tokenize()` creates a
6
+ * PaymentMethod (`pm_…`) client-side; only that id is sent to the BFF.
7
+ */
8
+
9
+ interface StripeCardElement { mount: (el: HTMLElement) => void; unmount: () => void }
10
+ interface StripeElements { create: (type: string, options?: unknown) => StripeCardElement }
11
+ interface StripeInstance {
12
+ elements: (options?: unknown) => StripeElements;
13
+ createPaymentMethod: (params: unknown) => Promise<{ paymentMethod?: { id: string }; error?: { message: string } }>;
14
+ }
15
+ type StripeCtor = (key: string) => StripeInstance;
16
+
17
+ const STRIPE_JS = "https://js.stripe.com/v3";
18
+
19
+ function loadStripeJs(): Promise<StripeCtor> {
20
+ const w = window as unknown as { Stripe?: StripeCtor };
21
+ if (w.Stripe) {
22
+ return Promise.resolve(w.Stripe);
23
+ }
24
+ return new Promise((resolve, reject) => {
25
+ const existing = document.querySelector<HTMLScriptElement>(`script[src="${STRIPE_JS}"]`);
26
+ const onload = () => w.Stripe ? resolve(w.Stripe) : reject(new Error("Stripe.js loaded but window.Stripe missing"));
27
+ if (existing) {
28
+ existing.addEventListener("load", onload, { once: true });
29
+ existing.addEventListener("error", () => reject(new Error("failed to load Stripe.js")), { once: true });
30
+ return;
31
+ }
32
+ const script = document.createElement("script");
33
+ script.src = STRIPE_JS;
34
+ script.async = true;
35
+ script.addEventListener("load", onload, { once: true });
36
+ script.addEventListener("error", () => reject(new Error("failed to load Stripe.js")), { once: true });
37
+ document.head.appendChild(script);
38
+ });
39
+ }
40
+
41
+ export function createStripeTokenizer(): PspTokenizer {
42
+ let stripe: StripeInstance | null = null;
43
+ let card: StripeCardElement | null = null;
44
+
45
+ return {
46
+ async mount(ctx: PspMountContext) {
47
+ const Stripe = await loadStripeJs();
48
+ stripe = Stripe(ctx.publishableKey);
49
+ const elements = stripe.elements({ locale: ctx.locale });
50
+ card = elements.create("card", { hidePostalCode: true });
51
+ card.mount(ctx.el);
52
+ },
53
+ async tokenize() {
54
+ if (!stripe || !card) {
55
+ throw new Error("card field is not ready");
56
+ }
57
+ const { paymentMethod, error } = await stripe.createPaymentMethod({ type: "card", card });
58
+ if (error || !paymentMethod) {
59
+ throw new Error(error?.message ?? "could not tokenize card");
60
+ }
61
+ return { token: paymentMethod.id };
62
+ },
63
+ destroy() {
64
+ card?.unmount();
65
+ card = null;
66
+ stripe = null;
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Per-PSP client-side tokenizer contract. The checkout collects card (or other
3
+ * sensitive instrument) data through the PSP's own secure field and turns it
4
+ * into a single-use token — the raw data never touches our servers (PCI-safe).
5
+ *
6
+ * One implementation per PSP that needs inline collection (Stripe → Elements).
7
+ * PSPs that only redirect need NO tokenizer — the generic next_action/redirect
8
+ * handler covers them. Add Novalnet/Mollie by adding a factory to the registry.
9
+ */
10
+ export interface PspMountContext {
11
+ /** Public/publishable key for the PSP (from /api/payment/psp-config). */
12
+ publishableKey: string;
13
+ testMode: boolean;
14
+ /** Element the PSP mounts its secure card field into. */
15
+ el: HTMLElement;
16
+ /** UI locale, e.g. "de" / "en". */
17
+ locale?: string;
18
+ }
19
+
20
+ export interface PspTokenizer {
21
+ /** Mount the secure field into `ctx.el`; resolves once it is ready. */
22
+ mount: (ctx: PspMountContext) => Promise<void>;
23
+ /** Produce a single-use token (e.g. Stripe `pm_…`) from the mounted field. */
24
+ tokenize: () => Promise<{ token: string }>;
25
+ /** Tear the field down (on unmount / method change). */
26
+ destroy: () => void;
27
+ }
28
+
29
+ export type PspTokenizerFactory = () => PspTokenizer;
@@ -126,6 +126,7 @@
126
126
  "poNumber": "Bestellnummer",
127
127
  "poNumberPlaceholder": "Bestellnummer eingeben...",
128
128
  "costCenter": "Kostenstelle",
129
+ "cardDetails": "Kartendaten",
129
130
  "change": "Ändern",
130
131
  "noMethod": "Keine Zahlungsmethode ausgewählt",
131
132
  "confirm": "Bestätigen",
@@ -126,6 +126,7 @@
126
126
  "poNumber": "PO Number",
127
127
  "poNumberPlaceholder": "Enter PO number...",
128
128
  "costCenter": "Cost Center",
129
+ "cardDetails": "Card details",
129
130
  "change": "Change",
130
131
  "noMethod": "No payment method selected",
131
132
  "confirm": "Confirm",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/cover",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Cover \u2014 revenexx design system for Nuxt. Distributed as a Nuxt layer: generic UI components, theming tokens and stores shared by the demo shop, custom storefronts and the Blokkli theme.",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -32,6 +32,10 @@ interface OrderPayload {
32
32
  partialDelivery?: boolean;
33
33
  orderNote?: string;
34
34
  promoCode?: string;
35
+ /** PSP single-use token from the checkout's client-side tokenizer (e.g. Stripe pm_…). */
36
+ paymentInstrument?: { token?: string };
37
+ /** Where the PSP returns the buyer after a redirect/3DS step-up. */
38
+ returnUrl?: string;
35
39
  }
36
40
 
37
41
  function isNonEmptyString(v: unknown): v is string {
@@ -310,6 +314,9 @@ export default defineEventHandler(async (event) => {
310
314
  // again; a declined payment fails the order with 402 (and cancels
311
315
  // the already placed live order).
312
316
  let livePaymentStatus: string | null = null;
317
+ // Set when the PSP needs buyer action (3DS step-up / redirect PSPs) —
318
+ // the checkout hands the buyer to redirectUrl and confirms on return.
319
+ let pendingAction: { paymentId: string; redirectUrl: string } | null = null;
313
320
  if (livePayments) {
314
321
  const totalRow = calculation.totals.find(row => row.key === "total");
315
322
  const address = body.address as Record<string, unknown> | undefined;
@@ -317,23 +324,39 @@ export default defineEventHandler(async (event) => {
317
324
  ? address?.billing as Record<string, unknown> | undefined
318
325
  : address;
319
326
  const country = toIsoCountry(body.billingCountry ?? billing?.country ?? "");
327
+ // The instrument (PSP token from the checkout's client-side tokenizer)
328
+ // is forwarded as-is; paymentsCreate's typed params drop unknown
329
+ // fields, so the create goes through the low-level transport.
330
+ const instrument = body.paymentInstrument as { token?: string } | undefined;
320
331
  try {
321
- const created = await useRevenexxSdk().payments.paymentsCreate({
322
- methodCode: payment.method,
323
- // Charge exactly what the orders app computed (incl. shipping tax).
324
- amount: Math.max(0, Math.round((liveOrderTotal ?? totalRow?.amount ?? 0) * 100) / 100),
325
- currency: selectedCurrency ?? calculation.currency,
326
- country,
327
- orderRef: orderId,
328
- idempotencyKey: body.checkoutSessionToken,
329
- metadata: {
330
- ...(payment.poNumber ? { po_number: payment.poNumber } : {}),
331
- ...(payment.costCenter ? { cost_center: payment.costCenter } : {}),
332
+ const created = await useRevenexxSdk().call<{ id: string; status: string; error_message?: string | null; next_action?: { type?: string; url?: string } | null }>(
333
+ "POST",
334
+ "/v1/payments",
335
+ {
336
+ body: {
337
+ method_code: payment.method,
338
+ // Charge exactly what the orders app computed (incl. shipping tax).
339
+ amount: Math.max(0, Math.round((liveOrderTotal ?? totalRow?.amount ?? 0) * 100) / 100),
340
+ currency: selectedCurrency ?? calculation.currency,
341
+ country,
342
+ order_ref: orderId,
343
+ idempotency_key: body.checkoutSessionToken,
344
+ ...(instrument?.token ? { token: instrument.token } : {}),
345
+ ...(body.returnUrl ? { return_url: body.returnUrl } : {}),
346
+ metadata: {
347
+ ...(payment.poNumber ? { po_number: payment.poNumber } : {}),
348
+ ...(payment.costCenter ? { cost_center: payment.costCenter } : {}),
349
+ },
350
+ },
332
351
  },
333
- }) as unknown as { id: string; status: string; error_message?: string | null };
352
+ );
334
353
  if (created.status === "failed") {
335
354
  throw createError({ status: 402, message: created.error_message ?? "Payment was declined" });
336
355
  }
356
+ if (created.status === "requires_action" && created.next_action?.url) {
357
+ // Order stays placed (payment pending); buyer completes at the PSP.
358
+ pendingAction = { paymentId: created.id, redirectUrl: created.next_action.url };
359
+ }
337
360
  livePaymentStatus = created.status;
338
361
  }
339
362
  catch (err) {
@@ -359,8 +382,10 @@ export default defineEventHandler(async (event) => {
359
382
  }
360
383
 
361
384
  // The payment dimension of the live order follows the payment outcome.
385
+ // The payments app's terminal "paid" state is `captured` (PSP auto/
386
+ // manual capture); `succeeded`/`paid` are accepted as synonyms.
362
387
  if (liveOrderUuid && livePaymentStatus) {
363
- const mapped = livePaymentStatus === "succeeded" || livePaymentStatus === "paid"
388
+ const mapped = ["captured", "succeeded", "paid"].includes(livePaymentStatus)
364
389
  ? OrderPaymentStatus.Paid
365
390
  : livePaymentStatus === "authorized" ? OrderPaymentStatus.Authorized : OrderPaymentStatus.Pending;
366
391
  try {
@@ -437,6 +462,20 @@ export default defineEventHandler(async (event) => {
437
462
  }
438
463
  }
439
464
 
465
+ // PSP needs buyer action (3DS / redirect PSPs): hand the redirect to the
466
+ // checkout. The order is placed; the payment finalizes on return.
467
+ if (pendingAction) {
468
+ return {
469
+ orderId,
470
+ status: "requires-action",
471
+ requiresAction: true,
472
+ redirectUrl: pendingAction.redirectUrl,
473
+ paymentId: pendingAction.paymentId,
474
+ createdAt: new Date().toISOString(),
475
+ payment,
476
+ };
477
+ }
478
+
440
479
  return {
441
480
  orderId,
442
481
  status: blockingApprovals.length ? "approval-pending" : "confirmed",
@@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => {
51
51
  fee: m.fee,
52
52
  feeType: m.fee_type,
53
53
  kind: m.kind,
54
+ ...(m.provider ? { provider: m.provider } : {}),
54
55
  }));
55
56
 
56
57
  return {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Public PSP config for the checkout's client-side tokenizers — ONLY the
3
+ * publishable/public keys of enabled PSP providers, never the secret keys.
4
+ * The per-PSP frontend registry (see app/payments/psp) reads this to know
5
+ * which provider needs client-side tokenization and with which public key.
6
+ *
7
+ * Shape: { providers: { stripe: { publishableKey, testMode }, … } }
8
+ * Adding Novalnet/Mollie later = they appear here automatically once enabled
9
+ * with a publishable key on the provider; the FE registry gets a new entry.
10
+ */
11
+ interface ApiProviderRow {
12
+ provider: string;
13
+ enabled: boolean;
14
+ test_mode: boolean;
15
+ credentials: Record<string, unknown> | null;
16
+ }
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ if (resolvePaymentServiceKey(event) !== "api") {
20
+ return { providers: {} as Record<string, { publishableKey: string; testMode: boolean }> };
21
+ }
22
+ try {
23
+ const { items } = await useRevenexxSdk().call<{ items: ApiProviderRow[] }>("GET", "/v1/payments/providers");
24
+ const providers: Record<string, { publishableKey: string; testMode: boolean }> = {};
25
+ for (const row of items) {
26
+ if (!row.enabled) {
27
+ continue;
28
+ }
29
+ const pk = row.credentials?.publishable_key;
30
+ if (typeof pk === "string" && pk.length > 0) {
31
+ providers[row.provider] = { publishableKey: pk, testMode: row.test_mode !== false };
32
+ }
33
+ }
34
+ return { providers };
35
+ }
36
+ catch (err) {
37
+ getLogService().error("Service error: payment/psp-config", apiErrorContext(err));
38
+ return { providers: {} as Record<string, { publishableKey: string; testMode: boolean }> };
39
+ }
40
+ });