flintn-checkout 0.0.16 → 0.0.18

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
@@ -166,7 +166,7 @@ For UI events (LIST and RADIO variants), `view` is the view that became active:
166
166
 
167
167
  ```
168
168
  onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
169
- onEvent → { event: 'SOFT_DECLINE', method: 'PAYMENT_CARD', code: 'do_not_honor', message: 'Payment declined.' }
169
+ onEvent → { event: 'SOFT_DECLINE', method: 'PAYMENT_CARD', code: 'FNT-EC-2003', message: 'Insufficient funds.' }
170
170
  // buyer retries with another card
171
171
  onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
172
172
  onEvent → { event: 'AUTHORIZED', method: 'PAYMENT_CARD', paymentId: 'pay_...' }
@@ -270,7 +270,7 @@ When a card payment requires 3DS, the SDK shows the bank's challenge in a
270
270
  `THREE_DS_CANCELLED` payment error. While the challenge is open, the
271
271
  `submit()` promise stays pending with a 10-minute timeout.
272
272
 
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.
273
+ > **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
274
 
275
275
  **React**
276
276
 
@@ -331,7 +331,11 @@ function CheckoutForm() {
331
331
  </div>
332
332
  <div style={{ flex: 1 }}>
333
333
  <label>CVV</label>
334
- <div ref={cvvRef} style={{ height: 40, marginBottom: 4 }} />
334
+ {/* position/zIndex let the CVV helper panel overlay content below */}
335
+ <div
336
+ ref={cvvRef}
337
+ style={{ height: 40, marginBottom: 4, position: 'relative', zIndex: 1 }}
338
+ />
335
339
  {fieldErrors['cvv'] && (
336
340
  <span style={{ color: 'red' }}>{fieldErrors['cvv']}</span>
337
341
  )}
@@ -379,7 +383,7 @@ const fields = createFlintNFields({
379
383
 
380
384
  const cardNumber = fields.createField('card-number', { placeholder: '4111 1111 1111 1111' });
381
385
  const expiry = fields.createField('expiry', { placeholder: 'MM/YY' });
382
- const cvv = fields.createField('cvv', { placeholder: 'CVC' });
386
+ const cvv = fields.createField('cvv');
383
387
 
384
388
  cardNumber.mount('#card-number');
385
389
  expiry.mount('#expiry');
@@ -405,6 +409,8 @@ if (validation.isValid) {
405
409
  | `onChange` | `(event: FieldChangeEvent) => void` | No | — | Field value changed |
406
410
  | `onFocus` | `(fieldType: TFieldType) => void` | No | — | Field gained focus |
407
411
  | `onBlur` | `(fieldType: TFieldType, state: FieldState) => void` | No | — | Field lost focus |
412
+ | `onEvent` | `(event: AttemptEvent) => void` | No | — | Per-attempt analytics for the card flow (`method: 'PAYMENT_CARD'`) |
413
+ | `onPostalCodeRequirement` | `(required: boolean) => void` | No | — | Entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
408
414
  | `onError` | `(error: PaymentError) => void` | No | — | SDK initialization error |
409
415
  | `debug` | `boolean` | No | `false` | Enable console debug logs |
410
416
 
@@ -429,13 +435,13 @@ Additional React-only options:
429
435
  | `onChange` | `(event: FieldChangeEvent) => void` | Optional additional callback |
430
436
  | `onFocus` | `(fieldType: TFieldType) => void` | Optional additional callback |
431
437
  | `onBlur` | `(fieldType: TFieldType, state: FieldState) => void` | Optional additional callback |
432
- | `onEvent` | `(event: ExpressAttempt) => void` | Per-attempt wallet analytics from the express buttons (see "Express buttons") |
438
+ | `onEvent` | `(event: AttemptEvent) => void` | Per-attempt analytics card flow (`method: 'PAYMENT_CARD'`) and express buttons share this stream |
433
439
 
434
440
  ### FieldOptions
435
441
 
436
442
  | Property | Type | Default | Description |
437
443
  |----------|------|---------|-------------|
438
- | `placeholder` | `string` | Field-specific default | Input placeholder text |
444
+ | `placeholder` | `string` | `'1234 1234 1234 1234'` / `'MM/YY'` / `'***'` | Input placeholder text |
439
445
  | `disabled` | `boolean` | `false` | Disable the input |
440
446
 
441
447
  ### React Hook Return Values
@@ -453,6 +459,7 @@ Additional React-only options:
453
459
  | `fieldErrors` | `Partial<Record<TFieldType, string \| null>>` | Per-field validation errors (populated after first submit) |
454
460
  | `fieldStates` | `Partial<Record<TFieldType, FieldState>>` | Per-field state (always up to date) |
455
461
  | `cardBrand` | `string \| null` | Detected card brand (e.g. `"visa"`, `"mastercard"`) |
462
+ | `postalCodeRequired` | `boolean` | The entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
456
463
  | `paymentResult` | `PaymentResult \| null` | Result after payment attempt |
457
464
  | `error` | `PaymentError \| null` | SDK error if any |
458
465
  | `validate` | `() => Promise<FieldsValidationResult>` | Validate all fields |
@@ -469,6 +476,29 @@ Validation mirrors react-hook-form `mode: 'onSubmit'`:
469
476
  - After first submit: errors update on every field change (revalidation)
470
477
  - Card number changes trigger CVV revalidation (CVV length depends on card brand)
471
478
  - All three fields (card number, expiry, CVV) are required — submitting with a missing field returns a `MISSING_FIELDS` error
479
+ - 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
480
+
481
+ ### Postal code (BIN-driven)
482
+
483
+ Some issuers require a postal code for online payments. Once the buyer has
484
+ typed enough digits, the card-number field checks the BIN against the payments
485
+ API and reports whether a postal code is required — `postalCodeRequired`
486
+ (React) / `onPostalCodeRequirement` (vanilla). Show your own postal code input
487
+ when it flips to `true` and pass the value at submit:
488
+
489
+ ```tsx
490
+ const { postalCodeRequired, submit } = useFlintNFields({ ... });
491
+
492
+ // render your postal input when postalCodeRequired === true, then:
493
+ await submit({
494
+ cardholderName,
495
+ billingAddress: { postalCode },
496
+ });
497
+ ```
498
+
499
+ If the BIN requires a postal code and `billingAddress.postalCode` is missing,
500
+ `submit()` resolves with `PAYMENT_ERROR` / `POSTAL_CODE_REQUIRED` without
501
+ calling the payments API.
472
502
 
473
503
  ### Field Types
474
504
 
@@ -476,7 +506,7 @@ Validation mirrors react-hook-form `mode: 'onSubmit'`:
476
506
  |-------|-------------|
477
507
  | `'card-number'` | Card number input with automatic formatting and brand detection |
478
508
  | `'expiry'` | Expiry date input (MM/YY format) |
479
- | `'cvv'` | CVV/CVC input (3 or 4 digits depending on card brand) |
509
+ | `'cvv'` | CVV/CVC input (3 or 4 digits depending on card brand). Includes a built-in “where to find CVV” helper: tapping the card icon expands an explainer panel below the input |
480
510
 
481
511
  ### Individual Field Methods (Vanilla JS)
482
512
 
@@ -612,6 +642,23 @@ interface PaymentError {
612
642
  }
613
643
  ```
