@funnelfox/payments-sdk 0.2.0-beta.2 → 0.2.0-beta.3

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/dist/index.cjs CHANGED
@@ -33,10 +33,44 @@ var CheckoutError = class extends Error {
33
33
  this.name = "CheckoutError";
34
34
  }
35
35
  };
36
+ var CHECKOUT_BLOCK_REASONS = ["duplicated_subscription", "duplicated_payment"];
37
+ function isCheckoutBlockReason(reason) {
38
+ return CHECKOUT_BLOCK_REASONS.some((known) => known === reason);
39
+ }
40
+ var CheckoutBlockedError = class extends CheckoutError {
41
+ constructor(reason, buyerMessage) {
42
+ super(`Checkout blocked: ${reason}`);
43
+ this.reason = reason;
44
+ this.buyerMessage = buyerMessage;
45
+ this.name = "CheckoutBlockedError";
46
+ }
47
+ reason;
48
+ buyerMessage;
49
+ };
36
50
 
37
51
  // src/providers/stripe/index.ts
38
52
  var import_stripe_js = require("@stripe/stripe-js");
39
53
 
54
+ // src/providers/stripe/return-url.ts
55
+ var RETURNED_SESSION_PARAM = "ff_checkout_session";
56
+ var SESSION_ID_PLACEHOLDER = "{CHECKOUT_SESSION_ID}";
57
+ var SESSION_ID = /^cs_\w+$/;
58
+ function withReturnedSessionParam(href) {
59
+ const url = new URL(href);
60
+ url.searchParams.set(RETURNED_SESSION_PARAM, SESSION_ID_PLACEHOLDER);
61
+ return url.href.replace(encodeURIComponent(SESSION_ID_PLACEHOLDER), SESSION_ID_PLACEHOLDER);
62
+ }
63
+ function popSessionIdFromUrl() {
64
+ const url = new URL(window.location.href);
65
+ const sessionId = url.searchParams.get(RETURNED_SESSION_PARAM);
66
+ if (sessionId === null) {
67
+ return null;
68
+ }
69
+ url.searchParams.delete(RETURNED_SESSION_PARAM);
70
+ window.history.replaceState(window.history.state, "", url.href);
71
+ return SESSION_ID.test(sessionId) ? sessionId : null;
72
+ }
73
+
40
74
  // src/providers/stripe/helpers.ts
41
75
  function buildStripeCheckoutSessionBody(params) {
42
76
  return {
@@ -62,6 +96,7 @@ function buildStripeCheckoutSessionBody(params) {
62
96
  }
63
97
  },
64
98
  ...params.upgrade && { upgrade: params.upgrade },
99
+ ...params.isSubscription !== void 0 && { is_subscription: params.isSubscription },
65
100
  ...params.walletFirst && { wallet_first: params.walletFirst },
66
101
  ...params.uiMode && { ui_mode: params.uiMode }
67
102
  };
@@ -119,6 +154,12 @@ var StripeCheckoutService = class extends HttpService {
119
154
  `/stripe/purchase${query}`,
120
155
  buildStripeCheckoutSessionBody(params)
121
156
  );
157
+ if (data.status === "failed") {
158
+ if (isCheckoutBlockReason(data.reason)) {
159
+ throw new CheckoutBlockedError(data.reason, data.message || void 0);
160
+ }
161
+ throw new CheckoutError(`Checkout session refused: ${data.reason}`);
162
+ }
122
163
  if (!data.client_secret) {
123
164
  throw new CheckoutError("Backend returned no client_secret");
124
165
  }
