flintn-checkout 0.0.17 → 0.0.19

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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ npm install flintn-checkout
10
10
 
11
11
  ## Iframe Checkout
12
12
 
13
- Full checkout UI rendered inside a single iframe. Includes card form, express payments (Apple Pay, PayPal), and 3DS handling.
13
+ Full checkout UI rendered inside a single iframe. Includes card form, express payments (Apple Pay, Google Pay, PayPal), and 3DS handling.
14
14
 
15
15
  **React**
16
16
 
@@ -108,6 +108,92 @@ payment.mount('#payment-container');
108
108
  </html>
109
109
  ```
110
110
 
111
+ ### External express buttons (`expressButtons: false`)
112
+
113
+ By default the iframe renders express buttons (Apple Pay / Google Pay /
114
+ PayPal) inside the checkout form. Set `config.expressButtons: false` to keep
115
+ the iframe card-form only and render the wallet buttons directly in your own
116
+ page — full control over placement and layout, and wallet payment sheets open
117
+ from the top-level page instead of inside an iframe.
118
+
119
+ **React** — the hook mounts the buttons for you and merges wallet results
120
+ into the same `onPayment` / `onEvent` callbacks as the card form:
121
+
122
+ ```tsx
123
+ import { useFlintNPayment } from 'flintn-checkout/react';
124
+
125
+ function Checkout() {
126
+ const {
127
+ containerRef,
128
+ applePayRef,
129
+ googlePayRef,
130
+ payPalRef,
131
+ expressAvailable,
132
+ paymentResult,
133
+ } = useFlintNPayment({
134
+ config: {
135
+ clientSessionId: 'your_client_session_id',
136
+ expressButtons: false,
137
+ },
138
+ // Single callback for wallets AND the card form
139
+ onPayment: (result) => console.log('Payment:', result),
140
+ // Single per-attempt stream for wallets AND the card form
141
+ onEvent: (event) => console.log('Event:', event),
142
+ });
143
+
144
+ return (
145
+ <div style={{ maxWidth: 440 }}>
146
+ {/* Your layout, your styling. Hide with CSS while unavailable —
147
+ do not conditionally unmount the refs. */}
148
+ <div style={{ display: expressAvailable ? 'block' : 'none' }}>
149
+ <div ref={applePayRef} />
150
+ <div ref={googlePayRef} />
151
+ <div ref={payPalRef} />
152
+ <hr />
153
+ </div>
154
+
155
+ {/* Card form iframe — no express buttons inside */}
156
+ <div ref={containerRef} style={{ width: '100%' }} />
157
+ </div>
158
+ );
159
+ }
160
+ ```
161
+
162
+ **Vanilla JavaScript** — pair `createFlintNPayment` with the standalone
163
+ express module:
164
+
165
+ ```javascript
166
+ import { createFlintNPayment, createFlintNExpressButtons } from 'flintn-checkout';
167
+
168
+ const payment = createFlintNPayment({
169
+ config: { clientSessionId: 'your_client_session_id', expressButtons: false },
170
+ onPayment: (result) => console.log('Card payment:', result),
171
+ });
172
+ payment.mount('#payment-container');
173
+
174
+ const express = createFlintNExpressButtons({
175
+ config: { clientSessionId: 'your_client_session_id' },
176
+ onPayment: (result) => console.log('Wallet payment:', result),
177
+ onCapability: (cap) => {
178
+ // Show/hide your express section based on device availability
179
+ document.getElementById('express-row').hidden =
180
+ !(cap.applePay || cap.googlePay || cap.paypal);
181
+ },
182
+ });
183
+ express.mountExpressButtons([
184
+ { method: 'APPLE_PAY', elementSelector: '#apple-pay' },
185
+ { method: 'GOOGLE_PAY', elementSelector: '#google-pay' },
186
+ { method: 'PAYPAL', elementSelector: '#paypal' },
187
+ ]);
188
+ ```
189
+
190
+ Notes:
191
+
192
+ - `formFields.formVariant` `LIST`/`RADIO` are ignored with
193
+ `expressButtons: false` — the express/card switch lives in your page now.
194
+ - After a successful wallet payment, disable or hide the card form — the
195
+ session is completed and further card submits will fail.
196
+
111
197
  ## Events — terminal vs per-attempt
112
198
 
113
199
  The SDK fires two streams of events. Use the one that fits your use case:
@@ -166,7 +252,7 @@ For UI events (LIST and RADIO variants), `view` is the view that became active:
166
252
 
167
253
  ```
168
254
  onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
169
- onEvent → { event: 'SOFT_DECLINE', method: 'PAYMENT_CARD', code: 'do_not_honor', message: 'Payment declined.' }
255
+ onEvent → { event: 'SOFT_DECLINE', method: 'PAYMENT_CARD', code: 'FNT-EC-2003', message: 'Insufficient funds.' }
170
256
  // buyer retries with another card
171
257
  onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
172
258
  onEvent → { event: 'AUTHORIZED', method: 'PAYMENT_CARD', paymentId: 'pay_...' }
@@ -246,6 +332,7 @@ Do **not** set a fixed `height` on the container — it will leave empty space b
246
332
  | `styles` | `FormStyles` | No | Custom styles (colors, border radius, text) for the checkout form |
247
333
  | `placeholders` | `FormPlaceholders` | No | Per-field placeholder text overrides (see `FormPlaceholders`) |
