@shware/purchase 7.5.0 → 8.0.0

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.
Files changed (74) hide show
  1. package/README.md +25 -14
  2. package/dist/app-store/config.cjs +24 -3
  3. package/dist/app-store/config.cjs.map +1 -1
  4. package/dist/app-store/config.d.cts +14 -2
  5. package/dist/app-store/config.d.cts.map +1 -1
  6. package/dist/app-store/config.d.mts +14 -2
  7. package/dist/app-store/config.d.mts.map +1 -1
  8. package/dist/app-store/config.mjs +24 -4
  9. package/dist/app-store/config.mjs.map +1 -1
  10. package/dist/app-store/index.cjs +2 -0
  11. package/dist/app-store/index.d.cts +3 -3
  12. package/dist/app-store/index.d.mts +3 -3
  13. package/dist/app-store/index.mjs +3 -3
  14. package/dist/app-store/mapper.cjs +22 -0
  15. package/dist/app-store/mapper.cjs.map +1 -1
  16. package/dist/app-store/mapper.d.cts +17 -1
  17. package/dist/app-store/mapper.d.cts.map +1 -1
  18. package/dist/app-store/mapper.d.mts +17 -1
  19. package/dist/app-store/mapper.d.mts.map +1 -1
  20. package/dist/app-store/mapper.mjs +22 -1
  21. package/dist/app-store/mapper.mjs.map +1 -1
  22. package/dist/catalog.cjs +22 -0
  23. package/dist/catalog.cjs.map +1 -0
  24. package/dist/catalog.d.cts +21 -0
  25. package/dist/catalog.d.cts.map +1 -0
  26. package/dist/catalog.d.mts +21 -0
  27. package/dist/catalog.d.mts.map +1 -0
  28. package/dist/catalog.mjs +19 -0
  29. package/dist/catalog.mjs.map +1 -0
  30. package/dist/google-play/config.cjs +23 -3
  31. package/dist/google-play/config.cjs.map +1 -1
  32. package/dist/google-play/config.d.cts +14 -2
  33. package/dist/google-play/config.d.cts.map +1 -1
  34. package/dist/google-play/config.d.mts +14 -2
  35. package/dist/google-play/config.d.mts.map +1 -1
  36. package/dist/google-play/config.mjs +23 -4
  37. package/dist/google-play/config.mjs.map +1 -1
  38. package/dist/google-play/index.cjs +2 -0
  39. package/dist/google-play/index.d.cts +3 -3
  40. package/dist/google-play/index.d.mts +3 -3
  41. package/dist/google-play/index.mjs +3 -3
  42. package/dist/google-play/mapper.cjs +12 -0
  43. package/dist/google-play/mapper.cjs.map +1 -1
  44. package/dist/google-play/mapper.d.cts +10 -1
  45. package/dist/google-play/mapper.d.cts.map +1 -1
  46. package/dist/google-play/mapper.d.mts +10 -1
  47. package/dist/google-play/mapper.d.mts.map +1 -1
  48. package/dist/google-play/mapper.mjs +12 -1
  49. package/dist/google-play/mapper.mjs.map +1 -1
  50. package/dist/index.cjs +3 -0
  51. package/dist/index.d.cts +4 -4
  52. package/dist/index.d.mts +4 -4
  53. package/dist/index.mjs +4 -4
  54. package/dist/stripe/config.cjs +50 -4
  55. package/dist/stripe/config.cjs.map +1 -1
  56. package/dist/stripe/config.d.cts +36 -2
  57. package/dist/stripe/config.d.cts.map +1 -1
  58. package/dist/stripe/config.d.mts +36 -2
  59. package/dist/stripe/config.d.mts.map +1 -1
  60. package/dist/stripe/config.mjs +50 -5
  61. package/dist/stripe/config.mjs.map +1 -1
  62. package/dist/stripe/index.cjs +5 -0
  63. package/dist/stripe/index.d.cts +3 -2
  64. package/dist/stripe/index.d.mts +3 -2
  65. package/dist/stripe/index.mjs +3 -2
  66. package/dist/stripe/plan-change.cjs +400 -0
  67. package/dist/stripe/plan-change.cjs.map +1 -0
  68. package/dist/stripe/plan-change.d.cts +142 -0
  69. package/dist/stripe/plan-change.d.cts.map +1 -0
  70. package/dist/stripe/plan-change.d.mts +142 -0
  71. package/dist/stripe/plan-change.d.mts.map +1 -0
  72. package/dist/stripe/plan-change.mjs +397 -0
  73. package/dist/stripe/plan-change.mjs.map +1 -0
  74. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
+ import { assertCatalog, assertDeclared, assertInNamespace } from "../catalog.mjs";
1
2
  import { invariant } from "@shware/utils";
2
3
  import ms from "ms";
3
4
  //#region src/stripe/config.ts
4
5
  const addMethod = Symbol("addMethod");
