@funnelfox/billing 0.8.0-ffb-395.9 → 0.8.0-ffb-395.12

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.
@@ -532,6 +532,7 @@ const API_ENDPOINTS = {
532
532
  RESUME_PAYMENT: '/v1/checkout/resume_payment',
533
533
  ONE_CLICK: '/v1/checkout/one_click',
534
534
  CREATE_SIMPLE_CLIENT_SESSION: '/v1/checkout/create_simple_client_session',
535
+ RECALCULATE_TAX: '/v1/checkout/recalculate_tax',
535
536
  };
536
537
  const ERROR_CODES = {
537
538
  SDK_ERROR: 'SDK_ERROR',
@@ -1169,11 +1170,35 @@ class APIClient {
1169
1170
  if (params.postalCode !== undefined) {
1170
1171
  payload.postal_code = params.postalCode;
1171
1172
  }
1173
+ if (params.subdivision !== undefined) {
1174
+ payload.subdivision = params.subdivision;
1175
+ }
1176
+ if (params.taxCalculationId !== undefined) {
1177
+ payload.tax_calculation_id = params.taxCalculationId;
1178
+ }
1172
1179
  return (await this.request(API_ENDPOINTS.CREATE_PAYMENT, {
1173
1180
  method: 'POST',
1174
1181
  body: JSON.stringify(payload),
1175
1182
  }));
1176
1183
  }
1184
+ async recalculateTax(params) {
1185
+ const payload = {
1186
+ order_id: params.orderId,
1187
+ client_token: params.clientToken,
1188
+ country_code: params.countryCode,
1189
+ };
1190
+ if (params.postalCode) {
1191
+ payload.postal_code = params.postalCode;
1192
+ }
1193
+ if (params.subdivision) {
1194
+ payload.subdivision = params.subdivision;
1195
+ }
1196
+ const raw = (await this.request(API_ENDPOINTS.RECALCULATE_TAX, {
1197
+ method: 'POST',
1198
+ body: JSON.stringify(payload),
1199
+ }));
1200
+ return raw.data;
1201
+ }
1177
1202
  async resumePayment(params) {
1178
1203
  const payload = {
1179
1204
  order_id: params.orderId,
@@ -1753,6 +1778,7 @@ function getPageUrl() {
1753
1778
  /**
1754
1779
  * @fileoverview Checkout instance manager for Funnefox SDK
1755
1780
  */
1781
+ const TAX_RECALC_DEBOUNCE_MS = 600;
1756
1782
  class CheckoutInstance extends EventEmitter {
1757
1783
  constructor(config) {
1758
1784
  super();
@@ -1788,11 +1814,71 @@ class CheckoutInstance extends EventEmitter {
1788
1814
  if (!this.isPostalCodeVisible()) {
1789
1815
  this.cardPostalCode = undefined;
1790
1816
  }
1791
- return;
1792
1817
  }
1793
- if (inputName === 'postalCode') {
1818
+ else if (inputName === 'postalCode') {
1794
1819
  this.cardPostalCode = value?.trim() || undefined;
1795
1820
  }
1821
+ if (this.isTaxEnabled()) {
1822
+ this.scheduleTaxRecalc();
1823
+ }
1824
+ };
1825
+ // Tax is driven by the tenant's tax_calculation_provider setting (surfaced as session.tax_enabled).
1826
+ // enableTax stays as a legacy host override only when the backend doesn't report the flag.
1827
+ this.isTaxEnabled = () => this.cachedSessionResponse?.data?.tax_enabled ??
1828
+ this.checkoutConfig.enableTax ??
1829
+ false;
1830
+ this.emitSessionTaxEstimate = () => {
1831
+ const data = this.cachedSessionResponse?.data;
1832
+ if (!data || data.amount_total == null) {
1833
+ return;
1834
+ }
1835
+ this.checkoutConfig.onTaxChange?.({
1836
+ amountTotal: data.amount_total,
1837
+ taxAmount: data.tax_amount ?? 0,
1838
+ currency: data.currency ?? '',
1839
+ });
1840
+ };
1841
+ this.runTaxRecalc = async () => {
1842
+ const orderId = this.orderId;
1843
+ const countryCode = this.getSelectedCountryCode();
1844
+ if (!orderId || !countryCode) {
1845
+ return;
1846
+ }
1847
+ try {
1848
+ const tax = await this.apiClient.recalculateTax({
1849
+ orderId,
1850
+ clientToken: this.cachedSessionResponse?.data?.client_token ?? '',
1851
+ countryCode,
1852
+ postalCode: this.cardPostalCode,
1853
+ });
1854
+ this.checkoutConfig.onTaxChange?.({
1855
+ amountTotal: tax.amount_total,
1856
+ taxAmount: tax.tax_amount,
1857
+ currency: tax.currency,
1858
+ });
1859
+ }
1860
+ catch (err) {
1861
+ // Recalc failed for the new address; surface it instead of silently showing a stale total.
1862
+ console.warn('[funnelfox-billing] tax recalculation failed', err);
1863
+ this.checkoutConfig.onTaxError?.(err);
1864
+ }
1865
+ };
1866
+ this.scheduleTaxRecalc = () => {
1867
+ if (this.taxRecalcDebounce) {
1868
+ clearTimeout(this.taxRecalcDebounce);
1869
+ }
1870
+ this.taxRecalcDebounce = setTimeout(this.runTaxRecalc, TAX_RECALC_DEBOUNCE_MS);
1871
+ };
1872
+ // Flush a pending recalc so a card charge reflects the final entered address (no estimate drift).
1873
+ // Used for card payments only; wallets authorize their own amount and are left untouched.
1874
+ this.flushTaxRecalc = async () => {
1875
+ if (this.taxRecalcDebounce) {
1876
+ clearTimeout(this.taxRecalcDebounce);
1877
+ this.taxRecalcDebounce = undefined;
1878
+ }
1879
+ if (this.isTaxEnabled()) {
1880
+ await this.runTaxRecalc();
1881
+ }
1796
1882
  };
1797
1883
  this.handleMethodRender = (method) => {
1798
1884
  this.emit(EVENTS.METHOD_RENDER, method);
@@ -1812,6 +1898,11 @@ class CheckoutInstance extends EventEmitter {
1812
1898
  try {
1813
1899
  this.onLoaderChangeWithRace(true);
1814
1900
  this._setState('processing');
1901
+ // Card charge must reflect the final entered address; wallets authorize their own amount, so
1902
+ // only flush the pending recalc for card payments.
1903
+ if (this.activePaymentMethodType === exports.PaymentMethod.PAYMENT_CARD) {
1904
+ await this.flushTaxRecalc();
1905
+ }
1815
1906
  const [radarSessionId, airwallexDeviceId] = await Promise.all([
1816
1907
  this.cachedSessionResponse?.radarSessionId,
1817
1908
  this.cachedSessionResponse?.airwallexDeviceId,
@@ -1929,6 +2020,9 @@ class CheckoutInstance extends EventEmitter {
1929
2020
  await this.createSession();
1930
2021
  await this._initializePrimerCheckout();
1931
2022
  this._setState('ready');
2023
+ if (this.isTaxEnabled()) {
2024
+ this.emitSessionTaxEstimate();
2025
+ }
1932
2026
  this.startUnhandledTelemetry();
1933
2027
  this.checkoutConfig?.onInitialized?.();
1934
2028
  return this;
@@ -2203,6 +2297,7 @@ class CheckoutInstance extends EventEmitter {
2203
2297
  wasPaymentProcessedStarted = true;
2204
2298
  },
2205
2299
  onTokenizeShouldStart: data => {
2300
+ this.activePaymentMethodType = data.paymentMethodType;
2206
2301
  this.emit(EVENTS.ERROR, undefined);
2207
2302
  this.emit(EVENTS.START_PURCHASE, data.paymentMethodType);
2208
2303
  return true;
@@ -2242,6 +2337,9 @@ class CheckoutInstance extends EventEmitter {
2242
2337
  await this.primerWrapper.refreshClientSession();
2243
2338
  this.onLoaderChangeWithRace(false);
2244
2339
  this._setState('ready');
2340
+ if (this.isTaxEnabled()) {
2341
+ this.scheduleTaxRecalc();
2342
+ }
2245
2343
  }
2246
2344
  catch (error) {
2247
2345
  this.onLoaderChangeWithRace(false);
@@ -530,6 +530,7 @@ const API_ENDPOINTS = {
530
530
  RESUME_PAYMENT: '/v1/checkout/resume_payment',
531
531
  ONE_CLICK: '/v1/checkout/one_click',
532
532
  CREATE_SIMPLE_CLIENT_SESSION: '/v1/checkout/create_simple_client_session',
533
+ RECALCULATE_TAX: '/v1/checkout/recalculate_tax',
533
534
  };
534
535
  const ERROR_CODES = {
535
536
  SDK_ERROR: 'SDK_ERROR',
@@ -1167,11 +1168,35 @@ class APIClient {
1167
1168
  if (params.postalCode !== undefined) {
1168
1169
  payload.postal_code = params.postalCode;
1169
1170
  }
1171
+ if (params.subdivision !== undefined) {
1172
+ payload.subdivision = params.subdivision;
1173
+ }
1174
+ if (params.taxCalculationId !== undefined) {
1175
+ payload.tax_calculation_id = params.taxCalculationId;
1176
+ }
1170
1177
  return (await this.request(API_ENDPOINTS.CREATE_PAYMENT, {
1171
1178
  method: 'POST',
1172
1179
  body: JSON.stringify(payload),
1173
1180
  }));
1174
1181
  }
1182
+ async recalculateTax(params) {
1183
+ const payload = {
1184
+ order_id: params.orderId,
1185
+ client_token: params.clientToken,
1186
+ country_code: params.countryCode,
1187
+ };
1188
+ if (params.postalCode) {
1189
+ payload.postal_code = params.postalCode;
1190
+ }
1191
+ if (params.subdivision) {
1192
+ payload.subdivision = params.subdivision;
1193
+ }
1194
+ const raw = (await this.request(API_ENDPOINTS.RECALCULATE_TAX, {
1195
+ method: 'POST',
1196
+ body: JSON.stringify(payload),
1197
+ }));
1198
+ return raw.data;
1199
+ }
1175
1200
  async resumePayment(params) {
1176
1201
  const payload = {
1177
1202
  order_id: params.orderId,
@@ -1751,6 +1776,7 @@ function getPageUrl() {
1751
1776
  /**
1752
1777
  * @fileoverview Checkout instance manager for Funnefox SDK
1753
1778
  */
1779
+ const TAX_RECALC_DEBOUNCE_MS = 600;
1754
1780
  class CheckoutInstance extends EventEmitter {
1755
1781
  constructor(config) {
1756
1782
  super();
@@ -1786,11 +1812,71 @@ class CheckoutInstance extends EventEmitter {
1786
1812
  if (!this.isPostalCodeVisible()) {
1787
1813
  this.cardPostalCode = undefined;
1788
1814
  }
1789
- return;
1790
1815
  }
1791
- if (inputName === 'postalCode') {
1816
+ else if (inputName === 'postalCode') {
1792
1817
  this.cardPostalCode = value?.trim() || undefined;
1793
1818
  }
1819
+ if (this.isTaxEnabled()) {
1820
+ this.scheduleTaxRecalc();
1821
+ }
1822
+ };
1823
+ // Tax is driven by the tenant's tax_calculation_provider setting (surfaced as session.tax_enabled).
1824
+ // enableTax stays as a legacy host override only when the backend doesn't report the flag.
1825
+ this.isTaxEnabled = () => this.cachedSessionResponse?.data?.tax_enabled ??
1826
+ this.checkoutConfig.enableTax ??
1827
+ false;
1828
+ this.emitSessionTaxEstimate = () => {
1829
+ const data = this.cachedSessionResponse?.data;
1830
+ if (!data || data.amount_total == null) {
1831
+ return;
1832
+ }
1833
+ this.checkoutConfig.onTaxChange?.({
1834
+ amountTotal: data.amount_total,
1835
+ taxAmount: data.tax_amount ?? 0,
1836
+ currency: data.currency ?? '',
1837
+ });
1838
+ };
1839
+ this.runTaxRecalc = async () => {
1840
+ const orderId = this.orderId;
1841
+ const countryCode = this.getSelectedCountryCode();
1842
+ if (!orderId || !countryCode) {
1843
+ return;
1844
+ }
1845
+ try {
1846
+ const tax = await this.apiClient.recalculateTax({
1847
+ orderId,
1848
+ clientToken: this.cachedSessionResponse?.data?.client_token ?? '',
1849
+ countryCode,
1850
+ postalCode: this.cardPostalCode,
1851
+ });
1852
+ this.checkoutConfig.onTaxChange?.({
1853
+ amountTotal: tax.amount_total,
1854
+ taxAmount: tax.tax_amount,
1855
+ currency: tax.currency,
1856
+ });
1857
+ }
1858
+ catch (err) {
1859
+ // Recalc failed for the new address; surface it instead of silently showing a stale total.
1860
+ console.warn('[funnelfox-billing] tax recalculation failed', err);
1861
+ this.checkoutConfig.onTaxError?.(err);
1862
+ }
1863
+ };
1864
+ this.scheduleTaxRecalc = () => {
1865
+ if (this.taxRecalcDebounce) {
1866
+ clearTimeout(this.taxRecalcDebounce);
1867
+ }
1868
+ this.taxRecalcDebounce = setTimeout(this.runTaxRecalc, TAX_RECALC_DEBOUNCE_MS);
1869
+ };
1870
+ // Flush a pending recalc so a card charge reflects the final entered address (no estimate drift).
1871
+ // Used for card payments only; wallets authorize their own amount and are left untouched.
1872
+ this.flushTaxRecalc = async () => {
1873
+ if (this.taxRecalcDebounce) {
1874
+ clearTimeout(this.taxRecalcDebounce);
1875
+ this.taxRecalcDebounce = undefined;
1876
+ }
1877
+ if (this.isTaxEnabled()) {
1878
+ await this.runTaxRecalc();
1879
+ }
1794
1880
  };
1795
1881
  this.handleMethodRender = (method) => {
1796
1882
  this.emit(EVENTS.METHOD_RENDER, method);
@@ -1810,6 +1896,11 @@ class CheckoutInstance extends EventEmitter {
1810
1896
  try {
1811
1897
  this.onLoaderChangeWithRace(true);
1812
1898
  this._setState('processing');
1899
+ // Card charge must reflect the final entered address; wallets authorize their own amount, so
1900
+ // only flush the pending recalc for card payments.
1901
+ if (this.activePaymentMethodType === PaymentMethod.PAYMENT_CARD) {
1902
+ await this.flushTaxRecalc();
1903
+ }
1813
1904
  const [radarSessionId, airwallexDeviceId] = await Promise.all([
1814
1905
  this.cachedSessionResponse?.radarSessionId,
1815
1906
  this.cachedSessionResponse?.airwallexDeviceId,
@@ -1927,6 +2018,9 @@ class CheckoutInstance extends EventEmitter {
1927
2018
  await this.createSession();
1928
2019
  await this._initializePrimerCheckout();
1929
2020
  this._setState('ready');
2021
+ if (this.isTaxEnabled()) {
2022
+ this.emitSessionTaxEstimate();
2023
+ }
1930
2024
  this.startUnhandledTelemetry();
1931
2025
  this.checkoutConfig?.onInitialized?.();
1932
2026
  return this;
@@ -2201,6 +2295,7 @@ class CheckoutInstance extends EventEmitter {
2201
2295
  wasPaymentProcessedStarted = true;
2202
2296
  },
2203
2297
  onTokenizeShouldStart: data => {
2298
+ this.activePaymentMethodType = data.paymentMethodType;
2204
2299
  this.emit(EVENTS.ERROR, undefined);
2205
2300
  this.emit(EVENTS.START_PURCHASE, data.paymentMethodType);
2206
2301
  return true;
@@ -2240,6 +2335,9 @@ class CheckoutInstance extends EventEmitter {
2240
2335
  await this.primerWrapper.refreshClientSession();
2241
2336
  this.onLoaderChangeWithRace(false);
2242
2337
  this._setState('ready');
2338
+ if (this.isTaxEnabled()) {
2339
+ this.scheduleTaxRecalc();
2340
+ }
2243
2341
  }
2244
2342
  catch (error) {
2245
2343
  this.onLoaderChangeWithRace(false);
@@ -10,11 +10,16 @@
10
10
  var stripeLoader = require('./chunk-stripe-loader.cjs.js');
11
11
  require('./chunk-index.cjs.js');
12
12
 
13
+ const TAX_RECALC_DEBOUNCE_MS = 600;
13
14
  async function mountStripeCardForm(element, session, params) {
14
15
  const { stripe_public_key, amount, currency, order_id, is_link_enabled } = session.data;
16
+ // Tax flow is driven by the tenant setting (session.tax_enabled); enableTax is a legacy override.
17
+ const taxEnabled = session.data.tax_enabled ?? params.enableTax ?? false;
15
18
  const stripe = await stripeLoader.getStripe(stripe_public_key);
16
19
  if (!stripe)
17
20
  throw new Error('Failed to load Stripe');
21
+ // Remounting (e.g. picking another price) must replace the form, not stack a second one.
22
+ element.replaceChildren();
18
23
  const stripeElements = stripe.elements({
19
24
  mode: 'subscription',
20
25
  amount,
@@ -31,7 +36,81 @@ async function mountStripeCardForm(element, session, params) {
31
36
  },
32
37
  terms: { card: 'never' },
33
38
  });
34
- paymentElement.mount(element);
39
+ // Tax mode mounts Stripe's Address Element (its change events are readable, so tax recalculates live
40
+ // as the buyer edits the address) above the Payment Element. The country is pre-filled from the
41
+ // detected location so tax is computed from the address (not a separate IP estimate); the resulting
42
+ // calculation id is attached to the PaymentIntent at payment time so Stripe records the tax.
43
+ let taxCalculationId;
44
+ let taxAddress = {};
45
+ // Flushed on submit so the charge always reflects the final entered address (no estimate drift).
46
+ let flushTaxRecalc = async () => { };
47
+ if (taxEnabled) {
48
+ const addressContainer = document.createElement('div');
49
+ const paymentContainer = document.createElement('div');
50
+ element.appendChild(addressContainer);
51
+ element.appendChild(paymentContainer);
52
+ const addressElement = stripeElements.create('address', {
53
+ mode: 'billing',
54
+ fields: { phone: 'never' },
55
+ ...(session.data.detected_country_code
56
+ ? {
57
+ defaultValues: {
58
+ address: { country: session.data.detected_country_code },
59
+ },
60
+ }
61
+ : {}),
62
+ });
63
+ addressElement.mount(addressContainer);
64
+ paymentElement.mount(paymentContainer);
65
+ const runRecalc = async () => {
66
+ const country = taxAddress.country;
67
+ if (!country)
68
+ return;
69
+ try {
70
+ const tax = await params.apiClient.recalculateTax({
71
+ orderId: order_id,
72
+ clientToken: session.data.client_token,
73
+ countryCode: country,
74
+ postalCode: taxAddress.postalCode,
75
+ subdivision: taxAddress.state,
76
+ });
77
+ taxCalculationId = tax.tax_calculation_id;
78
+ stripeElements.update({ amount: tax.amount_total });
79
+ params.onTaxChange?.({
80
+ amountTotal: tax.amount_total,
81
+ taxAmount: tax.tax_amount,
82
+ currency: tax.currency,
83
+ });
84
+ }
85
+ catch {
86
+ // Best-effort: keep the current total if recalculation fails.
87
+ }
88
+ };
89
+ let debounce;
90
+ flushTaxRecalc = async () => {
91
+ if (debounce) {
92
+ clearTimeout(debounce);
93
+ debounce = undefined;
94
+ }
95
+ await runRecalc();
96
+ };
97
+ addressElement.on('change', event => {
98
+ const address = event.value.address;
99
+ taxAddress = {
100
+ country: address.country,
101
+ postalCode: address.postal_code || undefined,
102
+ state: address.state || undefined,
103
+ };
104
+ if (!taxAddress.country)
105
+ return;
106
+ if (debounce)
107
+ clearTimeout(debounce);
108
+ debounce = setTimeout(runRecalc, TAX_RECALC_DEBOUNCE_MS);
109
+ });
110
+ }
111
+ else {
112
+ paymentElement.mount(element);
113
+ }
35
114
  await new Promise((resolve, reject) => {
36
115
  paymentElement.once('ready', () => resolve());
37
116
  // 'loaderror' is a valid Stripe event but not yet in the @stripe/stripe-js types
@@ -42,6 +121,7 @@ async function mountStripeCardForm(element, session, params) {
42
121
  submit: async () => {
43
122
  params.onLoaderChange?.(true);
44
123
  try {
124
+ await flushTaxRecalc();
45
125
  const { error: submitError } = await stripeElements.submit();
46
126
  if (submitError)
47
127
  throw submitError;
@@ -54,7 +134,10 @@ async function mountStripeCardForm(element, session, params) {
54
134
  orderId: order_id,
55
135
  paymentMethodToken: paymentMethod.id,
56
136
  email: params.email,
57
- countryCode: params.countryCode,
137
+ countryCode: taxEnabled ? taxAddress.country : params.countryCode,
138
+ postalCode: taxEnabled ? taxAddress.postalCode : undefined,
139
+ subdivision: taxEnabled ? taxAddress.state : undefined,
140
+ taxCalculationId: taxEnabled ? taxCalculationId : undefined,
58
141
  clientMetadata: params.clientMetadata,
59
142
  });
60
143
  const result = params.apiClient.processPaymentResponse(raw);
@@ -8,11 +8,16 @@
8
8
  import { g as getStripe } from './chunk-stripe-loader.es.js';
9
9
  import './chunk-index.es.js';
10
10
 
11
+ const TAX_RECALC_DEBOUNCE_MS = 600;
11
12
  async function mountStripeCardForm(element, session, params) {
12
13
  const { stripe_public_key, amount, currency, order_id, is_link_enabled } = session.data;
14
+ // Tax flow is driven by the tenant setting (session.tax_enabled); enableTax is a legacy override.
15
+ const taxEnabled = session.data.tax_enabled ?? params.enableTax ?? false;
13
16
  const stripe = await getStripe(stripe_public_key);
14
17
  if (!stripe)
15
18
  throw new Error('Failed to load Stripe');
19
+ // Remounting (e.g. picking another price) must replace the form, not stack a second one.
20
+ element.replaceChildren();
16
21
  const stripeElements = stripe.elements({
17
22
  mode: 'subscription',
18
23
  amount,
@@ -29,7 +34,81 @@ async function mountStripeCardForm(element, session, params) {
29
34
  },
30
35
  terms: { card: 'never' },
31
36
  });
32
- paymentElement.mount(element);
37
+ // Tax mode mounts Stripe's Address Element (its change events are readable, so tax recalculates live
38
+ // as the buyer edits the address) above the Payment Element. The country is pre-filled from the
39
+ // detected location so tax is computed from the address (not a separate IP estimate); the resulting
40
+ // calculation id is attached to the PaymentIntent at payment time so Stripe records the tax.
41
+ let taxCalculationId;
42
+ let taxAddress = {};
43
+ // Flushed on submit so the charge always reflects the final entered address (no estimate drift).
44
+ let flushTaxRecalc = async () => { };
45
+ if (taxEnabled) {
46
+ const addressContainer = document.createElement('div');
47
+ const paymentContainer = document.createElement('div');
48
+ element.appendChild(addressContainer);
49
+ element.appendChild(paymentContainer);
50
+ const addressElement = stripeElements.create('address', {
51
+ mode: 'billing',
52
+ fields: { phone: 'never' },
53
+ ...(session.data.detected_country_code
54
+ ? {
55
+ defaultValues: {
56
+ address: { country: session.data.detected_country_code },
57
+ },
58
+ }
59
+ : {}),
60
+ });
61
+ addressElement.mount(addressContainer);
62
+ paymentElement.mount(paymentContainer);
63
+ const runRecalc = async () => {
64
+ const country = taxAddress.country;
65
+ if (!country)
66
+ return;
67
+ try {
68
+ const tax = await params.apiClient.recalculateTax({
69
+ orderId: order_id,
70
+ clientToken: session.data.client_token,
71
+ countryCode: country,
72
+ postalCode: taxAddress.postalCode,
73
+ subdivision: taxAddress.state,
74
+ });
75
+ taxCalculationId = tax.tax_calculation_id;
76
+ stripeElements.update({ amount: tax.amount_total });
77
+ params.onTaxChange?.({
78
+ amountTotal: tax.amount_total,
79
+ taxAmount: tax.tax_amount,
80
+ currency: tax.currency,
81
+ });
82
+ }
83
+ catch {
84
+ // Best-effort: keep the current total if recalculation fails.
85
+ }
86
+ };
87
+ let debounce;
88
+ flushTaxRecalc = async () => {
89
+ if (debounce) {
90
+ clearTimeout(debounce);
91
+ debounce = undefined;
92
+ }
93
+ await runRecalc();
94
+ };
95
+ addressElement.on('change', event => {
96
+ const address = event.value.address;
97
+ taxAddress = {
98
+ country: address.country,
99
+ postalCode: address.postal_code || undefined,
100
+ state: address.state || undefined,
101
+ };
102
+ if (!taxAddress.country)
103
+ return;
104
+ if (debounce)
105
+ clearTimeout(debounce);
106
+ debounce = setTimeout(runRecalc, TAX_RECALC_DEBOUNCE_MS);
107
+ });
108
+ }
109
+ else {
110
+ paymentElement.mount(element);
111
+ }
33
112
  await new Promise((resolve, reject) => {
34
113
  paymentElement.once('ready', () => resolve());
35
114
  // 'loaderror' is a valid Stripe event but not yet in the @stripe/stripe-js types
@@ -40,6 +119,7 @@ async function mountStripeCardForm(element, session, params) {
40
119
  submit: async () => {
41
120
  params.onLoaderChange?.(true);
42
121
  try {
122
+ await flushTaxRecalc();
43
123
  const { error: submitError } = await stripeElements.submit();
44
124
  if (submitError)
45
125
  throw submitError;
@@ -52,7 +132,10 @@ async function mountStripeCardForm(element, session, params) {
52
132
  orderId: order_id,
53
133
  paymentMethodToken: paymentMethod.id,
54
134
  email: params.email,
55
- countryCode: params.countryCode,
135
+ countryCode: taxEnabled ? taxAddress.country : params.countryCode,
136
+ postalCode: taxEnabled ? taxAddress.postalCode : undefined,
137
+ subdivision: taxEnabled ? taxAddress.state : undefined,
138
+ taxCalculationId: taxEnabled ? taxCalculationId : undefined,
56
139
  clientMetadata: params.clientMetadata,
57
140
  });
58
141
  const result = params.apiClient.processPaymentResponse(raw);
@@ -30,13 +30,24 @@ function buildPaymentRequest(stripe, data, totalLabel) {
30
30
  const applePay = raw
31
31
  ? { recurringPaymentRequest: raw }
32
32
  : undefined;
33
+ const base = data.amount;
34
+ const tax = data.tax_amount ?? 0;
35
+ const total = data.amount_total ?? base;
33
36
  return stripe.paymentRequest({
34
37
  country: data.country,
35
38
  currency: data.currency,
36
39
  total: {
37
40
  label: totalLabel?.trim() || 'Total',
38
- amount: data.amount,
41
+ amount: total,
39
42
  },
43
+ // Detected-country tax. The wallet sheet amount is fixed at open (not recomputed from the address
44
+ // picked in the sheet), so this line is the final charged tax, shown plainly as "Tax".
45
+ displayItems: tax > 0
46
+ ? [
47
+ { label: 'Subtotal', amount: base },
48
+ { label: 'Tax', amount: tax },
49
+ ]
50
+ : undefined,
40
51
  requestPayerName: false,
41
52
  requestPayerEmail: false,
42
53
  applePay,
@@ -59,6 +70,7 @@ async function getAvailableWallet(session) {
59
70
  }
60
71
  async function purchaseWallet(session, params) {
61
72
  const { stripe_public_key, order_id } = session.data;
73
+ const taxEnabled = session.data.tax_enabled ?? false;
62
74
  const stripe = await stripeLoader.getStripe(stripe_public_key);
63
75
  if (!stripe)
64
76
  throw new Error('Failed to load Stripe');
@@ -70,11 +82,28 @@ async function purchaseWallet(session, params) {
70
82
  paymentRequest.on('paymentmethod', async (event) => {
71
83
  params.onLoaderChange?.(true);
72
84
  try {
85
+ // Tax on: charge stays the detected-country estimate (the amount the sheet authorized, via
86
+ // tax_calculation_id); commit the finalized tax from the card's real billing address, which
87
+ // Stripe only exposes on the payment method after authorization. Tax off: send only the
88
+ // caller-provided country, exactly as before the tax flow existed.
89
+ const billingAddress = taxEnabled
90
+ ? event.paymentMethod.billing_details.address
91
+ : undefined;
73
92
  const raw = await params.apiClient.createPayment({
74
93
  orderId: order_id,
75
94
  paymentMethodToken: event.paymentMethod.id,
76
95
  email: params.email,
77
- countryCode: params.countryCode,
96
+ countryCode: taxEnabled
97
+ ? billingAddress?.country ||
98
+ session.data.detected_country_code ||
99
+ params.countryCode
100
+ : params.countryCode,
101
+ postalCode: taxEnabled
102
+ ? billingAddress?.postal_code || undefined
103
+ : undefined,
104
+ taxCalculationId: taxEnabled
105
+ ? session.data.tax_calculation_id
106
+ : undefined,
78
107
  clientMetadata: params.clientMetadata,
79
108
  });
80
109
  const result = params.apiClient.processPaymentResponse(raw);