sanity-plugin-stripe 0.1.0 → 0.2.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/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as formatMoney, c as toMinorUnits, i as isObject, n as asNumber, o as formatPrice, r as asString, t as asArray } from "./guards-CAcETsf7.js";
2
- import { a as lineStatus, c as sanitizeLines, i as lineName, o as readCartProducts } from "./cart-CSjxtjq-.js";
3
- import { a as stockLeft, c as SHOP_SETTINGS_ID, h as stockId, i as readStockDocuments, l as SHOP_SETTINGS_TYPE, m as stockChangeId, n as STRIPE_SHIPPING_COUNTRIES, p as publishedId, r as STOCK_QUERY, t as EU_COUNTRIES, u as STOCK_CHANGE_TYPE } from "./countries-BuqQF1AW.js";
1
+ import { a as shippingQuote, c as describeInterval, d as lineStatus, f as readCartProducts, h as sanitizeLines, i as resolveRegions, m as readSubscription, n as regionOptions, r as regionsFromSettings, t as readShopSettings, u as lineName } from "./regions-CFDYMokP.js";
2
+ import { c as formatPrice, o as isObject, r as asArray, s as formatMoney, t as EU_COUNTRIES, u as toMinorUnits } from "./countries-Csjmv4lY.js";
3
+ import { c as STOCK_CHANGE_TYPE, f as publishedId, m as stockId, n as readStockDocuments, o as SHOP_SETTINGS_ID, p as stockChangeId, r as stockLeft, s as SHOP_SETTINGS_TYPE, t as STOCK_QUERY } from "./stock-DAsSGayt.js";
4
4
  import { Stripe } from "stripe";