@@ -280,6 +321,7 @@ var StripeProvider = class {
280
321
  ui;
281
322
  stripe = null;
282
323
  mounted = false;
324
+ blocked = false;
283
325
  clientSecret = null;
284
326
  snapshot = {};
285
327
  async init() {
@@ -299,7 +341,17 @@ var StripeProvider = class {
299
341
  throw new CheckoutError("Already rendered \u2014 call destroy() before rendering again");
300
342
  }
301
343
  this.mounted = true;
302
- await this.ui.mount(this.stripe, container, this.host());
344
+ this.blocked = false;
345
+ const returned = await this.takeReturnedSession();
346
+ if (returned) {
347
+ this.reportComplete(returned);
348
+ return;
349
+ }
350
+ try {
351
+ await this.ui.mount(this.stripe, container, this.host());
352
+ } catch (err) {
353
+ if (!this.blocked) throw err;
354
+ }
303
355
  }
304
356
  async reset() {
305
357
  this.destroy();
@@ -323,18 +375,42 @@ var StripeProvider = class {
323
375
  provider: "stripe",
324
376
  paymentMethodType: this.snapshot.paymentMethodType
325
377
  }),
326
- renderFailed: (error, detail) => this.emitError("render", error, detail),
378
+ renderFailed: (error, detail) => {
379
+ if (!this.blocked) this.emitError("render", error, detail);
380
+ },
327
381
  paymentFailed: (error, detail) => this.emitError("payment", error, detail),
328
382
  succeeded: (outcome) => void this.emitComplete(outcome)
329
383
  };
330
384
  }
331
385
  async createSession() {
332
- const secret = await this.service.createCheckoutSession({
333
- ...this.config,
334
- returnUrl: this.config.returnUrl ?? window.location.href
335
- });
336
- this.clientSecret = secret;
337
- return secret;
386
+ try {
387
+ const secret = await this.service.createCheckoutSession({
388
+ ...this.config,
389
+ returnUrl: this.config.returnUrl || withReturnedSessionParam(window.location.href)
390
+ });
391
+ this.clientSecret = secret;
392
+ return secret;
393
+ } catch (err) {
394
+ if (err instanceof CheckoutBlockedError && this.callbacks.onBlocked && this.mounted) {
395
+ this.blocked = true;
396
+ this.ui.destroy();
397
+ this.callbacks.onBlocked({
398
+ provider: "stripe",
399
+ reason: err.reason,
400
+ message: err.buyerMessage
401
+ });
402
+ }
403
+ throw err;
404
+ }
405
+ }
406
+ async takeReturnedSession() {
407
+ const sessionId = this.config.returnUrl ? null : popSessionIdFromUrl();
408
+ if (!sessionId) {
409
+ return null;
410
+ }
411
+ const lookup = () => this.service.getSession(sessionId, this.config.accountId);
412
+ const session = await lookup().catch(lookup).catch(() => null);
413
+ return session?.status === "complete" ? definedOnly(session) : null;
338
414
  }
339
415
  // Format: cs_test_{sessionId}_secret_{random}
340
416
  sessionId() {
@@ -374,10 +450,13 @@ var StripeProvider = class {
374
450
  session = {};
375
451
  }
376
452
  }
453
+ this.reportComplete({ ...fallback, ...session });
454
+ }
455
+ reportComplete(session) {
377
456
  this.callbacks.onComplete?.({
378
457
  provider: "stripe",
379
458
  paymentMethodType: this.snapshot.paymentMethodType,
380
- ...toCompleteFields({ ...fallback, ...session })
459
+ ...toCompleteFields(session)
381
460
  });
382
461
  }