614
644
 
645
+ ### SubmitOptions (Hosted Fields)
646
+
647
+ ```typescript
648
+ interface SubmitOptions {
649
+ cardholderName?: string;
650
+ email?: string;
651
+ billingAddress?: {
652
+ addressLine1?: string;
653
+ addressLine2?: string;
654
+ city?: string;
655
+ state?: string;
656
+ postalCode?: string; // required when the BIN demands it — see "Postal code (BIN-driven)"
657
+ country?: string;
658
+ };
659
+ }
660
+ ```
661
+
615
662
  ### Error handling
616
663
 
617
664
  Errors surface through three separate channels — don't conflate them:
@@ -632,8 +679,11 @@ Common `code` values:
632
679
  | `CONFIG_ERROR` | error | Session config failed to load and the API gave no code |
633
680
  | `NETWORK_ERROR` / `HTTP_<status>` / `INVALID_RESPONSE` | error or payment | Network failure / HTTP error without an API code / malformed response |
634
681
  | `VALIDATION_ERROR` | payment | `submit()` blocked by field validation (details in `fieldErrors`) |
635
- | `MISSING_FIELDS` / `NOT_INITIALIZED` / `SUBMIT_IN_PROGRESS` / `SUBMIT_TIMEOUT` | payment | Card `submit()` preconditions / duplicate call / no response in time (30s; 10 min during a 3DS challenge) |
636
- | `PAYMENT_DECLINED` / processor decline codes | payment | Declined; the processor's own code is passed through when present |
682
+ | `POSTAL_CODE_REQUIRED` | payment | The card BIN requires a postal code and `billingAddress.postalCode` was not passed to `submit()` |
683
+ | `MISSING_FIELDS` / `NOT_INITIALIZED` / `SUBMIT_IN_PROGRESS` / `SUBMIT_TIMEOUT` | payment | Card `submit()` preconditions / duplicate call / no response in time (30s) |
684
+ | `THREE_DS_TIMEOUT` | payment | A 3DS challenge stayed unresolved for 10 minutes |
685
+ | `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 |
686
+ | `PAYMENT_DECLINED` | payment | Declined without a specific decline code (fallback) |
637
687
  | `PAYMENT_FAILED` | payment | Non-declined failure (including unexpected 3DS outcomes) |
638
688
  | `APPLE_PAY_SDK_ERROR` / `GOOGLE_PAY_SDK_ERROR` / `PAYPAL_ERROR` | payment | The wallet SDK failed mid-flow |
639
689
  | `PAYPAL_MISSING_PAYMENT_ID` | payment | PayPal approved but no payment id was captured (anomaly) |
package/dist/index.d.mts CHANGED
@@ -173,6 +173,8 @@ declare const FieldEventType: {
173
173
  readonly FIELD_HEIGHT: "FIELD_HEIGHT";
174
174
  readonly FIELD_THREE_DS_REQUIRED: "FIELD_THREE_DS_REQUIRED";
175
175
  readonly FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT";
176
+ readonly FIELD_PAYMENT_ATTEMPT: "FIELD_PAYMENT_ATTEMPT";
177
+ readonly FIELD_POSTAL_CODE_REQUIREMENT: "FIELD_POSTAL_CODE_REQUIREMENT";
176
178
  };
177
179
  type TFieldEventType = (typeof FieldEventType)[keyof typeof FieldEventType];
178
180
  interface FieldOptions {
@@ -220,7 +222,7 @@ interface ExpressCapability {
220
222
  googlePay: boolean;
221
223
  paypal: boolean;
222
224
  }
223
- interface ExpressAttempt {
225
+ interface AttemptEvent {
224
226
  event: TPaymentAttemptResult;
225
227
  method: TPaymentMethod;
226
228
  paymentId?: string;
@@ -240,6 +242,15 @@ interface FlintNFieldsOptions {
240
242
  onChange?: (event: FieldChangeEvent) => void;
241
243
  onFocus?: (fieldType: TFieldType) => void;
242
244
  onBlur?: (fieldType: TFieldType, state: FieldState) => void;
245
+ /** Per-attempt analytics for the card flow (express buttons have their own). */
246
+ onEvent?: (event: AttemptEvent) => void;
247
+ /**
248
+ * The entered card BIN requires (or stops requiring) a postal code. Show
249
+ * your own postal field and pass its value via submit's
250
+ * `billingAddress.postalCode` — submit fails with POSTAL_CODE_REQUIRED
251
+ * otherwise.
252
+ */
253
+ onPostalCodeRequirement?: (required: boolean) => void;
243
254
  onError?: (error: PaymentError) => void;
244
255
  debug?: boolean;
245
256
  }
@@ -293,7 +304,7 @@ interface FlintNExpressButtonsOptions {
293
304
  apiUrl?: string;
294
305
  buttonBorderRadius?: number;
295
306
  onPayment?: (result: PaymentResult) => void;
296
- onEvent?: (event: ExpressAttempt) => void;
307
+ onEvent?: (event: AttemptEvent) => void;
297
308
  onCapability?: (capability: ExpressCapability) => void;
298
309
  onError?: (error: PaymentError) => void;
299
310
  debug?: boolean;
@@ -349,8 +360,11 @@ interface AuthorizeResponse {
349
360
  };
350
361
  status_reason?: {
351
362
  decline_reason?: string;
363
+ decline_code?: string;
364
+ decline_type?: 'SOFT_DECLINE' | 'HARD_DECLINE';
352
365
  message?: string;
353
366
  };
367
+ session_retryable?: boolean;
354
368
  }
355
369
  interface ApplePayToken {
356
370
  payment_data: {
@@ -377,7 +391,9 @@ interface GooglePayToken {
377
391
  interface WalletAuthorizeHandlers {
378
392
  onAuthorized: (paymentId: string) => void;
379
393
  onThreeDs: (redirectUrl: string, paymentId: string) => void;
380
- onFailed: (code: string, message: string, paymentId?: string) => void;
394
+ onFailed: (code: string, message: string, paymentId?: string,
395
+ /** Attempt analytics classification; defaults to HARD_DECLINE. */
396
+ event?: TPaymentAttemptResult) => void;
381
397
  }
382
398
 
383
399
  /**
@@ -425,4 +441,4 @@ declare const validateConfig: (config: {
425
441
  }) => void;
426
442
  declare const sanitizeToken: (token: string) => string;
427
443
 
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 };
444
+ 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
@@ -173,6 +173,8 @@ declare const FieldEventType: {
173
173
  readonly FIELD_HEIGHT: "FIELD_HEIGHT";
174
174
  readonly FIELD_THREE_DS_REQUIRED: "FIELD_THREE_DS_REQUIRED";
175
175
  readonly FIELD_THREE_DS_RESULT: "FIELD_THREE_DS_RESULT";
176
+ readonly FIELD_PAYMENT_ATTEMPT: "FIELD_PAYMENT_ATTEMPT";
177
+ readonly FIELD_POSTAL_CODE_REQUIREMENT: "FIELD_POSTAL_CODE_REQUIREMENT";
176
178
  };
177
179
  type TFieldEventType = (typeof FieldEventType)[keyof typeof FieldEventType];
178
180
  interface FieldOptions {
@@ -220,7 +222,7 @@ interface ExpressCapability {
220
222
  googlePay: boolean;
221
223
  paypal: boolean;
222
224
  }
223
- interface ExpressAttempt {
225
+ interface AttemptEvent {
224
226
  event: TPaymentAttemptResult;
225
227
  method: TPaymentMethod;
226
228
  paymentId?: string;
@@ -240,6 +242,15 @@ interface FlintNFieldsOptions {
240
242
  onChange?: (event: FieldChangeEvent) => void;
241
243
  onFocus?: (fieldType: TFieldType) => void;
242
244
  onBlur?: (fieldType: TFieldType, state: FieldState) => void;
245
+ /** Per-attempt analytics for the card flow (express buttons have their own). */
246
+ onEvent?: (event: AttemptEvent) => void;
247
+ /**
248
+ * The entered card BIN requires (or stops requiring) a postal code. Show
249
+ * your own postal field and pass its value via submit's
250
+ * `billingAddress.postalCode` — submit fails with POSTAL_CODE_REQUIRED
251
+ * otherwise.
252
+ */
253
+ onPostalCodeRequirement?: (required: boolean) => void;
243
254
  onError?: (error: PaymentError) => void;
244
255
  debug?: boolean;
245
256
  }
@@ -293,7 +304,7 @@ interface FlintNExpressButtonsOptions {
293
304
  apiUrl?: string;
294
305
  buttonBorderRadius?: number;
295
306
  onPayment?: (result: PaymentResult) => void;
296
- onEvent?: (event: ExpressAttempt) => void;
307
+ onEvent?: (event: AttemptEvent) => void;
297
308
  onCapability?: (capability: ExpressCapability) => void;
298
309
  onError?: (error: PaymentError) => void;
299
310
  debug?: boolean;
@@ -349,8 +360,11 @@ interface AuthorizeResponse {
349
360
  };
350
361
  status_reason?: {
351
362
  decline_reason?: string;
363
+ decline_code?: string;
364
+ decline_type?: 'SOFT_DECLINE' | 'HARD_DECLINE';
352
365
  message?: string;
353
366
  };
367
+ session_retryable?: boolean;
354
368
  }
355
369
  interface ApplePayToken {
356
370
  payment_data: {
@@ -377,7 +391,9 @@ interface GooglePayToken {
377
391
  interface WalletAuthorizeHandlers {
378
392
  onAuthorized: (paymentId: string) => void;
379
393
  onThreeDs: (redirectUrl: string, paymentId: string) => void;
380
- onFailed: (code: string, message: string, paymentId?: string) => void;
394
+ onFailed: (code: string, message: string, paymentId?: string,
395
+ /** Attempt analytics classification; defaults to HARD_DECLINE. */
396
+ event?: TPaymentAttemptResult) => void;
381
397
  }
382
398
 
383
399
  /**
@@ -425,4 +441,4 @@ declare const validateConfig: (config: {
425
441
  }) => void;
426
442
  declare const sanitizeToken: (token: string) => string;
427
443
 
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 };
444
+ 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
@@ -584,14 +591,25 @@ var ExpressApi = class {
584
591
  handlers.onThreeDs(res.required_action.redirect_url, res.id);
585
592
  } else {
586
593
  const code = declineCodeOf(
587
- res.status_reason?.decline_reason,
594
+ res.status_reason?.decline_code,
588
595
  res.status === AuthorizedStatus.DECLINED ? "PAYMENT_DECLINED" : "PAYMENT_FAILED"
589
596
  );
590
- handlers.onFailed(code, res.status_reason?.message ?? code, res.id);
597
+ const isHard = res.status_reason?.decline_type === "HARD_DECLINE" || res.status_reason?.decline_type == null && res.session_retryable === false;
598
+ handlers.onFailed(
599
+ code,
600
+ res.status_reason?.message ?? code,
601
+ res.id,
602
+ res.status === AuthorizedStatus.DECLINED && !isHard ? PaymentAttemptResult.SOFT_DECLINE : PaymentAttemptResult.HARD_DECLINE
603
+ );
591
604
  }
592
605
  } catch (err) {
593
606
  const code = errorCodeOf(err, "UNKNOWN_ERROR");
594
- handlers.onFailed(code, err instanceof Error ? err.message : code);
607
+ handlers.onFailed(
608
+ code,
609
+ err instanceof Error ? err.message : code,
610
+ void 0,
611
+ PaymentAttemptResult.NETWORK_ERROR
612
+ );
595
613
  }
596
614
  }
597
615
  /**
@@ -793,7 +811,7 @@ var injectAppleButtonStyle = () => {
793
811
  if (document.getElementById(APPLE_STYLE_ID)) return;
794
812
  const style = document.createElement("style");
795
813
  style.id = APPLE_STYLE_ID;
796
- style.textContent = `.${APPLE_BUTTON_CLASS}{cursor:pointer;transition:opacity .15s ease-in-out,transform .1s ease-in-out;}.${APPLE_BUTTON_CLASS}:hover{opacity:.9;}.${APPLE_BUTTON_CLASS}:active{opacity:.8;transform:scale(.99);}.${APPLE_BUTTON_CLASS}:focus-visible{outline:2px solid #0071e3;outline-offset:2px;}`;
814
+ style.textContent = `.${APPLE_BUTTON_CLASS}{cursor:pointer;transition:opacity .15s ease-in-out;}.${APPLE_BUTTON_CLASS}:hover{opacity:.8;}.${APPLE_BUTTON_CLASS}:active{opacity:.7;}.${APPLE_BUTTON_CLASS}:focus-visible{outline:2px solid #0071e3;outline-offset:2px;}`;
797
815
  document.head.appendChild(style);
798
816
  };