5
5
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({
6
6
  "&": "&amp;",
@@ -21,18 +21,21 @@ function addressLines(name, address) {
21
21
  address.country
22
22
  ].filter((line) => !!line);
23
23
  }
24
- /** Where the payment is in the Stripe Dashboard. */
24
+ /** Where the payment is in the Stripe Dashboard: a renewal's invoice, a subscription, a payment. */
25
25
  function dashboardUrl(order) {
26
26
  let base = `https://dashboard.stripe.com/${order.livemode ? "" : "test/"}`;
27
- return order.paymentIntentId ? `${base}payments/${order.paymentIntentId}` : `${base}checkout/sessions/${order.sessionId}`;
27
+ return order.invoiceId ? `${base}invoices/${order.invoiceId}` : order.subscription ? `${base}subscriptions/${order.subscription.id}` : order.paymentIntentId ? `${base}payments/${order.paymentIntentId}` : `${base}checkout/sessions/${order.sessionId}`;
28
28
  }
29
- /** A plain order email for the shop: what to pack, where to send it and who paid. */
29
+ /**
30
+ * A plain order email for the shop: what to pack, where to send it and who paid. It serves a
31
+ * subscription's renewal too, from `onRenewal`.
32
+ */
30
33
  function orderEmail(order, options) {
31
- let { shopName, locale = "en", stock = [], stockError } = options, money = (minor) => formatMoney(minor, order.currency, locale), count = order.items.reduce((sum, item) => sum + item.quantity, 0), who = order.shipping.name ?? order.customer.name ?? order.customer.email ?? "a customer", subject = `${order.livemode ? "" : "[Test] "}${shopName} order from ${who}: ${count} ${count === 1 ? "item" : "items"}, ${money(order.amountTotal)}`, address = addressLines(order.shipping.name, order.shipping.address), contact = [order.customer.email, order.customer.phone].filter((line) => !!line), shippingLabel = order.regionTitle ? `Shipping (${order.regionTitle})` : "Shipping", link = dashboardUrl(order), date = order.createdAt.toISOString().slice(0, 10), oversold = stock.filter((change) => change.to < 0).map((change) => order.items.find((candidate) => candidate.productId === change.productId && (candidate.variantKey ?? "") === (change.variantKey ?? ""))?.name ?? change.productId), warnings = [...oversold.length ? [`Check your stock: more was sold than was counted in stock for ${oversold.join(", ")}.`] : [], ...stockError ? [`Stock counts were not lowered for this order. Change them by hand. (${stockError})`] : []], text = [
32
- `New order on ${date}${order.livemode ? "" : " (test mode, no money moved)"}`,
34
+ let { shopName, locale = "en", stock = [], stockError } = options, money = (minor) => formatMoney(minor, order.currency, locale), count = order.items.reduce((sum, item) => sum + item.quantity, 0), who = order.shipping.name ?? order.customer.name ?? order.customer.email ?? "a customer", renewal = !!order.invoiceId, subject = `${order.livemode ? "" : "[Test] "}${shopName} ${renewal ? "renewal for" : "order from"} ${who}: ${count} ${count === 1 ? "item" : "items"}, ${money(order.amountTotal)}`, heading = renewal ? "Subscription renewal" : "New order", repeats = order.subscription?.interval ? `subscription, ${describeInterval(order.subscription.interval)}` : "subscription", nameOf = (item) => item.subscription && !renewal ? `${item.name} (${repeats})` : item.name, address = addressLines(order.shipping.name, order.shipping.address), contact = [order.customer.email, order.customer.phone].filter((line) => !!line), shippingLabel = order.regionTitle ? `Shipping (${order.regionTitle})` : "Shipping", link = dashboardUrl(order), date = order.createdAt.toISOString().slice(0, 10), oversold = stock.filter((change) => change.to < 0).map((change) => order.items.find((candidate) => candidate.productId === change.productId && (candidate.variantKey ?? "") === (change.variantKey ?? ""))?.name ?? change.productId), warnings = [...oversold.length ? [`Check your stock: more was sold than was counted in stock for ${oversold.join(", ")}.`] : [], ...stockError ? [`Stock counts were not lowered for this order. Change them by hand. (${stockError})`] : []], text = [
35
+ `${heading} on ${date}${order.livemode ? "" : " (test mode, no money moved)"}`,
33
36
  "",
34
37
  ...warnings.flatMap((warning) => [warning, ""]),
35
- ...order.items.map((item) => `${item.quantity} × ${item.name} ${money(item.amountTotal)}`),
38
+ ...order.items.map((item) => `${item.quantity} × ${nameOf(item)} ${money(item.amountTotal)}`),
36
39
  `${shippingLabel} ${money(order.amountShipping)}`,
37
40
  `Total ${money(order.amountTotal)}`,
38
41
  "",
@@ -43,12 +46,12 @@ function orderEmail(order, options) {
43
46
  ...contact,
44
47
  "",
45
48
  `In Stripe: ${link}`
46
- ].join("\n"), rows = order.items.map((item) => `<tr><td style="padding:4px 12px 4px 0">${item.quantity} ×</td><td style="padding:4px 12px 4px 0">${escapeHtml(item.name)}</td><td style="padding:4px 0;text-align:right">${money(item.amountTotal)}</td></tr>`).join("");
49
+ ].join("\n"), rows = order.items.map((item) => `<tr><td style="padding:4px 12px 4px 0">${item.quantity} ×</td><td style="padding:4px 12px 4px 0">${escapeHtml(nameOf(item))}</td><td style="padding:4px 0;text-align:right">${money(item.amountTotal)}</td></tr>`).join("");
47
50
  return {
48
51
  subject,
49
52
  text,
50
53
  html: `<div style="font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:1.45;color:#191919">
51
- <p>New order on ${date}${order.livemode ? "" : " <strong>(test mode, no money moved)</strong>"}</p>
54
+ <p>${heading} on ${date}${order.livemode ? "" : " <strong>(test mode, no money moved)</strong>"}</p>
52
55
  ${warnings.map((warning) => `<p><strong>${escapeHtml(warning)}</strong></p>\n`).join("")}<table style="border-collapse:collapse">${rows}
53
56
  <tr><td></td><td style="padding:4px 12px 4px 0">${escapeHtml(shippingLabel)}</td><td style="padding:4px 0;text-align:right">${money(order.amountShipping)}</td></tr>
54
57
  <tr><td></td><td style="padding:4px 12px 4px 0"><strong>Total</strong></td><td style="padding:4px 0;text-align:right"><strong>${money(order.amountTotal)}</strong></td></tr>
@@ -64,13 +67,19 @@ function verifyEvent(stripe, payload, signature, secret) {
64
67
  if (!signature) throw Error("Missing Stripe-Signature header");
65
68
  return stripe.webhooks.constructEvent(payload, signature, secret);
66
69
  }
67
- /** The events a webhook endpoint for the shop listens to. */
70
+ /**
71
+ * The events a webhook endpoint for the shop listens to. The last three are for subscriptions; a
72
+ * shop without any can leave them out.
73
+ */
68
74
  const WEBHOOK_EVENTS = [
69
75
  "checkout.session.completed",
70
76
  "checkout.session.async_payment_succeeded",
71
- "checkout.session.async_payment_failed"
77
+ "checkout.session.async_payment_failed",
78
+ "invoice.paid",
79
+ "invoice.payment_failed",
80
+ "customer.subscription.deleted"
72
81
  ];
73
- /** What a webhook event means for the shop. Only `paid` is fulfilled. */
82
+ /** What a webhook event means for the shop. Only `paid` and a renewal's `invoicePaid` are fulfilled. */
74
83
  function classifyEvent(event) {
75
84
  switch (event.type) {
76
85
  case "checkout.session.completed":
@@ -88,6 +97,18 @@ function classifyEvent(event) {
88
97
  type: "failed",
89
98
  session: event.data.object
90
99
  };
100
+ case "invoice.paid": return {
101
+ type: "invoicePaid",
102
+ invoice: event.data.object
103
+ };
104
+ case "invoice.payment_failed": return {
105
+ type: "invoiceFailed",
106
+ invoice: event.data.object
107
+ };
108
+ case "customer.subscription.deleted": return {
109
+ type: "subscriptionEnded",
110
+ subscription: event.data.object
111
+ };
91
112
  default: return { type: "ignored" };
92
113
  }
93
114
  }
@@ -115,7 +136,7 @@ const typeFilter = (types) => `_type in [${types.map((type) => JSON.stringify(ty
115
136
  */
116
137
  function cartProductProjection(options) {
117
138
  let { field, title, slug, image } = resolveProductQuery(options);
118
- return `_id, "title": ${title}, "slug": ${slug}, "imageUrl": ${image}, "commerce": ${field}{availability, price, taxClass, variants[]{_key, label, availability}}`;
139
+ return `_id, "title": ${title}, "slug": ${slug}, "imageUrl": ${image}, "commerce": ${field}{availability, price, taxClass, variants[]{_key, label, availability}, subscription{enabled, price, every, unit}}`;
119
140
  }
120
141
  /** Every product that has the commerce field filled in, for the cart page. */
121
142
  function cartProductsQuery(options) {
@@ -139,50 +160,8 @@ function productVersionsQuery(productIds, options) {
139
160
  params
140
161
  };
141
162
  }
142
- /** The shop settings: shipping regions and whether stock sells out by itself. */
143
- const SHOP_SETTINGS_QUERY = `*[_type == "${SHOP_SETTINGS_TYPE}" && _id == "${SHOP_SETTINGS_ID}"][0]{shippingRegions[]{_key, title, everywhereElse, countries, rate}, autoSoldOut}`;
144
- /** The shop settings as Sanity returned them, or null when they aren't published yet. */
145
- function readShopSettings(value) {
146
- return isObject(value) ? {
147
- shippingRegions: asArray(value.shippingRegions).flatMap((region) => isObject(region) && typeof region._key == "string" ? [{
148
- _key: region._key,
149
- title: asString(region.title),
150
- everywhereElse: region.everywhereElse === !0,
151
- countries: asArray(region.countries).filter((country) => typeof country == "string"),
152
- rate: asNumber(region.rate)
153
- }] : []),
154
- autoSoldOut: typeof value.autoSoldOut == "boolean" ? value.autoSoldOut : null
155
- } : null;
156
- }
157
- /** Shipping regions from the shop settings, leaving out any that aren't filled in. */
158
- function regionsFromSettings(settings) {
159
- return (settings?.shippingRegions ?? []).flatMap((region) => region.title && typeof region.rate == "number" ? [{
160
- id: region._key,
161
- title: region.title,
162
- countries: region.countries ?? [],
163
- everywhereElse: !!region.everywhereElse,
164
- rate: region.rate
165
- }] : []);
166
- }
167
- /**
168
- * Fills in the "everywhere else" region with every country Stripe ships to that no other region
169
- * lists, and drops country codes Stripe doesn't accept. Regions left without countries are removed.
170
- */
171
- function resolveRegions(regions) {
172
- let supported = new Set(STRIPE_SHIPPING_COUNTRIES), listed = new Set(regions.filter((region) => !region.everywhereElse).flatMap((region) => region.countries));
173
- return regions.map((region) => ({
174
- ...region,
175
- countries: region.everywhereElse ? STRIPE_SHIPPING_COUNTRIES.filter((country) => !listed.has(country)) : region.countries.filter((country) => supported.has(country))
176
- })).filter((region) => region.countries.length > 0);
177
- }
178
- /** The regions a buyer can choose from, the same ones the checkout accepts. */
179
- function regionOptions(settings) {
180
- return resolveRegions(regionsFromSettings(settings)).map(({ id, title, rate }) => ({
181
- id,
182
- title,
183
- rate
184
- }));
185
- }
163
+ /** The shop settings: shipping regions, selling out at 0, and delivery for subscriptions. */
164
+ const SHOP_SETTINGS_QUERY = `*[_type == "${SHOP_SETTINGS_TYPE}" && _id == "${SHOP_SETTINGS_ID}"][0]{shippingRegions[]{_key, title, everywhereElse, countries, rate, freeFrom}, autoSoldOut, subscriptionDelivery}`, sameInterval = (a, b) => a.unit === b.unit && a.every === b.every;
186
165
  function buildCheckoutSession(input) {
187
166
  let max = input.maxQuantity ?? 10, currency = input.currency.toLowerCase(), lines = sanitizeLines(input.lines, max);
188
167
  if (!lines.length) return {
@@ -196,24 +175,27 @@ function buildCheckoutSession(input) {
196
175
  error: "region",
197
176
  problems: []
198
177
  };
199
- let products = new Map(input.products.map((product) => [product._id, product])), problems = [], lineItems = [];
178
+ let products = new Map(input.products.map((product) => [product._id, product])), problems = [], lineItems = [], taken = /* @__PURE__ */ new Map(), interval, goods = 0, repeating = 0;
200
179
  for (let line of lines) {
201
- let product = products.get(line.productId), status = lineStatus(product, line.variantKey, max), ref = {
180
+ let product = products.get(line.productId), status = lineStatus(product, line.variantKey, max, line.plan), ref = {
202
181
  productId: line.productId,
203
182
  ...line.variantKey ? { variantKey: line.variantKey } : {}
183
+ }, asked = {
184
+ ...ref,
185
+ ...line.plan ? { plan: line.plan } : {}
204
186
  };
205
187
  if (!product || !status.available) {
206
188
  problems.push({
207
- ...ref,
189
+ ...asked,
208
190
  ...product?.title ? { name: product.title } : {},
209
191
  reason: status.available ? "missing" : status.reason
210
192
  });
211
193
  continue;
212
194
  }
213
- let name = lineName(product, status.variant), left = stockLeft(input.stock?.get(line.productId), line.variantKey);
195
+ let name = lineName(product, status.variant), counted = stockLeft(input.stock?.get(line.productId), line.variantKey), stockKey = `${line.productId}:${line.variantKey ?? ""}`, left = counted === null ? null : counted - (taken.get(stockKey) ?? 0);
214
196
  if (left !== null && left <= 0) {
215
197
  problems.push({
216
- ...ref,
198
+ ...asked,
217
199
  name,
218
200
  reason: "soldOut"
219
201
  });
@@ -222,19 +204,33 @@ function buildCheckoutSession(input) {
222
204
  let most = left === null ? status.maxQuantity : Math.min(status.maxQuantity, left);
223
205
  if (line.quantity > most) {
224
206
  problems.push({
225
- ...ref,
207
+ ...asked,
226
208
  name,
227
209
  reason: "tooMany",
228
210
  ...input.hideRemaining ? {} : { maxQuantity: most }
229
211
  });
230
212
  continue;
231
213
  }
214
+ if (taken.set(stockKey, (taken.get(stockKey) ?? 0) + line.quantity), status.interval && (interval ??= status.interval, !sameInterval(status.interval, interval))) {
215
+ problems.push({
216
+ ...asked,
217
+ name,
218
+ reason: "otherInterval"
219
+ });
220
+ continue;
221
+ }
222
+ let unitAmount = toMinorUnits(status.price, currency);
223
+ goods += unitAmount * line.quantity, status.interval && (repeating += unitAmount * line.quantity);
232
224
  let taxClass = product.commerce?.taxClass, taxRate = taxClass ? input.taxRates?.[taxClass] : void 0;
233
225
  lineItems.push({
234
226
  quantity: line.quantity,
235
227
  price_data: {
236
228
  currency,
237
- unit_amount: toMinorUnits(status.price, currency),
229
+ unit_amount: unitAmount,
230
+ ...status.interval ? { recurring: {
231
+ interval: status.interval.unit,
232
+ interval_count: status.interval.every
233
+ } } : {},
238
234
  product_data: {
239
235
  name,
240
236
  ...product.imageUrl ? { images: [product.imageUrl] } : {},
@@ -244,37 +240,57 @@ function buildCheckoutSession(input) {
244
240
  ...taxRate ? { tax_rates: [taxRate] } : {}
245
241
  });
246
242
  }
247
- return problems.length ? {
243
+ if (problems.length) return {
248
244
  ok: !1,
249
245
  error: "unavailable",
250
246
  problems
251
- } : {
247
+ };
248
+ let rate = toMinorUnits(region.rate, currency), freeFrom = region.freeFrom === void 0 ? null : toMinorUnits(region.freeFrom, currency), charge = (amount) => freeFrom !== null && amount >= freeFrom ? 0 : rate;
249
+ interval && input.subscriptionDelivery === "rate" && charge(repeating) > 0 && lineItems.push({
250
+ quantity: 1,
251
+ price_data: {
252
+ currency,
253
+ unit_amount: charge(repeating),
254
+ recurring: {
255
+ interval: interval.unit,
256
+ interval_count: interval.every
257
+ },
258
+ product_data: {
259
+ name: `Delivery: ${region.title}`,
260
+ metadata: { delivery: region.id }
261
+ }
262
+ }
263
+ });
264
+ let where = {
265
+ region: region.id,
266
+ regionTitle: region.title
267
+ };
268
+ return {
252
269
  ok: !0,
253
270
  lines,
254
271
  region,
255
272
  params: {
256
- mode: "payment",
273
+ mode: interval ? "subscription" : "payment",
257
274
  line_items: lineItems,
258
275
  shipping_address_collection: { allowed_countries: region.countries },
259
- shipping_options: [{ shipping_rate_data: {
276
+ ...interval ? { subscription_data: { metadata: where } } : { shipping_options: [{ shipping_rate_data: {
260
277
  type: "fixed_amount",
261
278
  display_name: region.title,
262
279
  fixed_amount: {
263
- amount: toMinorUnits(region.rate, currency),
280
+ amount: charge(goods),
264
281
  currency
265
282
  }
266
- } }],
283
+ } }] },
284
+ ...input.promotionCodes ? { allow_promotion_codes: !0 } : {},
267
285
  success_url: input.successUrl,
268
286
  cancel_url: input.cancelUrl,
269
- metadata: {
270
- region: region.id,
271
- regionTitle: region.title
272
- },
287
+ metadata: where,
273
288
  integration_identifier: input.integrationIdentifier,
274
289
  ...input.expiresAt ? { expires_at: input.expiresAt } : {}
275
290
  }
276
291
  };
277
292
  }
293
+ const keyOf = (step) => `fulfilled_${step}`, stamp = (step) => ({ metadata: { [keyOf(step)]: (/* @__PURE__ */ new Date()).toISOString() } });
278
294
  /**
279
295
  * Runs a fulfilment step once per Checkout Session, even when Stripe delivers the event again.
280
296
  * The step's completion is recorded in the session's own metadata, so a failed step is retried on
@@ -286,29 +302,58 @@ function buildCheckoutSession(input) {
286
302
  * with an idempotency key. Stock is guarded on its own, in `decrementStock`.
287
303
  */
288
304
  async function once(stripe, session, step, fn) {
289
- let key = `fulfilled_${step}`;
290
- return session.metadata?.[key] ? "skipped" : (await fn(), await stripe.checkout.sessions.update(session.id, { metadata: { [key]: (/* @__PURE__ */ new Date()).toISOString() } }), "done");
305
+ return session.metadata?.[keyOf(step)] ? "skipped" : (await fn(), await stripe.checkout.sessions.update(session.id, stamp(step)), "done");
291
306
  }
307
+ /**
308
+ * The same for a renewal: once per invoice, recorded in the invoice's metadata. `invoice` must be
309
+ * read fresh from Stripe's API.
310
+ */
311
+ async function oncePerInvoice(stripe, invoice, step, fn) {
312
+ return invoice.metadata?.[keyOf(step)] ? "skipped" : (await fn(), await stripe.invoices.update(invoice.id, stamp(step)), "done");
313
+ }
314
+ function readInterval(recurring) {
315
+ if (!recurring) return null;
316
+ let every = recurring.interval_count;
317
+ return recurring.interval === "week" ? {
318
+ unit: "week",
319
+ every
320
+ } : recurring.interval === "month" ? {
321
+ unit: "month",
322
+ every
323
+ } : null;
324
+ }
325
+ const metadataOf = (product) => product && typeof product == "object" && !product.deleted ? product.metadata : {}, idOf = (value) => typeof value == "string" ? value : value?.id ?? null;
292
326
  /** Reads a completed session and its line items into an order. */
293
327
  async function getOrder(stripe, session) {
294
- let items = (await stripe.checkout.sessions.listLineItems(session.id, {
328
+ let lineItems = await stripe.checkout.sessions.listLineItems(session.id, {
295
329
  limit: 100,
296
330
  expand: ["data.price.product"]
297
- })).data.map((item) => {
298
- let product = item.price?.product, metadata = product && typeof product == "object" && !product.deleted ? product.metadata : {}, ordered = {
331
+ }), items = [], interval = null, delivery = 0;
332
+ for (let item of lineItems.data) {
333
+ let metadata = metadataOf(item.price?.product);
334
+ if (metadata.delivery) {
335
+ delivery += item.amount_total;
336
+ continue;
337
+ }
338
+ let ordered = {
299
339
  name: item.description ?? "Item",
300
340
  quantity: item.quantity ?? 1,
301
341
  amountTotal: item.amount_total
302
342
  };
303
- return metadata.productId && (ordered.productId = metadata.productId), metadata.variantKey && (ordered.variantKey = metadata.variantKey), ordered;
304
- }), shippingDetails = session.collected_information?.shipping_details, paymentIntent = session.payment_intent;
343
+ metadata.productId && (ordered.productId = metadata.productId), metadata.variantKey && (ordered.variantKey = metadata.variantKey), item.price?.recurring && (ordered.subscription = !0, interval ??= readInterval(item.price.recurring)), items.push(ordered);
344
+ }
345
+ let shippingDetails = session.collected_information?.shipping_details, subscriptionId = idOf(session.subscription);
305
346
  return {
306
347
  sessionId: session.id,
307
- paymentIntentId: typeof paymentIntent == "string" ? paymentIntent : paymentIntent?.id ?? null,
348
+ paymentIntentId: idOf(session.payment_intent),
349
+ ...subscriptionId ? { subscription: {
350
+ id: subscriptionId,
351
+ interval
352
+ } } : {},
308
353
  livemode: session.livemode,
309
354
  currency: session.currency ?? "",
310
355
  amountTotal: session.amount_total ?? 0,
311
- amountShipping: session.shipping_cost?.amount_total ?? 0,
356
+ amountShipping: (session.shipping_cost?.amount_total ?? 0) + delivery,
312
357
  ...session.metadata?.region ? { regionId: session.metadata.region } : {},
313
358
  ...session.metadata?.regionTitle ? { regionTitle: session.metadata.regionTitle } : {},
314
359
  customer: {
@@ -325,6 +370,65 @@ async function getOrder(stripe, session) {
325
370
  };
326
371
  }
327
372
  /**
373
+ * Reads a paid renewal into an order: what the subscription sends again, and where. The address
374
+ * is the customer's as it is now, so one changed in the customer portal is the one used.
375
+ *
376
+ * `session` is the Checkout Session that started the subscription.
377
+ */
378
+ async function getRenewal(stripe, invoice, session, subscriptionId) {
379
+ let subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price.product"] }), sold = /* @__PURE__ */ new Map();
380
+ for (let item of subscription.items.data) {
381
+ let { product, recurring } = item.price;
382
+ typeof product == "object" && !product.deleted && sold.set(product.id, {
383
+ name: product.name,
384
+ metadata: product.metadata,
385
+ interval: readInterval(recurring)
386
+ });
387
+ }
388
+ let items = [], interval = null, delivery = 0;
389
+ for (let line of invoice.lines.data) {
390
+ let product = sold.get(line.pricing?.price_details?.product ?? "");
391
+ if (product?.metadata.delivery) {
392
+ delivery += line.amount;
393
+ continue;
394
+ }
395
+ let ordered = {
396
+ name: product?.name ?? line.description ?? "Item",
397
+ quantity: line.quantity ?? 1,
398
+ amountTotal: line.amount,
399
+ subscription: !0
400
+ };
401
+ product?.metadata.productId && (ordered.productId = product.metadata.productId), product?.metadata.variantKey && (ordered.variantKey = product.metadata.variantKey), interval ??= product?.interval ?? null, items.push(ordered);
402
+ }
403
+ let started = session.collected_information?.shipping_details, where = invoice.parent?.subscription_details?.metadata ?? session.metadata;
404
+ return {
405
+ sessionId: session.id,
406
+ paymentIntentId: null,
407
+ invoiceId: invoice.id,
408
+ subscription: {
409
+ id: subscriptionId,
410
+ interval
411
+ },
412
+ livemode: invoice.livemode,
413
+ currency: invoice.currency,
414
+ amountTotal: invoice.total,
415
+ amountShipping: delivery,
416
+ ...where?.region ? { regionId: where.region } : {},
417
+ ...where?.regionTitle ? { regionTitle: where.regionTitle } : {},
418
+ customer: {
419
+ name: invoice.customer_name,
420
+ email: invoice.customer_email,
421
+ phone: invoice.customer_phone
422
+ },
423
+ shipping: {
424
+ name: invoice.customer_shipping?.name ?? started?.name ?? null,
425
+ address: invoice.customer_shipping?.address ?? started?.address ?? null
426
+ },
427
+ items,
428
+ createdAt: /* @__PURE__ */ new Date((invoice.status_transitions.paid_at ?? invoice.created) * 1e3)
429
+ };
430
+ }
431
+ /**
328
432
  * Stock documents and every version of a product are only visible with a token, the raw
329
433
  * perspective and an API version that knows Content Releases.
330
434
  */
@@ -483,7 +587,9 @@ const consoleLog = (level, message) => console[level](message), json = (body, st
483
587
  };
484
588
  /**
485
589
  * The site's checkout route. The browser posts the cart's product references and the chosen
486
- * shipping region; prices, stock and shipping are read fresh from Sanity here.
590
+ * shipping region; prices, stock and shipping are read fresh from Sanity here. A cart with a
591
+ * subscription in it becomes a subscription checkout; anything bought once in the same cart is
592
+ * charged with the first payment.
487
593
  *
488
594
  * ```ts
489
595
  * // app/api/checkout/route.ts
@@ -534,6 +640,8 @@ function checkoutRoute(options) {
534
640
  integrationIdentifier: options.integrationIdentifier,
535
641
  ...stock ? { stock } : {},
536
642
  ...options.hideRemaining ? { hideRemaining: !0 } : {},
643
+ ...options.promotionCodes ? { promotionCodes: !0 } : {},
644
+ ...settings?.subscriptionDelivery ? { subscriptionDelivery: settings.subscriptionDelivery } : {},
537
645
  ...options.taxRates ? { taxRates: options.taxRates } : {},
538
646
  ...options.maxQuantity ? { maxQuantity: options.maxQuantity } : {},
539
647
  ...options.expiresInMinutes ? { expiresAt: Math.floor(Date.now() / 1e3) + Math.max(1860, options.expiresInMinutes * 60) } : {}
@@ -566,11 +674,15 @@ function checkoutRoute(options) {
566
674
  function absoluteUrl(url, origin) {
567
675
  return /^https?:\/\//.test(url) ? url : `${origin}${url.startsWith("/") ? "" : "/"}${url}`;
568
676
  }
677
+ const subscriptionOf = (invoice) => {
678
+ let subscription = invoice.parent?.subscription_details?.subscription;
679
+ return typeof subscription == "string" ? subscription : subscription?.id ?? null;
680
+ };
569
681
  /**
570
682
  * The site's Stripe webhook. A paid order of this shop lowers stock and calls `onOrder`, each
571
- * once per payment however often Stripe delivers the event. Payments made any other way on the
572
- * same Stripe account (a Payment Link, another site) are left alone. Nothing is fulfilled on the
573
- * thank-you page.
683
+ * once per payment however often Stripe delivers the event. A paid renewal of a subscription
684
+ * does the same and calls `onRenewal`. Payments made any other way on the same Stripe account (a
685
+ * Payment Link, another site) are left alone. Nothing is fulfilled on the thank-you page.
574
686
  *
575
687
  * ```ts
576
688
  * // app/api/stripe/webhook/route.ts
@@ -583,8 +695,30 @@ function webhookRoute(options) {
583
695
  let { client, log = consoleLog } = options;
584
696
  resolveProductQuery(options.products);
585
697
  let saidUncounted = !1, ownSession = async (stripe, sessionId) => {
586
- let session = await stripe.checkout.sessions.retrieve(sessionId), own = session.mode === "payment" && session.integration_identifier === options.integrationIdentifier;
698
+ let session = await stripe.checkout.sessions.retrieve(sessionId), own = session.mode !== "setup" && session.integration_identifier === options.integrationIdentifier;
587
699
  return own || log("info", `[stripe webhook] ${sessionId}: not one of this shop's, left alone.`), own ? session : null;
700
+ }, lowerStock = async (id, order, settings) => {
701
+ if (!options.stockClient) return saidUncounted || (saidUncounted = !0, log("info", "[stripe webhook] No stockClient: stock counts are left as they are.")), { stock: [] };
702
+ try {
703
+ let result = await decrementStock(options.stockClient, {
704
+ sessionId: id,
705
+ items: order.items
706
+ }, {
707
+ autoSoldOut: settings?.autoSoldOut ?? !0,
708
+ ...options.products ? { products: options.products } : {}
709
+ });
710
+ if (result.status === "lowered") {
711
+ let summary = result.changes.map(({ productId, variantKey, from, to }) => `${productId}${variantKey ? ` ${variantKey}` : ""} ${from} → ${to}`).join(", ");
712
+ log("info", `[stripe webhook] ${id}: stock ${summary}`);
713
+ }
714
+ return { stock: result.changes };
715
+ } catch (error) {
716
+ let stockError = reason(error);
717
+ return log("error", `[stripe webhook] ${id}: stock was not lowered: ${stockError}`), {
718
+ stock: [],
719
+ stockError
720
+ };
721
+ }
588
722
  }, fulfil = async (stripe, sessionId) => {
589
723
  let session = await ownSession(stripe, sessionId);
590
724
  if (!session) return;
@@ -592,21 +726,7 @@ function webhookRoute(options) {
592
726
  log("info", `[stripe webhook] ${sessionId}: not paid yet.`);
593
727
  return;
594
728
  }
595
- let [order, foundSettings] = await Promise.all([getOrder(stripe, session), client.fetch(SHOP_SETTINGS_QUERY)]), settings = readShopSettings(foundSettings), stock = [], stockError;
596
- if (options.stockClient) try {
597
- let result = await decrementStock(options.stockClient, order, {
598
- autoSoldOut: settings?.autoSoldOut ?? !0,
599
- ...options.products ? { products: options.products } : {}
600
- });
601
- if (stock = result.changes, result.status === "lowered") {
602
- let summary = stock.map(({ productId, variantKey, from, to }) => `${productId}${variantKey ? ` ${variantKey}` : ""} ${from} → ${to}`).join(", ");
603
- log("info", `[stripe webhook] ${sessionId}: stock ${summary}`);
604
- }
605
- } catch (error) {
606
- stockError = reason(error), log("error", `[stripe webhook] ${sessionId}: stock was not lowered: ${stockError}`);
607
- }
608
- else saidUncounted || (saidUncounted = !0, log("info", "[stripe webhook] No stockClient: stock counts are left as they are."));
609
- let { onOrder } = options;
729
+ let [order, foundSettings] = await Promise.all([getOrder(stripe, session), client.fetch(SHOP_SETTINGS_QUERY)]), settings = readShopSettings(foundSettings), { stock, stockError } = await lowerStock(sessionId, order, settings), { onOrder } = options;
610
730
  if (onOrder) {
611
731
  let outcome = await once(stripe, session, "order", async () => {
612
732
  await onOrder(order, {
@@ -620,6 +740,36 @@ function webhookRoute(options) {
620
740
  log("info", `[stripe webhook] ${sessionId}: order ${outcome}.`);
621
741
  }
622
742
  if (stockError) throw Error(`Stock was not lowered: ${stockError}`);
743
+ }, startedBy = async (stripe, subscriptionId) => {
744
+ let { data } = await stripe.checkout.sessions.list({
745
+ subscription: subscriptionId,
746
+ limit: 1
747
+ }), session = data[0], own = session?.integration_identifier === options.integrationIdentifier;
748
+ return own || log("info", `[stripe webhook] ${subscriptionId}: not one of this shop's, left alone.`), own ? session : null;
749
+ }, renew = async (stripe, invoiceId) => {
750
+ let invoice = await stripe.invoices.retrieve(invoiceId), subscriptionId = subscriptionOf(invoice);
751
+ if (!subscriptionId || invoice.billing_reason !== "subscription_cycle") return;
752
+ let session = await startedBy(stripe, subscriptionId);
753
+ if (!session) return;
754
+ if (invoice.status !== "paid") {
755
+ log("info", `[stripe webhook] ${invoiceId}: not paid yet.`);
756
+ return;
757
+ }
758
+ let [order, foundSettings] = await Promise.all([getRenewal(stripe, invoice, session, subscriptionId), client.fetch(SHOP_SETTINGS_QUERY)]), settings = readShopSettings(foundSettings), { stock, stockError } = await lowerStock(invoiceId, order, settings), { onRenewal } = options;
759
+ if (onRenewal) {
760
+ let outcome = await oncePerInvoice(stripe, invoice, "renewal", async () => {
761
+ await onRenewal(order, {
762
+ settings,
763
+ stock,
764
+ ...stockError ? { stockError } : {},
765
+ session,
766
+ invoice,
767
+ stripe
768
+ });
769
+ });
770
+ log("info", `[stripe webhook] ${invoiceId}: renewal ${outcome}.`);
771
+ }
772
+ if (stockError) throw Error(`Stock was not lowered: ${stockError}`);
623
773
  };
624
774
  return async (request) => {
625
775
  let { stripe, webhookSecret } = options;
@@ -644,6 +794,33 @@ function webhookRoute(options) {
644
794
  session && (log("warn", `[stripe webhook] ${session.id}: the delayed payment failed.`), await options.onPaymentFailed?.(session));
645
795
  break;
646
796
  }
797
+ case "invoicePaid":
798
+ await renew(stripe, result.invoice.id);
799
+ break;
800
+ case "invoiceFailed": {
801
+ let { onRenewalFailed } = options;
802
+ if (!onRenewalFailed) break;
803
+ let invoice = await stripe.invoices.retrieve(result.invoice.id), subscriptionId = subscriptionOf(invoice), session = subscriptionId ? await startedBy(stripe, subscriptionId) : null;
804
+ session && (log("warn", `[stripe webhook] ${invoice.id}: a renewal's payment failed.`), await onRenewalFailed(invoice, {
805
+ session,
806
+ stripe
807
+ }));
808
+ break;
809
+ }
810
+ case "subscriptionEnded": {
811
+ let { onSubscriptionEnded } = options;
812
+ if (!onSubscriptionEnded) break;
813
+ let session = await startedBy(stripe, result.subscription.id);
814
+ if (session) {
815
+ let subscription = await stripe.subscriptions.retrieve(result.subscription.id);
816
+ log("info", `[stripe webhook] ${subscription.id}: the subscription ended.`), await onSubscriptionEnded(subscription, {
817
+ session,
818
+ stripe
819
+ });
820
+ }
821
+ break;
822
+ }
823
+ case "ignored": await options.onEvent?.(event, { stripe });
647
824
  }
648
825
  } catch (error) {
649
826
  return log("error", `[stripe webhook] ${event.type} ${event.id} failed: ${reason(error)}`), json({ error: "Fulfilment failed" }, 500);
@@ -651,6 +828,52 @@ function webhookRoute(options) {
651
828
  return json({ received: !0 });
652
829
  };
653
830
  }
831
+ /**
832
+ * Sends a visitor to Stripe's customer portal, where a subscriber changes their card or address,
833
+ * pauses or cancels, and finds their invoices. There are no accounts to build: the visitor types
834
+ * their email on Stripe's page and Stripe emails them a link in.
835
+ *
836
+ * ```ts
837
+ * // app/account/route.ts
838
+ * export const GET = portalRoute({stripe})
839
+ * ```
840
+ *
841
+ * Switch the portal's login link on once, in the Stripe Dashboard under Settings, Billing,
842
+ * Customer portal, and choose there what subscribers may change.
843
+ */
844
+ function portalRoute(options) {
845
+ let { log = consoleLog } = options, link, unavailable = (detail) => (log("error", `[portal] ${detail}`), new Response(options.message ?? "The account page isn't available right now.", {
846
+ status: 503,
847
+ headers: {
848
+ "Content-Type": "text/plain; charset=utf-8",
849
+ "Cache-Control": "no-store"
850
+ }
851
+ }));
852
+ return async () => {
853
+ let { stripe } = options;
854
+ if (!stripe) return unavailable("There is no Stripe key. Set one in the environment.");
855
+ if (!link || Date.now() - link.at > 3e5) try {
856
+ let { data } = await stripe.billingPortal.configurations.list({
857
+ active: !0,
858
+ limit: 100
859
+ }), linked = data.filter((configuration) => configuration.login_page.url), url = (linked.find((configuration) => configuration.is_default) ?? linked[0])?.login_page.url;
860
+ if (!url) return unavailable("The customer portal has no login link. Activate it in the Stripe Dashboard: Settings, Billing, Customer portal.");
861
+ link = {
862
+ url,
863
+ at: Date.now()
864
+ };
865
+ } catch (error) {
866
+ return unavailable(`Stripe could not be asked for the portal's address: ${reason(error)}`);
867
+ }
868
+ return new Response(null, {
869
+ status: 303,
870
+ headers: {
871
+ Location: link.url,
872
+ "Cache-Control": "no-store"
873
+ }
874
+ });
875
+ };
876
+ }
654
877
  /** The package's version, sent to Stripe with each call. A test keeps it in step with package.json. */
655
878
  const STRIPE_API_VERSION = "2026-10-28.endive";
656
879
  /**
@@ -670,11 +893,11 @@ function createStripe(secretKey, config = {}) {
670
893
  apiVersion: STRIPE_API_VERSION,
671
894
  appInfo: {
672
895
  name: "sanity-plugin-stripe",
673
- version: "0.1.0",
896
+ version: "0.2.0",
674
897
  url: "https://github.com/ConfederationStudio/sanity-plugin-stripe"
675
898
  }
676
899
  });
677
900
  }
678
- export { EU_COUNTRIES, SHOP_SETTINGS_QUERY, STRIPE_API_VERSION, WEBHOOK_EVENTS, cartProductProjection, cartProductsQuery, checkoutRoute, createStripe, dashboardUrl, formatMoney, formatPrice, orderEmail, readCartProducts, readShopSettings, readStock, regionOptions, stockLeft, webhookRoute };
901
+ export { EU_COUNTRIES, SHOP_SETTINGS_QUERY, STRIPE_API_VERSION, WEBHOOK_EVENTS, cartProductProjection, cartProductsQuery, checkoutRoute, createStripe, dashboardUrl, describeInterval, formatMoney, formatPrice, orderEmail, portalRoute, readCartProducts, readShopSettings, readStock, readSubscription, regionOptions, shippingQuote, stockLeft, webhookRoute };
679
902
 
680
903
  //# sourceMappingURL=server.js.map