@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.
- package/README.md +25 -14
- package/dist/app-store/config.cjs +24 -3
- package/dist/app-store/config.cjs.map +1 -1
- package/dist/app-store/config.d.cts +14 -2
- package/dist/app-store/config.d.cts.map +1 -1
- package/dist/app-store/config.d.mts +14 -2
- package/dist/app-store/config.d.mts.map +1 -1
- package/dist/app-store/config.mjs +24 -4
- package/dist/app-store/config.mjs.map +1 -1
- package/dist/app-store/index.cjs +2 -0
- package/dist/app-store/index.d.cts +3 -3
- package/dist/app-store/index.d.mts +3 -3
- package/dist/app-store/index.mjs +3 -3
- package/dist/app-store/mapper.cjs +22 -0
- package/dist/app-store/mapper.cjs.map +1 -1
- package/dist/app-store/mapper.d.cts +17 -1
- package/dist/app-store/mapper.d.cts.map +1 -1
- package/dist/app-store/mapper.d.mts +17 -1
- package/dist/app-store/mapper.d.mts.map +1 -1
- package/dist/app-store/mapper.mjs +22 -1
- package/dist/app-store/mapper.mjs.map +1 -1
- package/dist/catalog.cjs +22 -0
- package/dist/catalog.cjs.map +1 -0
- package/dist/catalog.d.cts +21 -0
- package/dist/catalog.d.cts.map +1 -0
- package/dist/catalog.d.mts +21 -0
- package/dist/catalog.d.mts.map +1 -0
- package/dist/catalog.mjs +19 -0
- package/dist/catalog.mjs.map +1 -0
- package/dist/google-play/config.cjs +23 -3
- package/dist/google-play/config.cjs.map +1 -1
- package/dist/google-play/config.d.cts +14 -2
- package/dist/google-play/config.d.cts.map +1 -1
- package/dist/google-play/config.d.mts +14 -2
- package/dist/google-play/config.d.mts.map +1 -1
- package/dist/google-play/config.mjs +23 -4
- package/dist/google-play/config.mjs.map +1 -1
- package/dist/google-play/index.cjs +2 -0
- package/dist/google-play/index.d.cts +3 -3
- package/dist/google-play/index.d.mts +3 -3
- package/dist/google-play/index.mjs +3 -3
- package/dist/google-play/mapper.cjs +12 -0
- package/dist/google-play/mapper.cjs.map +1 -1
- package/dist/google-play/mapper.d.cts +10 -1
- package/dist/google-play/mapper.d.cts.map +1 -1
- package/dist/google-play/mapper.d.mts +10 -1
- package/dist/google-play/mapper.d.mts.map +1 -1
- package/dist/google-play/mapper.mjs +12 -1
- package/dist/google-play/mapper.mjs.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +4 -4
- package/dist/stripe/config.cjs +50 -4
- package/dist/stripe/config.cjs.map +1 -1
- package/dist/stripe/config.d.cts +36 -2
- package/dist/stripe/config.d.cts.map +1 -1
- package/dist/stripe/config.d.mts +36 -2
- package/dist/stripe/config.d.mts.map +1 -1
- package/dist/stripe/config.mjs +50 -5
- package/dist/stripe/config.mjs.map +1 -1
- package/dist/stripe/index.cjs +5 -0
- package/dist/stripe/index.d.cts +3 -2
- package/dist/stripe/index.d.mts +3 -2
- package/dist/stripe/index.mjs +3 -2
- package/dist/stripe/plan-change.cjs +400 -0
- package/dist/stripe/plan-change.cjs.map +1 -0
- package/dist/stripe/plan-change.d.cts +142 -0
- package/dist/stripe/plan-change.d.cts.map +1 -0
- package/dist/stripe/plan-change.d.mts +142 -0
- package/dist/stripe/plan-change.d.mts.map +1 -0
- package/dist/stripe/plan-change.mjs +397 -0
- package/dist/stripe/plan-change.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { mapDecline } from "./decline.mjs";
|
|
2
|
+
import { expanded, idOf } from "./expandable.mjs";
|
|
3
|
+
import { mapTime, minorUnits, money } from "./mapper.mjs";
|
|
4
|
+
import { hasDelayedNotification } from "./payment-method.mjs";
|
|
5
|
+
import { METADATA_KEYS } from "./metadata.mjs";
|
|
6
|
+
//#region src/stripe/plan-change.ts
|
|
7
|
+
/**
|
|
8
|
+
* Why a change was refused:
|
|
9
|
+
* - NOT_A_SUBSCRIPTION: the target product is not a subscription in the config.
|
|
10
|
+
* - ALREADY_CANCELED: the subscription has ended.
|
|
11
|
+
* - NOT_SWITCHABLE: past due, unpaid, paused, incomplete or trialing; the fix is the payment
|
|
12
|
+
* method, not another charge, and what a switch mid-trial should do is the app's to decide.
|
|
13
|
+
* - CANCEL_SCHEDULED: set to cancel at the period end; reactivate first.
|
|
14
|
+
* - CHANGE_PENDING: an earlier immediate change still waits on its payment; only the same target
|
|
15
|
+
* may be retried.
|
|
16
|
+
* - UNCHANGED: already on the target product.
|
|
17
|
+
* - REQUIRES_INSTANT_METHOD: an immediate change would be paid by a bank debit
|
|
18
|
+
* (`hasDelayedNotification`); `paymentMethodType` names it.
|
|
19
|
+
* - DECLINED: the card was refused; `decline` says why.
|
|
20
|
+
* - CONFLICT: a concurrent change on the same snapshot was applied instead.
|
|
21
|
+
* - MISCONFIGURED: the subscription or config is not what a change needs (no item, no product
|
|
22
|
+
* metadata, a price without a flat amount, a pending invoice without a payment intent).
|
|
23
|
+
*/
|
|
24
|
+
const PLAN_CHANGE_ERROR_REASONS = [
|
|
25
|
+
"NOT_A_SUBSCRIPTION",
|
|
26
|
+
"ALREADY_CANCELED",
|
|
27
|
+
"NOT_SWITCHABLE",
|
|
28
|
+
"CANCEL_SCHEDULED",
|
|
29
|
+
"CHANGE_PENDING",
|
|
30
|
+
"UNCHANGED",
|
|
31
|
+
"REQUIRES_INSTANT_METHOD",
|
|
32
|
+
"DECLINED",
|
|
33
|
+
"CONFLICT",
|
|
34
|
+
"MISCONFIGURED"
|
|
35
|
+
];
|
|
36
|
+
/** A refused change. The app turns `reason` (and `decline`) into its own error response. */
|
|
37
|
+
var PlanChangeError = class extends Error {
|
|
38
|
+
reason;
|
|
39
|
+
decline;
|
|
40
|
+
paymentMethodType;
|
|
41
|
+
constructor(reason, message, extra = {}) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "PlanChangeError";
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
this.decline = extra.decline ?? null;
|
|
46
|
+
this.paymentMethodType = extra.paymentMethodType ?? null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const DEFAULT_TIMING = {
|
|
50
|
+
upgrade: "immediate",
|
|
51
|
+
downgrade: "period_end",
|
|
52
|
+
longer: "period_end",
|
|
53
|
+
shorter: "period_end"
|
|
54
|
+
};
|
|
55
|
+
/** stripe-node's error classes, matched by their `type` so this module needs no runtime import. */
|
|
56
|
+
function stripeErrorType(error) {
|
|
57
|
+
if (typeof error !== "object" || error === null || !("type" in error)) return null;
|
|
58
|
+
return typeof error.type === "string" ? error.type : null;
|
|
59
|
+
}
|
|
60
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
61
|
+
function createPlanChange(options) {
|
|
62
|
+
const { stripe, config, sync } = options;
|
|
63
|
+
const timing = {
|
|
64
|
+
...DEFAULT_TIMING,
|
|
65
|
+
...options.timing
|
|
66
|
+
};
|
|
67
|
+
async function planSummary(productId) {
|
|
68
|
+
const price = await stripe.prices.retrieve(config.getPriceId(productId));
|
|
69
|
+
if (price.unit_amount === null) throw new PlanChangeError("MISCONFIGURED", `Price ${price.id} has no amount`);
|
|
70
|
+
return {
|
|
71
|
+
productId,
|
|
72
|
+
plan: config.getPlan(productId),
|
|
73
|
+
billingPeriod: config.getBillingPeriod(productId),
|
|
74
|
+
price: money(price.unit_amount, price.currency),
|
|
75
|
+
credits: config.getCreditAmount(productId)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether a pending update moves the subscription to `productId`: its one item carries the price
|
|
80
|
+
* an immediate change sets for that product. Anything else counts as a different target.
|
|
81
|
+
*/
|
|
82
|
+
function pendingUpdateTargets(pending, productId) {
|
|
83
|
+
const items = pending.subscription_items ?? [];
|
|
84
|
+
return (items.length === 1 ? items[0] : void 0)?.price.id === config.getPriceId(productId);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The payment method an invoice on this subscription is charged to: its own default when it has
|
|
88
|
+
* one (Checkout pins the card it collected), else the customer's invoice default. Retrieved whole
|
|
89
|
+
* because an immediate change needs its `type`.
|
|
90
|
+
*/
|
|
91
|
+
async function chargedPaymentMethod(sub) {
|
|
92
|
+
const own = sub.default_payment_method;
|
|
93
|
+
if (own) return typeof own === "string" ? stripe.paymentMethods.retrieve(own) : own;
|
|
94
|
+
const customer = await stripe.customers.retrieve(idOf(sub.customer), { expand: ["invoice_settings.default_payment_method"] });
|
|
95
|
+
if (customer.deleted) return null;
|
|
96
|
+
const preferred = customer.invoice_settings.default_payment_method;
|
|
97
|
+
if (!preferred) return null;
|
|
98
|
+
return typeof preferred === "string" ? stripe.paymentMethods.retrieve(preferred) : preferred;
|
|
99
|
+
}
|
|
100
|
+
/** The subscription plus the checks every change starts with. */
|
|
101
|
+
async function subscriptionForChange(stripeSubscriptionId, productId) {
|
|
102
|
+
if (!config.isSubscription(productId)) throw new PlanChangeError("NOT_A_SUBSCRIPTION", `Not a subscription product: ${productId}`);
|
|
103
|
+
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ["schedule"] });
|
|
104
|
+
if (sub.status === "canceled") throw new PlanChangeError("ALREADY_CANCELED", "Subscription already canceled");
|
|
105
|
+
if (sub.status !== "active") throw new PlanChangeError("NOT_SWITCHABLE", `Subscription is ${sub.status}`);
|
|
106
|
+
if (sub.cancel_at_period_end || sub.cancel_at !== null) throw new PlanChangeError("CANCEL_SCHEDULED", "Subscription is scheduled to cancel");
|
|
107
|
+
const resumesPendingUpdate = sub.pending_update ? pendingUpdateTargets(sub.pending_update, productId) : false;
|
|
108
|
+
if (sub.pending_update && !resumesPendingUpdate) throw new PlanChangeError("CHANGE_PENDING", `Subscription has a pending update until ${mapTime(sub.pending_update.expires_at)}`);
|
|
109
|
+
const currentProductId = sub.metadata[METADATA_KEYS.PRODUCT_ID];
|
|
110
|
+
if (!currentProductId || !config.isSubscription(currentProductId)) throw new PlanChangeError("MISCONFIGURED", `Subscription ${sub.id} names no known product`);
|
|
111
|
+
const comparison = config.comparePlans(currentProductId, productId);
|
|
112
|
+
if (comparison === "same") throw new PlanChangeError("UNCHANGED", "Already on this plan");
|
|
113
|
+
const item = sub.items.data.at(0);
|
|
114
|
+
if (!item) throw new PlanChangeError("MISCONFIGURED", `Subscription ${sub.id} has no items`);
|
|
115
|
+
const immediate = timing[comparison] === "immediate";
|
|
116
|
+
const base = {
|
|
117
|
+
sub,
|
|
118
|
+
item,
|
|
119
|
+
currentProductId,
|
|
120
|
+
comparison,
|
|
121
|
+
immediate,
|
|
122
|
+
resumesPendingUpdate
|
|
123
|
+
};
|
|
124
|
+
if (!immediate) return {
|
|
125
|
+
...base,
|
|
126
|
+
paymentMethodId: null
|
|
127
|
+
};
|
|
128
|
+
const paymentMethod = await chargedPaymentMethod(sub);
|
|
129
|
+
if (paymentMethod && hasDelayedNotification(paymentMethod.type)) throw new PlanChangeError("REQUIRES_INSTANT_METHOD", `Immediate plan change cannot be paid by ${paymentMethod.type}`, { paymentMethodType: paymentMethod.type });
|
|
130
|
+
return {
|
|
131
|
+
...base,
|
|
132
|
+
paymentMethodId: paymentMethod?.id ?? null
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The parameters of an immediate change, shared by the preview and the real update: the new plan
|
|
137
|
+
* is charged in full today, the unused rest of the current period is credited on the same
|
|
138
|
+
* invoice, and the billing cycle restarts now (Apple's upgrade, Play's `CHARGE_FULL_PRICE`), so
|
|
139
|
+
* "due today" is the new price minus that credit whatever the intervals involved.
|
|
140
|
+
*/
|
|
141
|
+
function immediateChangeParams(item, productId) {
|
|
142
|
+
return {
|
|
143
|
+
items: [{
|
|
144
|
+
id: item.id,
|
|
145
|
+
price: config.getPriceId(productId)
|
|
146
|
+
}],
|
|
147
|
+
proration_behavior: "always_invoice",
|
|
148
|
+
billing_cycle_anchor: { type: "now" }
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function settleParkedUpdate(sub) {
|
|
152
|
+
const invoiceId = idOf(sub.latest_invoice);
|
|
153
|
+
if (!invoiceId) throw new PlanChangeError("MISCONFIGURED", `Pending update on ${sub.id} has no invoice`);
|
|
154
|
+
const invoice = await stripe.invoices.retrieve(invoiceId, { expand: ["payments.data.payment.payment_intent"] });
|
|
155
|
+
const intent = invoice.payments?.data.at(0)?.payment.payment_intent;
|
|
156
|
+
if (!intent || typeof intent === "string" || !intent.client_secret) throw new PlanChangeError("MISCONFIGURED", `Pending invoice ${invoiceId} has no payment intent`);
|
|
157
|
+
switch (intent.status) {
|
|
158
|
+
case "requires_action": return {
|
|
159
|
+
state: "challenge",
|
|
160
|
+
result: {
|
|
161
|
+
effect: "requires_action",
|
|
162
|
+
effectiveAt: now(),
|
|
163
|
+
clientSecret: intent.client_secret
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
case "succeeded": return { state: "paid" };
|
|
167
|
+
case "requires_payment_method":
|
|
168
|
+
if (invoice.status !== "void") await stripe.invoices.voidInvoice(invoiceId);
|
|
169
|
+
return {
|
|
170
|
+
state: "declined",
|
|
171
|
+
failure: intent.last_payment_error
|
|
172
|
+
};
|
|
173
|
+
default: throw new PlanChangeError("MISCONFIGURED", `Pending invoice ${invoiceId} has its payment in state ${intent.status}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function declined(failure) {
|
|
177
|
+
const decline = mapDecline(failure);
|
|
178
|
+
return new PlanChangeError("DECLINED", decline.message ?? "Payment declined", { decline });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Mirror the subscription after an attempt that already failed. The original error is what the
|
|
182
|
+
* caller needs (a decline, a conflict); a sync failure on top of it is only logged, and the next
|
|
183
|
+
* webhook or sync puts the row right.
|
|
184
|
+
*/
|
|
185
|
+
async function syncAfterFailure(stripeSubscriptionId) {
|
|
186
|
+
try {
|
|
187
|
+
await sync(stripeSubscriptionId);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(`Sync of ${stripeSubscriptionId} after a failed plan change failed:`, error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const applied = () => ({
|
|
193
|
+
effect: "immediate",
|
|
194
|
+
effectiveAt: now(),
|
|
195
|
+
clientSecret: null
|
|
196
|
+
});
|
|
197
|
+
async function readScheduled(sub) {
|
|
198
|
+
const pending = pendingPhase(sub);
|
|
199
|
+
if (!pending) return null;
|
|
200
|
+
const { schedule, next, productId } = pending;
|
|
201
|
+
if (!config.isSubscription(productId)) {
|
|
202
|
+
console.error(`Schedule ${schedule.id} next phase names unknown product ${productId}`);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const priceId = idOf(next.items.at(0)?.price);
|
|
206
|
+
if (!priceId) {
|
|
207
|
+
console.error(`Schedule ${schedule.id} next phase has no price`);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const price = await stripe.prices.retrieve(priceId);
|
|
211
|
+
if (price.unit_amount === null) {
|
|
212
|
+
console.error(`Schedule ${schedule.id} next phase price ${priceId} has no unit amount`);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
productId,
|
|
217
|
+
plan: config.getPlan(productId),
|
|
218
|
+
billingPeriod: config.getBillingPeriod(productId),
|
|
219
|
+
price: money(price.unit_amount, price.currency),
|
|
220
|
+
effectiveAt: mapTime(next.start_date)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async function preview(stripeSubscriptionId, productId) {
|
|
224
|
+
const { sub, item, currentProductId, comparison, immediate } = await subscriptionForChange(stripeSubscriptionId, productId);
|
|
225
|
+
const [current, target] = await Promise.all([planSummary(currentProductId), planSummary(productId)]);
|
|
226
|
+
if (!immediate) return {
|
|
227
|
+
comparison,
|
|
228
|
+
effect: "scheduled",
|
|
229
|
+
current,
|
|
230
|
+
target,
|
|
231
|
+
effectiveAt: mapTime(item.current_period_end),
|
|
232
|
+
charge: null
|
|
233
|
+
};
|
|
234
|
+
const invoice = await stripe.invoices.createPreview({
|
|
235
|
+
subscription: sub.id,
|
|
236
|
+
subscription_details: immediateChangeParams(item, productId)
|
|
237
|
+
});
|
|
238
|
+
let creditBack = 0;
|
|
239
|
+
let newPlan = 0;
|
|
240
|
+
let nextBillingAt = invoice.period_end;
|
|
241
|
+
for (const line of invoice.lines.data) if (line.parent?.subscription_item_details?.proration) creditBack -= line.amount;
|
|
242
|
+
else {
|
|
243
|
+
newPlan += line.amount;
|
|
244
|
+
nextBillingAt = Math.max(nextBillingAt, line.period.end);
|
|
245
|
+
}
|
|
246
|
+
const units = minorUnits(invoice.currency);
|
|
247
|
+
const balanceApplied = Math.min(Math.max(0, -invoice.starting_balance), Math.max(0, newPlan - creditBack));
|
|
248
|
+
return {
|
|
249
|
+
comparison,
|
|
250
|
+
effect: "immediate",
|
|
251
|
+
current,
|
|
252
|
+
target,
|
|
253
|
+
effectiveAt: now(),
|
|
254
|
+
charge: {
|
|
255
|
+
newPlan: newPlan / units,
|
|
256
|
+
creditBack: creditBack / units,
|
|
257
|
+
dueToday: invoice.amount_due / units,
|
|
258
|
+
balanceApplied: balanceApplied / units,
|
|
259
|
+
accountCredit: Math.max(0, creditBack - newPlan) / units,
|
|
260
|
+
currency: invoice.currency,
|
|
261
|
+
nextBillingAt: mapTime(nextBillingAt)
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function applyNow(sub, item, currentProductId, productId, paymentMethodId) {
|
|
266
|
+
const scheduleId = idOf(sub.schedule);
|
|
267
|
+
if (scheduleId) await stripe.subscriptionSchedules.release(scheduleId, {}, { idempotencyKey: `release:${scheduleId}` });
|
|
268
|
+
const idempotencyKey = [
|
|
269
|
+
"plan",
|
|
270
|
+
sub.id,
|
|
271
|
+
currentProductId,
|
|
272
|
+
productId,
|
|
273
|
+
item.current_period_start,
|
|
274
|
+
idOf(sub.latest_invoice) ?? "none",
|
|
275
|
+
paymentMethodId ?? "none"
|
|
276
|
+
].join(":");
|
|
277
|
+
let updated;
|
|
278
|
+
try {
|
|
279
|
+
updated = await stripe.subscriptions.update(sub.id, {
|
|
280
|
+
...immediateChangeParams(item, productId),
|
|
281
|
+
metadata: {
|
|
282
|
+
...sub.metadata,
|
|
283
|
+
[METADATA_KEYS.PRODUCT_ID]: productId
|
|
284
|
+
},
|
|
285
|
+
payment_behavior: "pending_if_incomplete"
|
|
286
|
+
}, { idempotencyKey });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
await syncAfterFailure(sub.id);
|
|
289
|
+
if (stripeErrorType(error) === "StripeCardError") throw declined(error);
|
|
290
|
+
if (stripeErrorType(error) === "StripeIdempotencyError") throw new PlanChangeError("CONFLICT", "Plan change conflicts with a concurrent one");
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
await sync(sub.id);
|
|
294
|
+
if (!updated.pending_update) return applied();
|
|
295
|
+
const parked = await settleParkedUpdate(updated);
|
|
296
|
+
if (parked.state === "challenge") return parked.result;
|
|
297
|
+
if (parked.state === "paid") {
|
|
298
|
+
await sync(sub.id);
|
|
299
|
+
return applied();
|
|
300
|
+
}
|
|
301
|
+
await syncAfterFailure(sub.id);
|
|
302
|
+
throw declined(parked.failure);
|
|
303
|
+
}
|
|
304
|
+
async function applyAtPeriodEnd(sub, item, productId) {
|
|
305
|
+
const targetPrice = await stripe.prices.retrieve(config.getPriceId(productId));
|
|
306
|
+
if (!targetPrice.recurring) throw new PlanChangeError("MISCONFIGURED", `Price ${targetPrice.id} is not recurring`);
|
|
307
|
+
const schedule = sub.schedule && typeof sub.schedule !== "string" ? sub.schedule : await stripe.subscriptionSchedules.create({ from_subscription: sub.id });
|
|
308
|
+
const current = schedule.current_phase;
|
|
309
|
+
const phase = schedule.phases.find((p) => p.start_date === current?.start_date);
|
|
310
|
+
if (!current || !phase) throw new PlanChangeError("MISCONFIGURED", `Schedule ${schedule.id} has no current phase`);
|
|
311
|
+
const discounts = sub.discounts.map((d) => ({ discount: idOf(d) }));
|
|
312
|
+
await stripe.subscriptionSchedules.update(schedule.id, {
|
|
313
|
+
end_behavior: "release",
|
|
314
|
+
phases: [{
|
|
315
|
+
start_date: current.start_date,
|
|
316
|
+
end_date: item.current_period_end,
|
|
317
|
+
items: [{
|
|
318
|
+
price: item.price.id,
|
|
319
|
+
quantity: item.quantity
|
|
320
|
+
}],
|
|
321
|
+
metadata: sub.metadata,
|
|
322
|
+
discounts,
|
|
323
|
+
proration_behavior: "none"
|
|
324
|
+
}, {
|
|
325
|
+
items: [{
|
|
326
|
+
price: targetPrice.id,
|
|
327
|
+
quantity: item.quantity
|
|
328
|
+
}],
|
|
329
|
+
duration: {
|
|
330
|
+
interval: targetPrice.recurring.interval,
|
|
331
|
+
interval_count: targetPrice.recurring.interval_count
|
|
332
|
+
},
|
|
333
|
+
metadata: {
|
|
334
|
+
...sub.metadata,
|
|
335
|
+
[METADATA_KEYS.PRODUCT_ID]: productId
|
|
336
|
+
},
|
|
337
|
+
discounts,
|
|
338
|
+
proration_behavior: "none"
|
|
339
|
+
}]
|
|
340
|
+
});
|
|
341
|
+
await sync(sub.id);
|
|
342
|
+
return {
|
|
343
|
+
effect: "scheduled",
|
|
344
|
+
effectiveAt: mapTime(item.current_period_end),
|
|
345
|
+
clientSecret: null
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function apply(stripeSubscriptionId, productId) {
|
|
349
|
+
const { sub, item, currentProductId, immediate, resumesPendingUpdate, paymentMethodId } = await subscriptionForChange(stripeSubscriptionId, productId);
|
|
350
|
+
if (resumesPendingUpdate) {
|
|
351
|
+
const parked = await settleParkedUpdate(sub);
|
|
352
|
+
if (parked.state === "challenge") return parked.result;
|
|
353
|
+
if (parked.state === "paid") {
|
|
354
|
+
await sync(sub.id);
|
|
355
|
+
return applied();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (immediate) return applyNow(sub, item, currentProductId, productId, paymentMethodId);
|
|
359
|
+
return applyAtPeriodEnd(sub, item, productId);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The schedule's next phase when it switches the subscription to another product, whatever that
|
|
363
|
+
* product is: also one the config no longer knows, or a price the dashboard set without a flat
|
|
364
|
+
* amount, which `readScheduled` does not show but "keep current plan" must still be able to drop.
|
|
365
|
+
*/
|
|
366
|
+
function pendingPhase(sub) {
|
|
367
|
+
const schedule = expanded(sub.schedule, `Schedule of subscription ${sub.id}`);
|
|
368
|
+
if (!schedule) return null;
|
|
369
|
+
if (schedule.status !== "active" && schedule.status !== "not_started") return null;
|
|
370
|
+
const current = schedule.current_phase;
|
|
371
|
+
if (!current) return null;
|
|
372
|
+
const next = schedule.phases.find((phase) => phase.start_date >= current.end_date);
|
|
373
|
+
const productId = next?.metadata?.[METADATA_KEYS.PRODUCT_ID];
|
|
374
|
+
if (!next || !productId || productId === sub.metadata[METADATA_KEYS.PRODUCT_ID]) return null;
|
|
375
|
+
return {
|
|
376
|
+
schedule,
|
|
377
|
+
next,
|
|
378
|
+
productId
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function cancelScheduled(stripeSubscriptionId) {
|
|
382
|
+
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ["schedule"] });
|
|
383
|
+
const pending = pendingPhase(sub);
|
|
384
|
+
if (pending) await stripe.subscriptionSchedules.release(pending.schedule.id);
|
|
385
|
+
await sync(sub.id);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
preview,
|
|
389
|
+
apply,
|
|
390
|
+
cancelScheduled,
|
|
391
|
+
readScheduled
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
export { PLAN_CHANGE_ERROR_REASONS, PlanChangeError, createPlanChange };
|
|
396
|
+
|
|
397
|
+
//# sourceMappingURL=plan-change.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-change.mjs","names":[],"sources":["../../src/stripe/plan-change.ts"],"sourcesContent":["import type { Stripe } from 'stripe';\nimport type { PlanComparison, StripeConfig } from './config';\nimport { type CardFailure, type Decline, mapDecline } from './decline';\nimport { expanded, idOf } from './expandable';\nimport { type Money, mapTime, minorUnits, money } from './mapper';\nimport { METADATA_KEYS } from './metadata';\nimport { hasDelayedNotification } from './payment-method';\n\n/**\n * Switching a Stripe subscription to another subscription product, with Apple's rules as the\n * default: a higher plan applies now, a lower plan or another period of the same plan at the\n * period end.\n *\n * An immediate change is a Stripe update with `payment_behavior: pending_if_incomplete`, so the\n * subscription stays exactly as it was unless today's invoice is paid: a card that needs 3DS parks\n * the change as a pending update (`requires_action`), a declined one voids it. A period-end change\n * is a two-phase subscription schedule: the current price until the period end, then the new one.\n * The product id travels as subscription (or phase) metadata under `METADATA_KEYS.PRODUCT_ID`,\n * which is how the app knows the plan.\n */\n\n/** When a kind of change takes effect: now, charged on today's invoice, or at the period end. */\nexport type PlanChangeTiming = 'immediate' | 'period_end';\n\nexport interface PlanChangeOptions<PE extends string, BP extends string> {\n stripe: Stripe;\n /** declares `plans` and `billingPeriods`, which order the changes (`comparePlans`) */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n config: StripeConfig<any, PE, BP, any, any>;\n /**\n * Mirror the subscription after Stripe changed it, as the app stores it. Called after every\n * change, and after a failed immediate one too, so the row never shows a state Stripe no longer\n * has. What it throws propagates.\n */\n sync: (stripeSubscriptionId: string) => Promise<void>;\n /**\n * When each kind of change applies. The defaults are Apple's: `upgrade` immediate; `downgrade`,\n * `longer` and `shorter` at the period end.\n */\n timing?: Partial<Record<Exclude<PlanComparison, 'same'>, PlanChangeTiming>>;\n}\n\nexport interface PlanSummary<PE extends string = string, BP extends string = string> {\n productId: string;\n plan: PE;\n billingPeriod: BP;\n /** the plan's list price */\n price: Money;\n /** credits granted per billing period */\n credits: number;\n}\n\n/** A change parked in the subscription's schedule, to apply at the period end. */\nexport interface ScheduledPlanChange<PE extends string = string, BP extends string = string> {\n productId: string;\n plan: PE;\n billingPeriod: BP;\n price: Money;\n effectiveAt: string;\n}\n\n/** What a change would do, before anything is charged. */\nexport interface PlanChangePreview<PE extends string = string, BP extends string = string> {\n comparison: Exclude<PlanComparison, 'same'>;\n effect: 'immediate' | 'scheduled';\n current: PlanSummary<PE, BP>;\n target: PlanSummary<PE, BP>;\n /** now for an immediate change, the current period end for a scheduled one */\n effectiveAt: string;\n /** immediate only: today's invoice */\n charge: {\n newPlan: number;\n creditBack: number;\n dueToday: number;\n /** existing customer balance Stripe applies to this invoice */\n balanceApplied: number;\n /** credit beyond the new plan's price, left on the customer balance */\n accountCredit: number;\n currency: string;\n /** the first regular renewal on the new plan */\n nextBillingAt: string;\n } | null;\n}\n\n/**\n * The outcome of a change. `requires_action`: the card needs authentication (3DS) for\n * `clientSecret` with stripe.js; the change applies once that invoice is paid\n * (`customer.subscription.pending_update_applied`), or lapses after ~23 hours\n * (`pending_update_expired`) and nothing changes.\n */\nexport interface PlanChangeResult {\n effect: 'immediate' | 'scheduled' | 'requires_action';\n effectiveAt: string;\n clientSecret: string | null;\n}\n\n/**\n * Why a change was refused:\n * - NOT_A_SUBSCRIPTION: the target product is not a subscription in the config.\n * - ALREADY_CANCELED: the subscription has ended.\n * - NOT_SWITCHABLE: past due, unpaid, paused, incomplete or trialing; the fix is the payment\n * method, not another charge, and what a switch mid-trial should do is the app's to decide.\n * - CANCEL_SCHEDULED: set to cancel at the period end; reactivate first.\n * - CHANGE_PENDING: an earlier immediate change still waits on its payment; only the same target\n * may be retried.\n * - UNCHANGED: already on the target product.\n * - REQUIRES_INSTANT_METHOD: an immediate change would be paid by a bank debit\n * (`hasDelayedNotification`); `paymentMethodType` names it.\n * - DECLINED: the card was refused; `decline` says why.\n * - CONFLICT: a concurrent change on the same snapshot was applied instead.\n * - MISCONFIGURED: the subscription or config is not what a change needs (no item, no product\n * metadata, a price without a flat amount, a pending invoice without a payment intent).\n */\nexport const PLAN_CHANGE_ERROR_REASONS = [\n 'NOT_A_SUBSCRIPTION',\n 'ALREADY_CANCELED',\n 'NOT_SWITCHABLE',\n 'CANCEL_SCHEDULED',\n 'CHANGE_PENDING',\n 'UNCHANGED',\n 'REQUIRES_INSTANT_METHOD',\n 'DECLINED',\n 'CONFLICT',\n 'MISCONFIGURED',\n] as const;\n\nexport type PlanChangeErrorReason = (typeof PLAN_CHANGE_ERROR_REASONS)[number];\n\n/** A refused change. The app turns `reason` (and `decline`) into its own error response. */\nexport class PlanChangeError extends Error {\n readonly reason: PlanChangeErrorReason;\n readonly decline: Decline | null;\n readonly paymentMethodType: string | null;\n\n constructor(\n reason: PlanChangeErrorReason,\n message: string,\n extra: { decline?: Decline; paymentMethodType?: string } = {}\n ) {\n super(message);\n this.name = 'PlanChangeError';\n this.reason = reason;\n this.decline = extra.decline ?? null;\n this.paymentMethodType = extra.paymentMethodType ?? null;\n }\n}\n\nexport interface PlanChange<PE extends string = string, BP extends string = string> {\n /** What switching `stripeSubscriptionId` to `productId` would charge and when it would apply. */\n preview(stripeSubscriptionId: string, productId: string): Promise<PlanChangePreview<PE, BP>>;\n /**\n * Switch `stripeSubscriptionId` to `productId`; throws {@link PlanChangeError} when refused.\n *\n * Decided from the subscription as Stripe has it now, not as a `preview` saw it. If it changed in\n * between (another tab switched plans or scheduled a change), the switch is worked out from the\n * new state and the last request wins: a change scheduled elsewhere is released by an immediate\n * one or replaced by another scheduled one, and the charge can differ from what the preview\n * showed. Deliberately so: it takes two sessions changing the same subscription at once, the\n * outcome is still the customer's latest choice, and the refusals above (cancellation scheduled,\n * a change already pending, already on the plan) cover the conflicts that would charge wrongly.\n * Refusing a stale request would need a token from `preview` checked here, carried by every\n * client; worth adding only if customers report a charge that did not match its preview.\n */\n apply(stripeSubscriptionId: string, productId: string): Promise<PlanChangeResult>;\n /** Drop a change scheduled for the period end (\"keep current plan\"); nothing pending is fine. */\n cancelScheduled(stripeSubscriptionId: string): Promise<void>;\n /**\n * The change scheduled on a subscription retrieved with `expand: ['schedule']`, for the app's\n * mirror of it, or null when none is pending.\n */\n readScheduled(subscription: Stripe.Subscription): Promise<ScheduledPlanChange<PE, BP> | null>;\n}\n\nconst DEFAULT_TIMING: Record<Exclude<PlanComparison, 'same'>, PlanChangeTiming> = {\n upgrade: 'immediate',\n downgrade: 'period_end',\n longer: 'period_end',\n shorter: 'period_end',\n};\n\n/**\n * Where an immediate change parked as a pending update stands, read from its invoice's\n * PaymentIntent (the subscription's latest invoice; a pending update expires by the period end at\n * the latest, so no renewal invoice can come after it).\n * - `challenge`: the bank wants the cardholder to authenticate; stripe.js runs it with the secret.\n * - `paid`: paid in the meantime (another tab finished the challenge); Stripe applies the update.\n * - `declined`: the attempt is over (a declined card, or a challenge that failed or was abandoned).\n * The invoice is voided, which drops the pending update; a concurrent duplicate finds it void.\n */\ntype ParkedUpdate =\n | { state: 'challenge'; result: PlanChangeResult }\n | { state: 'paid' }\n | { state: 'declined'; failure: CardFailure };\n\n/** stripe-node's error classes, matched by their `type` so this module needs no runtime import. */\nfunction stripeErrorType(error: unknown): string | null {\n if (typeof error !== 'object' || error === null || !('type' in error)) return null;\n return typeof error.type === 'string' ? error.type : null;\n}\n\nconst now = () => new Date().toISOString();\n\nexport function createPlanChange<PE extends string, BP extends string>(\n options: PlanChangeOptions<PE, BP>\n): PlanChange<PE, BP> {\n const { stripe, config, sync } = options;\n const timing = { ...DEFAULT_TIMING, ...options.timing };\n\n async function planSummary(productId: string): Promise<PlanSummary<PE, BP>> {\n const price = await stripe.prices.retrieve(config.getPriceId(productId));\n if (price.unit_amount === null) {\n throw new PlanChangeError('MISCONFIGURED', `Price ${price.id} has no amount`);\n }\n return {\n productId,\n plan: config.getPlan(productId),\n billingPeriod: config.getBillingPeriod(productId),\n price: money(price.unit_amount, price.currency),\n credits: config.getCreditAmount(productId),\n };\n }\n\n /**\n * Whether a pending update moves the subscription to `productId`: its one item carries the price\n * an immediate change sets for that product. Anything else counts as a different target.\n */\n function pendingUpdateTargets(pending: Stripe.Subscription.PendingUpdate, productId: string) {\n const items = pending.subscription_items ?? [];\n const only = items.length === 1 ? items[0] : undefined;\n return only?.price.id === config.getPriceId(productId);\n }\n\n /**\n * The payment method an invoice on this subscription is charged to: its own default when it has\n * one (Checkout pins the card it collected), else the customer's invoice default. Retrieved whole\n * because an immediate change needs its `type`.\n */\n async function chargedPaymentMethod(sub: Stripe.Subscription) {\n const own = sub.default_payment_method;\n if (own) return typeof own === 'string' ? stripe.paymentMethods.retrieve(own) : own;\n const customer = await stripe.customers.retrieve(idOf(sub.customer), {\n expand: ['invoice_settings.default_payment_method'],\n });\n if (customer.deleted) return null;\n const preferred = customer.invoice_settings.default_payment_method;\n if (!preferred) return null;\n return typeof preferred === 'string' ? stripe.paymentMethods.retrieve(preferred) : preferred;\n }\n\n /** The subscription plus the checks every change starts with. */\n async function subscriptionForChange(stripeSubscriptionId: string, productId: string) {\n if (!config.isSubscription(productId)) {\n throw new PlanChangeError('NOT_A_SUBSCRIPTION', `Not a subscription product: ${productId}`);\n }\n const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ['schedule'] });\n if (sub.status === 'canceled') {\n throw new PlanChangeError('ALREADY_CANCELED', 'Subscription already canceled');\n }\n if (sub.status !== 'active') {\n throw new PlanChangeError('NOT_SWITCHABLE', `Subscription is ${sub.status}`);\n }\n if (sub.cancel_at_period_end || sub.cancel_at !== null) {\n throw new PlanChangeError('CANCEL_SCHEDULED', 'Subscription is scheduled to cancel');\n }\n // An earlier immediate change parked until its invoice is paid (3DS). The customer may still be\n // authenticating it, so it is never voided or replaced here: asking for the same target again\n // resumes it, anything else is refused. Checked before UNCHANGED because the metadata still\n // names the plan from before the pending update.\n const resumesPendingUpdate = sub.pending_update\n ? pendingUpdateTargets(sub.pending_update, productId)\n : false;\n if (sub.pending_update && !resumesPendingUpdate) {\n throw new PlanChangeError(\n 'CHANGE_PENDING',\n `Subscription has a pending update until ${mapTime(sub.pending_update.expires_at)}`\n );\n }\n const currentProductId = sub.metadata[METADATA_KEYS.PRODUCT_ID];\n if (!currentProductId || !config.isSubscription(currentProductId)) {\n throw new PlanChangeError('MISCONFIGURED', `Subscription ${sub.id} names no known product`);\n }\n const comparison = config.comparePlans(currentProductId, productId);\n if (comparison === 'same') throw new PlanChangeError('UNCHANGED', 'Already on this plan');\n const item = sub.items.data.at(0);\n if (!item) throw new PlanChangeError('MISCONFIGURED', `Subscription ${sub.id} has no items`);\n\n const immediate = timing[comparison] === 'immediate';\n const base = { sub, item, currentProductId, comparison, immediate, resumesPendingUpdate };\n if (!immediate) return { ...base, paymentMethodId: null };\n // Checked here so the preview refuses before the customer confirms, and the change refuses\n // before a parked period-end change is released. A bank debit would leave the pending update\n // processing past its ~23 hours and then report a decline the customer never had.\n const paymentMethod = await chargedPaymentMethod(sub);\n if (paymentMethod && hasDelayedNotification(paymentMethod.type)) {\n throw new PlanChangeError(\n 'REQUIRES_INSTANT_METHOD',\n `Immediate plan change cannot be paid by ${paymentMethod.type}`,\n { paymentMethodType: paymentMethod.type }\n );\n }\n return { ...base, paymentMethodId: paymentMethod?.id ?? null };\n }\n\n /**\n * The parameters of an immediate change, shared by the preview and the real update: the new plan\n * is charged in full today, the unused rest of the current period is credited on the same\n * invoice, and the billing cycle restarts now (Apple's upgrade, Play's `CHARGE_FULL_PRICE`), so\n * \"due today\" is the new price minus that credit whatever the intervals involved.\n */\n function immediateChangeParams(item: Stripe.SubscriptionItem, productId: string) {\n return {\n items: [{ id: item.id, price: config.getPriceId(productId) }],\n proration_behavior: 'always_invoice' as const,\n billing_cycle_anchor: { type: 'now' as const },\n };\n }\n\n async function settleParkedUpdate(sub: Stripe.Subscription): Promise<ParkedUpdate> {\n const invoiceId = idOf(sub.latest_invoice);\n if (!invoiceId) {\n throw new PlanChangeError('MISCONFIGURED', `Pending update on ${sub.id} has no invoice`);\n }\n // A second call because Stripe caps expansion at four levels below the subscription.\n const invoice = await stripe.invoices.retrieve(invoiceId, {\n expand: ['payments.data.payment.payment_intent'],\n });\n const intent = invoice.payments?.data.at(0)?.payment.payment_intent;\n if (!intent || typeof intent === 'string' || !intent.client_secret) {\n throw new PlanChangeError(\n 'MISCONFIGURED',\n `Pending invoice ${invoiceId} has no payment intent`\n );\n }\n switch (intent.status) {\n case 'requires_action':\n return {\n state: 'challenge',\n result: {\n effect: 'requires_action',\n effectiveAt: now(),\n clientSecret: intent.client_secret,\n },\n };\n case 'succeeded':\n return { state: 'paid' };\n case 'requires_payment_method':\n if (invoice.status !== 'void') await stripe.invoices.voidInvoice(invoiceId);\n return { state: 'declined', failure: intent.last_payment_error };\n default:\n throw new PlanChangeError(\n 'MISCONFIGURED',\n `Pending invoice ${invoiceId} has its payment in state ${intent.status}`\n );\n }\n }\n\n function declined(failure: CardFailure) {\n const decline = mapDecline(failure);\n return new PlanChangeError('DECLINED', decline.message ?? 'Payment declined', { decline });\n }\n\n /**\n * Mirror the subscription after an attempt that already failed. The original error is what the\n * caller needs (a decline, a conflict); a sync failure on top of it is only logged, and the next\n * webhook or sync puts the row right.\n */\n async function syncAfterFailure(stripeSubscriptionId: string) {\n try {\n await sync(stripeSubscriptionId);\n } catch (error) {\n console.error(`Sync of ${stripeSubscriptionId} after a failed plan change failed:`, error);\n }\n }\n\n const applied = (): PlanChangeResult => ({\n effect: 'immediate',\n effectiveAt: now(),\n clientSecret: null,\n });\n\n async function readScheduled(sub: Stripe.Subscription) {\n const pending = pendingPhase(sub);\n if (!pending) return null;\n const { schedule, next, productId } = pending;\n // A product that left the config, or a next phase without a flat price (set up in the\n // dashboard), must not wedge every sync of this subscription; it just stops showing.\n if (!config.isSubscription(productId)) {\n console.error(`Schedule ${schedule.id} next phase names unknown product ${productId}`);\n return null;\n }\n const priceId = idOf(next.items.at(0)?.price);\n if (!priceId) {\n console.error(`Schedule ${schedule.id} next phase has no price`);\n return null;\n }\n const price = await stripe.prices.retrieve(priceId);\n if (price.unit_amount === null) {\n console.error(`Schedule ${schedule.id} next phase price ${priceId} has no unit amount`);\n return null;\n }\n return {\n productId,\n plan: config.getPlan(productId),\n billingPeriod: config.getBillingPeriod(productId),\n price: money(price.unit_amount, price.currency),\n effectiveAt: mapTime(next.start_date),\n };\n }\n\n async function preview(stripeSubscriptionId: string, productId: string) {\n const { sub, item, currentProductId, comparison, immediate } = await subscriptionForChange(\n stripeSubscriptionId,\n productId\n );\n const [current, target] = await Promise.all([\n planSummary(currentProductId),\n planSummary(productId),\n ]);\n if (!immediate) {\n return {\n comparison,\n effect: 'scheduled' as const,\n current,\n target,\n effectiveAt: mapTime(item.current_period_end),\n charge: null,\n };\n }\n\n const invoice = await stripe.invoices.createPreview({\n subscription: sub.id,\n subscription_details: immediateChangeParams(item, productId),\n });\n // Proration lines are the credit for unused time (negative); the rest is the new plan.\n let creditBack = 0;\n let newPlan = 0;\n let nextBillingAt = invoice.period_end;\n for (const line of invoice.lines.data) {\n if (line.parent?.subscription_item_details?.proration) {\n creditBack -= line.amount;\n } else {\n newPlan += line.amount;\n nextBillingAt = Math.max(nextBillingAt, line.period.end);\n }\n }\n const units = minorUnits(invoice.currency);\n // starting_balance is negative when the customer holds credit; Stripe applies it to the total.\n const balanceApplied = Math.min(\n Math.max(0, -invoice.starting_balance),\n Math.max(0, newPlan - creditBack)\n );\n return {\n comparison,\n effect: 'immediate' as const,\n current,\n target,\n effectiveAt: now(),\n charge: {\n newPlan: newPlan / units,\n creditBack: creditBack / units,\n dueToday: invoice.amount_due / units,\n balanceApplied: balanceApplied / units,\n accountCredit: Math.max(0, creditBack - newPlan) / units,\n currency: invoice.currency,\n nextBillingAt: mapTime(nextBillingAt),\n },\n };\n }\n\n async function applyNow(\n sub: Stripe.Subscription,\n item: Stripe.SubscriptionItem,\n currentProductId: string,\n productId: string,\n paymentMethodId: string | null\n ): Promise<PlanChangeResult> {\n // Stripe refuses item changes on a subscription a schedule manages, so a parked period-end\n // change is released first. A released schedule is terminal, so its id alone keys the release:\n // a concurrent duplicate gets the first release back instead of an \"already released\" error.\n const scheduleId = idOf(sub.schedule);\n if (scheduleId) {\n await stripe.subscriptionSchedules.release(\n scheduleId,\n {},\n { idempotencyKey: `release:${scheduleId}` }\n );\n }\n // Nothing else serializes two identical requests: both pass the UNCHANGED check on the same\n // snapshot, and a second update would bill again. So the update is keyed by its intent: this\n // subscription, from this plan to that one, within this period, on top of this latest invoice,\n // charged to this payment method. Requests read from the same snapshot build the same key and\n // parameters, so Stripe applies the update once and replays it to the other; any real change\n // (a later A→B→C, a new period, a declined attempt's own invoice, another card) gets a new key.\n const idempotencyKey = [\n 'plan',\n sub.id,\n currentProductId,\n productId,\n item.current_period_start,\n idOf(sub.latest_invoice) ?? 'none',\n paymentMethodId ?? 'none',\n ].join(':');\n let updated: Stripe.Subscription;\n try {\n updated = await stripe.subscriptions.update(\n sub.id,\n {\n ...immediateChangeParams(item, productId),\n metadata: { ...sub.metadata, [METADATA_KEYS.PRODUCT_ID]: productId },\n payment_behavior: 'pending_if_incomplete',\n },\n { idempotencyKey }\n );\n } catch (error) {\n // A released schedule may be all that changed; the row has to stop showing it.\n await syncAfterFailure(sub.id);\n // `pending_if_incomplete` absorbs declines, so a card error here is one Stripe refused to\n // attempt at all (an expired card, say).\n if (stripeErrorType(error) === 'StripeCardError') throw declined(error as CardFailure);\n // Same key, different parameters: the subscription changed between two requests built on\n // the same snapshot. Nothing was applied by this one.\n if (stripeErrorType(error) === 'StripeIdempotencyError') {\n throw new PlanChangeError('CONFLICT', 'Plan change conflicts with a concurrent one');\n }\n throw error;\n }\n // Synced as soon as Stripe took the update, before anything else can fail: the row shows the\n // new plan, or the released schedule while the payment is parked.\n await sync(sub.id);\n if (!updated.pending_update) return applied();\n const parked = await settleParkedUpdate(updated);\n if (parked.state === 'challenge') return parked.result;\n if (parked.state === 'paid') {\n await sync(sub.id);\n return applied();\n }\n // A declined card parks the update just like 3DS does; it was voided above.\n await syncAfterFailure(sub.id);\n throw declined(parked.failure);\n }\n\n async function applyAtPeriodEnd(\n sub: Stripe.Subscription,\n item: Stripe.SubscriptionItem,\n productId: string\n ): Promise<PlanChangeResult> {\n // The new phase lasts one interval of the target price, after which the schedule releases the\n // subscription and it keeps renewing on that price.\n const targetPrice = await stripe.prices.retrieve(config.getPriceId(productId));\n if (!targetPrice.recurring) {\n throw new PlanChangeError('MISCONFIGURED', `Price ${targetPrice.id} is not recurring`);\n }\n // No idempotency keys here: Stripe allows one schedule per subscription, so a concurrent\n // duplicate's `create` fails, and rewriting the phases twice from one snapshot changes nothing.\n // A key would replay a released schedule for \"keep current plan\" then the same change again.\n const schedule =\n sub.schedule && typeof sub.schedule !== 'string'\n ? sub.schedule\n : await stripe.subscriptionSchedules.create({ from_subscription: sub.id });\n const current = schedule.current_phase;\n const phase = schedule.phases.find((p) => p.start_date === current?.start_date);\n if (!current || !phase) {\n throw new PlanChangeError('MISCONFIGURED', `Schedule ${schedule.id} has no current phase`);\n }\n // A discount on the subscription has to be carried into both phases by hand, since the update\n // replaces every phase. Reusing it by id keeps its clock (a repeating coupon re-attached would\n // start its months over), and one that has run out is simply dropped by Stripe.\n const discounts = sub.discounts.map((d) => ({ discount: idOf(d) }));\n await stripe.subscriptionSchedules.update(schedule.id, {\n end_behavior: 'release',\n phases: [\n {\n start_date: current.start_date,\n end_date: item.current_period_end,\n items: [{ price: item.price.id, quantity: item.quantity }],\n metadata: sub.metadata,\n discounts,\n proration_behavior: 'none',\n },\n {\n items: [{ price: targetPrice.id, quantity: item.quantity }],\n duration: {\n interval: targetPrice.recurring.interval,\n interval_count: targetPrice.recurring.interval_count,\n },\n metadata: { ...sub.metadata, [METADATA_KEYS.PRODUCT_ID]: productId },\n discounts,\n proration_behavior: 'none',\n },\n ],\n });\n await sync(sub.id);\n return {\n effect: 'scheduled',\n effectiveAt: mapTime(item.current_period_end),\n clientSecret: null,\n };\n }\n\n async function apply(stripeSubscriptionId: string, productId: string) {\n const { sub, item, currentProductId, immediate, resumesPendingUpdate, paymentMethodId } =\n await subscriptionForChange(stripeSubscriptionId, productId);\n\n if (resumesPendingUpdate) {\n // The same change again while an earlier attempt is parked. A challenge still waiting is\n // handed back as is; one that failed or was abandoned has its invoice voided and the change is\n // attempted again below, so retrying after closing the 3DS window starts a fresh challenge.\n const parked = await settleParkedUpdate(sub);\n if (parked.state === 'challenge') return parked.result;\n if (parked.state === 'paid') {\n await sync(sub.id);\n return applied();\n }\n }\n if (immediate) return applyNow(sub, item, currentProductId, productId, paymentMethodId);\n return applyAtPeriodEnd(sub, item, productId);\n }\n\n /**\n * The schedule's next phase when it switches the subscription to another product, whatever that\n * product is: also one the config no longer knows, or a price the dashboard set without a flat\n * amount, which `readScheduled` does not show but \"keep current plan\" must still be able to drop.\n */\n function pendingPhase(sub: Stripe.Subscription) {\n const schedule = expanded(sub.schedule, `Schedule of subscription ${sub.id}`);\n if (!schedule) return null;\n if (schedule.status !== 'active' && schedule.status !== 'not_started') return null;\n // The next phase is found from `current_phase`, not the wall clock, so it also holds for\n // customers on a Stripe test clock.\n const current = schedule.current_phase;\n if (!current) return null;\n const next = schedule.phases.find((phase) => phase.start_date >= current.end_date);\n const productId = next?.metadata?.[METADATA_KEYS.PRODUCT_ID];\n if (!next || !productId || productId === sub.metadata[METADATA_KEYS.PRODUCT_ID]) return null;\n return { schedule, next, productId };\n }\n\n async function cancelScheduled(stripeSubscriptionId: string) {\n const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, { expand: ['schedule'] });\n const pending = pendingPhase(sub);\n if (pending) await stripe.subscriptionSchedules.release(pending.schedule.id);\n await sync(sub.id);\n }\n\n return { preview, apply, cancelScheduled, readScheduled };\n}\n"],"mappings":"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"}
|