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/README.md +130 -53
- package/dist/{guards-CAcETsf7.js → countries-Csjmv4lY.js} +3 -3
- package/dist/countries-Csjmv4lY.js.map +1 -0
- package/dist/index.d.ts +20 -4
- package/dist/index.js +135 -34
- package/dist/index.js.map +1 -1
- package/dist/money-XMQaHsbV.d.ts +5 -0
- package/dist/react.d.ts +21 -10
- package/dist/react.js +13 -7
- package/dist/react.js.map +1 -1
- package/dist/regions-Bfhg4QDs.d.ts +157 -0
- package/dist/regions-CFDYMokP.js +207 -0
- package/dist/regions-CFDYMokP.js.map +1 -0
- package/dist/server.d.ts +97 -19
- package/dist/server.js +330 -107
- package/dist/server.js.map +1 -1
- package/dist/{countries-BuqQF1AW.js → stock-DAsSGayt.js} +4 -10
- package/dist/stock-DAsSGayt.js.map +1 -0
- package/package.json +15 -34
- package/dist/cart-CSjxtjq-.js +0 -111
- package/dist/cart-CSjxtjq-.js.map +0 -1
- package/dist/cart-D2KxuwQz.d.ts +0 -74
- package/dist/countries-BuqQF1AW.js.map +0 -1
- package/dist/guards-CAcETsf7.js.map +0 -1
- package/dist/regions-DbCIM0I1.d.ts +0 -32
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
"&": "&",
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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`
|
|
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
|
|
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
|
-
...
|
|
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),
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
328
|
+
let lineItems = await stripe.checkout.sessions.listLineItems(session.id, {
|
|
295
329
|
limit: 100,
|
|
296
330
|
expand: ["data.price.product"]
|
|
297
|
-
})
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
}
|
|
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:
|
|
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.
|
|
572
|
-
* same
|
|
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
|
|
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 =
|
|
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.
|
|
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
|