248
334
  | `successRedirectUrl` | `string` | No | Redirect URL after successful payment |
335
+ | `expressButtons` | `boolean` | No | Default `true` — express buttons (Apple Pay / Google Pay / PayPal) render inside the iframe form. Set `false` to render the card form only and mount express buttons in your own page via `createFlintNExpressButtons`; `formFields.formVariant` `LIST`/`RADIO` are ignored in that case |
249
336
 
250
337
  ### React Hook Return Values
251
338
 
@@ -253,9 +340,13 @@ Do **not** set a fixed `height` on the container — it will leave empty space b
253
340
  |-------|------|-------------|
254
341
  | `containerRef` | `RefObject<HTMLDivElement>` | Ref to attach to container element |
255
342
  | `isReady` | `boolean` | Widget is loaded and ready |
256
- | `paymentResult` | `PaymentResult \| null` | Result after terminal `onPayment` event |
343
+ | `paymentResult` | `PaymentResult \| null` | Result after terminal `onPayment` event (card or wallet) |
257
344
  | `events` | `CheckoutEvent[]` | Per-attempt event log (accumulated since mount) |
258
345
  | `error` | `PaymentError \| null` | SDK error if any |
346
+ | `applePayRef` | `RefObject<HTMLDivElement>` | Apple Pay mount point — used with `expressButtons: false` |
347
+ | `googlePayRef` | `RefObject<HTMLDivElement>` | Google Pay mount point — used with `expressButtons: false` |
348
+ | `payPalRef` | `RefObject<HTMLDivElement>` | PayPal mount point — used with `expressButtons: false` |
349
+ | `expressAvailable` | `boolean` | At least one wallet is available on this device/session (`expressButtons: false` only) |
259
350
 
260
351
  ---
261
352
 
@@ -270,7 +361,7 @@ When a card payment requires 3DS, the SDK shows the bank's challenge in a
270
361
  `THREE_DS_CANCELLED` payment error. While the challenge is open, the
271
362
  `submit()` promise stays pending with a 10-minute timeout.
272
363
 
273
- > **Note:** For card fields, Hosted Fields emits only the terminal `onPayment` event. The express buttons additionally emit per-attempt wallet analytics via the top-level `onEvent` callback (same `onPayment` / `onEvent` split as Iframe Checkout) see "Express buttons" below. For full per-attempt analytics across the card form too, use Iframe Checkout.
364
+ > **Note:** Hosted Fields emit the same per-attempt analytics as Iframe Checkout — the card flow and the express buttons both report through the top-level `onEvent` callback (`method: 'PAYMENT_CARD'` for the card form), while terminal results arrive via `onPayment`. See "Events terminal vs per-attempt" above; UI events (`FORM_ENTERED`/`FORM_EXITED`) do not apply since your page owns the layout.
274
365
 
275
366
  **React**
276
367
 
