@funnelfox/billing 0.9.0-beta.7 → 0.9.0-ffb-467-adyen.13

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
@@ -11,6 +11,7 @@ A modern TypeScript SDK for subscription payments with Primer Headless Checkout
11
11
  - 🔧 **Robust**: Built-in error handling, retries, and validation
12
12
  - 📦 **Lightweight**: Minimal dependencies, browser-optimized
13
13
  - 🎨 **Headless Checkout**: Full control over checkout UI with Primer Headless Checkout
14
+ - 💳 **Stripe Integration**: Native Stripe Elements card form and Apple Pay / Google Pay wallets
14
15
 
15
16
  ## Installation
16
17
 
@@ -711,6 +712,216 @@ const headlessCheckout = await Primer.createHeadless(session.clientToken, {
711
712
  await headlessCheckout.start();
712
713
  ```
713
714
 
715
+ ## Stripe Integration
716
+
717
+ The `Billing.stripe` namespace provides a Stripe-native checkout experience — no Primer dependency required. It supports card payments via Stripe Elements and native wallet payments (Apple Pay / Google Pay) via the Payment Request API.
718
+
719
+ > **Note:** `@primer-io/checkout-web` is **not** required for Stripe integration. Only `@funnelfox/billing` and a Stripe-enabled price in your Funnelfox account are needed.
720
+
721
+ ---
722
+
723
+ ### `Billing.stripe.createCardForm(element, params)`
724
+
725
+ Mounts a Stripe Elements payment form into a DOM element. Returns a `{ submit() }` handle — you control when payment is triggered (e.g. on your own button click).
726
+
727
+ ```javascript
728
+ const element = document.getElementById('card-form');
729
+
730
+ const cardForm = await Billing.stripe.createCardForm(element, {
731
+ // Required
732
+ priceId: 'price_123',
733
+ externalId: 'user_456',
734
+
735
+ // Optional
736
+ orgId: 'your-org-id',
737
+ email: 'user@example.com',
738
+ countryCode: 'US',
739
+ showWallets: false, // show Apple Pay / Google Pay inside the form
740
+ appearance: {
741
+ // Stripe Elements appearance API
742
+ theme: 'stripe',
743
+ },
744
+
745
+ // Callbacks
746
+ onRenderSuccess: () => {
747
+ document.getElementById('pay-button').disabled = false;
748
+ },
749
+ onLoaderChange: loading => {
750
+ document.getElementById('pay-button').disabled = loading;
751
+ },
752
+ onPaymentSuccess: (paymentMethod, orderId) => {
753
+ window.location.href = '/success?order=' + orderId;
754
+ },
755
+ onPaymentFail: error => {
756
+ console.error('Payment failed:', error.message);
757
+ },
758
+ });
759
+
760
+ // Wire up your own submit button
761
+ document.getElementById('pay-button').addEventListener('click', async () => {
762
+ await cardForm.submit();
763
+ });
764
+ ```
765
+
766
+ **Key parameters:**
767
+
768
+ | Parameter | Type | Description |
769
+ | ---------------- | -------- | --------------------------------------------------------------------------------- |
770
+ | `priceId` | string | Price identifier |
771
+ | `externalId` | string | Your user identifier |
772
+ | `email` | string? | Customer email |
773
+ | `orgId` | string? | Org ID (if not globally configured) |
774
+ | `showWallets` | boolean? | Show Apple Pay / Google Pay inside the Stripe form |
775
+ | `appearance` | object? | [Stripe Elements Appearance API](https://stripe.com/docs/elements/appearance-api) |
776
+ | `clientMetadata` | object? | Custom metadata attached to the order |
777
+
778
+ **Returns:** `Promise<{ submit: () => Promise<void> }>`
779
+
780
+ ---
781
+
782
+ ### `Billing.stripe.getAvailableWallet(params)`
783
+
784
+ Checks whether Apple Pay or Google Pay is available on the current device and browser. Use this to conditionally show a wallet button before attempting payment.
785
+
786
+ ```javascript
787
+ const wallet = await Billing.stripe.getAvailableWallet({
788
+ priceId: 'price_123',
789
+ externalId: 'user_456',
790
+ });
791
+
792
+ if (wallet === 'APPLE_PAY') {
793
+ document.getElementById('apple-pay-btn').style.display = 'block';
794
+ } else if (wallet === 'GOOGLE_PAY') {
795
+ document.getElementById('google-pay-btn').style.display = 'block';
796
+ } else {
797
+ // No wallet available — show card form only
798
+ }
799
+ ```
800
+
801
+ **Returns:** `Promise<'APPLE_PAY' | 'GOOGLE_PAY' | null>`
802
+
803
+ ---
804
+
805
+ ### `Billing.stripe.purchaseWallet(params)`
806
+
807
+ Triggers the native Apple Pay or Google Pay payment sheet. Call this on button click after confirming a wallet is available via `getAvailableWallet`.
808
+
809
+ ```javascript
810
+ document.getElementById('wallet-btn').addEventListener('click', async () => {
811
+ await Billing.stripe.purchaseWallet({
812
+ priceId: 'price_123',
813
+ externalId: 'user_456',
814
+ totalLabel: 'Premium Plan', // Label shown in the payment sheet
815
+
816
+ onPaymentSuccess: (paymentMethod, orderId) => {
817
+ window.location.href = '/success?order=' + orderId;
818
+ },
819
+ onPaymentFail: error => {
820
+ console.error('Wallet payment failed:', error.message);
821
+ },
822
+ onPaymentCancel: () => {
823
+ console.log('User cancelled');
824
+ },
825
+ onLoaderChange: loading => {
826
+ document.getElementById('wallet-btn').disabled = loading;
827
+ },
828
+ });
829
+ });
830
+ ```
831
+
832
+ **Key parameters:**
833
+
834
+ | Parameter | Type | Description |
835
+ | ---------------- | ------- | --------------------------------------------------- |
836
+ | `priceId` | string | Price identifier |
837
+ | `externalId` | string | Your user identifier |
838
+ | `totalLabel` | string? | Label shown next to the amount in the payment sheet |
839
+ | `email` | string? | Customer email |
840
+ | `clientMetadata` | object? | Custom metadata attached to the order |
841
+
842
+ ---
843
+
844
+ ### `Billing.stripe.getAvailablePaymentMethods(params)`
845
+
846
+ Returns all Stripe payment methods available for the current device. Always includes `PAYMENT_CARD`; also includes a wallet method if one is detected.
847
+
848
+ ```javascript
849
+ const methods = await Billing.stripe.getAvailablePaymentMethods({
850
+ priceId: 'price_123',
851
+ externalId: 'user_456',
852
+ });
853
+
854
+ // methods: ['PAYMENT_CARD', 'APPLE_PAY'] or ['PAYMENT_CARD'] etc.
855
+ console.log('Available:', methods);
856
+ ```
857
+
858
+ **Returns:** `Promise<PaymentMethod[]>` — always contains `PAYMENT_CARD`, optionally `APPLE_PAY` or `GOOGLE_PAY`
859
+
860
+ ---
861
+
862
+ ### Combined Example: Wallet Detection + Card Form Fallback
863
+
864
+ The recommended pattern — show a wallet button when available, always show the card form as fallback:
865
+
866
+ ```javascript
867
+ import { Billing } from '@funnelfox/billing';
868
+
869
+ Billing.configure({ orgId: 'your-org-id' });
870
+
871
+ const params = {
872
+ priceId: 'price_123',
873
+ externalId: 'user_456',
874
+ email: 'user@example.com',
875
+ };
876
+
877
+ async function initCheckout() {
878
+ // 1. Check for wallet availability
879
+ const wallet = await Billing.stripe.getAvailableWallet(params);
880
+
881
+ if (wallet) {
882
+ const walletBtn = document.getElementById('wallet-btn');
883
+ walletBtn.textContent =
884
+ wallet === 'APPLE_PAY' ? 'Pay with Apple Pay' : 'Pay with Google Pay';
885
+ walletBtn.style.display = 'block';
886
+
887
+ walletBtn.addEventListener('click', () => {
888
+ Billing.stripe.purchaseWallet({
889
+ ...params,
890
+ totalLabel: 'Premium Plan',
891
+ onPaymentSuccess: (_, orderId) => {
892
+ window.location.href = '/success?order=' + orderId;
893
+ },
894
+ onPaymentFail: err => alert(err.message),
895
+ onPaymentCancel: () => console.log('Cancelled'),
896
+ });
897
+ });
898
+ }
899
+
900
+ // 2. Always mount card form as fallback
901
+ const cardForm = await Billing.stripe.createCardForm(
902
+ document.getElementById('card-form'),
903
+ {
904
+ ...params,
905
+ onPaymentSuccess: (_, orderId) => {
906
+ window.location.href = '/success?order=' + orderId;
907
+ },
908
+ onPaymentFail: err => alert(err.message),
909
+ onLoaderChange: loading => {
910
+ document.getElementById('pay-btn').disabled = loading;
911
+ },
912
+ }
913
+ );
914
+
915
+ document.getElementById('pay-btn').addEventListener('click', () => {
916
+ cardForm.submit();
917
+ });
918
+ }
919
+
920
+ initCheckout();
921
+ ```
922
+
923
+ ---
924
+
714
925
  ## Browser Support
715
926
 
716
927
  - Chrome 60+
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @funnelfox/billing v0.1.0
3
+ * JavaScript SDK for Funnelfox billing with Primer integration
4
+ *
5
+ * @author Funnelfox
6
+ * @license MIT
7
+ */
8
+ 'use strict';
9
+
10
+ var index = require('./chunk-index.cjs.js');
11
+
12
+ /**
13
+ * @fileoverview Adyen Web SDK (Drop-in/Components) CDN loader.
14
+ *
15
+ * Adyen Web is loaded from the checkoutshopper CDN (not npm), so we declare the minimal shapes we
16
+ * use rather than depend on @adyen/adyen-web types.
17
+ */
18
+ const ADYEN_WEB_VERSION = '5.66.0';
19
+ /**
20
+ * Loads Adyen Web (adyen.js + adyen.css) from the CDN and returns the AdyenCheckout factory.
21
+ * Host is livemode-aware; the loaders dedupe so repeated calls are cheap.
22
+ */
23
+ async function getAdyenCheckout(isLivemode) {
24
+ const host = isLivemode
25
+ ? 'https://checkoutshopper-live.adyen.com'
26
+ : 'https://checkoutshopper-test.adyen.com';
27
+ const base = `${host}/checkoutshopper/sdk/${ADYEN_WEB_VERSION}`;
28
+ await Promise.all([
29
+ index.loadStylesheet({ href: `${base}/adyen.css` }),
30
+ index.loadScript({ id: 'adyen-web-sdk', src: `${base}/adyen.js`, async: true }),
31
+ ]);
32
+ const factory = window.AdyenCheckout;
33
+ if (!factory)
34
+ throw new Error('Failed to load Adyen Web SDK');
35
+ return factory;
36
+ }
37
+
38
+ const TAX_RECALC_DEBOUNCE_MS = 600;
39
+ async function mountAdyenCardForm(element, session, params) {
40
+ const { adyen_client_key, adyen_payment_methods, amount, amount_total, currency, is_livemode, order_id, client_token, detected_country_code, show_country_selector_field, show_postal_code_field, } = session.data;
41
+ const taxEnabled = session.data.tax_enabled ?? params.enableTax ?? false;
42
+ const currencyUpper = (currency || 'usd').toUpperCase();
43
+ const AdyenCheckout = await getAdyenCheckout(!!is_livemode);
44
+ // Remounting (e.g. picking another price) must replace the form, not stack a second one.
45
+ element.replaceChildren();
46
+ let taxCalculationId;
47
+ let taxAddress = {};
48
+ let flushTaxRecalc = async () => { };
49
+ let resolvePayment;
50
+ let rejectPayment;
51
+ const runRecalc = async () => {
52
+ const country = taxAddress.country || detected_country_code;
53
+ // US tax needs the ZIP to resolve a jurisdiction; skip until it's entered.
54
+ if (!country || (country === 'US' && !taxAddress.postalCode))
55
+ return;
56
+ try {
57
+ const tax = await params.apiClient.recalculateTax({
58
+ orderId: order_id,
59
+ clientToken: client_token,
60
+ countryCode: country,
61
+ postalCode: taxAddress.postalCode,
62
+ subdivision: taxAddress.state,
63
+ });
64
+ taxCalculationId = tax.tax_calculation_id;
65
+ params.onTaxChange?.({
66
+ amountTotal: tax.amount_total,
67
+ taxAmount: tax.tax_amount,
68
+ currency: tax.currency,
69
+ });
70
+ }
71
+ catch {
72
+ // Best-effort: keep the current total if recalculation fails.
73
+ }
74
+ };
75
+ const finalize = async (raw, component) => {
76
+ const result = params.apiClient.processPaymentResponse(raw);
77
+ if (result.type === 'action_required') {
78
+ // Adyen packs the next action (3DS) JSON into action_required_token; the challenge result
79
+ // comes back through onAdditionalDetails, which resumes the payment.
80
+ component.handleAction(JSON.parse(result.clientToken));
81
+ return;
82
+ }
83
+ params.onPaymentSuccess?.(order_id);
84
+ resolvePayment?.();
85
+ };
86
+ const checkout = await AdyenCheckout({
87
+ environment: is_livemode ? 'live' : 'test',
88
+ clientKey: adyen_client_key,
89
+ paymentMethodsResponse: adyen_payment_methods,
90
+ amount: { value: amount_total ?? amount ?? 0, currency: currencyUpper },
91
+ locale: 'en-US',
92
+ onSubmit: (state, component) => {
93
+ void (async () => {
94
+ try {
95
+ const address = state.data.billingAddress || {};
96
+ const raw = await params.apiClient.createPayment({
97
+ orderId: order_id,
98
+ paymentMethodToken: JSON.stringify(state.data),
99
+ email: params.email,
100
+ countryCode: taxEnabled ? address.country : params.countryCode,
101
+ postalCode: taxEnabled ? address.postalCode : undefined,
102
+ subdivision: taxEnabled ? address.stateOrProvince : undefined,
103
+ taxCalculationId: taxEnabled ? taxCalculationId : undefined,
104
+ clientMetadata: params.clientMetadata,
105
+ });
106
+ await finalize(raw, component);
107
+ }
108
+ catch (err) {
109
+ params.onPaymentFail?.(err);
110
+ rejectPayment?.(err);
111
+ }
112
+ })();
113
+ },
114
+ onAdditionalDetails: (state, component) => {
115
+ void (async () => {
116
+ try {
117
+ const raw = await params.apiClient.resumePayment({
118
+ orderId: order_id,
119
+ resumeToken: JSON.stringify(state.data),
120
+ });
121
+ await finalize(raw, component);
122
+ }
123
+ catch (err) {
124
+ params.onPaymentFail?.(err);
125
+ rejectPayment?.(err);
126
+ }
127
+ })();
128
+ },
129
+ onError: error => params.onPaymentFail?.(error),
130
+ });
131
+ const billingFields = [];
132
+ if (show_country_selector_field)
133
+ billingFields.push('country');
134
+ if (show_postal_code_field)
135
+ billingFields.push('postalCode');
136
+ let debounce;
137
+ const cardConfig = {
138
+ showPayButton: false,
139
+ billingAddressRequired: billingFields.length > 0,
140
+ billingAddressRequiredFields: billingFields,
141
+ onChange: (state) => {
142
+ if (!taxEnabled)
143
+ return;
144
+ const address = state.data.billingAddress;
145
+ if (!address)
146
+ return;
147
+ taxAddress = {
148
+ country: address.country,
149
+ postalCode: address.postalCode || undefined,
150
+ state: address.stateOrProvince || undefined,
151
+ };
152
+ if (debounce)
153
+ clearTimeout(debounce);
154
+ debounce = setTimeout(() => void runRecalc(), TAX_RECALC_DEBOUNCE_MS);
155
+ },
156
+ };
157
+ if (detected_country_code) {
158
+ cardConfig.data = { billingAddress: { country: detected_country_code } };
159
+ }
160
+ const card = checkout.create('card', cardConfig);
161
+ card.mount(element);
162
+ flushTaxRecalc = async () => {
163
+ if (debounce) {
164
+ clearTimeout(debounce);
165
+ debounce = undefined;
166
+ }
167
+ if (taxEnabled)
168
+ await runRecalc();
169
+ };
170
+ params.onRenderSuccess?.();
171
+ return {
172
+ submit: async () => {
173
+ params.onLoaderChange?.(true);
174
+ const done = new Promise((resolve, reject) => {
175
+ resolvePayment = resolve;
176
+ rejectPayment = reject;
177
+ });
178
+ try {
179
+ await flushTaxRecalc();
180
+ card.submit();
181
+ await done;
182
+ }
183
+ finally {
184
+ params.onLoaderChange?.(false);
185
+ }
186
+ },
187
+ };
188
+ }
189
+
190
+ exports.mountAdyenCardForm = mountAdyenCardForm;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @funnelfox/billing v0.1.0
3
+ * JavaScript SDK for Funnelfox billing with Primer integration
4
+ *
5
+ * @author Funnelfox
6
+ * @license MIT
7
+ */
8
+ import { l as loadStylesheet, a as loadScript } from './chunk-index.es.js';
9
+
10
+ /**
11
+ * @fileoverview Adyen Web SDK (Drop-in/Components) CDN loader.
12
+ *
13
+ * Adyen Web is loaded from the checkoutshopper CDN (not npm), so we declare the minimal shapes we
14
+ * use rather than depend on @adyen/adyen-web types.
15
+ */
16
+ const ADYEN_WEB_VERSION = '5.66.0';
17
+ /**
18
+ * Loads Adyen Web (adyen.js + adyen.css) from the CDN and returns the AdyenCheckout factory.
19
+ * Host is livemode-aware; the loaders dedupe so repeated calls are cheap.
20
+ */
21
+ async function getAdyenCheckout(isLivemode) {
22
+ const host = isLivemode
23
+ ? 'https://checkoutshopper-live.adyen.com'
24
+ : 'https://checkoutshopper-test.adyen.com';
25
+ const base = `${host}/checkoutshopper/sdk/${ADYEN_WEB_VERSION}`;
26
+ await Promise.all([
27
+ loadStylesheet({ href: `${base}/adyen.css` }),
28
+ loadScript({ id: 'adyen-web-sdk', src: `${base}/adyen.js`, async: true }),
29
+ ]);
30
+ const factory = window.AdyenCheckout;
31
+ if (!factory)
32
+ throw new Error('Failed to load Adyen Web SDK');
33
+ return factory;
34
+ }
35
+
36
+ const TAX_RECALC_DEBOUNCE_MS = 600;
37
+ async function mountAdyenCardForm(element, session, params) {
38
+ const { adyen_client_key, adyen_payment_methods, amount, amount_total, currency, is_livemode, order_id, client_token, detected_country_code, show_country_selector_field, show_postal_code_field, } = session.data;
39
+ const taxEnabled = session.data.tax_enabled ?? params.enableTax ?? false;
40
+ const currencyUpper = (currency || 'usd').toUpperCase();
41
+ const AdyenCheckout = await getAdyenCheckout(!!is_livemode);
42
+ // Remounting (e.g. picking another price) must replace the form, not stack a second one.
43
+ element.replaceChildren();
44
+ let taxCalculationId;
45
+ let taxAddress = {};
46
+ let flushTaxRecalc = async () => { };
47
+ let resolvePayment;
48
+ let rejectPayment;
49
+ const runRecalc = async () => {
50
+ const country = taxAddress.country || detected_country_code;
51
+ // US tax needs the ZIP to resolve a jurisdiction; skip until it's entered.
52
+ if (!country || (country === 'US' && !taxAddress.postalCode))
53
+ return;
54
+ try {
55
+ const tax = await params.apiClient.recalculateTax({
56
+ orderId: order_id,
57
+ clientToken: client_token,
58
+ countryCode: country,
59
+ postalCode: taxAddress.postalCode,
60
+ subdivision: taxAddress.state,
61
+ });
62
+ taxCalculationId = tax.tax_calculation_id;
63
+ params.onTaxChange?.({
64
+ amountTotal: tax.amount_total,
65
+ taxAmount: tax.tax_amount,
66
+ currency: tax.currency,
67
+ });
68
+ }
69
+ catch {
70
+ // Best-effort: keep the current total if recalculation fails.
71
+ }
72
+ };
73
+ const finalize = async (raw, component) => {
74
+ const result = params.apiClient.processPaymentResponse(raw);
75
+ if (result.type === 'action_required') {
76
+ // Adyen packs the next action (3DS) JSON into action_required_token; the challenge result
77
+ // comes back through onAdditionalDetails, which resumes the payment.
78
+ component.handleAction(JSON.parse(result.clientToken));
79
+ return;
80
+ }
81
+ params.onPaymentSuccess?.(order_id);
82
+ resolvePayment?.();
83
+ };
84
+ const checkout = await AdyenCheckout({
85
+ environment: is_livemode ? 'live' : 'test',
86
+ clientKey: adyen_client_key,
87
+ paymentMethodsResponse: adyen_payment_methods,
88
+ amount: { value: amount_total ?? amount ?? 0, currency: currencyUpper },
89
+ locale: 'en-US',
90
+ onSubmit: (state, component) => {
91
+ void (async () => {
92
+ try {
93
+ const address = state.data.billingAddress || {};
94
+ const raw = await params.apiClient.createPayment({
95
+ orderId: order_id,
96
+ paymentMethodToken: JSON.stringify(state.data),
97
+ email: params.email,
98
+ countryCode: taxEnabled ? address.country : params.countryCode,
99
+ postalCode: taxEnabled ? address.postalCode : undefined,
100
+ subdivision: taxEnabled ? address.stateOrProvince : undefined,
101
+ taxCalculationId: taxEnabled ? taxCalculationId : undefined,
102
+ clientMetadata: params.clientMetadata,
103
+ });
104
+ await finalize(raw, component);
105
+ }
106
+ catch (err) {
107
+ params.onPaymentFail?.(err);
108
+ rejectPayment?.(err);
109
+ }
110
+ })();
111
+ },
112
+ onAdditionalDetails: (state, component) => {
113
+ void (async () => {
114
+ try {
115
+ const raw = await params.apiClient.resumePayment({
116
+ orderId: order_id,
117
+ resumeToken: JSON.stringify(state.data),
118
+ });
119
+ await finalize(raw, component);
120
+ }
121
+ catch (err) {
122
+ params.onPaymentFail?.(err);
123
+ rejectPayment?.(err);
124
+ }
125
+ })();
126
+ },
127
+ onError: error => params.onPaymentFail?.(error),
128
+ });
129
+ const billingFields = [];
130
+ if (show_country_selector_field)
131
+ billingFields.push('country');
132
+ if (show_postal_code_field)
133
+ billingFields.push('postalCode');
134
+ let debounce;
135
+ const cardConfig = {
136
+ showPayButton: false,
137
+ billingAddressRequired: billingFields.length > 0,
138
+ billingAddressRequiredFields: billingFields,
139
+ onChange: (state) => {
140
+ if (!taxEnabled)
141
+ return;
142
+ const address = state.data.billingAddress;
143
+ if (!address)
144
+ return;
145
+ taxAddress = {
146
+ country: address.country,
147
+ postalCode: address.postalCode || undefined,
148
+ state: address.stateOrProvince || undefined,
149
+ };
150
+ if (debounce)
151
+ clearTimeout(debounce);
152
+ debounce = setTimeout(() => void runRecalc(), TAX_RECALC_DEBOUNCE_MS);
153
+ },
154
+ };
155
+ if (detected_country_code) {
156
+ cardConfig.data = { billingAddress: { country: detected_country_code } };
157
+ }
158
+ const card = checkout.create('card', cardConfig);
159
+ card.mount(element);
160
+ flushTaxRecalc = async () => {
161
+ if (debounce) {
162
+ clearTimeout(debounce);
163
+ debounce = undefined;
164
+ }
165
+ if (taxEnabled)
166
+ await runRecalc();
167
+ };
168
+ params.onRenderSuccess?.();
169
+ return {
170
+ submit: async () => {
171
+ params.onLoaderChange?.(true);
172
+ const done = new Promise((resolve, reject) => {
173
+ resolvePayment = resolve;
174
+ rejectPayment = reject;
175
+ });
176
+ try {
177
+ await flushTaxRecalc();
178
+ card.submit();
179
+ await done;
180
+ }
181
+ finally {
182
+ params.onLoaderChange?.(false);
183
+ }
184
+ },
185
+ };
186
+ }
187
+
188
+ export { mountAdyenCardForm };