383
462
  emitError(phase, error, detail) {
package/dist/index.d.cts CHANGED
@@ -49,7 +49,7 @@ interface StripeConfig {
49
49
  customerId?: string;
50
50
  /** New customer details — required when customerId is not provided */
51
51
  customer?: StripeCustomer;
52
- /** URL to redirect to after payment completes. Defaults to window.location.href for embedded checkout. */
52
+ /** Where redirect-based methods return the buyer. A custom URL completes the session itself. */
53
53
  returnUrl?: string;
54
54
  /** When true, Stripe displays a promotional code input field in the embedded checkout */
55
55
  allowPromoCodes?: boolean;
@@ -61,6 +61,8 @@ interface StripeConfig {
61
61
  trial?: StripeCheckoutTrial;
62
62
  /** When true, treats this as a subscription upgrade — requires customerId to be set */
63
63
  upgrade?: boolean;
64
+ /** Enables the backend's one-subscription-per-email limit. */
65
+ isSubscription?: boolean;
64
66
  /** When true, surfaces Apple Pay / Google Pay as the primary payment option (Wallet-First mode) */
65
67
  walletFirst?: boolean;
66
68
  /** Which checkout UI Stripe renders. Defaults to `embedded_page`. */
@@ -94,6 +96,12 @@ type ProviderConfigMap = {
94
96
  fastspring: FastSpringConfig;
95
97
  };
96
98
 
99
+ declare class CheckoutError extends Error {
100
+ constructor(message: string);
101
+ }
102
+ declare const CHECKOUT_BLOCK_REASONS: readonly ["duplicated_subscription", "duplicated_payment"];
103
+ type CheckoutBlockReason = (typeof CHECKOUT_BLOCK_REASONS)[number];
104
+
97
105
  interface CheckoutCompleteEvent {
98
106
  provider: ProviderName;
99
107
  paymentMethodType?: PaymentMethodType;
@@ -119,6 +127,12 @@ interface CheckoutErrorEvent {
119
127
  message?: string;
120
128
  error: unknown;
121
129
  }
130
+ /** The backend refused to open checkout for a repeat purchase. */
131
+ interface CheckoutBlockedEvent {
132
+ provider: ProviderName;
133
+ reason: CheckoutBlockReason;
134
+ message?: string;
135
+ }
122
136
  interface CheckoutCancelEvent {
123
137
  provider: ProviderName;
124
138
  paymentMethodType?: PaymentMethodType;
@@ -153,6 +167,7 @@ interface CheckoutCallbacks {
153
167
  onReady?: (event: CheckoutReadyEvent) => void;
154
168
  onConfirm?: (event: CheckoutConfirmEvent) => void;
155
169
  onChange?: (event: CheckoutChangeEvent) => void;
170
+ onBlocked?: (event: CheckoutBlockedEvent) => void;
156
171
  }
157
172
 
158
173
  interface RenderOptions {
@@ -179,13 +194,9 @@ type InitCheckoutConfig<T extends ProviderName> = {
179
194
  } & CheckoutCallbacks;
180
195
  declare function initCheckout<T extends ProviderName>(options: InitCheckoutConfig<T>): Promise<CheckoutInstance>;
181
196
 
182
- declare class CheckoutError extends Error {
183
- constructor(message: string);
184
- }
185
-
186
197
  type CreateCheckoutSessionParams = Omit<StripeConfig, 'publishableKey' | 'returnUrl' | 'form'> & {
187
198
  returnUrl: string;
188
199
  };
189
200
  declare function buildStripeCheckoutSessionBody(params: CreateCheckoutSessionParams): Record<string, unknown>;
190
201
 
191
- export { type CheckoutCallbacks, type CheckoutCancelEvent, type CheckoutChangeEvent, type CheckoutCompleteEvent, type CheckoutConfirmEvent, CheckoutError, type CheckoutErrorEvent, type CheckoutErrorPhase, type CheckoutInstance, type CheckoutReadyEvent, type CreateCheckoutSessionParams, type FastSpringConfig, type PaymentMethodType, type PaymentProvider, type ProviderConfigMap, type ProviderName, type RenderOptions, type StripeCheckoutTrial, type StripeConfig, type StripeFormOptions, type StripeUiMode, buildStripeCheckoutSessionBody, initCheckout };
202
+ export { type CheckoutBlockReason, type CheckoutBlockedEvent, type CheckoutCallbacks, type CheckoutCancelEvent, type CheckoutChangeEvent, type CheckoutCompleteEvent, type CheckoutConfirmEvent, CheckoutError, type CheckoutErrorEvent, type CheckoutErrorPhase, type CheckoutInstance, type CheckoutReadyEvent, type CreateCheckoutSessionParams, type FastSpringConfig, type PaymentMethodType, type PaymentProvider, type ProviderConfigMap, type ProviderName, type RenderOptions, type StripeCheckoutTrial, type StripeConfig, type StripeFormOptions, type StripeUiMode, buildStripeCheckoutSessionBody, initCheckout };
package/dist/index.d.ts CHANGED
@@ -49,7 +49,7 @@ interface StripeConfig {
49
49
  customerId?: string;
50
50
  /** New customer details — required when customerId is not provided */
51
51
  customer?: StripeCustomer;
52
- /** URL to redirect to after payment completes. Defaults to window.location.href for embedded checkout. */
52
+ /** Where redirect-based methods return the buyer. A custom URL completes the session itself. */
53
53
  returnUrl?: string;
54
54
  /** When true, Stripe displays a promotional code input field in the embedded checkout */
55
55
  allowPromoCodes?: boolean;
@@ -61,6 +61,8 @@ interface StripeConfig {
61
61
  trial?: StripeCheckoutTrial;
62
62
  /** When true, treats this as a subscription upgrade — requires customerId to be set */
63
63
  upgrade?: boolean;
64
+ /** Enables the backend's one-subscription-per-email limit. */
65
+ isSubscription?: boolean;
64
66
  /** When true, surfaces Apple Pay / Google Pay as the primary payment option (Wallet-First mode) */
65
67
  walletFirst?: boolean;
66
68
  /** Which checkout UI Stripe renders. Defaults to `embedded_page`. */
@@ -94,6 +96,12 @@ type ProviderConfigMap = {
94
96
  fastspring: FastSpringConfig;
95
97
  };
96
98
 
99
+ declare class CheckoutError extends Error {
100
+ constructor(message: string);
101
+ }
102
+ declare const CHECKOUT_BLOCK_REASONS: readonly ["duplicated_subscription", "duplicated_payment"];
103
+ type CheckoutBlockReason = (typeof CHECKOUT_BLOCK_REASONS)[number];
104
+
97
105
  interface CheckoutCompleteEvent {
98
106
  provider: ProviderName;
99
107
  paymentMethodType?: PaymentMethodType;
@@ -119,6 +127,12 @@ interface CheckoutErrorEvent {
119
127
  message?: string;
120
128
  error: unknown;
121
129
  }
130
+ /** The backend refused to open checkout for a repeat purchase. */
131
+ interface CheckoutBlockedEvent {
132
+ provider: ProviderName;
133
+ reason: CheckoutBlockReason;
134
+ message?: string;
135
+ }
122
136
  interface CheckoutCancelEvent {
123
137
  provider: ProviderName;
124
138
  paymentMethodType?: PaymentMethodType;
@@ -153,6 +167,7 @@ interface CheckoutCallbacks {
153
167
  onReady?: (event: CheckoutReadyEvent) => void;
154
168
  onConfirm?: (event: CheckoutConfirmEvent) => void;
155
169
  onChange?: (event: CheckoutChangeEvent) => void;
170
+ onBlocked?: (event: CheckoutBlockedEvent) => void;
156
171
  }
157
172
 
158
173
  interface RenderOptions {
@@ -179,13 +194,9 @@ type InitCheckoutConfig<T extends ProviderName> = {
179
194
  } & CheckoutCallbacks;
180
195
  declare function initCheckout<T extends ProviderName>(options: InitCheckoutConfig<T>): Promise<CheckoutInstance>;
181
196
 
182
- declare class CheckoutError extends Error {
183
- constructor(message: string);
184
- }
185
-
186
197
  type CreateCheckoutSessionParams = Omit<StripeConfig, 'publishableKey' | 'returnUrl' | 'form'> & {
187
198
  returnUrl: string;
188
199
  };
189
200
  declare function buildStripeCheckoutSessionBody(params: CreateCheckoutSessionParams): Record<string, unknown>;
190
201
 
191
- export { type CheckoutCallbacks, type CheckoutCancelEvent, type CheckoutChangeEvent, type CheckoutCompleteEvent, type CheckoutConfirmEvent, CheckoutError, type CheckoutErrorEvent, type CheckoutErrorPhase, type CheckoutInstance, type CheckoutReadyEvent, type CreateCheckoutSessionParams, type FastSpringConfig, type PaymentMethodType, type PaymentProvider, type ProviderConfigMap, type ProviderName, type RenderOptions, type StripeCheckoutTrial, type StripeConfig, type StripeFormOptions, type StripeUiMode, buildStripeCheckoutSessionBody, initCheckout };
202
+ export { type CheckoutBlockReason, type CheckoutBlockedEvent, type CheckoutCallbacks, type CheckoutCancelEvent, type CheckoutChangeEvent, type CheckoutCompleteEvent, type CheckoutConfirmEvent, CheckoutError, type CheckoutErrorEvent, type CheckoutErrorPhase, type CheckoutInstance, type CheckoutReadyEvent, type CreateCheckoutSessionParams, type FastSpringConfig, type PaymentMethodType, type PaymentProvider, type ProviderConfigMap, type ProviderName, type RenderOptions, type StripeCheckoutTrial, type StripeConfig, type StripeFormOptions, type StripeUiMode, buildStripeCheckoutSessionBody, initCheckout };
package/dist/index.js CHANGED
@@ -5,10 +5,44 @@ var CheckoutError = class extends Error {
5
5
  this.name = "CheckoutError";
6
6
  }
7
7
  };
8
+ var CHECKOUT_BLOCK_REASONS = ["duplicated_subscription", "duplicated_payment"];
9
+ function isCheckoutBlockReason(reason) {
10
+ return CHECKOUT_BLOCK_REASONS.some((known) => known === reason);
11
+ }
12
+ var CheckoutBlockedError = class extends CheckoutError {
13
+ constructor(reason, buyerMessage) {
14
+ super(`Checkout blocked: ${reason}`);
15
+ this.reason = reason;
16
+ this.buyerMessage = buyerMessage;
17
+ this.name = "CheckoutBlockedError";
18
+ }
19
+ reason;
20
+ buyerMessage;
21
+ };
8
22
 
9
23
  // src/providers/stripe/index.ts
10
24
  import { loadStripe } from "@stripe/stripe-js";
11
25
 
26
+ // src/providers/stripe/return-url.ts
27
+ var RETURNED_SESSION_PARAM = "ff_checkout_session";
28
+ var SESSION_ID_PLACEHOLDER = "{CHECKOUT_SESSION_ID}";
29
+ var SESSION_ID = /^cs_\w+$/;
30
+ function withReturnedSessionParam(href) {
31
+ const url = new URL(href);
32
+ url.searchParams.set(RETURNED_SESSION_PARAM, SESSION_ID_PLACEHOLDER);
33
+ return url.href.replace(encodeURIComponent(SESSION_ID_PLACEHOLDER), SESSION_ID_PLACEHOLDER);
34
+ }
35
+ function popSessionIdFromUrl() {
36
+ const url = new URL(window.location.href);
37
+ const sessionId = url.searchParams.get(RETURNED_SESSION_PARAM);
38
+ if (sessionId === null) {
39
+ return null;
40
+ }
41
+ url.searchParams.delete(RETURNED_SESSION_PARAM);
42
+ window.history.replaceState(window.history.state, "", url.href);
43
+ return SESSION_ID.test(sessionId) ? sessionId : null;
44
+ }
45
+
12
46
  // src/providers/stripe/helpers.ts
13
47
  function buildStripeCheckoutSessionBody(params) {
14
48
  return {
@@ -34,6 +68,7 @@ function buildStripeCheckoutSessionBody(params) {
34
68
  }
35
69
  },
36
70
  ...params.upgrade && { upgrade: params.upgrade },
71
+ ...params.isSubscription !== void 0 && { is_subscription: params.isSubscription },
37
72
  ...params.walletFirst && { wallet_first: params.walletFirst },
38
73
  ...params.uiMode && { ui_mode: params.uiMode }
39
74
  };
@@ -91,6 +126,12 @@ var StripeCheckoutService = class extends HttpService {
91
126
  `/stripe/purchase${query}`,
92
127
  buildStripeCheckoutSessionBody(params)
93
128
  );
129
+ if (data.status === "failed") {
130
+ if (isCheckoutBlockReason(data.reason)) {
131
+ throw new CheckoutBlockedError(data.reason, data.message || void 0);
132
+ }
133
+ throw new CheckoutError(`Checkout session refused: ${data.reason}`);
134
+ }
94
135
  if (!data.client_secret) {
95
136
  throw new CheckoutError("Backend returned no client_secret");
96
137
  }
@@ -252,6 +293,7 @@ var StripeProvider = class {
252
293
  ui;
253
294
  stripe = null;
254
295
  mounted = false;
296
+ blocked = false;
255
297
  clientSecret = null;
256
298
  snapshot = {};
257
299
  async init() {
@@ -271,7 +313,17 @@ var StripeProvider = class {
271
313
  throw new CheckoutError("Already rendered \u2014 call destroy() before rendering again");
272
314
  }
273
315
  this.mounted = true;
274
- await this.ui.mount(this.stripe, container, this.host());
316
+ this.blocked = false;
317
+ const returned = await this.takeReturnedSession();
318
+ if (returned) {
319
+ this.reportComplete(returned);
320
+ return;
321
+ }
322
+ try {
323
+ await this.ui.mount(this.stripe, container, this.host());
324
+ } catch (err) {
325
+ if (!this.blocked) throw err;
326
+ }
275
327
  }
276
328
  async reset() {
277
329
  this.destroy();
@@ -295,18 +347,42 @@ var StripeProvider = class {
295
347
  provider: "stripe",
296
348
  paymentMethodType: this.snapshot.paymentMethodType
297
349
  }),
298
- renderFailed: (error, detail) => this.emitError("render", error, detail),
350
+ renderFailed: (error, detail) => {
351
+ if (!this.blocked) this.emitError("render", error, detail);
352
+ },
299
353
  paymentFailed: (error, detail) => this.emitError("payment", error, detail),
300
354
  succeeded: (outcome) => void this.emitComplete(outcome)
301
355
  };
302
356
  }
303
357
  async createSession() {
304
- const secret = await this.service.createCheckoutSession({
305
- ...this.config,
306
- returnUrl: this.config.returnUrl ?? window.location.href
307
- });
308
- this.clientSecret = secret;
309
- return secret;
358
+ try {
359
+ const secret = await this.service.createCheckoutSession({
360
+ ...this.config,
361
+ returnUrl: this.config.returnUrl || withReturnedSessionParam(window.location.href)
362
+ });
363
+ this.clientSecret = secret;
364
+ return secret;
365
+ } catch (err) {
366
+ if (err instanceof CheckoutBlockedError && this.callbacks.onBlocked && this.mounted) {
367
+ this.blocked = true;
368
+ this.ui.destroy();
369
+ this.callbacks.onBlocked({
370
+ provider: "stripe",
371
+ reason: err.reason,
372
+ message: err.buyerMessage
373
+ });
374
+ }
375
+ throw err;
376
+ }
377
+ }
378
+ async takeReturnedSession() {
379
+ const sessionId = this.config.returnUrl ? null : popSessionIdFromUrl();
380
+ if (!sessionId) {
381
+ return null;
382
+ }
383
+ const lookup = () => this.service.getSession(sessionId, this.config.accountId);
384
+ const session = await lookup().catch(lookup).catch(() => null);
385
+ return session?.status === "complete" ? definedOnly(session) : null;
310
386
  }
311
387
  // Format: cs_test_{sessionId}_secret_{random}
312
388
  sessionId() {
@@ -346,10 +422,13 @@ var StripeProvider = class {
346
422
  session = {};
347
423
  }
348
424
  }
425
+ this.reportComplete({ ...fallback, ...session });
426
+ }
427
+ reportComplete(session) {
349
428
  this.callbacks.onComplete?.({
350
429
  provider: "stripe",
351
430
  paymentMethodType: this.snapshot.paymentMethodType,
352
- ...toCompleteFields({ ...fallback, ...session })
431
+ ...toCompleteFields(session)
353
432
  });
354
433
  }
355
434
  emitError(phase, error, detail) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelfox/payments-sdk",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",