@@ -409,6 +500,8 @@ if (validation.isValid) {
409
500
  | `onChange` | `(event: FieldChangeEvent) => void` | No | — | Field value changed |
410
501
  | `onFocus` | `(fieldType: TFieldType) => void` | No | — | Field gained focus |
411
502
  | `onBlur` | `(fieldType: TFieldType, state: FieldState) => void` | No | — | Field lost focus |
503
+ | `onEvent` | `(event: AttemptEvent) => void` | No | — | Per-attempt analytics for the card flow (`method: 'PAYMENT_CARD'`) |
504
+ | `onPostalCodeRequirement` | `(required: boolean) => void` | No | — | Entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
412
505
  | `onError` | `(error: PaymentError) => void` | No | — | SDK initialization error |
413
506
  | `debug` | `boolean` | No | `false` | Enable console debug logs |
414
507
 
@@ -433,7 +526,7 @@ Additional React-only options:
433
526
  | `onChange` | `(event: FieldChangeEvent) => void` | Optional additional callback |
434
527
  | `onFocus` | `(fieldType: TFieldType) => void` | Optional additional callback |
435
528
  | `onBlur` | `(fieldType: TFieldType, state: FieldState) => void` | Optional additional callback |
436
- | `onEvent` | `(event: ExpressAttempt) => void` | Per-attempt wallet analytics from the express buttons (see "Express buttons") |
529
+ | `onEvent` | `(event: AttemptEvent) => void` | Per-attempt analytics card flow (`method: 'PAYMENT_CARD'`) and express buttons share this stream |
437
530
 
438
531
  ### FieldOptions
439
532
 
@@ -457,6 +550,7 @@ Additional React-only options:
457
550
  | `fieldErrors` | `Partial<Record<TFieldType, string \| null>>` | Per-field validation errors (populated after first submit) |
458
551
  | `fieldStates` | `Partial<Record<TFieldType, FieldState>>` | Per-field state (always up to date) |
459
552
  | `cardBrand` | `string \| null` | Detected card brand (e.g. `"visa"`, `"mastercard"`) |
553
+ | `postalCodeRequired` | `boolean` | The entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
460
554
  | `paymentResult` | `PaymentResult \| null` | Result after payment attempt |
461
555
  | `error` | `PaymentError \| null` | SDK error if any |
462
556
  | `validate` | `() => Promise<FieldsValidationResult>` | Validate all fields |
@@ -475,6 +569,29 @@ Validation mirrors react-hook-form `mode: 'onSubmit'`:
475
569
  - All three fields (card number, expiry, CVV) are required — submitting with a missing field returns a `MISSING_FIELDS` error
476
570
  - The CVV field ships a built-in helper: tapping the card icon expands a “where to find CVV” panel below the input. The field iframe grows to fit it, so keep the CVV container at a fixed `height` with `position: relative; z-index: 1` — the open panel then overlays the content below (like a popover) instead of pushing your layout
477
571
 
572
+ ### Postal code (BIN-driven)
573
+
574
+ Some issuers require a postal code for online payments (currently US-issued
575
+ cards — the requirement is resolved server-side by BIN). Once the buyer has
576
+ typed enough digits, the card-number field checks the BIN against the payments
577
+ API and reports whether a postal code is required — `postalCodeRequired`
578
+ (React) / `onPostalCodeRequirement` (vanilla). Show your own postal code input
579
+ when it flips to `true` and pass the value at submit:
580
+
581
+ ```tsx
582
+ const { postalCodeRequired, submit } = useFlintNFields({ ... });
583
+
584
+ // render your postal input when postalCodeRequired === true, then:
585
+ await submit({
586
+ cardholderName,
587
+ billingAddress: { postalCode },
588
+ });
589
+ ```
590
+
591
+ If the BIN requires a postal code and `billingAddress.postalCode` is missing,
592
+ `submit()` resolves with `PAYMENT_ERROR` / `POSTAL_CODE_REQUIRED` without
593
+ calling the payments API.
594
+
478
595
  ### Field Types
479
596
 
480
597
  | Value | Description |
@@ -617,6 +734,25 @@ interface PaymentError {
617
734
  }
618
735
  ```
619
736
 
737
+ ### SubmitOptions (Hosted Fields)
738
+
739
+ ```typescript
740
+ interface SubmitOptions {
741
+ // Required by default — the API rejects card payments without it unless the
742
+ // merchant disabled the cardholder name field in the client session request.
743
+ cardholderName?: string;
744
+ email?: string;
745
+ billingAddress?: {
746
+ addressLine1?: string;
747
+ addressLine2?: string;
748
+ city?: string;
749
+ state?: string;
750
+ postalCode?: string; // required when the BIN demands it — see "Postal code (BIN-driven)"
751
+ country?: string;
752
+ };
753
+ }
754
+ ```
755
+
620
756
  ### Error handling
621
757
 
622
758
  Errors surface through three separate channels — don't conflate them:
@@ -637,9 +773,11 @@ Common `code` values:
637
773
  | `CONFIG_ERROR` | error | Session config failed to load and the API gave no code |
638
774
  | `NETWORK_ERROR` / `HTTP_<status>` / `INVALID_RESPONSE` | error or payment | Network failure / HTTP error without an API code / malformed response |
639
775
  | `VALIDATION_ERROR` | payment | `submit()` blocked by field validation (details in `fieldErrors`) |
776
+ | `POSTAL_CODE_REQUIRED` | payment | The card BIN requires a postal code and `billingAddress.postalCode` was not passed to `submit()` |
640
777
  | `MISSING_FIELDS` / `NOT_INITIALIZED` / `SUBMIT_IN_PROGRESS` / `SUBMIT_TIMEOUT` | payment | Card `submit()` preconditions / duplicate call / no response in time (30s) |
641
778
  | `THREE_DS_TIMEOUT` | payment | A 3DS challenge stayed unresolved for 10 minutes |
642
- | `PAYMENT_DECLINED` / processor decline codes | payment | Declined; the processor's own code is passed through when present |
779
+ | `FNT-EC-*` decline codes | payment | Declined the FNT-EC decline code identifies the reason (e.g. `FNT-EC-2003` insufficient funds). Sent for both immediate and post-3DS declines |
780
+ | `PAYMENT_DECLINED` | payment | Declined without a specific decline code (fallback) |
643
781
  | `PAYMENT_FAILED` | payment | Non-declined failure (including unexpected 3DS outcomes) |
644
782
  | `APPLE_PAY_SDK_ERROR` / `GOOGLE_PAY_SDK_ERROR` / `PAYPAL_ERROR` | payment | The wallet SDK failed mid-flow |
645
783
  | `PAYPAL_MISSING_PAYMENT_ID` | payment | PayPal approved but no payment id was captured (anomaly) |
@@ -870,7 +1008,10 @@ on `createField` (or the `fields` prop of `useFlintNFields`) instead.
870
1008
 
871
1009
  ## Form Variants
872
1010
 
873
- The iframe checkout supports three layout variants via `formFields.formVariant`:
1011
+ The iframe checkout supports three layout variants via `formFields.formVariant`.
1012
+ Variants only apply when express buttons render inside the iframe — with
1013
+ `expressButtons: false` the variant is ignored and the iframe always shows the
1014
+ plain card form.
874
1015
 
875
1016
  - **DEFAULT** — Express payment methods shown above the card form with dividers.
876
1017
  - **LIST** — Express payment methods shown first; users tap a "Credit or Debit Card" button to navigate to the card form, with a back arrow to return to the express view. Toggling emits `FORM_ENTERED` / `FORM_EXITED` UI events.
package/dist/index.d.mts CHANGED
@@ -81,6 +81,14 @@ interface FlintNConfig {
81
81
  styles?: FormStyles;
82
82
  placeholders?: FormPlaceholders;
83
83
  successRedirectUrl?: string;
84
+ /**
85
+ * Whether the iframe renders express buttons (Apple Pay / Google Pay /
86
+ * PayPal) inside the checkout form. Default true. Set false to render the
87
+ * card form only and mount express buttons in your own page via
88
+ * `createFlintNExpressButtons` — note `formFields.formVariant` LIST/RADIO
89
+ * are ignored in that case.
90
+ */
91
+ expressButtons?: boolean;
84
92
  }
85
93
  declare const PaymentMethod: {
86
94
  readonly PAYMENT_CARD: "PAYMENT_CARD";
@@ -173,6 +181,8 @@ declare const FieldEventType: {
173
181
  readonly FIELD_HEIGHT: "FIELD_HEIGHT";
174
182
  readonly FIELD_THREE_DS_REQUIRED: "FIELD_THREE_DS_REQUIRED";
175
183
  readonly FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT";
184
+ readonly FIELD_PAYMENT_ATTEMPT: "FIELD_PAYMENT_ATTEMPT";
185
+ readonly FIELD_POSTAL_CODE_REQUIREMENT: "FIELD_POSTAL_CODE_REQUIREMENT";
176
186
  };
177
187
  type TFieldEventType = (typeof FieldEventType)[keyof typeof FieldEventType];
178
188
  interface FieldOptions {
@@ -220,7 +230,7 @@ interface ExpressCapability {
220
230
  googlePay: boolean;
221
231
  paypal: boolean;
222
232
  }
223
- interface ExpressAttempt {
233
+ interface AttemptEvent {
224
234
  event: TPaymentAttemptResult;
225
235
  method: TPaymentMethod;
226
236
  paymentId?: string;
@@ -240,6 +250,15 @@ interface FlintNFieldsOptions {
240
250
  onChange?: (event: FieldChangeEvent) => void;
241
251
  onFocus?: (fieldType: TFieldType) => void;
242
252
  onBlur?: (fieldType: TFieldType, state: FieldState) => void;
253
+ /** Per-attempt analytics for the card flow (express buttons have their own). */
254
+ onEvent?: (event: AttemptEvent) => void;
255
+ /**
256
+ * The entered card BIN requires (or stops requiring) a postal code. Show
257
+ * your own postal field and pass its value via submit's
258
+ * `billingAddress.postalCode` — submit fails with POSTAL_CODE_REQUIRED
259
+ * otherwise.
260
+ */
261
+ onPostalCodeRequirement?: (required: boolean) => void;
243
262
  onError?: (error: PaymentError) => void;
244
263
  debug?: boolean;
245
264
  }
@@ -293,7 +312,7 @@ interface FlintNExpressButtonsOptions {
293
312
  apiUrl?: string;
294
313
  buttonBorderRadius?: number;
295
314
  onPayment?: (result: PaymentResult) => void;
296
- onEvent?: (event: ExpressAttempt) => void;
315
+ onEvent?: (event: AttemptEvent) => void;
297
316
  onCapability?: (capability: ExpressCapability) => void;
298
317
  onError?: (error: PaymentError) => void;
299
318
  debug?: boolean;
@@ -349,8 +368,11 @@ interface AuthorizeResponse {
349
368
  };
350
369
  status_reason?: {
351
370
  decline_reason?: string;
371
+ decline_code?: string;
372
+ decline_type?: 'SOFT_DECLINE' | 'HARD_DECLINE';
352
373
  message?: string;
353
374
  };
375
+ session_retryable?: boolean;
354
376
  }
355
377
  interface ApplePayToken {
356
378
  payment_data: {
@@ -377,7 +399,9 @@ interface GooglePayToken {
377
399
  interface WalletAuthorizeHandlers {
378
400
  onAuthorized: (paymentId: string) => void;
379
401
  onThreeDs: (redirectUrl: string, paymentId: string) => void;
380
- onFailed: (code: string, message: string, paymentId?: string) => void;
402
+ onFailed: (code: string, message: string, paymentId?: string,
403
+ /** Attempt analytics classification; defaults to HARD_DECLINE. */
404
+ event?: TPaymentAttemptResult) => void;
381
405
  }
382
406
 
383
407
  /**
@@ -425,4 +449,4 @@ declare const validateConfig: (config: {
425
449
  }) => void;
426
450
  declare const sanitizeToken: (token: string) => string;
427
451
 
428
- export { type ApplePayConfig, type ApplePayToken, type AuthorizeResponse, AuthorizedStatus, type CheckoutEvent, CheckoutFormVariant, CheckoutView, EXPRESS_BUTTON_HEIGHT, EventType, type ExpressAttempt, type ExpressButtonMount, type ExpressButtonsOptions, type ExpressCapability, ExpressMethod, type FieldChangeEvent, FieldEventType, type FieldOptions, type FieldState, FieldType, type FieldValidationResult, type FieldsValidationResult, type FlintNConfig, type FlintNExpressButtons, type FlintNExpressButtonsOptions, type FlintNField, type FlintNFieldInternalCallbacks, type FlintNFields, type FlintNFieldsConfig, type FlintNFieldsOptions, type FlintNPayment, type FlintNPaymentOptions, type FormFields, type FormPlaceholders, type FormStyles, type GooglePayConfig, type GooglePayToken, type PayPalConfig, type PaymentAttempt, PaymentAttemptResult, type PaymentError, PaymentMethod, type PaymentResult, type PaymentUI, PaymentUIEvent, type SessionConfig, type SubmitBillingAddress, type SubmitOptions, type TAuthorizedStatus, type TCheckoutFormVariant, type TCheckoutView, type TEventType, type TExpressMethod, type TFieldEventType, type TFieldType, type TPaymentAttemptResult, type TPaymentMethod, type TPaymentUIEvent, type WalletAuthorizeHandlers, buildIframeSrc, createFlintNExpressButtons, createFlintNField, createFlintNFields, createFlintNPayment, createIframeElement, deriveApiUrl, isAttemptEvent, isUIEvent, parseOrigin, sanitizeToken, validateConfig };
452
+ export { type ApplePayConfig, type ApplePayToken, type AttemptEvent, type AuthorizeResponse, AuthorizedStatus, type CheckoutEvent, CheckoutFormVariant, CheckoutView, EXPRESS_BUTTON_HEIGHT, EventType, type ExpressButtonMount, type ExpressButtonsOptions, type ExpressCapability, ExpressMethod, type FieldChangeEvent, FieldEventType, type FieldOptions, type FieldState, FieldType, type FieldValidationResult, type FieldsValidationResult, type FlintNConfig, type FlintNExpressButtons, type FlintNExpressButtonsOptions, type FlintNField, type FlintNFieldInternalCallbacks, type FlintNFields, type FlintNFieldsConfig, type FlintNFieldsOptions, type FlintNPayment, type FlintNPaymentOptions, type FormFields, type FormPlaceholders, type FormStyles, type GooglePayConfig, type GooglePayToken, type PayPalConfig, type PaymentAttempt, PaymentAttemptResult, type PaymentError, PaymentMethod, type PaymentResult, type PaymentUI, PaymentUIEvent, type SessionConfig, type SubmitBillingAddress, type SubmitOptions, type TAuthorizedStatus, type TCheckoutFormVariant, type TCheckoutView, type TEventType, type TExpressMethod, type TFieldEventType, type TFieldType, type TPaymentAttemptResult, type TPaymentMethod, type TPaymentUIEvent, type WalletAuthorizeHandlers, buildIframeSrc, createFlintNExpressButtons, createFlintNField, createFlintNFields, createFlintNPayment, createIframeElement, deriveApiUrl, isAttemptEvent, isUIEvent, parseOrigin, sanitizeToken, validateConfig };
package/dist/index.d.ts CHANGED
@@ -81,6 +81,14 @@ interface FlintNConfig {
81
81
  styles?: FormStyles;
82
82
  placeholders?: FormPlaceholders;
83
83
  successRedirectUrl?: string;
84
+ /**
85
+ * Whether the iframe renders express buttons (Apple Pay / Google Pay /
86
+ * PayPal) inside the checkout form. Default true. Set false to render the
87
+ * card form only and mount express buttons in your own page via
88
+ * `createFlintNExpressButtons` — note `formFields.formVariant` LIST/RADIO
89
+ * are ignored in that case.
90
+ */
91
+ expressButtons?: boolean;
84
92
  }
85
93
  declare const PaymentMethod: {
86
94
  readonly PAYMENT_CARD: "PAYMENT_CARD";
@@ -173,6 +181,8 @@ declare const FieldEventType: {
173
181
  readonly FIELD_HEIGHT: "FIELD_HEIGHT";
174
182
  readonly FIELD_THREE_DS_REQUIRED: "FIELD_THREE_DS_REQUIRED";
175
183
  readonly FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT";
184
+ readonly FIELD_PAYMENT_ATTEMPT: "FIELD_PAYMENT_ATTEMPT";
185
+ readonly FIELD_POSTAL_CODE_REQUIREMENT: "FIELD_POSTAL_CODE_REQUIREMENT";
176
186
  };
177
187
  type TFieldEventType = (typeof FieldEventType)[keyof typeof FieldEventType];
178
188
  interface FieldOptions {
@@ -220,7 +230,7 @@ interface ExpressCapability {
220
230
  googlePay: boolean;
221
231
  paypal: boolean;
222
232
  }
223
- interface ExpressAttempt {
233
+ interface AttemptEvent {
224
234
  event: TPaymentAttemptResult;
225
235
  method: TPaymentMethod;
226
236
  paymentId?: string;
@@ -240,6 +250,15 @@ interface FlintNFieldsOptions {
240
250
  onChange?: (event: FieldChangeEvent) => void;
241
251
  onFocus?: (fieldType: TFieldType) => void;
242
252
  onBlur?: (fieldType: TFieldType, state: FieldState) => void;
253
+ /** Per-attempt analytics for the card flow (express buttons have their own). */
254
+ onEvent?: (event: AttemptEvent) => void;
255
+ /**
256
+ * The entered card BIN requires (or stops requiring) a postal code. Show
257
+ * your own postal field and pass its value via submit's
258
+ * `billingAddress.postalCode` — submit fails with POSTAL_CODE_REQUIRED
259
+ * otherwise.
260
+ */
261
+ onPostalCodeRequirement?: (required: boolean) => void;
243
262
  onError?: (error: PaymentError) => void;
244
263
  debug?: boolean;
245
264
  }
@@ -293,7 +312,7 @@ interface FlintNExpressButtonsOptions {
293
312
  apiUrl?: string;
294
313
  buttonBorderRadius?: number;
295
314
  onPayment?: (result: PaymentResult) => void;
296
- onEvent?: (event: ExpressAttempt) => void;
315
+ onEvent?: (event: AttemptEvent) => void;
297
316
  onCapability?: (capability: ExpressCapability) => void;
298
317
  onError?: (error: PaymentError) => void;
299
318
  debug?: boolean;
@@ -349,8 +368,11 @@ interface AuthorizeResponse {
349
368
  };
350
369
  status_reason?: {
351
370
  decline_reason?: string;
371
+ decline_code?: string;
372
+ decline_type?: 'SOFT_DECLINE' | 'HARD_DECLINE';
352
373
  message?: string;
353
374
  };
375
+ session_retryable?: boolean;
354
376
  }
355
377
  interface ApplePayToken {
356
378
  payment_data: {
@@ -377,7 +399,9 @@ interface GooglePayToken {
377
399
  interface WalletAuthorizeHandlers {
378
400
  onAuthorized: (paymentId: string) => void;
379
401
  onThreeDs: (redirectUrl: string, paymentId: string) => void;
380
- onFailed: (code: string, message: string, paymentId?: string) => void;
402
+ onFailed: (code: string, message: string, paymentId?: string,
403
+ /** Attempt analytics classification; defaults to HARD_DECLINE. */
404
+ event?: TPaymentAttemptResult) => void;
381
405
  }
382
406
 
383
407
  /**
@@ -425,4 +449,4 @@ declare const validateConfig: (config: {
425
449
  }) => void;
426
450
  declare const sanitizeToken: (token: string) => string;
427
451
 
428
- export { type ApplePayConfig, type ApplePayToken, type AuthorizeResponse, AuthorizedStatus, type CheckoutEvent, CheckoutFormVariant, CheckoutView, EXPRESS_BUTTON_HEIGHT, EventType, type ExpressAttempt, type ExpressButtonMount, type ExpressButtonsOptions, type ExpressCapability, ExpressMethod, type FieldChangeEvent, FieldEventType, type FieldOptions, type FieldState, FieldType, type FieldValidationResult, type FieldsValidationResult, type FlintNConfig, type FlintNExpressButtons, type FlintNExpressButtonsOptions, type FlintNField, type FlintNFieldInternalCallbacks, type FlintNFields, type FlintNFieldsConfig, type FlintNFieldsOptions, type FlintNPayment, type FlintNPaymentOptions, type FormFields, type FormPlaceholders, type FormStyles, type GooglePayConfig, type GooglePayToken, type PayPalConfig, type PaymentAttempt, PaymentAttemptResult, type PaymentError, PaymentMethod, type PaymentResult, type PaymentUI, PaymentUIEvent, type SessionConfig, type SubmitBillingAddress, type SubmitOptions, type TAuthorizedStatus, type TCheckoutFormVariant, type TCheckoutView, type TEventType, type TExpressMethod, type TFieldEventType, type TFieldType, type TPaymentAttemptResult, type TPaymentMethod, type TPaymentUIEvent, type WalletAuthorizeHandlers, buildIframeSrc, createFlintNExpressButtons, createFlintNField, createFlintNFields, createFlintNPayment, createIframeElement, deriveApiUrl, isAttemptEvent, isUIEvent, parseOrigin, sanitizeToken, validateConfig };
452
+ export { type ApplePayConfig, type ApplePayToken, type AttemptEvent, type AuthorizeResponse, AuthorizedStatus, type CheckoutEvent, CheckoutFormVariant, CheckoutView, EXPRESS_BUTTON_HEIGHT, EventType, type ExpressButtonMount, type ExpressButtonsOptions, type ExpressCapability, ExpressMethod, type FieldChangeEvent, FieldEventType, type FieldOptions, type FieldState, FieldType, type FieldValidationResult, type FieldsValidationResult, type FlintNConfig, type FlintNExpressButtons, type FlintNExpressButtonsOptions, type FlintNField, type FlintNFieldInternalCallbacks, type FlintNFields, type FlintNFieldsConfig, type FlintNFieldsOptions, type FlintNPayment, type FlintNPaymentOptions, type FormFields, type FormPlaceholders, type FormStyles, type GooglePayConfig, type GooglePayToken, type PayPalConfig, type PaymentAttempt, PaymentAttemptResult, type PaymentError, PaymentMethod, type PaymentResult, type PaymentUI, PaymentUIEvent, type SessionConfig, type SubmitBillingAddress, type SubmitOptions, type TAuthorizedStatus, type TCheckoutFormVariant, type TCheckoutView, type TEventType, type TExpressMethod, type TFieldEventType, type TFieldType, type TPaymentAttemptResult, type TPaymentMethod, type TPaymentUIEvent, type WalletAuthorizeHandlers, buildIframeSrc, createFlintNExpressButtons, createFlintNField, createFlintNFields, createFlintNPayment, createIframeElement, deriveApiUrl, isAttemptEvent, isUIEvent, parseOrigin, sanitizeToken, validateConfig };
package/dist/index.js CHANGED
@@ -113,7 +113,14 @@ var FieldEventType = {
113
113
  // modal (the field iframe is too small, and popups get blocked).
114
114
  FIELD_THREE_DS_REQUIRED: "FIELD_THREE_DS_REQUIRED",
115
115
  // SDK reports the challenge outcome back into the card iframe.
116
- FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT"
116
+ FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT",
117
+ // Card iframe reports attempt analytics (INITIATED / declines / …) which
118
+ // the SDK forwards to the merchant's onEvent — same stream the direct
119
+ // iframe checkout emits.
120
+ FIELD_PAYMENT_ATTEMPT: "FIELD_PAYMENT_ATTEMPT",
121
+ // Card iframe reports whether the entered BIN requires a postal code so
122
+ // the merchant can show/require their own postal field.
123
+ FIELD_POSTAL_CODE_REQUIREMENT: "FIELD_POSTAL_CODE_REQUIREMENT"
117
124
  };
118
125
 
119
126
  // src/utils.ts
@@ -583,15 +590,24 @@ var ExpressApi = class {
583
590
  } else if (res.status === AuthorizedStatus.THREE_DS_INITIATED && res.required_action?.type === "REDIRECT_TO_URL") {
584
591
  handlers.onThreeDs(res.required_action.redirect_url, res.id);
585
592
  } else {
586
- const code = declineCodeOf(
587
- res.status_reason?.decline_reason,
588
- res.status === AuthorizedStatus.DECLINED ? "PAYMENT_DECLINED" : "PAYMENT_FAILED"
593
+ const fallback = res.status === AuthorizedStatus.DECLINED ? "PAYMENT_DECLINED" : "PAYMENT_FAILED";
594
+ const code = declineCodeOf(res.status_reason?.decline_code, fallback);
595
+ const isHard = res.status_reason?.decline_type === "HARD_DECLINE" || res.status_reason?.decline_type == null && res.session_retryable === false;
596
+ handlers.onFailed(
597
+ code,
598
+ res.status_reason?.message ?? (fallback === "PAYMENT_DECLINED" ? "Payment declined." : "Payment failed."),
599
+ res.id,
600
+ res.status === AuthorizedStatus.DECLINED && !isHard ? PaymentAttemptResult.SOFT_DECLINE : PaymentAttemptResult.HARD_DECLINE
589
601
  );
590
- handlers.onFailed(code, res.status_reason?.message ?? code, res.id);
591
602
  }
592
603
  } catch (err) {
593
604
  const code = errorCodeOf(err, "UNKNOWN_ERROR");
594
- handlers.onFailed(code, err instanceof Error ? err.message : code);
605
+ handlers.onFailed(
606
+ code,
607
+ err instanceof Error ? err.message : code,
608
+ void 0,
609
+ PaymentAttemptResult.NETWORK_ERROR
610
+ );
595
611
  }
596
612
  }
597
613
  /**
@@ -1115,7 +1131,12 @@ var extractThreeDsResult = (event, check) => {
1115
1131
  if (!check.expectedSource || event.source !== check.expectedSource) {
1116
1132
  return null;
1117
1133
  }
1118
- const { type, payment_id: paymentId, status } = event.data ?? {};
1134
+ const {
1135
+ type,
1136
+ payment_id: paymentId,
1137
+ status,
1138
+ decline_code: declineCode
1139
+ } = event.data ?? {};
1119
1140
  if (type !== "3ds-result" || !status || paymentId !== check.expectedPaymentId) {
1120
1141
  return null;
1121
1142
  }
@@ -1129,7 +1150,10 @@ var extractThreeDsResult = (event, check) => {
1129
1150
  );
1130
1151
  return null;
1131
1152
  }
1132
- return String(status);
1153
+ return {
1154
+ status: String(status),
1155
+ declineCode: typeof declineCode === "string" && declineCode ? declineCode : void 0
1156
+ };
1133
1157
  };
1134
1158
 
1135
1159
  // src/express/three-ds.ts
@@ -1152,16 +1176,16 @@ var createThreeDsPopup = (payOrigin, handlers) => {
1152
1176
  expectedPaymentId = null;
1153
1177
  };
1154
1178
  const handleMessage = (event) => {
1155
- const status = extractThreeDsResult(event, {
1179
+ const result = extractThreeDsResult(event, {
1156
1180
  expectedSource: popup,
1157
1181
  allowedOrigins,
1158
1182
  expectedPaymentId
1159
1183
  });
1160
- if (status === null || handled) return;
1184
+ if (result === null || handled) return;
1161
1185
  const paymentId = expectedPaymentId;
1162
1186
  handled = true;
1163
1187
  cleanup();
1164
- handlers.onComplete(paymentId, status);
1188
+ handlers.onComplete(paymentId, result.status, result.declineCode);
1165
1189
  };
1166
1190
  window.addEventListener("message", handleMessage);
1167
1191
  const open = (redirectUrl, paymentId) => {
@@ -1272,24 +1296,28 @@ function createFlintNExpressButtons(options) {
1272
1296
  };
1273
1297
  let threeDsMethod = ExpressMethod.APPLE_PAY;
1274
1298
  const threeDs = createThreeDsPopup(payOrigin, {
1275
- onComplete: (paymentId, status) => {
1299
+ onComplete: (paymentId, status, declineCode) => {
1276
1300
  if (status === "AUTHORIZED") succeed(threeDsMethod, paymentId);
1277
- else
1278
- fail(
1279
- threeDsMethod,
1280
- status === "DECLINED" ? "THREE_DS_FAILED" : "PAYMENT_FAILED",
1281
- status,
1282
- { paymentId }
1283
- );
1301
+ else {
1302
+ const code = declineCode ?? (status === "DECLINED" ? "PAYMENT_DECLINED" : "PAYMENT_FAILED");
1303
+ const message = status === "DECLINED" ? "Payment declined." : "Payment failed.";
1304
+ fail(threeDsMethod, code, message, { paymentId });
1305
+ }
1284
1306
  },
1285
- onClose: () => fail(threeDsMethod, "THREE_DS_CANCELLED", "THREE_DS_CANCELLED", {
1286
- event: PaymentAttemptResult.CANCELLED
1287
- }),
1307
+ onClose: () => fail(
1308
+ threeDsMethod,
1309
+ "THREE_DS_CANCELLED",
1310
+ "3DS verification was cancelled by the buyer.",
1311
+ { event: PaymentAttemptResult.CANCELLED }
1312
+ ),
1288
1313
  // Routed through fail() so analytics get a terminal attempt event too
1289
1314
  // (NETWORK_ERROR — a client-side technical failure, not a decline).
1290
- onPopupBlocked: () => fail(threeDsMethod, "THREE_DS_POPUP_BLOCKED", "THREE_DS_POPUP_BLOCKED", {
1291
- event: PaymentAttemptResult.NETWORK_ERROR
1292
- })
1315
+ onPopupBlocked: () => fail(
1316
+ threeDsMethod,
1317
+ "THREE_DS_POPUP_BLOCKED",
1318
+ "The 3DS verification window was blocked by the browser.",
1319
+ { event: PaymentAttemptResult.NETWORK_ERROR }
1320
+ )
1293
1321
  });
1294
1322
  const walletHandlers = (method) => (complete) => ({
1295
1323
  onAuthorized: (paymentId) => {
@@ -1306,9 +1334,9 @@ function createFlintNExpressButtons(options) {
1306
1334
  threeDsMethod = method;
1307
1335
  threeDs.open(redirectUrl, paymentId);
1308
1336
  },
1309
- onFailed: (code, message, paymentId) => {
1337
+ onFailed: (code, message, paymentId, event) => {
1310
1338
  complete(false, message);
1311
- fail(method, code, message, { paymentId });
1339
+ fail(method, code, message, { paymentId, event });
1312
1340
  }
1313
1341
  });
1314
1342
  const detection = (async () => {
@@ -1531,7 +1559,7 @@ var openThreeDsDialog = (options) => {
1531
1559
  }
1532
1560
  };
1533
1561
  let finished = false;
1534
- const finish = (status) => {
1562
+ const finish = (status, declineCode) => {
1535
1563
  if (finished) return;
1536
1564
  finished = true;
1537
1565
  window.removeEventListener("message", handleMessage);
@@ -1539,15 +1567,15 @@ var openThreeDsDialog = (options) => {
1539
1567
  overlay.remove();
1540
1568
  closeCurrent = null;
1541
1569
  previouslyFocused?.focus();
1542
- options.onFinish(status);
1570
+ options.onFinish(status, declineCode);
1543
1571
  };
1544
1572
  const handleMessage = (event) => {
1545
- const status = extractThreeDsResult(event, {
1573
+ const result = extractThreeDsResult(event, {
1546
1574
  expectedSource: iframe.contentWindow,
1547
1575
  allowedOrigins: options.allowedOrigins,
1548
1576
  expectedPaymentId: options.paymentId
1549
1577
  });
1550
- if (status !== null) finish(status);
1578
+ if (result !== null) finish(result.status, result.declineCode);
1551
1579
  };
1552
1580
  closeButton.addEventListener("click", () => finish("CANCELLED"));
1553
1581
  window.addEventListener("message", handleMessage);
@@ -1683,6 +1711,26 @@ function createFlintNFields(options) {
1683
1711
  }
1684
1712
  return;
1685
1713
  }
1714
+ if (type === FieldEventType.FIELD_PAYMENT_ATTEMPT) {
1715
+ log("Attempt event:", payload);
1716
+ const { event: attemptEvent, paymentId, code, message } = payload ?? {};
1717
+ if (attemptEvent) {
1718
+ options.onEvent?.({
1719
+ event: attemptEvent,
1720
+ method: PaymentMethod.PAYMENT_CARD,
1721
+ paymentId,
1722
+ code,
1723
+ message,
1724
+ timestamp: Date.now()
1725
+ });
1726
+ }
1727
+ return;
1728
+ }
1729
+ if (type === FieldEventType.FIELD_POSTAL_CODE_REQUIREMENT) {
1730
+ log("Postal code requirement:", payload);
1731
+ options.onPostalCodeRequirement?.(!!payload?.required);
1732
+ return;
1733
+ }
1686
1734
  if (type === FieldEventType.FIELD_VALIDATION && fieldType) {
1687
1735
  log("FIELD_VALIDATION received:", fieldType, payload);
1688
1736
  const pending = pendingValidations.get(fieldType);
@@ -1710,11 +1758,12 @@ function createFlintNFields(options) {
1710
1758
  redirectUrl,
1711
1759
  paymentId,
1712
1760
  allowedOrigins: threeDsResultOrigins(origin, apiUrl),
1713
- onFinish: (status) => {
1761
+ onFinish: (status, declineCode) => {
1714
1762
  log("3DS challenge finished:", status);
1715
1763
  fields.get(fieldType)?._sendMessage(FieldEventType.FIELD_THREE_DS_RESULT, {
1716
1764
  paymentId,
1717
- status
1765
+ status,
1766
+ declineCode
1718
1767
  });
1719
1768
  }
1720
1769
  });