799
817
  var mountApplePayButton = (container, config, api, borderRadius, callbacks) => {
@@ -818,17 +836,23 @@ var mountApplePayButton = (container, config, api, borderRadius, callbacks) => {
818
836
  if (processing || !applePayConfig || version === null) return;
819
837
  if (!callbacks.canStart()) return;
820
838
  callbacks.onStart();
821
- const session = new window.ApplePaySession(version, {
822
- countryCode: applePayConfig.country_code,
823
- currencyCode: config.session.currency_code,
824
- supportedNetworks: applePayConfig.supported_networks,
825
- merchantCapabilities: applePayConfig.merchant_capabilities,
826
- total: {
827
- label: applePayConfig.display_name,
828
- amount: (config.session.amount / 100).toFixed(2),
829
- type: "final"
830
- }
831
- });
839
+ let session;
840
+ try {
841
+ session = new window.ApplePaySession(version, {
842
+ countryCode: applePayConfig.country_code,
843
+ currencyCode: config.session.currency_code,
844
+ supportedNetworks: applePayConfig.supported_networks,
845
+ merchantCapabilities: applePayConfig.merchant_capabilities,
846
+ total: {
847
+ label: applePayConfig.display_name,
848
+ amount: (config.session.amount / 100).toFixed(2),
849
+ type: "final"
850
+ }
851
+ });
852
+ } catch {
853
+ callbacks.onSdkError("Apple Pay is not available in this browser.");
854
+ return;
855
+ }
832
856
  processing = true;
833
857
  let abortedDueToError = false;
834
858
  session.onvalidatemerchant = async (event) => {
@@ -871,7 +895,13 @@ var mountApplePayButton = (container, config, api, borderRadius, callbacks) => {
871
895
  }
872
896
  callbacks.onCancel();
873
897
  };
874
- session.begin();
898
+ try {
899
+ session.begin();
900
+ } catch {
901
+ processing = false;
902
+ callbacks.onSdkError("Apple Pay is not available in this browser.");
903
+ return;
904
+ }
875
905
  styleApplePayModalWhenReady();
876
906
  };
877
907
  button.addEventListener("click", handleClick);
@@ -962,13 +992,15 @@ var detectGooglePay = async (config) => {
962
992
  };
963
993
  var GPAY_WRAP_CLASS = "flintn-gpay";
964
994
  var GPAY_STYLE_ID = "flintn-gpay-style";
995
+ var gpaySurfaceSelectors = [".gpay-button", ".gpay-card-info-container"].flatMap((s) => [s, `${s}:hover`, `${s}:active`, `${s}.hover`, `${s}.active`]).map((s) => `.${GPAY_WRAP_CLASS} ${s}`).join(",");
965
996
  var injectGpayStyle = () => {
966
997
  if (document.getElementById(GPAY_STYLE_ID)) return;
967
998
  const style = document.createElement("style");
968
999
  style.id = GPAY_STYLE_ID;
969
- style.textContent = `.gpay-button:focus:not(:focus-visible){outline:none;box-shadow:none;}.gpay-button-fill:focus-within:not(:has(:focus-visible)){outline:none;box-shadow:none;}.${GPAY_WRAP_CLASS}{cursor:pointer;}.${GPAY_WRAP_CLASS} button{height:100%;cursor:pointer;}.${GPAY_WRAP_CLASS} > *{width:100%;height:100%;}`;
1000
+ style.textContent = `.gpay-button:focus:not(:focus-visible){outline:none;box-shadow:none;}.gpay-button-fill:focus-within:not(:has(:focus-visible)){outline:none;box-shadow:none;}.${GPAY_WRAP_CLASS}{cursor:pointer;transition:opacity .15s ease-in-out;}.${GPAY_WRAP_CLASS}:hover{opacity:.8;}.${GPAY_WRAP_CLASS}:active{opacity:.7;}.${GPAY_WRAP_CLASS} button{height:100%;cursor:pointer;}${gpaySurfaceSelectors}{border:none!important;outline:none!important;box-shadow:none!important;background-color:#000!important;}.${GPAY_WRAP_CLASS} .gpay-card-info-container:focus-visible,.${GPAY_WRAP_CLASS} .gpay-button:focus-visible{outline:2px solid #0071e3!important;outline-offset:2px;}.${GPAY_WRAP_CLASS} .gpay-card-info-iframe{display:none!important;}.${GPAY_WRAP_CLASS} .gpay-button-fill,.${GPAY_WRAP_CLASS} .gpay-card-info-animation-container-fade-out{opacity:1!important;}.${GPAY_WRAP_CLASS} .gpay-button-static-content-container-loading::before,.${GPAY_WRAP_CLASS} .gpay-button-static-content-container-loading::after{display:none!important;}.${GPAY_WRAP_CLASS} .gpay-button.gpay-button-size-match-height{height:50%!important;}.${GPAY_WRAP_CLASS} > *{width:100%;height:100%;}`;
970
1001
  document.head.appendChild(style);
971
1002
  };
1003
+ var SHEET_SETTLE_GRACE_MS = 3e3;
972
1004
  var mountGooglePayButton = (container, client, config, borderRadius, callbacks) => {
973
1005
  let processing = false;
974
1006
  const handleClick = () => {
@@ -977,7 +1009,31 @@ var mountGooglePayButton = (container, client, config, borderRadius, callbacks)
977
1009
  if (!callbacks.canStart()) return;
978
1010
  processing = true;
979
1011
  callbacks.onStart();
1012
+ let settled = false;
1013
+ let graceTimer = null;
1014
+ const disarmWatchdog = () => {
1015
+ document.removeEventListener("visibilitychange", onVisibleAgain);
1016
+ if (graceTimer !== null) clearTimeout(graceTimer);
1017
+ };
1018
+ const trySettle = () => {
1019
+ if (settled) return false;
1020
+ settled = true;
1021
+ disarmWatchdog();
1022
+ return true;
1023
+ };
1024
+ const onVisibleAgain = () => {
1025
+ if (document.visibilityState !== "visible" || graceTimer !== null) {
1026
+ return;
1027
+ }
1028
+ graceTimer = setTimeout(() => {
1029
+ if (!trySettle()) return;
1030
+ processing = false;
1031
+ callbacks.onCancel();
1032
+ }, SHEET_SETTLE_GRACE_MS);
1033
+ };
1034
+ document.addEventListener("visibilitychange", onVisibleAgain);
980
1035
  client.loadPaymentData(request2).then((paymentData) => {
1036
+ if (!trySettle()) return;
981
1037
  const complete = (_success, errorMessage) => {
982
1038
  processing = false;
983
1039
  if (errorMessage) callbacks.onSdkError(errorMessage);
@@ -987,6 +1043,7 @@ var mountGooglePayButton = (container, client, config, borderRadius, callbacks)
987
1043
  callbacks.handlers(complete)
988
1044
  );
989
1045
  }).catch((err) => {
1046
+ if (!trySettle()) return;
990
1047
  processing = false;
991
1048
  if (err?.statusCode === "CANCELED") callbacks.onCancel();
992
1049
  else callbacks.onSdkError("Google Pay payment failed");
@@ -1076,7 +1133,12 @@ var extractThreeDsResult = (event, check) => {
1076
1133
  if (!check.expectedSource || event.source !== check.expectedSource) {
1077
1134
  return null;
1078
1135
  }
1079
- const { type, payment_id: paymentId, status } = event.data ?? {};
1136
+ const {
1137
+ type,
1138
+ payment_id: paymentId,
1139
+ status,
1140
+ decline_code: declineCode
1141
+ } = event.data ?? {};
1080
1142
  if (type !== "3ds-result" || !status || paymentId !== check.expectedPaymentId) {
1081
1143
  return null;
1082
1144
  }
@@ -1090,7 +1152,10 @@ var extractThreeDsResult = (event, check) => {
1090
1152
  );
1091
1153
  return null;
1092
1154
  }
1093
- return String(status);
1155
+ return {
1156
+ status: String(status),
1157
+ declineCode: typeof declineCode === "string" && declineCode ? declineCode : void 0
1158
+ };
1094
1159
  };
1095
1160
 
1096
1161
  // src/express/three-ds.ts
@@ -1113,16 +1178,16 @@ var createThreeDsPopup = (payOrigin, handlers) => {
1113
1178
  expectedPaymentId = null;
1114
1179
  };
1115
1180
  const handleMessage = (event) => {
1116
- const status = extractThreeDsResult(event, {
1181
+ const result = extractThreeDsResult(event, {
1117
1182
  expectedSource: popup,
1118
1183
  allowedOrigins,
1119
1184
  expectedPaymentId
1120
1185
  });
1121
- if (status === null || handled) return;
1186
+ if (result === null || handled) return;
1122
1187
  const paymentId = expectedPaymentId;
1123
1188
  handled = true;
1124
1189
  cleanup();
1125
- handlers.onComplete(paymentId, status);
1190
+ handlers.onComplete(paymentId, result.status, result.declineCode);
1126
1191
  };
1127
1192
  window.addEventListener("message", handleMessage);
1128
1193
  const open = (redirectUrl, paymentId) => {
@@ -1233,15 +1298,12 @@ function createFlintNExpressButtons(options) {
1233
1298
  };
1234
1299
  let threeDsMethod = ExpressMethod.APPLE_PAY;
1235
1300
  const threeDs = createThreeDsPopup(payOrigin, {
1236
- onComplete: (paymentId, status) => {
1301
+ onComplete: (paymentId, status, declineCode) => {
1237
1302
  if (status === "AUTHORIZED") succeed(threeDsMethod, paymentId);
1238
- else
1239
- fail(
1240
- threeDsMethod,
1241
- status === "DECLINED" ? "THREE_DS_FAILED" : "PAYMENT_FAILED",
1242
- status,
1243
- { paymentId }
1244
- );
1303
+ else {
1304
+ const code = declineCode ?? (status === "DECLINED" ? "PAYMENT_DECLINED" : "PAYMENT_FAILED");
1305
+ fail(threeDsMethod, code, code, { paymentId });
1306
+ }
1245
1307
  },
1246
1308
  onClose: () => fail(threeDsMethod, "THREE_DS_CANCELLED", "THREE_DS_CANCELLED", {
1247
1309
  event: PaymentAttemptResult.CANCELLED
@@ -1267,9 +1329,9 @@ function createFlintNExpressButtons(options) {
1267
1329
  threeDsMethod = method;
1268
1330
  threeDs.open(redirectUrl, paymentId);
1269
1331
  },
1270
- onFailed: (code, message, paymentId) => {
1332
+ onFailed: (code, message, paymentId, event) => {
1271
1333
  complete(false, message);
1272
- fail(method, code, message, { paymentId });
1334
+ fail(method, code, message, { paymentId, event });
1273
1335
  }
1274
1336
  });
1275
1337
  const detection = (async () => {
@@ -1462,6 +1524,9 @@ var openThreeDsDialog = (options) => {
1462
1524
  overlay.id = OVERLAY_ID;
1463
1525
  overlay.style.cssText = "position: fixed; inset: 0; z-index: 2147483647; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center;";
1464
1526
  const panel = document.createElement("div");
1527
+ panel.setAttribute("role", "dialog");
1528
+ panel.setAttribute("aria-modal", "true");
1529
+ panel.setAttribute("aria-label", "3DS verification");
1465
1530
  panel.style.cssText = `position: relative; background: #fff; border-radius: 8px; width: min(${PANEL_WIDTH}px, calc(100vw - 32px)); height: min(${PANEL_HEIGHT}px, calc(100vh - 32px)); box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25); overflow: hidden;`;
1466
1531
  const closeButton = document.createElement("button");
1467
1532
  closeButton.type = "button";
@@ -1475,26 +1540,43 @@ var openThreeDsDialog = (options) => {
1475
1540
  iframe.style.cssText = "width: 100%; height: 100%; border: none; display: block;";
1476
1541
  panel.append(iframe, closeButton);
1477
1542
  overlay.appendChild(panel);
1543
+ const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
1544
+ const trapFocus = (event) => {
1545
+ if (event.key !== "Tab") return;
1546
+ const active = document.activeElement;
1547
+ const inside = active !== null && overlay.contains(active);
1548
+ if (event.shiftKey && (!inside || active === iframe)) {
1549
+ event.preventDefault();
1550
+ closeButton.focus();
1551
+ } else if (!event.shiftKey && (!inside || active === closeButton)) {
1552
+ event.preventDefault();
1553
+ iframe.focus();
1554
+ }
1555
+ };
1478
1556
  let finished = false;
1479
- const finish = (status) => {
1557
+ const finish = (status, declineCode) => {
1480
1558
  if (finished) return;
1481
1559
  finished = true;
1482
1560
  window.removeEventListener("message", handleMessage);
1561
+ window.removeEventListener("keydown", trapFocus);
1483
1562
  overlay.remove();
1484
1563
  closeCurrent = null;
1485
- options.onFinish(status);
1564
+ previouslyFocused?.focus();
1565
+ options.onFinish(status, declineCode);
1486
1566
  };
1487
1567
  const handleMessage = (event) => {
1488
- const status = extractThreeDsResult(event, {
1568
+ const result = extractThreeDsResult(event, {
1489
1569
  expectedSource: iframe.contentWindow,
1490
1570
  allowedOrigins: options.allowedOrigins,
1491
1571
  expectedPaymentId: options.paymentId
1492
1572
  });
1493
- if (status !== null) finish(status);
1573
+ if (result !== null) finish(result.status, result.declineCode);
1494
1574
  };
1495
1575
  closeButton.addEventListener("click", () => finish("CANCELLED"));
1496
1576
  window.addEventListener("message", handleMessage);
1577
+ window.addEventListener("keydown", trapFocus);
1497
1578
  document.body.appendChild(overlay);
1579
+ closeButton.focus();
1498
1580
  closeCurrent = () => finish("CANCELLED");
1499
1581
  };
1500
1582
 
@@ -1607,7 +1689,15 @@ function createFlintNFields(options) {
1607
1689
  const pending = pendingSubmit;
1608
1690
  pending.timer = setTimeout(() => {
1609
1691
  if (pendingSubmit === pending) {
1610
- pendingSubmit.reject(new Error("3DS challenge timed out"));
1692
+ const timeoutResult = {
1693
+ status: "PAYMENT_ERROR",
1694
+ error: {
1695
+ code: "THREE_DS_TIMEOUT",
1696
+ message: "3DS challenge timed out"
1697
+ }
1698
+ };
1699
+ pendingSubmit.resolve(timeoutResult);
1700
+ options.onPayment?.(timeoutResult);
1611
1701
  pendingSubmit = null;
1612
1702
  }
1613
1703
  }, THREE_DS_TIMEOUT);
@@ -1616,6 +1706,26 @@ function createFlintNFields(options) {
1616
1706
  }
1617
1707
  return;
1618
1708
  }
1709
+ if (type === FieldEventType.FIELD_PAYMENT_ATTEMPT) {
1710
+ log("Attempt event:", payload);
1711
+ const { event: attemptEvent, paymentId, code, message } = payload ?? {};
1712
+ if (attemptEvent) {
1713
+ options.onEvent?.({
1714
+ event: attemptEvent,
1715
+ method: PaymentMethod.PAYMENT_CARD,
1716
+ paymentId,
1717
+ code,
1718
+ message,
1719
+ timestamp: Date.now()
1720
+ });
1721
+ }
1722
+ return;
1723
+ }
1724
+ if (type === FieldEventType.FIELD_POSTAL_CODE_REQUIREMENT) {
1725
+ log("Postal code requirement:", payload);
1726
+ options.onPostalCodeRequirement?.(!!payload?.required);
1727
+ return;
1728
+ }
1619
1729
  if (type === FieldEventType.FIELD_VALIDATION && fieldType) {
1620
1730
  log("FIELD_VALIDATION received:", fieldType, payload);
1621
1731
  const pending = pendingValidations.get(fieldType);
@@ -1643,11 +1753,12 @@ function createFlintNFields(options) {
1643
1753
  redirectUrl,
1644
1754
  paymentId,
1645
1755
  allowedOrigins: threeDsResultOrigins(origin, apiUrl),
1646
- onFinish: (status) => {
1756
+ onFinish: (status, declineCode) => {
1647
1757
  log("3DS challenge finished:", status);
1648
1758
  fields.get(fieldType)?._sendMessage(FieldEventType.FIELD_THREE_DS_RESULT, {
1649
1759
  paymentId,
1650
- status
1760
+ status,
1761
+ declineCode
1651
1762
  });
1652
1763
  }
1653
1764
  });