@funnelfox/billing 0.8.0-ffb-550.38 → 0.8.0-ffb-631.40

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
@@ -421,6 +421,8 @@ try {
421
421
  console.log('Error Code:', error.errorCode); // e.g., 'double_purchase'
422
422
  console.log('Error Type:', error.errorType); // e.g., 'api_exception'
423
423
  console.log('Request ID:', error.requestId); // For support
424
+ console.log('Decline Code:', error.declineCode); // e.g., 'INSUFFICIENT_FUNDS', set on failed payments
425
+ console.log('Decline Source:', error.declineCodeSource); // 'primer' | 'stripe' | 'adyen', when the provider reports one
424
426
  console.log('Message:', error.message);
425
427
  } else if (error instanceof PrimerError) {
426
428
  // Primer SDK error
@@ -634,6 +636,8 @@ const paymentMethod = await Billing.initMethod(
634
636
  },
635
637
  onPaymentFail: error => {
636
638
  console.error('Payment failed:', error.message);
639
+ console.log('Decline code:', error.declineCode); // e.g., 'INSUFFICIENT_FUNDS', when the provider reports one
640
+ console.log('Decline source:', error.declineCodeSource); // 'primer' | 'stripe' | 'adyen'
637
641
  },
638
642
  onPaymentCancel: () => {
639
643
  console.log('Payment was cancelled');
@@ -119,6 +119,8 @@ class APIError extends FunnefoxSDKError {
119
119
  this.errorType = options.errorType || null;
120
120
  this.requestId = options.requestId || null;
121
121
  this.response = options.response || null;
122
+ this.declineCode = options.declineCode || null;
123
+ this.declineCodeSource = options.declineCodeSource || null;
122
124
  }
123
125
  }
124
126
  class PrimerError extends FunnefoxSDKError {
@@ -355,6 +357,32 @@ function getVisibleHostedInputError(input) {
355
357
  return input.touched ? input.error : null;
356
358
  }
357
359
  function resolveTaxDisplay(source) {
360
+ return {
361
+ ...resolveTaxAmount(source),
362
+ ...resolveTaxBreakdown(source.tax_breakdown),
363
+ };
364
+ }
365
+ function resolveTaxBreakdown(breakdown) {
366
+ if (!Array.isArray(breakdown)) {
367
+ return {};
368
+ }
369
+ const taxBreakdown = breakdown.map(entry => ({
370
+ amount: entry.amount,
371
+ percentage: entry.percentage,
372
+ inclusive: entry.inclusive,
373
+ taxType: entry.tax_type,
374
+ country: entry.country,
375
+ subdivision: entry.subdivision,
376
+ taxabilityReason: entry.taxability_reason,
377
+ }));
378
+ const percentages = taxBreakdown.map(entry => Number(entry.percentage));
379
+ if (!percentages.length || !percentages.every(Number.isFinite)) {
380
+ return { taxBreakdown };
381
+ }
382
+ const taxRate = Number(percentages.reduce((sum, percentage) => sum + percentage, 0).toFixed(6));
383
+ return { taxBreakdown, taxRate };
384
+ }
385
+ function resolveTaxAmount(source) {
358
386
  const taxBehavior = source.tax_behavior ??
359
387
  ((source.tax_amount ?? 0) > 0 ? 'exclusive' : undefined);
360
388
  if (taxBehavior === 'inclusive') {
@@ -533,7 +561,7 @@ exports.PaymentMethod = void 0;
533
561
  /**
534
562
  * @fileoverview Constants for Funnefox SDK
535
563
  */
536
- const SDK_VERSION = '0.8.0-ffb-550.38';
564
+ const SDK_VERSION = '0.8.0-ffb-631.40';
537
565
  const DEFAULTS = {
538
566
  BASE_URL: 'https://billing.funnelfox.com',
539
567
  REGION: 'default',
@@ -626,6 +654,7 @@ const DEFAULT_PAYMENT_METHOD_ORDER = [
626
654
  const DEFAULT_BUTTONS_OPTIONS = {
627
655
  [exports.PaymentMethod.APPLE_PAY]: {
628
656
  buttonStyle: 'black',
657
+ buttonType: 'continue',
629
658
  },
630
659
  [exports.PaymentMethod.PAYPAL]: {
631
660
  buttonColor: 'gold',
@@ -670,6 +699,117 @@ function requireString(value, fieldName) {
670
699
  return true;
671
700
  }
672
701
 
702
+ /**
703
+ * @fileoverview Support for Apple's official <apple-pay-button> custom element.
704
+ *
705
+ * Primer's checkout-web v2 renders the Apple Pay button through the CSS
706
+ * approach (-webkit-appearance: -apple-pay-button), and WebKit only accepts
707
+ * the 7 legacy values for -apple-pay-button-type. Newer types such as
708
+ * 'continue' are silently dropped and fall back to 'plain' (verified in
709
+ * Safari 26 on macOS and iOS, see PRD-1426).
710
+ *
711
+ * Apple's official custom element supports the full set of button types, so
712
+ * for "native-only" types we overlay it on top of the (visually hidden)
713
+ * Primer button and forward clicks, keeping Primer's payment flow untouched.
714
+ */
715
+ const APPLE_PAY_SDK_URL = 'https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js';
716
+ /** How long to wait for the <apple-pay-button> element definition before
717
+ * giving up and falling back to Primer's own button. */
718
+ const ELEMENT_DEFINITION_TIMEOUT_MS = 4000;
719
+ /**
720
+ * Button types WebKit can render through Primer's CSS approach.
721
+ * Everything outside this list requires the native <apple-pay-button> element.
722
+ */
723
+ const CSS_RENDERABLE_APPLE_PAY_TYPES = [
724
+ 'plain',
725
+ 'buy',
726
+ 'set-up',
727
+ 'donate',
728
+ 'check-out',
729
+ 'book',
730
+ 'subscribe',
731
+ ];
732
+ /**
733
+ * Returns true when the requested Apple Pay buttonType can only be rendered
734
+ * with Apple's native <apple-pay-button> element.
735
+ */
736
+ function isNativeOnlyApplePayButtonType(type) {
737
+ return !!type && !CSS_RENDERABLE_APPLE_PAY_TYPES.includes(type);
738
+ }
739
+ /**
740
+ * Loads Apple's apple-pay-sdk.js (defines the <apple-pay-button> custom
741
+ * element). Resolves to true when the element is available.
742
+ */
743
+ async function loadApplePayButtonElement() {
744
+ if (typeof window === 'undefined' || !window.customElements) {
745
+ return false;
746
+ }
747
+ if (window.customElements.get('apple-pay-button')) {
748
+ return true;
749
+ }
750
+ try {
751
+ await loadScript$1({
752
+ src: APPLE_PAY_SDK_URL,
753
+ crossOrigin: 'anonymous',
754
+ appendTo: 'head',
755
+ });
756
+ // Never hang on a script that loaded but did not define the element;
757
+ // fall back to Primer's own button instead.
758
+ const defined = window.customElements
759
+ .whenDefined('apple-pay-button')
760
+ .then(() => true);
761
+ const timeout = new Promise(resolve => {
762
+ setTimeout(() => resolve(false), ELEMENT_DEFINITION_TIMEOUT_MS);
763
+ });
764
+ return await Promise.race([defined, timeout]);
765
+ }
766
+ catch {
767
+ return false;
768
+ }
769
+ }
770
+ /**
771
+ * Renders Apple's native <apple-pay-button> stretched over `wrapper` and
772
+ * forwards clicks to the Primer-rendered <button> inside it. The synchronous
773
+ * forwarded click preserves the user activation required by ApplePaySession.
774
+ *
775
+ * Returns the overlay element, or null when the Apple Pay SDK could not be
776
+ * loaded (callers should keep Primer's own button visible as a fallback).
777
+ */
778
+ async function overlayNativeApplePayButton(wrapper, options) {
779
+ const available = await loadApplePayButtonElement();
780
+ if (!available) {
781
+ return null;
782
+ }
783
+ // The overlay is only safe when Primer's own clickable button is present;
784
+ // otherwise there is nothing to forward the click to.
785
+ if (!wrapper.querySelector('button')) {
786
+ return null;
787
+ }
788
+ const native = document.createElement('apple-pay-button');
789
+ native.setAttribute('type', options.buttonType);
790
+ native.setAttribute('buttonstyle', options.buttonStyle || 'black');
791
+ native.setAttribute('locale', (typeof navigator !== 'undefined' && navigator.language) || 'en-US');
792
+ native.style.cssText = [
793
+ 'position:absolute',
794
+ 'inset:0',
795
+ 'width:100%',
796
+ 'height:100%',
797
+ '--apple-pay-button-width:100%',
798
+ '--apple-pay-button-height:100%',
799
+ '--apple-pay-button-border-radius:28px',
800
+ 'display:block',
801
+ 'cursor:pointer',
802
+ 'z-index:1',
803
+ ].join(';');
804
+ wrapper.style.position = 'relative';
805
+ native.addEventListener('click', () => {
806
+ const primerButton = wrapper.querySelector('button');
807
+ primerButton?.click();
808
+ });
809
+ wrapper.appendChild(native);
810
+ return native;
811
+ }
812
+
673
813
  /**
674
814
  * @fileoverview Primer SDK integration wrapper
675
815
  */
@@ -708,6 +848,7 @@ class PrimerWrapper {
708
848
  }
709
849
  async createHeadlessCheckout(clientToken, options, method) {
710
850
  await this.ensurePrimerLoaded();
851
+ this.applePayOptions = options.applePay;
711
852
  this.currentHeadless = PrimerWrapper.headlessManager.getOrCreate(clientToken, options, method);
712
853
  return this.currentHeadless;
713
854
  }
@@ -754,16 +895,56 @@ class PrimerWrapper {
754
895
  }
755
896
  htmlNode.appendChild(wrapper);
756
897
  /* end hack */
898
+ /* PRD-1426: WebKit cannot render newer Apple Pay button types (e.g.
899
+ * 'continue') through the CSS approach Primer v2 uses. For those types
900
+ * we overlay Apple's official <apple-pay-button> element on top of the
901
+ * visually hidden Primer button and forward clicks, keeping Primer's
902
+ * payment flow untouched. The Primer button is hidden BEFORE it renders
903
+ * to avoid a flash of the plain button; if the Apple SDK fails to load,
904
+ * the rule is removed and Primer's own button stays visible (graceful
905
+ * fallback to 'plain'). */
906
+ const applePayConfig = this.applePayOptions;
907
+ const nativeButtonType = allowedPaymentMethod === exports.PaymentMethod.APPLE_PAY &&
908
+ applePayConfig?.buttonType &&
909
+ isNativeOnlyApplePayButtonType(applePayConfig.buttonType)
910
+ ? applePayConfig.buttonType
911
+ : null;
912
+ let hideRuleIndex = null;
913
+ if (nativeButtonType && sheet) {
914
+ hideRuleIndex = sheet.insertRule(`
915
+ .${wrapper.className} > button {
916
+ opacity: 0 !important;
917
+ }
918
+ `);
919
+ }
757
920
  button = pmManager.createButton();
758
921
  await button.render(wrapper, {});
759
- this.destroyCallbacks.push(() => button.clean());
922
+ let nativeOverlay = null;
923
+ if (nativeButtonType) {
924
+ nativeOverlay = await overlayNativeApplePayButton(wrapper, {
925
+ buttonType: nativeButtonType,
926
+ buttonStyle: applePayConfig?.buttonStyle,
927
+ });
928
+ if (!nativeOverlay && sheet && hideRuleIndex !== null) {
929
+ sheet.deleteRule(hideRuleIndex);
930
+ }
931
+ }
932
+ this.destroyCallbacks.push(() => {
933
+ nativeOverlay?.remove();
934
+ button.clean();
935
+ });
760
936
  onMethodRender(allowedPaymentMethod);
761
937
  return {
762
938
  setDisabled: (disabled) => {
763
939
  button.setDisabled(disabled);
940
+ if (nativeOverlay) {
941
+ nativeOverlay.style.pointerEvents = disabled ? 'none' : '';
942
+ nativeOverlay.style.opacity = disabled ? '0.5' : '';
943
+ }
764
944
  },
765
945
  destroy: () => {
766
946
  styleEl.remove();
947
+ nativeOverlay?.remove();
767
948
  button.clean();
768
949
  },
769
950
  };
@@ -1121,6 +1302,37 @@ class PrimerWrapper {
1121
1302
  }
1122
1303
  PrimerWrapper.headlessManager = new HeadlessManager();
1123
1304
 
1305
+ /**
1306
+ * @fileoverview Normalization of payment failure errors
1307
+ */
1308
+ const STRIPE_CARD_ERROR = 'card_error';
1309
+ function declineCodeFromStripeError(error) {
1310
+ const { type, code } = error;
1311
+ if (type !== STRIPE_CARD_ERROR || typeof code !== 'string' || !code) {
1312
+ return null;
1313
+ }
1314
+ return code.toUpperCase();
1315
+ }
1316
+ function toPaymentFailure(error) {
1317
+ const failure = new Error(error?.message || 'Payment processing failed');
1318
+ if (error instanceof APIError) {
1319
+ if (error.declineCode) {
1320
+ failure.declineCode = error.declineCode;
1321
+ }
1322
+ if (error.declineCodeSource) {
1323
+ failure.declineCodeSource = error.declineCodeSource;
1324
+ }
1325
+ }
1326
+ else if (error instanceof Error) {
1327
+ const declineCode = declineCodeFromStripeError(error);
1328
+ if (declineCode) {
1329
+ failure.declineCode = declineCode;
1330
+ failure.declineCodeSource = 'stripe';
1331
+ }
1332
+ }
1333
+ return failure;
1334
+ }
1335
+
1124
1336
  /**
1125
1337
  * @fileoverview API client for Funnefox backend integration
1126
1338
  */
@@ -1318,7 +1530,11 @@ class APIClient {
1318
1530
  status: 'succeeded',
1319
1531
  };
1320
1532
  case 'failed':
1321
- throw new APIError(data.failed_message_for_user || 'Payment failed', null, { response });
1533
+ throw new APIError(data.failed_message_for_user || 'Payment failed', null, {
1534
+ response,
1535
+ declineCode: data.decline_code,
1536
+ declineCodeSource: data.decline_code_source,
1537
+ });
1322
1538
  case 'cancelled':
1323
1539
  throw new APIError('Payment was cancelled by user', null, {
1324
1540
  response,
@@ -1869,6 +2085,9 @@ class CheckoutInstance extends EventEmitter {
1869
2085
  this.counter = 0;
1870
2086
  this.cachedSessionResponse = null;
1871
2087
  this.taxRecalcSeq = 0;
2088
+ // WebKit allows one ApplePaySession per page: a second tap while the sheet is
2089
+ // still opening throws InvalidAccessError, but Primer asks to start first.
2090
+ this.isApplePaySheetOpen = false;
1872
2091
  this.cardSessionFieldConfig = {};
1873
2092
  this.isTelemetryEnabled = false;
1874
2093
  this.telemetryCleanup = null;
@@ -2031,10 +2250,11 @@ class CheckoutInstance extends EventEmitter {
2031
2250
  }
2032
2251
  catch (error) {
2033
2252
  this._setState('error');
2034
- this.emit(EVENTS.PURCHASE_FAILURE, new Error(error.message || 'Payment processing failed'));
2035
- primerHandler.handleFailure(error.message || 'Payment processing failed');
2253
+ this.emit(EVENTS.PURCHASE_FAILURE, toPaymentFailure(error));
2254
+ primerHandler.handleFailure(error?.message || 'Payment processing failed');
2036
2255
  }
2037
2256
  finally {
2257
+ this.isApplePaySheetOpen = false;
2038
2258
  this.onLoaderChangeWithRace(false);
2039
2259
  this._setState('ready');
2040
2260
  }
@@ -2056,10 +2276,11 @@ class CheckoutInstance extends EventEmitter {
2056
2276
  }
2057
2277
  catch (error) {
2058
2278
  this._setState('error');
2059
- this.emit(EVENTS.PURCHASE_FAILURE, new Error(error.message || 'Payment processing failed'));
2060
- primerHandler.handleFailure(error.message || 'Payment processing failed');
2279
+ this.emit(EVENTS.PURCHASE_FAILURE, toPaymentFailure(error));
2280
+ primerHandler.handleFailure(error?.message || 'Payment processing failed');
2061
2281
  }
2062
2282
  finally {
2283
+ this.isApplePaySheetOpen = false;
2063
2284
  this.emit(EVENTS.PURCHASE_COMPLETED);
2064
2285
  this.onLoaderChangeWithRace(false);
2065
2286
  this._setState('ready');
@@ -2384,10 +2605,11 @@ class CheckoutInstance extends EventEmitter {
2384
2605
  ...checkoutConfig,
2385
2606
  ...options,
2386
2607
  card: merge(this.getPrimerCardConfig() || {}, options.card || {}),
2387
- applePay: merge(this.checkoutConfig.applePay || {}, options.applePay || {}),
2608
+ applePay: merge(DEFAULT_BUTTONS_OPTIONS[exports.PaymentMethod.APPLE_PAY], options.applePay || {}, this.checkoutConfig.applePay || {}),
2388
2609
  onTokenizeSuccess: this.handleTokenizeSuccess,
2389
2610
  onResumeSuccess: this.handleResumeSuccess,
2390
2611
  onResumeError: error => {
2612
+ this.isApplePaySheetOpen = false;
2391
2613
  if (error.stack?.includes('PROCESSOR_3DS') &&
2392
2614
  error.code === 'RESUME_ERROR' &&
2393
2615
  error.message?.includes('fetch resume key')) {
@@ -2397,15 +2619,23 @@ class CheckoutInstance extends EventEmitter {
2397
2619
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2398
2620
  },
2399
2621
  onCheckoutFail: error => {
2622
+ this.isApplePaySheetOpen = false;
2400
2623
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2401
2624
  },
2402
2625
  onTokenizeError: error => {
2626
+ this.isApplePaySheetOpen = false;
2403
2627
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2404
2628
  },
2405
2629
  onTokenizeStart: () => {
2406
2630
  wasPaymentProcessedStarted = true;
2407
2631
  },
2408
2632
  onTokenizeShouldStart: data => {
2633
+ if (data.paymentMethodType === exports.PaymentMethod.APPLE_PAY) {
2634
+ if (this.isApplePaySheetOpen) {
2635
+ return false;
2636
+ }
2637
+ this.isApplePaySheetOpen = true;
2638
+ }
2409
2639
  this.activePaymentMethodType = data.paymentMethodType;
2410
2640
  this.emit(EVENTS.ERROR, undefined);
2411
2641
  this.emit(EVENTS.START_PURCHASE, data.paymentMethodType);
@@ -2417,6 +2647,7 @@ class CheckoutInstance extends EventEmitter {
2417
2647
  this.emit(EVENTS.ERROR, undefined);
2418
2648
  break;
2419
2649
  case 'PAYMENT_METHOD_UNSELECTED':
2650
+ this.isApplePaySheetOpen = false;
2420
2651
  if (!wasPaymentProcessedStarted) {
2421
2652
  this.emit(EVENTS.PURCHASE_CANCELLED);
2422
2653
  }
@@ -3011,3 +3242,4 @@ exports.createClientSession = createClientSession;
3011
3242
  exports.getAvailablePaymentMethods = getAvailablePaymentMethods;
3012
3243
  exports.loadStripe = loadStripe;
3013
3244
  exports.resolveTaxDisplay = resolveTaxDisplay;
3245
+ exports.toPaymentFailure = toPaymentFailure;