6
+ /** The private constructor's one door, for this module's `define` function only. */
7
+ const construct = Symbol("construct");
5
8
  var Product = class {
6
9
  id;
7
10
  config;
@@ -59,6 +62,9 @@ var StripeConfig = class StripeConfig {
59
62
  successUrl;
60
63
  cancellationCouponId;
61
64
  allowPromotionCodes;
65
+ namespace;
66
+ plans;
67
+ billingPeriods;
62
68
  get productIds() {
63
69
  return Array.from(this.products.keys());
64
70
  }
@@ -70,6 +76,10 @@ var StripeConfig = class StripeConfig {
70
76
  return this;
71
77
  };
72
78
  constructor(options) {
79
+ assertCatalog(options);
80
+ this.namespace = options.namespace;
81
+ this.plans = options.plans;
82
+ this.billingPeriods = options.billingPeriods;
73
83
  this.mode = options.mode;
74
84
  this.returnUrl = options.returnUrl;
75
85
  this.cancelUrl = options.cancelUrl;
@@ -77,14 +87,16 @@ var StripeConfig = class StripeConfig {
77
87
  this.cancellationCouponId = options.cancellationCouponId;
78
88
  this.allowPromotionCodes = options.allowPromotionCodes;
79
89
  }
80
- static create = (options) => {
81
- return new StripeConfig(options);
82
- };
90
+ static [construct] = (options) => new StripeConfig(options);
83
91
  /**
84
92
  * One-time products take no extra args; subscriptions require both plan and billingPeriod.
85
93
  * A plan+period pair may be declared on only one product — version its prices in that chain.
86
94
  */
87
- product = (productId, plan = null, billingPeriod = null) => new Product(this, productId, plan, billingPeriod);
95
+ product = (productId, plan = null, billingPeriod = null) => {
96
+ assertInNamespace(this.namespace, productId);
97
+ assertDeclared(this, plan, billingPeriod);
98
+ return new Product(this, productId, plan, billingPeriod);
99
+ };
88
100
  getPlan = (productId) => {
89
101
  const product = this.products.get(productId);
90
102
  invariant(product, `Product not found for ${productId}`);
@@ -102,6 +114,15 @@ var StripeConfig = class StripeConfig {
102
114
  invariant(product, `Product not found for ${productId}`);
103
115
  return product.plan === null ? "payment" : "subscription";
104
116
  };
117
+ /**
118
+ * Whether the config declares `productId` and sells it as a subscription, so `getPlan` and
119
+ * `getBillingPeriod` hold for it. False, not a throw, for an id it does not know: a product that
120
+ * left the config (renamed, retired) or one named by metadata set outside the app.
121
+ */
122
+ isSubscription = (productId) => {
123
+ const product = this.products.get(productId);
124
+ return product !== void 0 && product.plan !== null;
125
+ };
105
126
  /** The price new checkouts are created with: the default, or its test twin in test mode. */
106
127
  getPriceId = (productId) => {
107
128
  const product = this.products.get(productId);
@@ -119,6 +140,22 @@ var StripeConfig = class StripeConfig {
119
140
  for (const product of this.products.values()) if (product.prices.has(priceId)) return product.id;
120
141
  return null;
121
142
  };
143
+ /**
144
+ * How switching from one subscription product to another relates them (see
145
+ * {@link PlanComparison}), by the order `plans` and `billingPeriods` were declared in. Throws for
146
+ * a product that is not a subscription.
147
+ */
148
+ comparePlans = (fromProductId, toProductId) => {
149
+ const level = (productId) => ({
150
+ plan: this.plans.indexOf(this.getPlan(productId)),
151
+ period: this.billingPeriods.indexOf(this.getBillingPeriod(productId))
152
+ });
153
+ const from = level(fromProductId);
154
+ const to = level(toProductId);
155
+ if (to.plan !== from.plan) return to.plan > from.plan ? "upgrade" : "downgrade";
156
+ if (to.period !== from.period) return to.period > from.period ? "longer" : "shorter";
157
+ return "same";
158
+ };
122
159
  getCreditAmount = (productId, priceId) => {
123
160
  const product = this.products.get(productId);
124
161
  invariant(product, `Product not found for ${productId}`);
@@ -146,7 +183,15 @@ var StripeConfig = class StripeConfig {
146
183
  return new Date(Date.now() + expiresIn).toISOString();
147
184
  };
148
185
  };
186
+ /**
187
+ * Declare the Stripe product catalog. Every type parameter is inferred from the options:
188
+ * the namespace from `namespace`, the plans and billing periods from `plans` and `billingPeriods`
189
+ * (`const`, so a literal array needs no `as const`). Products are then added with the fluent chain.
190
+ */
191
+ function defineStripeConfig(options) {
192
+ return StripeConfig[construct](options);
193
+ }
149
194
  //#endregion
150
- export { StripeConfig };
195
+ export { StripeConfig, defineStripeConfig };
151
196
 
152
197
  //# sourceMappingURL=config.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../../src/stripe/config.ts"],"sourcesContent":["import { invariant } from '@shware/utils';\nimport ms from 'ms';\nimport type { Metadata } from '../types';\n\nexport type PriceId = `price_${string}`;\n\n/**\n * Which Stripe object space the config addresses. Test and live prices are\n * disjoint, so a config is built for exactly one of them. Derive it from the\n * secret key (`sk_test_` vs `sk_live_`) rather than from the deployment\n * environment: the key is what actually decides which space API calls hit.\n */\nexport type StripeMode = 'live' | 'test';\n\nconst addMethod = Symbol('addMethod');\n\nclass Product<\n NS extends Lowercase<string> = Lowercase<string>,\n PE extends string = string,\n BP extends string = string,\n PL extends string = never,\n PI extends string = never,\n I extends PriceId = never,\n> {\n readonly id: string;\n readonly config: StripeConfig<NS, PE, BP, PL, PI>;\n readonly plan: PE | null;\n readonly billingPeriod: BP | null;\n readonly prices: Map<PriceId, Metadata>;\n\n defaultPriceId: PriceId | null;\n\n constructor(\n config: StripeConfig<NS, PE, BP, PL, PI>,\n id: string,\n plan: PE | null = null,\n billingPeriod: BP | null = null\n ) {\n this.id = id;\n this.config = config;\n this.plan = plan;\n this.billingPeriod = billingPeriod;\n this.prices = new Map();\n this.defaultPriceId = null;\n }\n\n /**\n * Non-default prices stay declared on purpose: they keep resolving for\n * grandfathered subscribers and serve as price-experiment arms. Never\n * create new checkouts with a legacy price unless that is intentional.\n */\n price = <K extends PriceId>(\n priceId: K extends I ? never : K,\n metadata: Metadata = { credits: 0, expiresIn: '0' }\n ): Product<NS, PE, BP, PL, PI, I | K> => {\n invariant(!this.prices.has(priceId), `Duplicate price ${priceId}`);\n this.prices.set(priceId, metadata);\n return this as Product<NS, PE, BP, PL, PI, I | K>;\n };\n\n /**\n * `test` is the sandbox twin of the default price. In test mode it is what\n * checkouts are created with, and it resolves to the default price's\n * entitlement, so credits are declared once and cannot drift between the\n * two modes. Legacy prices need no twin: a sandbox holds no grandfathered\n * subscribers. Live mode ignores it, so a test price reaching a live config\n * still fails as an unknown price instead of being granted an entitlement.\n */\n default = (defaultPriceId: I, twin?: { test: PriceId }) => {\n const metadata = this.prices.get(defaultPriceId);\n invariant(metadata, `Default price ${defaultPriceId} is not declared`);\n if (this.config.mode === 'test') {\n invariant(twin, `Test price not declared for default ${defaultPriceId} of ${this.id}`);\n invariant(!this.prices.has(twin.test), `Duplicate price ${twin.test}`);\n this.prices.set(twin.test, metadata);\n this.defaultPriceId = twin.test;\n } else {\n this.defaultPriceId = defaultPriceId;\n }\n this.config[addMethod](this);\n return this.config;\n };\n}\n\ntype Options = {\n mode: StripeMode;\n returnUrl: string;\n cancelUrl: string;\n successUrl: `${string}session_id={CHECKOUT_SESSION_ID}${string}`;\n cancellationCouponId: string;\n allowPromotionCodes: boolean;\n};\n\nexport class StripeConfig<\n NS extends Lowercase<string> = Lowercase<string>,\n PE extends string = string,\n BP extends string = string,\n PL extends string = never,\n PI extends string = never,\n> {\n private readonly products: Map<string, Product<NS, PE, BP, PL, PI>> = new Map();\n\n public readonly mode: StripeMode;\n public returnUrl: string;\n public cancelUrl: string;\n public successUrl: `${string}session_id={CHECKOUT_SESSION_ID}${string}`;\n public cancellationCouponId: string;\n public allowPromotionCodes: boolean;\n\n get productIds(): string[] {\n return Array.from(this.products.keys());\n }\n\n // runtime mirror of the type-level guards: generic accumulation only\n // protects a single fluent chain, not statement-style re-entry or casts\n [addMethod] = (product: Product<NS, PE, BP, PL, PI>) => {\n invariant(!this.products.has(product.id), `Duplicate product ${product.id}`);\n // A Stripe price belongs to one Stripe product, and getProductId must have one answer.\n for (const existing of this.products.values()) {\n for (const priceId of product.prices.keys()) {\n invariant(!existing.prices.has(priceId), `Duplicate price ${priceId} on ${existing.id}`);\n }\n }\n if (product.plan !== null) {\n for (const existing of this.products.values()) {\n invariant(\n existing.plan !== product.plan || existing.billingPeriod !== product.billingPeriod,\n `Duplicate plan ${product.plan}:${product.billingPeriod}`\n );\n }\n }\n this.products.set(product.id, product);\n return this;\n };\n\n private constructor(options: Options) {\n this.mode = options.mode;\n this.returnUrl = options.returnUrl;\n this.cancelUrl = options.cancelUrl;\n this.successUrl = options.successUrl;\n this.cancellationCouponId = options.cancellationCouponId;\n this.allowPromotionCodes = options.allowPromotionCodes;\n }\n\n static create = <NS extends Lowercase<string>, PE extends string, BP extends string = string>(\n options: Options\n ) => {\n return new StripeConfig<NS, PE, BP>(options);\n };\n\n /**\n * One-time products take no extra args; subscriptions require both plan and billingPeriod.\n * A plan+period pair may be declared on only one product — version its prices in that chain.\n */\n product: {\n <K extends `${NS}.${Lowercase<string>}`>(\n productId: K extends PI ? never : K\n ): Product<NS, PE, BP, PL, K | PI>;\n <K extends `${NS}.${Lowercase<string>}`, L extends PE, P extends BP>(\n productId: K extends PI ? never : K,\n plan: L,\n billingPeriod: `${L}:${P}` extends PL ? never : P\n ): Product<NS, PE, BP, `${L}:${P}` | PL, K | PI>;\n } = (productId: string, plan: PE | null = null, billingPeriod: BP | null = null) =>\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- widens to the overloads' accumulated generics\n new Product(this, productId, plan, billingPeriod) as never;\n\n getPlan = (productId: string): PE => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.plan, `Product ${productId} is not a subscription`);\n return product.plan;\n };\n\n getBillingPeriod = (productId: string): BP => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.billingPeriod, `Product ${productId} is not a subscription`);\n return product.billingPeriod;\n };\n\n getMode = (productId: string): 'payment' | 'subscription' => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n return product.plan === null ? 'payment' : 'subscription';\n };\n\n /** The price new checkouts are created with: the default, or its test twin in test mode. */\n getPriceId = (productId: string): PriceId => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return product.defaultPriceId;\n };\n\n /**\n * The product that declares `priceId` (a legacy price or the test twin included), or null for a\n * price this config does not know: one set up by hand in the dashboard, or a test price reaching\n * a live config. Reading the product from the price a Stripe object charged, rather than from\n * metadata that may already name the next plan, is what an invoice should be credited by.\n */\n getProductId = (priceId: string): string | null => {\n for (const product of this.products.values()) {\n if (product.prices.has(priceId as PriceId)) return product.id;\n }\n return null;\n };\n\n getCreditAmount = (productId: string, priceId?: string): number => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n if (priceId) {\n const price = product.prices.get(priceId as PriceId);\n invariant(price, `Price not found for ${priceId}`);\n return price.credits;\n }\n\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return product.prices.get(product.defaultPriceId)?.credits ?? 0;\n };\n\n private readonly getCreditExpiresIn = (productId: string, priceId?: string): number => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n if (priceId) {\n const price = product.prices.get(priceId as PriceId);\n invariant(price, `Price not found for ${priceId}`);\n return ms(price.expiresIn);\n }\n\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return ms(product.prices.get(product.defaultPriceId)?.expiresIn ?? '0');\n };\n\n getCreditExpiresAt = (productId: string, priceId?: string): string => {\n const expiresIn = this.getCreditExpiresIn(productId, priceId);\n return new Date(Date.now() + expiresIn).toISOString();\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../../src/stripe/config.ts"],"sourcesContent":["import { invariant } from '@shware/utils';\nimport ms from 'ms';\nimport { type Catalog, assertCatalog, assertDeclared, assertInNamespace } from '../catalog';\nimport type { Metadata } from '../types';\n\nexport type PriceId = `price_${string}`;\n\n/**\n * Which Stripe object space the config addresses. Test and live prices are\n * disjoint, so a config is built for exactly one of them. Derive it from the\n * secret key (`sk_test_` vs `sk_live_`) rather than from the deployment\n * environment: the key is what actually decides which space API calls hit.\n */\nexport type StripeMode = 'live' | 'test';\n\nconst addMethod = Symbol('addMethod');\n/** The private constructor's one door, for this module's `define` function only. */\nconst construct = Symbol('construct');\n\nclass Product<\n NS extends Lowercase<string> = Lowercase<string>,\n PE extends string = string,\n BP extends string = string,\n PL extends string = never,\n PI extends string = never,\n I extends PriceId = never,\n> {\n readonly id: string;\n readonly config: StripeConfig<NS, PE, BP, PL, PI>;\n readonly plan: PE | null;\n readonly billingPeriod: BP | null;\n readonly prices: Map<PriceId, Metadata>;\n\n defaultPriceId: PriceId | null;\n\n constructor(\n config: StripeConfig<NS, PE, BP, PL, PI>,\n id: string,\n plan: PE | null = null,\n billingPeriod: BP | null = null\n ) {\n this.id = id;\n this.config = config;\n this.plan = plan;\n this.billingPeriod = billingPeriod;\n this.prices = new Map();\n this.defaultPriceId = null;\n }\n\n /**\n * Non-default prices stay declared on purpose: they keep resolving for\n * grandfathered subscribers and serve as price-experiment arms. Never\n * create new checkouts with a legacy price unless that is intentional.\n */\n price = <K extends PriceId>(\n priceId: K extends I ? never : K,\n metadata: Metadata = { credits: 0, expiresIn: '0' }\n ): Product<NS, PE, BP, PL, PI, I | K> => {\n invariant(!this.prices.has(priceId), `Duplicate price ${priceId}`);\n this.prices.set(priceId, metadata);\n return this as Product<NS, PE, BP, PL, PI, I | K>;\n };\n\n /**\n * `test` is the sandbox twin of the default price. In test mode it is what\n * checkouts are created with, and it resolves to the default price's\n * entitlement, so credits are declared once and cannot drift between the\n * two modes. Legacy prices need no twin: a sandbox holds no grandfathered\n * subscribers. Live mode ignores it, so a test price reaching a live config\n * still fails as an unknown price instead of being granted an entitlement.\n */\n default = (defaultPriceId: I, twin?: { test: PriceId }) => {\n const metadata = this.prices.get(defaultPriceId);\n invariant(metadata, `Default price ${defaultPriceId} is not declared`);\n if (this.config.mode === 'test') {\n invariant(twin, `Test price not declared for default ${defaultPriceId} of ${this.id}`);\n invariant(!this.prices.has(twin.test), `Duplicate price ${twin.test}`);\n this.prices.set(twin.test, metadata);\n this.defaultPriceId = twin.test;\n } else {\n this.defaultPriceId = defaultPriceId;\n }\n this.config[addMethod](this);\n return this.config;\n };\n}\n\n/** `plans` and `billingPeriods` are the order {@link StripeConfig.comparePlans} reads. */\ntype Options<NS extends Lowercase<string>, PE extends string, BP extends string> = Catalog<\n NS,\n PE,\n BP\n> & {\n mode: StripeMode;\n returnUrl: string;\n cancelUrl: string;\n successUrl: `${string}session_id={CHECKOUT_SESSION_ID}${string}`;\n cancellationCouponId: string;\n allowPromotionCodes: boolean;\n};\n\n/**\n * How a switch between two subscription products relates them, by plan level first and billing\n * period second:\n * - `upgrade` / `downgrade`: a higher or lower plan, whatever the periods.\n * - `longer` / `shorter`: the same plan billed over a longer or shorter period (monthly → yearly\n * is `longer`). Apple calls these crossgrades.\n * - `same`: the same plan and period.\n */\nexport type PlanComparison = 'upgrade' | 'downgrade' | 'longer' | 'shorter' | 'same';\n\nexport class StripeConfig<\n NS extends Lowercase<string> = Lowercase<string>,\n PE extends string = string,\n BP extends string = string,\n PL extends string = never,\n PI extends string = never,\n> {\n private readonly products: Map<string, Product<NS, PE, BP, PL, PI>> = new Map();\n\n public readonly mode: StripeMode;\n public returnUrl: string;\n public cancelUrl: string;\n public successUrl: `${string}session_id={CHECKOUT_SESSION_ID}${string}`;\n public cancellationCouponId: string;\n public allowPromotionCodes: boolean;\n public readonly namespace: NS;\n public readonly plans: readonly PE[];\n public readonly billingPeriods: readonly BP[];\n\n get productIds(): string[] {\n return Array.from(this.products.keys());\n }\n\n // runtime mirror of the type-level guards: generic accumulation only\n // protects a single fluent chain, not statement-style re-entry or casts\n [addMethod] = (product: Product<NS, PE, BP, PL, PI>) => {\n invariant(!this.products.has(product.id), `Duplicate product ${product.id}`);\n // A Stripe price belongs to one Stripe product, and getProductId must have one answer.\n for (const existing of this.products.values()) {\n for (const priceId of product.prices.keys()) {\n invariant(!existing.prices.has(priceId), `Duplicate price ${priceId} on ${existing.id}`);\n }\n }\n if (product.plan !== null) {\n for (const existing of this.products.values()) {\n invariant(\n existing.plan !== product.plan || existing.billingPeriod !== product.billingPeriod,\n `Duplicate plan ${product.plan}:${product.billingPeriod}`\n );\n }\n }\n this.products.set(product.id, product);\n return this;\n };\n\n private constructor(options: Options<NS, PE, BP>) {\n assertCatalog(options);\n this.namespace = options.namespace;\n this.plans = options.plans;\n this.billingPeriods = options.billingPeriods;\n this.mode = options.mode;\n this.returnUrl = options.returnUrl;\n this.cancelUrl = options.cancelUrl;\n this.successUrl = options.successUrl;\n this.cancellationCouponId = options.cancellationCouponId;\n this.allowPromotionCodes = options.allowPromotionCodes;\n }\n\n static [construct] = <NS extends Lowercase<string>, PE extends string, BP extends string>(\n options: Options<NS, PE, BP>\n ) => new StripeConfig<NS, PE, BP>(options);\n\n /**\n * One-time products take no extra args; subscriptions require both plan and billingPeriod.\n * A plan+period pair may be declared on only one product — version its prices in that chain.\n */\n product: {\n <K extends `${NS}.${Lowercase<string>}`>(\n productId: K extends PI ? never : K\n ): Product<NS, PE, BP, PL, K | PI>;\n <K extends `${NS}.${Lowercase<string>}`, L extends PE, P extends BP>(\n productId: K extends PI ? never : K,\n plan: L,\n billingPeriod: `${L}:${P}` extends PL ? never : P\n ): Product<NS, PE, BP, `${L}:${P}` | PL, K | PI>;\n } = (productId: string, plan: PE | null = null, billingPeriod: BP | null = null) => {\n assertInNamespace(this.namespace, productId);\n assertDeclared(this, plan, billingPeriod);\n // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- widens to the overloads' accumulated generics\n return new Product(this, productId, plan, billingPeriod) as never;\n };\n\n getPlan = (productId: string): PE => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.plan, `Product ${productId} is not a subscription`);\n return product.plan;\n };\n\n getBillingPeriod = (productId: string): BP => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.billingPeriod, `Product ${productId} is not a subscription`);\n return product.billingPeriod;\n };\n\n getMode = (productId: string): 'payment' | 'subscription' => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n return product.plan === null ? 'payment' : 'subscription';\n };\n\n /**\n * Whether the config declares `productId` and sells it as a subscription, so `getPlan` and\n * `getBillingPeriod` hold for it. False, not a throw, for an id it does not know: a product that\n * left the config (renamed, retired) or one named by metadata set outside the app.\n */\n isSubscription = (productId: string): boolean => {\n const product = this.products.get(productId);\n return product !== undefined && product.plan !== null;\n };\n\n /** The price new checkouts are created with: the default, or its test twin in test mode. */\n getPriceId = (productId: string): PriceId => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return product.defaultPriceId;\n };\n\n /**\n * The product that declares `priceId` (a legacy price or the test twin included), or null for a\n * price this config does not know: one set up by hand in the dashboard, or a test price reaching\n * a live config. Reading the product from the price a Stripe object charged, rather than from\n * metadata that may already name the next plan, is what an invoice should be credited by.\n */\n getProductId = (priceId: string): string | null => {\n for (const product of this.products.values()) {\n if (product.prices.has(priceId as PriceId)) return product.id;\n }\n return null;\n };\n\n /**\n * How switching from one subscription product to another relates them (see\n * {@link PlanComparison}), by the order `plans` and `billingPeriods` were declared in. Throws for\n * a product that is not a subscription.\n */\n comparePlans = (fromProductId: string, toProductId: string): PlanComparison => {\n const level = (productId: string) => ({\n plan: this.plans.indexOf(this.getPlan(productId)),\n period: this.billingPeriods.indexOf(this.getBillingPeriod(productId)),\n });\n const from = level(fromProductId);\n const to = level(toProductId);\n if (to.plan !== from.plan) return to.plan > from.plan ? 'upgrade' : 'downgrade';\n if (to.period !== from.period) return to.period > from.period ? 'longer' : 'shorter';\n return 'same';\n };\n\n getCreditAmount = (productId: string, priceId?: string): number => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n if (priceId) {\n const price = product.prices.get(priceId as PriceId);\n invariant(price, `Price not found for ${priceId}`);\n return price.credits;\n }\n\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return product.prices.get(product.defaultPriceId)?.credits ?? 0;\n };\n\n private readonly getCreditExpiresIn = (productId: string, priceId?: string): number => {\n const product = this.products.get(productId);\n invariant(product, `Product not found for ${productId}`);\n if (priceId) {\n const price = product.prices.get(priceId as PriceId);\n invariant(price, `Price not found for ${priceId}`);\n return ms(price.expiresIn);\n }\n\n invariant(product.defaultPriceId, `Default price not found for product ${productId}`);\n return ms(product.prices.get(product.defaultPriceId)?.expiresIn ?? '0');\n };\n\n getCreditExpiresAt = (productId: string, priceId?: string): string => {\n const expiresIn = this.getCreditExpiresIn(productId, priceId);\n return new Date(Date.now() + expiresIn).toISOString();\n };\n}\n\n/**\n * Declare the Stripe product catalog. Every type parameter is inferred from the options:\n * the namespace from `namespace`, the plans and billing periods from `plans` and `billingPeriods`\n * (`const`, so a literal array needs no `as const`). Products are then added with the fluent chain.\n */\nexport function defineStripeConfig<\n const NS extends Lowercase<string>,\n const PE extends string,\n const BP extends string,\n>(options: Options<NS, PE, BP>): StripeConfig<NS, PE, BP> {\n return StripeConfig[construct](options);\n}\n"],"mappings":"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"}
@@ -9,10 +9,13 @@ const require_stripe_payment_method = require("./payment-method.cjs");
9
9
  const require_stripe_thin_handler = require("./thin-handler.cjs");
10
10
  const require_stripe_schema = require("./schema.cjs");
11
11
  const require_stripe_metadata = require("./metadata.cjs");
12
+ const require_stripe_plan_change = require("./plan-change.cjs");
12
13
  const require_stripe_pending_checkout = require("./pending-checkout.cjs");
13
14
  exports.DECLINE_REASONS = require_stripe_decline.DECLINE_REASONS;
14
15
  exports.DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES = require_stripe_payment_method.DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES;
15
16
  exports.METADATA_KEYS = require_stripe_metadata.METADATA_KEYS;
17
+ exports.PLAN_CHANGE_ERROR_REASONS = require_stripe_plan_change.PLAN_CHANGE_ERROR_REASONS;
18
+ exports.PlanChangeError = require_stripe_plan_change.PlanChangeError;
16
19
  exports.StripeConfig = require_stripe_config.StripeConfig;
17
20
  exports.StripeEventHandler = require_stripe_handler.StripeEventHandler;
18
21
  exports.StripeThinEventHandler = require_stripe_thin_handler.StripeThinEventHandler;
@@ -20,6 +23,8 @@ exports.addPendingCheckout = require_stripe_pending_checkout.addPendingCheckout;
20
23
  exports.cancellationComment = require_stripe_mapper.cancellationComment;
21
24
  exports.cancellationDetailsSchema = require_stripe_schema.cancellationDetailsSchema;
22
25
  exports.createCheckoutSessionSchema = require_stripe_schema.createCheckoutSessionSchema;
26
+ exports.createPlanChange = require_stripe_plan_change.createPlanChange;
27
+ exports.defineStripeConfig = require_stripe_config.defineStripeConfig;
23
28
  exports.expanded = require_stripe_expandable.expanded;
24
29
  exports.getBeginCheckoutProperties = require_stripe_mapper.getBeginCheckoutProperties;
25
30
  exports.getPendingCheckouts = require_stripe_pending_checkout.getPendingCheckouts;
@@ -1,5 +1,5 @@
1
1
  import { CancellationDetails } from "../subscription/index.cjs";
2
- import { PriceId, StripeConfig, StripeMode } from "./config.cjs";
2
+ import { PlanComparison, PriceId, StripeConfig, StripeMode, defineStripeConfig } from "./config.cjs";
3
3
  import { CardFailure, DECLINE_REASONS, Decline, DeclineReason, mapDecline } from "./decline.cjs";
4
4
  import { expanded, idOf } from "./expandable.cjs";
5
5
  import { StripeEventHandler } from "./handler.cjs";
@@ -9,6 +9,7 @@ import { DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, hasDelayedNotification } fro
9
9
  import { StripeThinEventHandler, StripeThinEventNotificationOf, StripeThinEventType } from "./thin-handler.cjs";
10
10
  import { AllowedOrigins, CreateCheckoutSessionDTO, CreateCheckoutSessionSchemaOptions, cancellationDetailsSchema, createCheckoutSessionSchema, returnUrlSchema } from "./schema.cjs";
11
11
  import { METADATA_KEYS } from "./metadata.cjs";
12
+ import { PLAN_CHANGE_ERROR_REASONS, PlanChange, PlanChangeError, PlanChangeErrorReason, PlanChangeOptions, PlanChangePreview, PlanChangeResult, PlanChangeTiming, PlanSummary, ScheduledPlanChange, createPlanChange } from "./plan-change.cjs";
12
13
  import { addPendingCheckout, getPendingCheckouts, removePendingCheckout } from "./pending-checkout.cjs";
13
14
  import { Stripe } from "stripe";
14
- export { type AllowedOrigins, type BeginCheckoutProperties, type CancellationDetails, type CardFailure, type CheckoutSession, type CreateCheckoutSessionDTO, type CreateCheckoutSessionSchemaOptions, DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, type Decline, type DeclineReason, type LineItem, METADATA_KEYS, type Money, type PaymentStatus, type Price, type PriceId, type ProductPrice, type PurchaseProperties, type Stripe, StripeConfig, StripeEventHandler, type StripeMode, type StripeSubscriptionState, StripeThinEventHandler, type StripeThinEventNotificationOf, type StripeThinEventType, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
15
+ export { type AllowedOrigins, type BeginCheckoutProperties, type CancellationDetails, type CardFailure, type CheckoutSession, type CreateCheckoutSessionDTO, type CreateCheckoutSessionSchemaOptions, DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, type Decline, type DeclineReason, type LineItem, METADATA_KEYS, type Money, PLAN_CHANGE_ERROR_REASONS, type PaymentStatus, type PlanChange, PlanChangeError, type PlanChangeErrorReason, type PlanChangeOptions, type PlanChangePreview, type PlanChangeResult, type PlanChangeTiming, type PlanComparison, type PlanSummary, type Price, type PriceId, type ProductPrice, type PurchaseProperties, type ScheduledPlanChange, type Stripe, StripeConfig, StripeEventHandler, type StripeMode, type StripeSubscriptionState, StripeThinEventHandler, type StripeThinEventNotificationOf, type StripeThinEventType, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, createPlanChange, defineStripeConfig, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
@@ -1,5 +1,5 @@
1
1
  import { CancellationDetails } from "../subscription/index.mjs";
2
- import { PriceId, StripeConfig, StripeMode } from "./config.mjs";
2
+ import { PlanComparison, PriceId, StripeConfig, StripeMode, defineStripeConfig } from "./config.mjs";
3
3
  import { CardFailure, DECLINE_REASONS, Decline, DeclineReason, mapDecline } from "./decline.mjs";
4
4
  import { expanded, idOf } from "./expandable.mjs";
5
5
  import { StripeEventHandler } from "./handler.mjs";
@@ -9,6 +9,7 @@ import { DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, hasDelayedNotification } fro
9
9
  import { StripeThinEventHandler, StripeThinEventNotificationOf, StripeThinEventType } from "./thin-handler.mjs";
10
10
  import { AllowedOrigins, CreateCheckoutSessionDTO, CreateCheckoutSessionSchemaOptions, cancellationDetailsSchema, createCheckoutSessionSchema, returnUrlSchema } from "./schema.mjs";
11
11
  import { METADATA_KEYS } from "./metadata.mjs";
12
+ import { PLAN_CHANGE_ERROR_REASONS, PlanChange, PlanChangeError, PlanChangeErrorReason, PlanChangeOptions, PlanChangePreview, PlanChangeResult, PlanChangeTiming, PlanSummary, ScheduledPlanChange, createPlanChange } from "./plan-change.mjs";
12
13
  import { addPendingCheckout, getPendingCheckouts, removePendingCheckout } from "./pending-checkout.mjs";
13
14
  import { Stripe } from "stripe";
14
- export { type AllowedOrigins, type BeginCheckoutProperties, type CancellationDetails, type CardFailure, type CheckoutSession, type CreateCheckoutSessionDTO, type CreateCheckoutSessionSchemaOptions, DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, type Decline, type DeclineReason, type LineItem, METADATA_KEYS, type Money, type PaymentStatus, type Price, type PriceId, type ProductPrice, type PurchaseProperties, type Stripe, StripeConfig, StripeEventHandler, type StripeMode, type StripeSubscriptionState, StripeThinEventHandler, type StripeThinEventNotificationOf, type StripeThinEventType, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
15
+ export { type AllowedOrigins, type BeginCheckoutProperties, type CancellationDetails, type CardFailure, type CheckoutSession, type CreateCheckoutSessionDTO, type CreateCheckoutSessionSchemaOptions, DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, type Decline, type DeclineReason, type LineItem, METADATA_KEYS, type Money, PLAN_CHANGE_ERROR_REASONS, type PaymentStatus, type PlanChange, PlanChangeError, type PlanChangeErrorReason, type PlanChangeOptions, type PlanChangePreview, type PlanChangeResult, type PlanChangeTiming, type PlanComparison, type PlanSummary, type Price, type PriceId, type ProductPrice, type PurchaseProperties, type ScheduledPlanChange, type Stripe, StripeConfig, StripeEventHandler, type StripeMode, type StripeSubscriptionState, StripeThinEventHandler, type StripeThinEventNotificationOf, type StripeThinEventType, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, createPlanChange, defineStripeConfig, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
@@ -1,4 +1,4 @@
1
- import { StripeConfig } from "./config.mjs";
1
+ import { StripeConfig, defineStripeConfig } from "./config.mjs";
2
2
  import { DECLINE_REASONS, mapDecline } from "./decline.mjs";
3
3
  import { expanded, idOf } from "./expandable.mjs";
4
4
  import { StripeEventHandler } from "./handler.mjs";
@@ -8,5 +8,6 @@ import { DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, hasDelayedNotification } fro
8
8
  import { StripeThinEventHandler } from "./thin-handler.mjs";
9
9
  import { cancellationDetailsSchema, createCheckoutSessionSchema, returnUrlSchema } from "./schema.mjs";
10
10
  import { METADATA_KEYS } from "./metadata.mjs";
11
+ import { PLAN_CHANGE_ERROR_REASONS, PlanChangeError, createPlanChange } from "./plan-change.mjs";
11
12
  import { addPendingCheckout, getPendingCheckouts, removePendingCheckout } from "./pending-checkout.mjs";
12
- export { DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, METADATA_KEYS, StripeConfig, StripeEventHandler, StripeThinEventHandler, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
13
+ export { DECLINE_REASONS, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES, METADATA_KEYS, PLAN_CHANGE_ERROR_REASONS, PlanChangeError, StripeConfig, StripeEventHandler, StripeThinEventHandler, addPendingCheckout, cancellationComment, cancellationDetailsSchema, createCheckoutSessionSchema, createPlanChange, defineStripeConfig, expanded, getBeginCheckoutProperties, getPendingCheckouts, getPurchaseProperties, hasDelayedNotification, idOf, mapCancellationDetails, mapCharge, mapCheckoutSession, mapDecline, mapDisputeStatus, mapInvoice, mapLineItem, mapListPrice, mapPaymentIntent, mapPaymentType, mapPrice, mapRefundStatus, mapSubscription, mapSubscriptionStatus, mapTime, minorUnits, money, price, removePendingCheckout, returnUrlSchema };
@@ -0,0 +1,400 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_stripe_decline = require("./decline.cjs");
3
+ const require_stripe_expandable = require("./expandable.cjs");
4
+ const require_stripe_mapper = require("./mapper.cjs");
5
+ const require_stripe_payment_method = require("./payment-method.cjs");
6
+ const require_stripe_metadata = require("./metadata.cjs");
7
+ //#region src/stripe/plan-change.ts
8
+ /**
9
+ * Why a change was refused:
10
+ * - NOT_A_SUBSCRIPTION: the target product is not a subscription in the config.
11
+ * - ALREADY_CANCELED: the subscription has ended.
12
+ * - NOT_SWITCHABLE: past due, unpaid, paused, incomplete or trialing; the fix is the payment
13
+ * method, not another charge, and what a switch mid-trial should do is the app's to decide.
14
+ * - CANCEL_SCHEDULED: set to cancel at the period end; reactivate first.
15
+ * - CHANGE_PENDING: an earlier immediate change still waits on its payment; only the same target
16
+ * may be retried.
17
+ * - UNCHANGED: already on the target product.
18
+ * - REQUIRES_INSTANT_METHOD: an immediate change would be paid by a bank debit
19
+ * (`hasDelayedNotification`); `paymentMethodType` names it.
20
+ * - DECLINED: the card was refused; `decline` says why.
21
+ * - CONFLICT: a concurrent change on the same snapshot was applied instead.
22
+ * - MISCONFIGURED: the subscription or config is not what a change needs (no item, no product
23
+ * metadata, a price without a flat amount, a pending invoice without a payment intent).
24
+ */
25
+ const PLAN_CHANGE_ERROR_REASONS = [
26
+ "NOT_A_SUBSCRIPTION",
27
+ "ALREADY_CANCELED",
28
+ "NOT_SWITCHABLE",
29
+ "CANCEL_SCHEDULED",
30
+ "CHANGE_PENDING",
31
+ "UNCHANGED",
32
+ "REQUIRES_INSTANT_METHOD",
33
+ "DECLINED",
34
+ "CONFLICT",
35
+ "MISCONFIGURED"
36
+ ];
37
+ /** A refused change. The app turns `reason` (and `decline`) into its own error response. */
38
+ var PlanChangeError = class extends Error {
39
+ reason;
40
+ decline;
41
+ paymentMethodType;
42
+ constructor(reason, message, extra = {}) {
43
+ super(message);
44
+ this.name = "PlanChangeError";
45
+ this.reason = reason;
46
+ this.decline = extra.decline ?? null;
47
+ this.paymentMethodType = extra.paymentMethodType ?? null;
48
+ }
49
+ };
50
+ const DEFAULT_TIMING = {
51
+ upgrade: "immediate",
52
+ downgrade: "period_end",
53
+ longer: "period_end",
54
+ shorter: "period_end"
55
+ };
56
+ /** stripe-node's error classes, matched by their `type` so this module needs no runtime import. */
57
+ function stripeErrorType(error) {
58
+ if (typeof error !== "object" || error === null || !("type" in error)) return null;
59
+ return typeof error.type === "string" ? error.type : null;
60
+ }
61
+ const now = () => (/* @__PURE__ */ new Date()).toISOString();
62
+ function createPlanChange(options) {
63
+ const { stripe, config, sync } = options;
64
+ const timing = {
65
+ ...DEFAULT_TIMING,
66
+ ...options.timing
67
+ };
68
+ async function planSummary(productId) {
69
+ const price = await stripe.prices.retrieve(config.getPriceId(productId));
70
+ if (price.unit_amount === null) throw new PlanChangeError("MISCONFIGURED", `Price ${price.id} has no amount`);
71
+ return {
72
+ productId,
73
+ plan: config.getPlan(productId),
74
+ billingPeriod: config.getBillingPeriod(productId),
75
+ price: require_stripe_mapper.money(price.unit_amount, price.currency),
76
+ credits: config.getCreditAmount(productId)
77
+ };
78
+ }
79
+ /**
80
+ * Whether a pending update moves the subscription to `productId`: its one item carries the price
81
+ * an immediate change sets for that product. Anything else counts as a different target.
82
+ */
83
+ function pendingUpdateTargets(pending, productId) {
84
+ const items = pending.subscription_items ?? [];
85
+ return (items.length === 1 ? items[0] : void 0)?.price.id === config.getPriceId(productId);
86
+ }
87
+ /**
88
+ * The payment method an invoice on this subscription is charged to: its own default when it has
89
+ * one (Checkout pins the card it collected), else the customer's invoice default. Retrieved whole
90
+ * because an immediate change needs its `type`.
91
+ */
92
+ async function chargedPaymentMethod(sub) {
93
+ const own = sub.default_payment_method;
94
+ if (own) return typeof own === "string" ? stripe.paymentMethods.retrieve(own) : own;
95
+ const customer = await stripe.customers.retrieve(require_stripe_expandable.idOf(sub.customer), { expand: ["invoice_settings.default_payment_method"] });
96
+ if (customer.deleted) return null;
97
+ const preferred = customer.invoice_settings.default_payment_method;
98
+ if (!preferred) return null;
99
+ return typeof preferred === "string" ? stripe.paymentMethods.retrieve(preferred) : preferred;
100
+ }
101
+ /** The subscription plus the checks every change starts with. */
102
+ async function subscriptionForChange(stripeSubscriptionId, productId) {
103
+ if (!config.isSubscription(productId)) throw new PlanChangeError("NOT_A_SUBSCRIPTION", `Not a subscription product: ${productId}`);
104
+ const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ["schedule"] });
105
+ if (sub.status === "canceled") throw new PlanChangeError("ALREADY_CANCELED", "Subscription already canceled");
106
+ if (sub.status !== "active") throw new PlanChangeError("NOT_SWITCHABLE", `Subscription is ${sub.status}`);
107
+ if (sub.cancel_at_period_end || sub.cancel_at !== null) throw new PlanChangeError("CANCEL_SCHEDULED", "Subscription is scheduled to cancel");
108
+ const resumesPendingUpdate = sub.pending_update ? pendingUpdateTargets(sub.pending_update, productId) : false;
109
+ if (sub.pending_update && !resumesPendingUpdate) throw new PlanChangeError("CHANGE_PENDING", `Subscription has a pending update until ${require_stripe_mapper.mapTime(sub.pending_update.expires_at)}`);
110
+ const currentProductId = sub.metadata[require_stripe_metadata.METADATA_KEYS.PRODUCT_ID];
111
+ if (!currentProductId || !config.isSubscription(currentProductId)) throw new PlanChangeError("MISCONFIGURED", `Subscription ${sub.id} names no known product`);
112
+ const comparison = config.comparePlans(currentProductId, productId);
113
+ if (comparison === "same") throw new PlanChangeError("UNCHANGED", "Already on this plan");
114
+ const item = sub.items.data.at(0);
115
+ if (!item) throw new PlanChangeError("MISCONFIGURED", `Subscription ${sub.id} has no items`);
116
+ const immediate = timing[comparison] === "immediate";
117
+ const base = {
118
+ sub,
119
+ item,
120
+ currentProductId,
121
+ comparison,
122
+ immediate,
123
+ resumesPendingUpdate
124
+ };
125
+ if (!immediate) return {
126
+ ...base,
127
+ paymentMethodId: null
128
+ };
129
+ const paymentMethod = await chargedPaymentMethod(sub);
130
+ if (paymentMethod && require_stripe_payment_method.hasDelayedNotification(paymentMethod.type)) throw new PlanChangeError("REQUIRES_INSTANT_METHOD", `Immediate plan change cannot be paid by ${paymentMethod.type}`, { paymentMethodType: paymentMethod.type });
131
+ return {
132
+ ...base,
133
+ paymentMethodId: paymentMethod?.id ?? null
134
+ };
135
+ }
136
+ /**
137
+ * The parameters of an immediate change, shared by the preview and the real update: the new plan
138
+ * is charged in full today, the unused rest of the current period is credited on the same
139
+ * invoice, and the billing cycle restarts now (Apple's upgrade, Play's `CHARGE_FULL_PRICE`), so
140
+ * "due today" is the new price minus that credit whatever the intervals involved.
141
+ */
142
+ function immediateChangeParams(item, productId) {
143
+ return {
144
+ items: [{
145
+ id: item.id,
146
+ price: config.getPriceId(productId)
147
+ }],
148
+ proration_behavior: "always_invoice",
149
+ billing_cycle_anchor: { type: "now" }
150
+ };
151
+ }
152
+ async function settleParkedUpdate(sub) {
153
+ const invoiceId = require_stripe_expandable.idOf(sub.latest_invoice);
154
+ if (!invoiceId) throw new PlanChangeError("MISCONFIGURED", `Pending update on ${sub.id} has no invoice`);
155
+ const invoice = await stripe.invoices.retrieve(invoiceId, { expand: ["payments.data.payment.payment_intent"] });
156
+ const intent = invoice.payments?.data.at(0)?.payment.payment_intent;
157
+ if (!intent || typeof intent === "string" || !intent.client_secret) throw new PlanChangeError("MISCONFIGURED", `Pending invoice ${invoiceId} has no payment intent`);
158
+ switch (intent.status) {
159
+ case "requires_action": return {
160
+ state: "challenge",
161
+ result: {
162
+ effect: "requires_action",
163
+ effectiveAt: now(),
164
+ clientSecret: intent.client_secret
165
+ }
166
+ };
167
+ case "succeeded": return { state: "paid" };
168
+ case "requires_payment_method":
169
+ if (invoice.status !== "void") await stripe.invoices.voidInvoice(invoiceId);
170
+ return {
171
+ state: "declined",
172
+ failure: intent.last_payment_error
173
+ };
174
+ default: throw new PlanChangeError("MISCONFIGURED", `Pending invoice ${invoiceId} has its payment in state ${intent.status}`);
175
+ }
176
+ }
177
+ function declined(failure) {
178
+ const decline = require_stripe_decline.mapDecline(failure);
179
+ return new PlanChangeError("DECLINED", decline.message ?? "Payment declined", { decline });
180
+ }
181
+ /**
182
+ * Mirror the subscription after an attempt that already failed. The original error is what the
183
+ * caller needs (a decline, a conflict); a sync failure on top of it is only logged, and the next
184
+ * webhook or sync puts the row right.
185
+ */
186
+ async function syncAfterFailure(stripeSubscriptionId) {
187
+ try {
188
+ await sync(stripeSubscriptionId);
189
+ } catch (error) {
190
+ console.error(`Sync of ${stripeSubscriptionId} after a failed plan change failed:`, error);
191
+ }
192
+ }
193
+ const applied = () => ({
194
+ effect: "immediate",
195
+ effectiveAt: now(),
196
+ clientSecret: null
197
+ });
198
+ async function readScheduled(sub) {
199
+ const pending = pendingPhase(sub);
200
+ if (!pending) return null;
201
+ const { schedule, next, productId } = pending;
202
+ if (!config.isSubscription(productId)) {
203
+ console.error(`Schedule ${schedule.id} next phase names unknown product ${productId}`);
204
+ return null;
205
+ }
206
+ const priceId = require_stripe_expandable.idOf(next.items.at(0)?.price);
207
+ if (!priceId) {
208
+ console.error(`Schedule ${schedule.id} next phase has no price`);
209
+ return null;
210
+ }
211
+ const price = await stripe.prices.retrieve(priceId);
212
+ if (price.unit_amount === null) {
213
+ console.error(`Schedule ${schedule.id} next phase price ${priceId} has no unit amount`);
214
+ return null;
215
+ }
216
+ return {
217
+ productId,
218
+ plan: config.getPlan(productId),
219
+ billingPeriod: config.getBillingPeriod(productId),
220
+ price: require_stripe_mapper.money(price.unit_amount, price.currency),
221
+ effectiveAt: require_stripe_mapper.mapTime(next.start_date)
222
+ };
223
+ }
224
+ async function preview(stripeSubscriptionId, productId) {
225
+ const { sub, item, currentProductId, comparison, immediate } = await subscriptionForChange(stripeSubscriptionId, productId);
226
+ const [current, target] = await Promise.all([planSummary(currentProductId), planSummary(productId)]);
227
+ if (!immediate) return {
228
+ comparison,
229
+ effect: "scheduled",
230
+ current,
231
+ target,
232
+ effectiveAt: require_stripe_mapper.mapTime(item.current_period_end),
233
+ charge: null
234
+ };
235
+ const invoice = await stripe.invoices.createPreview({
236
+ subscription: sub.id,
237
+ subscription_details: immediateChangeParams(item, productId)
238
+ });
239
+ let creditBack = 0;
240
+ let newPlan = 0;
241
+ let nextBillingAt = invoice.period_end;
242
+ for (const line of invoice.lines.data) if (line.parent?.subscription_item_details?.proration) creditBack -= line.amount;
243
+ else {
244
+ newPlan += line.amount;
245
+ nextBillingAt = Math.max(nextBillingAt, line.period.end);
246
+ }
247
+ const units = require_stripe_mapper.minorUnits(invoice.currency);
248
+ const balanceApplied = Math.min(Math.max(0, -invoice.starting_balance), Math.max(0, newPlan - creditBack));
249
+ return {
250
+ comparison,
251
+ effect: "immediate",
252
+ current,
253
+ target,
254
+ effectiveAt: now(),
255
+ charge: {
256
+ newPlan: newPlan / units,
257
+ creditBack: creditBack / units,
258
+ dueToday: invoice.amount_due / units,
259
+ balanceApplied: balanceApplied / units,
260
+ accountCredit: Math.max(0, creditBack - newPlan) / units,
261
+ currency: invoice.currency,
262
+ nextBillingAt: require_stripe_mapper.mapTime(nextBillingAt)
263
+ }
264
+ };
265
+ }
266
+ async function applyNow(sub, item, currentProductId, productId, paymentMethodId) {
267
+ const scheduleId = require_stripe_expandable.idOf(sub.schedule);
268
+ if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId, {}, { idempotencyKey: `release:${scheduleId}` });
269
+ const idempotencyKey = [
270
+ "plan",
271
+ sub.id,
272
+ currentProductId,
273
+ productId,
274
+ item.current_period_start,
275
+ require_stripe_expandable.idOf(sub.latest_invoice) ?? "none",
276
+ paymentMethodId ?? "none"
277
+ ].join(":");
278
+ let updated;
279
+ try {
280
+ updated = await stripe.subscriptions.update(sub.id, {
281
+ ...immediateChangeParams(item, productId),
282
+ metadata: {
283
+ ...sub.metadata,
284
+ [require_stripe_metadata.METADATA_KEYS.PRODUCT_ID]: productId
285
+ },
286
+ payment_behavior: "pending_if_incomplete"
287
+ }, { idempotencyKey });
288
+ } catch (error) {
289
+ await syncAfterFailure(sub.id);
290
+ if (stripeErrorType(error) === "StripeCardError") throw declined(error);
291
+ if (stripeErrorType(error) === "StripeIdempotencyError") throw new PlanChangeError("CONFLICT", "Plan change conflicts with a concurrent one");
292
+ throw error;
293
+ }
294
+ await sync(sub.id);
295
+ if (!updated.pending_update) return applied();
296
+ const parked = await settleParkedUpdate(updated);
297
+ if (parked.state === "challenge") return parked.result;
298
+ if (parked.state === "paid") {
299
+ await sync(sub.id);
300
+ return applied();
301
+ }
302
+ await syncAfterFailure(sub.id);
303
+ throw declined(parked.failure);
304
+ }
305
+ async function applyAtPeriodEnd(sub, item, productId) {
306
+ const targetPrice = await stripe.prices.retrieve(config.getPriceId(productId));
307
+ if (!targetPrice.recurring) throw new PlanChangeError("MISCONFIGURED", `Price ${targetPrice.id} is not recurring`);
308
+ const schedule = sub.schedule && typeof sub.schedule !== "string" ? sub.schedule : await stripe.subscriptionSchedules.create({ from_subscription: sub.id });
309
+ const current = schedule.current_phase;
310
+ const phase = schedule.phases.find((p) => p.start_date === current?.start_date);
311
+ if (!current || !phase) throw new PlanChangeError("MISCONFIGURED", `Schedule ${schedule.id} has no current phase`);
312
+ const discounts = sub.discounts.map((d) => ({ discount: require_stripe_expandable.idOf(d) }));
313
+ await stripe.subscriptionSchedules.update(schedule.id, {
314
+ end_behavior: "release",
315
+ phases: [{
316
+ start_date: current.start_date,
317
+ end_date: item.current_period_end,
318
+ items: [{
319
+ price: item.price.id,
320
+ quantity: item.quantity
321
+ }],
322
+ metadata: sub.metadata,
323
+ discounts,
324
+ proration_behavior: "none"
325
+ }, {
326
+ items: [{
327
+ price: targetPrice.id,
328
+ quantity: item.quantity
329
+ }],
330
+ duration: {
331
+ interval: targetPrice.recurring.interval,
332
+ interval_count: targetPrice.recurring.interval_count
333
+ },
334
+ metadata: {
335
+ ...sub.metadata,
336
+ [require_stripe_metadata.METADATA_KEYS.PRODUCT_ID]: productId
337
+ },
338
+ discounts,
339
+ proration_behavior: "none"
340
+ }]
341
+ });
342
+ await sync(sub.id);
343
+ return {
344
+ effect: "scheduled",
345
+ effectiveAt: require_stripe_mapper.mapTime(item.current_period_end),
346
+ clientSecret: null
347
+ };
348
+ }
349
+ async function apply(stripeSubscriptionId, productId) {
350
+ const { sub, item, currentProductId, immediate, resumesPendingUpdate, paymentMethodId } = await subscriptionForChange(stripeSubscriptionId, productId);
351
+ if (resumesPendingUpdate) {
352
+ const parked = await settleParkedUpdate(sub);
353
+ if (parked.state === "challenge") return parked.result;
354
+ if (parked.state === "paid") {
355
+ await sync(sub.id);
356
+ return applied();
357
+ }
358
+ }
359
+ if (immediate) return applyNow(sub, item, currentProductId, productId, paymentMethodId);
360
+ return applyAtPeriodEnd(sub, item, productId);
361
+ }
362
+ /**
363
+ * The schedule's next phase when it switches the subscription to another product, whatever that
364
+ * product is: also one the config no longer knows, or a price the dashboard set without a flat
365
+ * amount, which `readScheduled` does not show but "keep current plan" must still be able to drop.
366
+ */
367
+ function pendingPhase(sub) {
368
+ const schedule = require_stripe_expandable.expanded(sub.schedule, `Schedule of subscription ${sub.id}`);
369
+ if (!schedule) return null;
370
+ if (schedule.status !== "active" && schedule.status !== "not_started") return null;
371
+ const current = schedule.current_phase;
372
+ if (!current) return null;
373
+ const next = schedule.phases.find((phase) => phase.start_date >= current.end_date);
374
+ const productId = next?.metadata?.[require_stripe_metadata.METADATA_KEYS.PRODUCT_ID];
375
+ if (!next || !productId || productId === sub.metadata[require_stripe_metadata.METADATA_KEYS.PRODUCT_ID]) return null;
376
+ return {
377
+ schedule,
378
+ next,
379
+ productId
380
+ };
381
+ }
382
+ async function cancelScheduled(stripeSubscriptionId) {
383
+ const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ["schedule"] });
384
+ const pending = pendingPhase(sub);
385
+ if (pending) await stripe.subscriptionSchedules.release(pending.schedule.id);
386
+ await sync(sub.id);
387
+ }
388
+ return {
389
+ preview,
390
+ apply,
391
+ cancelScheduled,
392
+ readScheduled
393
+ };
394
+ }
395
+ //#endregion
396
+ exports.PLAN_CHANGE_ERROR_REASONS = PLAN_CHANGE_ERROR_REASONS;
397
+ exports.PlanChangeError = PlanChangeError;
398
+ exports.createPlanChange = createPlanChange;
399
+
400
+ //# sourceMappingURL=plan-change.cjs.map