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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # sanity-plugin-stripe
2
2
 
3
- Sell from a Sanity site with Stripe Checkout, without a shop platform in between. Editors set price, stock and variants on their own documents in the Studio; the site keeps a cart in the browser and sends buyers to Stripe's payment page; a webhook lowers stock and hands the paid order to your code. Made by Eurico Sá Fernandes at [Confederation Studio](https://github.com/ConfederationStudio). It isn't affiliated with or endorsed by Stripe or Sanity.
3
+ Sell from a Sanity site with Stripe Checkout, without a shop platform in between: once, or as a subscription. Editors set price, stock and variants on their own documents in the Studio; the site keeps a cart in the browser and sends buyers to Stripe's payment page; a webhook lowers stock and hands the paid order to your code. Made by Eurico Sá Fernandes at [Confederation Studio](https://github.com/ConfederationStudio). It isn't affiliated with or endorsed by Stripe or Sanity.
4
4
 
5
- > **Status:** 0.1, tried in a Stripe sandbox against a real, public Sanity dataset, from the cart to Stripe's page and back through the webhook, and in the Studio: stock went down once, also when the same event was delivered twice at the same moment; a resent event changed nothing; a declined card fulfilled nothing; a payment made outside the shop was left alone; a query without a token could read no stock, no draft and nothing about an order; and the Studio's stock boxes followed a count lowered from outside the Studio, as an order lowers it. Not tried yet: a delayed payment method (a bank debit, for one), a Sanity token that may only read, and three-decimal currencies. Until 1.0, a minor version may change the API; the [changelog](https://github.com/ConfederationStudio/sanity-plugin-stripe/blob/main/CHANGELOG.md) says how. It targets Sanity Studio 6, React 19.2 and stripe-node 23.
5
+ > **Status:** 0.2, tried in a Stripe sandbox against a real, public Sanity dataset, from the cart to Stripe's page and back through the webhook, and in the Studio: stock went down once, also when the same event was delivered twice at the same moment; a resent event changed nothing; a declined card fulfilled nothing; a payment made outside the shop was left alone; a query without a token could read no stock, no draft and nothing about an order; and the Studio's stock boxes followed a count lowered from outside the Studio, as an order lowers it. A subscription was bought in one cart with a one-off item, with its delivery charged as a repeating line. On a Stripe test clock its renewal lowered stock once and went to the address the customer had changed in between; the same event delivered again changed nothing; a renewal whose card was declined fulfilled nothing until it was paid; and cancelling reached the site. A promotion code took its 10% off the goods. The portal route arrived at Stripe's login page for the customer portal, and an address changed inside the portal landed where renewals read it. A bank debit was waited for: nothing was fulfilled until the money arrived, and nothing at all when it failed. With a Sanity token that may only read, the order still reached the shop, marked as not counted, and stock was lowered on a later delivery. Not tried yet: the portal's sign-in by email, and three-decimal currencies (Stripe only opens those to accounts with a bank account in the currency). Until 1.0, a minor version may change the API; the [changelog](https://github.com/ConfederationStudio/sanity-plugin-stripe/blob/main/CHANGELOG.md) says how. It targets Sanity Studio 6, React 19.2 and stripe-node 23.
6
6
 
7
- - **In the Studio.** A `stripeCommerce` field for any document type: on sale or sold out, price, tax class, variants that each sell out on their own, and optional stock counts. A Shop settings document holds the shipping regions with one flat rate each, and whether stock sells out by itself at 0.
7
+ - **In the Studio.** A `stripeCommerce` field for any document type: on sale or sold out, price, tax class, an optional subscription, variants that each sell out on their own, and optional stock counts. A Shop settings document holds the shipping regions with one flat rate each, free from an amount if you like, and whether stock sells out by itself at 0.
8
8
  - **In the browser.** A cart in localStorage that holds product references and quantities, never prices, with hooks for the buy button and the checkout. The markup is yours.
9
- - **On the server.** A checkout route that reads prices, stock and shipping fresh from Sanity and creates the Checkout Session, and a webhook route that lowers stock and calls your `onOrder`, each once per payment however often Stripe delivers the event.
9
+ - **On the server.** A checkout route that reads prices, stock and shipping fresh from Sanity and creates the Checkout Session, a webhook route that lowers stock and calls your `onOrder`, each once per payment however often Stripe delivers the event, and the same for each renewal of a [subscription](#subscriptions). A third route sends subscribers to Stripe's customer portal.
10
10
  - **Made for a public dataset.** Most Sanity datasets can be read by anyone. Buyers, addresses and orders are never written to Sanity; stock counts are kept where only a token can read them; keys stay in your site's environment. See [On a public dataset](#on-a-public-dataset).
11
11
 
12
12
  ## How it fits together
@@ -137,7 +137,7 @@ export const POST = webhookRoute({
137
137
 
138
138
  Both take a web `Request` and return a `Response`, so the same handlers fit Remix, Astro, SvelteKit or Hono. `createStripe` pins the Stripe API version the plugin is tested against. The plugin never reads the environment itself: you pass the values in.
139
139
 
140
- Give your email service `order.sessionId` as an idempotency key. Stock is lowered once per payment whatever happens, but when Stripe delivers one event twice in the same moment, `onOrder` can run for both.
140
+ Give your email service `order.sessionId` as an idempotency key (`order.invoiceId` for a renewal). Stock is lowered once per payment whatever happens, but when Stripe delivers one event twice in the same moment, `onOrder` can run for both.
141
141
 
142
142
  The webhook is not optional. It is the only place an order is fulfilled, because a buyer who pays and closes the tab never reaches the thank-you page. It fulfils only sessions that carry your `integrationIdentifier`: a Payment Link or another site on the same Stripe account is left alone.
143
143
 
@@ -145,7 +145,18 @@ Without `stockClient` nothing is counted: the shop sells by the on-sale switch a
145
145
 
146
146
  ### 3. Stripe
147
147
 
148
- Use a [sandbox](https://docs.stripe.com/sandboxes) while building, and a restricted key (`rk_…`) rather than a secret key. The key needs Checkout Sessions: Write. Reading back which products were bought may also need Products: Read. The smallest set that works has not been confirmed yet, so look at the webhook's log after your first test payment: a missing permission shows there, in Stripe's own words.
148
+ Use a [sandbox](https://docs.stripe.com/sandboxes) while building, and a restricted key (`rk_…`) rather than a secret key. A restricted key with these permissions ran everything the plugin does, in a sandbox:
149
+
150
+ | Permission | Level | For |
151
+ | ----------------- | ----- | --------------------------------------------------------------- |
152
+ | Checkout Sessions | Write | Starting a checkout, reading a paid one, marking it handled |
153
+ | Products | Read | Reading back which products were bought |
154
+ | Prices | Read | The same |
155
+ | Subscriptions | Read | A renewal's lines. Only with subscriptions. |
156
+ | Invoices | Write | Reading a renewal, marking it handled. Only with subscriptions. |
157
+ | Customer portal | Read | `portalRoute`. Only if you use it. |
158
+
159
+ Whether it also works without Products and Prices was not tried. A missing permission shows in the route's log, in Stripe's own words.
149
160
 
150
161
  Locally, forward events to the site and copy the `whsec_…` secret it prints into `STRIPE_WEBHOOK_SECRET`:
151
162
 
@@ -153,7 +164,7 @@ Locally, forward events to the site and copy the `whsec_…` secret it prints in
153
164
  stripe listen --forward-to localhost:3000/api/stripe/webhook --events checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed
154
165
  ```
155
166
 
156
- In production, add a webhook endpoint for `https://<your domain>/api/stripe/webhook` in the Stripe Dashboard with the same three events (`WEBHOOK_EVENTS` lists them).
167
+ In production, add a webhook endpoint for `https://<your domain>/api/stripe/webhook` in the Stripe Dashboard with the same three events. A shop with subscriptions adds `invoice.paid`, `invoice.payment_failed` and `customer.subscription.deleted`, there and to the command above. `WEBHOOK_EVENTS` lists all six.
157
168
 
158
169
  Payment methods are chosen in the Stripe Dashboard, not in code: the plugin never sends `payment_method_types`.
159
170
 
@@ -211,19 +222,65 @@ The browser knows whether something is on sale, not how many are left. When a ca
211
222
 
212
223
  `dev/site` in this repository is a complete, small example of all of it.
213
224
 
225
+ ## Subscriptions
226
+
227
+ A product can be offered as a subscription next to its normal price. In the Studio, switch on **Subscribe and save** on the product, type the price per delivery and say how often: every so many weeks or months. On your product page the buyer chooses; `useBuyBox` gives you `subscription`, `plan` and `choosePlan` for that.
228
+
229
+ - **One cart, both kinds.** A cart with a subscription in it becomes a subscription checkout. What is bought once in the same cart is charged with the first payment. One checkout takes one interval: a cart that mixes "every 6 weeks" with "every month" is turned down, with `otherInterval` as the reason.
230
+ - **The first payment is an order.** It reaches `onOrder` like any other, with `order.subscription` set and the lines that repeat marked.
231
+ - **Each renewal reaches `onRenewal`,** once per paid invoice, with the lines to send again and the address the customer has now, so one changed in the customer portal is the one used. Stock is lowered once per renewal. A renewal is paid before your site hears of it, so it can take a count below 0; `orderEmail` says so.
232
+ - **A failed renewal is Stripe's to chase.** Stripe tries again and writes to the customer. `onRenewalFailed` and `onSubscriptionEnded` tell your site, for a mailing-list tag or the like.
233
+ - **A subscriber keeps their price.** Changing the price in the Studio changes it for new subscribers.
234
+
235
+ ```ts
236
+ export const POST = webhookRoute({
237
+ // …as above
238
+ async onRenewal(order, {stock, stockError}) {
239
+ const email = orderEmail(order, {shopName: 'My shop', stock, stockError})
240
+ await sendEmail({to: process.env.ORDER_EMAIL_TO, idempotencyKey: order.invoiceId, ...email})
241
+ },
242
+ })
243
+ ```
244
+
245
+ ### Delivery on a subscription
246
+
247
+ Stripe has no shipping options for a subscription, so the Shop settings ask how subscriptions pay for delivery:
248
+
249
+ - **Free** (the default): delivery is in the subscription's price, and nothing is added.
250
+ - **The region's rate with every delivery:** the checkout adds a line, "Delivery: Netherlands", that repeats with the subscription. It is left out when the lines that repeat reach the region's "free from" amount.
251
+
252
+ One-off items in a subscription cart travel with the first parcel at no extra charge.
253
+
254
+ ### The customer portal
255
+
256
+ ```ts
257
+ // app/account/route.ts
258
+ import {portalRoute} from 'sanity-plugin-stripe/server'
259
+
260
+ export const GET = portalRoute({stripe})
261
+ ```
262
+
263
+ It sends the visitor to Stripe's customer portal, where a subscriber changes their card or address, pauses or cancels, and finds their invoices. There are no accounts to build: they type their email on Stripe's page and Stripe emails them a link in. Switch the portal's login link on once, in the Stripe Dashboard under Settings, Billing, Customer portal, and choose there what subscribers may change. Until then the route answers 503 and its log says what to do. A portal set up through Stripe's API with its login page enabled works as well.
264
+
265
+ ## Free delivery and promotion codes
266
+
267
+ Each shipping region can have a **Free from** amount: an order whose goods add up to it or more is delivered free there. The checkout works it out on the server. For the cart page, `shippingQuote(region, subtotal)` gives what will be charged and how much is missing, for "€4.01 more for free delivery".
268
+
269
+ `promotionCodes: true` on the checkout route shows a field for a code on Stripe's page. Codes and their rules are made in the Stripe Dashboard, and a checkout takes one. Free delivery goes by the goods before the code: the code is typed on Stripe's page, after the delivery charge is set. On a subscription, the code's coupon says whether renewals are discounted too.
270
+
214
271
  ## On a public dataset
215
272
 
216
273
  Sanity serves a public dataset to anyone, without a token, with one fixed exception: a document whose `_id` contains a dot (a draft, for one) is only served to signed-in project members and to API tokens. That is the only line Sanity draws below its Enterprise plan, so the plugin puts each thing on the right side of it, or outside Sanity altogether.
217
274
 
218
- | What | Where it lives | Who can read it |
219
- | ---------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------- |
220
- | Prices, on sale or sold out, variants | Your product documents | Anyone. The shop shows them. |
221
- | Shipping regions and rates, "sell out at 0" | The Shop settings document, id `stripeShopSettings` | Anyone. The cart shows them. |
222
- | Stock counts | `stripe.stock.<product id>` | Project members and tokens. |
223
- | What each payment did to stock | `stripe.stockChange.<session id>`, deleted after 30 days | Project members and tokens. It holds product ids and counts only. |
224
- | Buyers, addresses, orders, payments | Stripe | Whoever has the Stripe account. Never written to Sanity. |
225
- | The address order emails go to | Your site's environment | Your server. |
226
- | The Stripe key, the webhook secret, the Sanity token | Your site's environment | Your server. Never in Sanity, never in the browser. |
275
+ | What | Where it lives | Who can read it |
276
+ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------- |
277
+ | Prices, on sale or sold out, variants, the subscription a product offers | Your product documents | Anyone. The shop shows them. |
278
+ | Shipping regions, rates and "free from" amounts, "sell out at 0", delivery for subscriptions | The Shop settings document, id `stripeShopSettings` | Anyone. The cart shows them. |
279
+ | Stock counts | `stripe.stock.<product id>` | Project members and tokens. |
280
+ | What each payment did to stock | `stripe.stockChange.<session or invoice id>`, deleted after 30 days | Project members and tokens. It holds product ids and counts only. |
281
+ | Buyers, addresses, orders, subscriptions, payments | Stripe | Whoever has the Stripe account. Never written to Sanity. |
282
+ | The address order emails go to | Your site's environment | Your server. |
283
+ | The Stripe key, the webhook secret, the Sanity token | Your site's environment | Your server. Never in Sanity, never in the browser. |
227
284
 
228
285
  Why not keep orders in private documents too: a dot hides a document from the public, not from the rest of the project. Every member, including free Viewer seats, every API token that can read (the one many sites use for previews, for one), and every dataset export gets them, and Sanity keeps deleted content in history for 3 to 365 days depending on the plan. That is fine for stock. It is not fine for people's names and addresses.
229
286
 
@@ -245,6 +302,13 @@ The field saves this on your document:
245
302
  "availability": "onSale",
246
303
  "price": 29.5,
247
304
  "taxClass": "standard",
305
+ "subscription": {
306
+ "_type": "stripeSubscription",
307
+ "enabled": true,
308
+ "price": 26.5,
309
+ "every": 6,
310
+ "unit": "week"
311
+ },
248
312
  "variants": [
249
313
  {"_key": "a1", "_type": "stripeVariant", "label": "M"},
250
314
  {"_key": "b2", "_type": "stripeVariant", "label": "L", "availability": "soldOut"}
@@ -252,7 +316,7 @@ The field saves this on your document:
252
316
  }
253
317
  ```
254
318
 
255
- Prices are in currency units, as editors type them (29.5 is €29.50), and are converted to Stripe's minor units at checkout. Stripe's sandbox has accepted the plugin's amounts in euros and in zero-decimal currencies (yen, won); three-decimal currencies such as the Kuwaiti dinar follow Stripe's rule but have not been tried.
319
+ Prices are in currency units, as editors type them (29.5 is €29.50), and are converted to Stripe's minor units at checkout. Stripe's sandbox has accepted the plugin's amounts in euros and in zero-decimal currencies (yen, won). Three-decimal currencies such as the Kuwaiti dinar follow Stripe's rule but could not be tried: Stripe refuses them on an account without a bank account in that currency.
256
320
 
257
321
  Stock is a document of its own per product, written by the stock boxes and the webhook:
258
322
 
@@ -270,61 +334,74 @@ A product without variants has `stockCount` at the top. No count means it isn't
270
334
 
271
335
  ### `stripeCommerce()` in the Studio
272
336
 
273
- | Option | |
274
- | ---------------------- | --------------------------------------------------------------------------------------- |
275
- | `currency` | ISO 4217 code every price is in, such as `eur`. Required. |
276
- | `tax.label` | What tax is called where the shop is: `VAT`, `GST`, `sales tax`. Default `tax`. |
277
- | `tax.included` | Whether entered prices include tax. It only changes the labels. Default true. |
278
- | `tax.classes` | `{title, value}` choices for a product's tax class. Without any, the field is left out. |
279
- | `shop.shippingRegions` | Starting regions: `{title, countries, rate}` or `{title, everywhereElse: true, rate}`. |
337
+ | Option | |
338
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
339
+ | `currency` | ISO 4217 code every price is in, such as `eur`. Required. |
340
+ | `tax.label` | What tax is called where the shop is: `VAT`, `GST`, `sales tax`. Default `tax`. |
341
+ | `tax.included` | Whether entered prices include tax. It only changes the labels. Default true. |
342
+ | `tax.classes` | `{title, value}` choices for a product's tax class. Without any, the field is left out. |
343
+ | `shop.shippingRegions` | Starting regions: `{title, countries, rate}` or `{title, everywhereElse: true, rate}`, each with an optional `freeFrom`. |
280
344
 
281
345
  ### `checkoutRoute()`
282
346
 
283
- | Option | |
284
- | ----------------------- | ----------------------------------------------------------------------------------------------- |
285
- | `stripe` | From `createStripe`. Without one the route answers 503. Required. |
286
- | `client` | A Sanity client without a token that skips the CDN. Required. |
287
- | `stockClient` | A Sanity client with a token, to read stock. Without it nothing is counted. |
288
- | `currency` | The same code the Studio plugin has. Required. |
289
- | `successUrl` | A path or address, with `{CHECKOUT_SESSION_ID}` in it. Required. |
290
- | `cancelUrl` | Where Stripe's back link goes. Required. |
291
- | `integrationIdentifier` | Marks the shop's sessions for the webhook and the Stripe Dashboard. Required. |
292
- | `products` | Where products live: `{types, field, title, slug, image}`. Default `product` with `commerce`. |
293
- | `taxRates` | Stripe tax rate ids (`txr_…`) by tax class, so receipts show the tax. |
294
- | `maxQuantity` | Most of one item per order. Default 10. |
295
- | `hideRemaining` | Don't say how many are left when a cart asks for too many. |
296
- | `expiresInMinutes` | Minutes until an unpaid session expires, about 30 at least. Stripe's default is 24 hours. |
297
- | `extend` | Changes the session's parameters before it is created: promotion codes, phone numbers, consent. |
298
- | `messages` | What buyers read when the checkout can't start. |
299
- | `debug` | Adds the reason to error replies. For development. |
347
+ | Option | |
348
+ | ----------------------- | --------------------------------------------------------------------------------------------- |
349
+ | `stripe` | From `createStripe`. Without one the route answers 503. Required. |
350
+ | `client` | A Sanity client without a token that skips the CDN. Required. |
351
+ | `stockClient` | A Sanity client with a token, to read stock. Without it nothing is counted. |
352
+ | `currency` | The same code the Studio plugin has. Required. |
353
+ | `successUrl` | A path or address, with `{CHECKOUT_SESSION_ID}` in it. Required. |
354
+ | `cancelUrl` | Where Stripe's back link goes. Required. |
355
+ | `integrationIdentifier` | Marks the shop's sessions for the webhook and the Stripe Dashboard. Required. |
356
+ | `products` | Where products live: `{types, field, title, slug, image}`. Default `product` with `commerce`. |
357
+ | `taxRates` | Stripe tax rate ids (`txr_…`) by tax class, so receipts show the tax. |
358
+ | `maxQuantity` | Most of one item per order. Default 10. |
359
+ | `hideRemaining` | Don't say how many are left when a cart asks for too many. |
360
+ | `expiresInMinutes` | Minutes until an unpaid session expires, about 30 at least. Stripe's default is 24 hours. |
361
+ | `promotionCodes` | Shows a field for a promotion code on Stripe's page. |
362
+ | `extend` | Changes the session's parameters before it is created: phone numbers, consent, a customer. |
363
+ | `messages` | What buyers read when the checkout can't start. |
364
+ | `debug` | Adds the reason to error replies. For development. |
300
365
 
301
366
  ### `webhookRoute()`
302
367
 
303
- | Option | |
304
- | ----------------------- | -------------------------------------------------------------------------------------------- |
305
- | `stripe` | From `createStripe`. Required. |
306
- | `webhookSecret` | The endpoint's signing secret (`whsec_…`). Required. |
307
- | `integrationIdentifier` | The same value the checkout route has. Required. |
308
- | `client` | A Sanity client without a token that skips the CDN. Required. |
309
- | `stockClient` | A Sanity client with a token that can write, to lower stock. Without it stock is left alone. |
310
- | `products` | The same as the checkout route's. |
311
- | `onOrder` | Called once per paid order with the order, the shop settings and what happened to stock. |
312
- | `onPaymentFailed` | Called when a delayed payment of the shop fails. |
368
+ | Option | |
369
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
370
+ | `stripe` | From `createStripe`. Required. |
371
+ | `webhookSecret` | The endpoint's signing secret (`whsec_…`). Required. |
372
+ | `integrationIdentifier` | The same value the checkout route has. Required. |
373
+ | `client` | A Sanity client without a token that skips the CDN. Required. |
374
+ | `stockClient` | A Sanity client with a token that can write, to lower stock. Without it stock is left alone. |
375
+ | `products` | The same as the checkout route's. |
376
+ | `onOrder` | Called once per paid order with the order, the shop settings and what happened to stock. |
377
+ | `onPaymentFailed` | Called when a delayed payment of the shop fails. |
378
+ | `onRenewal` | Called once per paid renewal of a subscription, with the order to send again and what happened to stock. |
379
+ | `onRenewalFailed` | Called when a renewal's payment fails. Stripe retries and writes to the customer itself. |
380
+ | `onSubscriptionEnded` | Called when a subscription of the shop ends. |
381
+ | `onEvent` | Called for every other signed event the endpoint receives, such as a dispute. These are the Stripe account's, not sorted by shop. |
313
382
 
314
383
  If stock can't be lowered (a token that may only read), `onOrder` still runs, with `stockError` saying why, and Stripe is asked to deliver the event again; only the stock is tried again.
315
384
 
385
+ ### `portalRoute()`
386
+
387
+ | Option | |
388
+ | --------- | ----------------------------------------------------------------- |
389
+ | `stripe` | From `createStripe`. Without one the route answers 503. Required. |
390
+ | `message` | What a visitor reads when the portal can't be opened. |
391
+
316
392
  ## What it doesn't do
317
393
 
318
394
  - **Tax compliance.** Prices are what editors enter. A tax class can attach a Stripe tax rate you created, so the receipt shows the tax, and that is all. It does not use Stripe Tax and does not work out the VAT of the buyer's country for cross-border sales. Ask your accountant what your shop needs.
319
395
  - **Hold stock during checkout.** Stock is checked when Checkout starts and lowered when the payment lands. Two buyers can both pay for the last copy; the order email then says so. `expiresInMinutes` narrows the window.
320
396
  - **Show orders in the Studio.** Orders are in the Stripe Dashboard and in the email your `onOrder` sends. An Orders tab that reads them live from Stripe is planned.
321
397
  - **Send email.** `orderEmail()` writes the order email; sending it is your `onOrder`, with your email service.
322
- - **Subscriptions, discount logic, shipping by weight.** One-off payments with a flat rate per region. `extend` can switch on Stripe's promotion codes.
398
+ - **Shipping by weight, discount logic of its own.** A flat rate per region, free from an amount if you like. Discounts are Stripe's promotion codes.
399
+ - **Trials, or switching a subscription to another product.** A subscriber can pause, cancel and change their card and address in Stripe's portal. Their subscription keeps the product and price it started with.
323
400
 
324
401
  ## Security
325
402
 
326
403
  - The browser sends product ids, quantities and a region id. Prices, names and shipping rates are read from Sanity on the server, and anything else in the request is dropped.
327
- - Every webhook delivery's signature is checked against the raw body before it is parsed. The route then reads the session again from Stripe's API, checks that it is this shop's, and goes by that copy's payment status.
404
+ - Every webhook delivery's signature is checked against the raw body before it is parsed. The route then reads the session again from Stripe's API, checks that it is this shop's, and goes by that copy's payment status. A renewal is checked the same way: its invoice is read again from Stripe, and it is the shop's only when the checkout that started the subscription carries the shop's mark.
328
405
  - Keys come from your environment and are passed in. Use a restricted key, and a separate one for each environment.
329
406
  - Names inside the plugin's GROQ (document types, the field name) are checked when the routes are made. Ids travel as query parameters.
330
407
  - The order email escapes everything a buyer typed.
@@ -61,7 +61,7 @@ function formatPrice(amount, currency, locale = "en") {
61
61
  }).format(amount);
62
62
  }
63
63
  /** For reading what arrives as `unknown`: a request body, stored JSON, a reply from Sanity. */
64
- const isObject = (value) => typeof value == "object" && !!value && !Array.isArray(value), asArray = (value) => Array.isArray(value) ? value : [], asString = (value) => typeof value == "string" ? value : null, asNumber = (value) => typeof value == "number" && Number.isFinite(value) ? value : null;
65
- export { formatMoney as a, toMinorUnits as c, isObject as i, asNumber as n, formatPrice as o, asString as r, priceDecimals as s, asArray as t };
64
+ const isObject = (value) => typeof value == "object" && !!value && !Array.isArray(value), asArray = (value) => Array.isArray(value) ? value : [], asString = (value) => typeof value == "string" ? value : null, asNumber = (value) => typeof value == "number" && Number.isFinite(value) ? value : null, STRIPE_SHIPPING_COUNTRIES = /* @__PURE__ */ "AC.AD.AE.AF.AG.AI.AL.AM.AO.AQ.AR.AT.AU.AW.AX.AZ.BA.BB.BD.BE.BF.BG.BH.BI.BJ.BL.BM.BN.BO.BQ.BR.BS.BT.BV.BW.BY.BZ.CA.CD.CF.CG.CH.CI.CK.CL.CM.CN.CO.CR.CV.CW.CY.CZ.DE.DJ.DK.DM.DO.DZ.EC.EE.EG.EH.ER.ES.ET.FI.FJ.FK.FO.FR.GA.GB.GD.GE.GF.GG.GH.GI.GL.GM.GN.GP.GQ.GR.GS.GT.GU.GW.GY.HK.HN.HR.HT.HU.ID.IE.IL.IM.IN.IO.IQ.IS.IT.JE.JM.JO.JP.KE.KG.KH.KI.KM.KN.KR.KW.KY.KZ.LA.LB.LC.LI.LK.LR.LS.LT.LU.LV.LY.MA.MC.MD.ME.MF.MG.MK.ML.MM.MN.MO.MQ.MR.MS.MT.MU.MV.MW.MX.MY.MZ.NA.NC.NE.NG.NI.NL.NO.NP.NR.NU.NZ.OM.PA.PE.PF.PG.PH.PK.PL.PM.PN.PR.PS.PT.PY.QA.RE.RO.RS.RU.RW.SA.SB.SC.SD.SE.SG.SH.SI.SJ.SK.SL.SM.SN.SO.SR.SS.ST.SV.SX.SZ.TA.TC.TD.TF.TG.TH.TJ.TK.TL.TM.TN.TO.TR.TT.TV.TW.TZ.UA.UG.US.UY.UZ.VA.VC.VE.VG.VN.VU.WF.WS.XK.YE.YT.ZA.ZM.ZW".split("."), EU_COUNTRIES = /* @__PURE__ */ "AT.BE.BG.CY.CZ.DE.DK.EE.ES.FI.FR.GR.HR.HU.IE.IT.LT.LU.LV.MT.NL.PL.PT.RO.SE.SI.SK".split(".");
65
+ export { asString as a, formatPrice as c, asNumber as i, priceDecimals as l, STRIPE_SHIPPING_COUNTRIES as n, isObject as o, asArray as r, formatMoney as s, EU_COUNTRIES as t, toMinorUnits as u };
66
66
 
67
- //# sourceMappingURL=guards-CAcETsf7.js.map
67
+ //# sourceMappingURL=countries-Csjmv4lY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"countries-Csjmv4lY.js","names":[],"sources":["../src/core/money.ts","../src/core/guards.ts","../src/core/countries.ts"],"sourcesContent":["/**\n * Prices are entered in the Studio in currency units (25 means €25) and sent to Stripe in the\n * currency's minor unit (2500). Which is which follows https://docs.stripe.com/currencies.\n */\n\nconst ZERO_DECIMAL = new Set([\n 'bif',\n 'clp',\n 'djf',\n 'gnf',\n 'jpy',\n 'kmf',\n 'krw',\n 'mga',\n 'pyg',\n 'rwf',\n 'vnd',\n 'vuv',\n 'xaf',\n 'xof',\n 'xpf',\n])\n\nconst THREE_DECIMAL = new Set(['bhd', 'jod', 'kwd', 'omr', 'tnd'])\n\n/** Whole amounts only, which Stripe still takes as if they had two decimals: 5 ISK is `500`. */\nconst WHOLE_AS_TWO_DECIMAL = new Set(['isk', 'ugx'])\n\n/** How many decimals Stripe's amounts carry: `1099` is 10.99 USD, `10` is 10 JPY. */\nexport function minorUnitDigits(currency: string): 0 | 2 | 3 {\n const code = currency.toLowerCase()\n if (ZERO_DECIMAL.has(code)) return 0\n if (THREE_DECIMAL.has(code)) return 3\n return 2\n}\n\n/** How many decimals a price in this currency can have: 0 for yen, 2 for euros. */\nexport function priceDecimals(currency: string): 0 | 2 {\n const code = currency.toLowerCase()\n return ZERO_DECIMAL.has(code) || WHOLE_AS_TWO_DECIMAL.has(code) ? 0 : 2\n}\n\n/** Converts a price entered in the Studio (25, 29.5) into the amount Stripe expects. */\nexport function toMinorUnits(amount: number, currency: string): number {\n const code = currency.toLowerCase()\n if (WHOLE_AS_TWO_DECIMAL.has(code)) return Math.round(amount) * 100\n const digits = minorUnitDigits(code)\n const minor = Math.round(amount * 10 ** digits)\n // Stripe refuses three-decimal amounts that don't end in 0.\n return digits === 3 ? Math.round(minor / 10) * 10 : minor\n}\n\n/** The other way: Stripe's amount back into currency units. */\nexport function fromMinorUnits(minor: number, currency: string): number {\n return minor / 10 ** minorUnitDigits(currency)\n}\n\n/** Formats one of Stripe's amounts, such as `2500` and `eur`, for people: \"€25.00\". */\nexport function formatMoney(minor: number, currency: string, locale = 'en'): string {\n return new Intl.NumberFormat(locale, {\n style: 'currency',\n currency: currency.toUpperCase(),\n }).format(fromMinorUnits(minor, currency))\n}\n\n/** Formats a price as entered in the Studio. */\nexport function formatPrice(amount: number, currency: string, locale = 'en'): string {\n return new Intl.NumberFormat(locale, {\n style: 'currency',\n currency: currency.toUpperCase(),\n }).format(amount)\n}\n","/** For reading what arrives as `unknown`: a request body, stored JSON, a reply from Sanity. */\nexport const isObject = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\nexport const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])\n\nexport const asString = (value: unknown): string | null =>\n typeof value === 'string' ? value : null\n\nexport const asNumber = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? value : null\n","/**\n * Every country Stripe Checkout accepts in `shipping_address_collection.allowed_countries`\n * (stripe-node 23, API version 2026-10-28.endive). A region set to \"everywhere else\" ships to\n * these, minus the countries the other regions list.\n */\nexport const STRIPE_SHIPPING_COUNTRIES = [\n 'AC',\n 'AD',\n 'AE',\n 'AF',\n 'AG',\n 'AI',\n 'AL',\n 'AM',\n 'AO',\n 'AQ',\n 'AR',\n 'AT',\n 'AU',\n 'AW',\n 'AX',\n 'AZ',\n 'BA',\n 'BB',\n 'BD',\n 'BE',\n 'BF',\n 'BG',\n 'BH',\n 'BI',\n 'BJ',\n 'BL',\n 'BM',\n 'BN',\n 'BO',\n 'BQ',\n 'BR',\n 'BS',\n 'BT',\n 'BV',\n 'BW',\n 'BY',\n 'BZ',\n 'CA',\n 'CD',\n 'CF',\n 'CG',\n 'CH',\n 'CI',\n 'CK',\n 'CL',\n 'CM',\n 'CN',\n 'CO',\n 'CR',\n 'CV',\n 'CW',\n 'CY',\n 'CZ',\n 'DE',\n 'DJ',\n 'DK',\n 'DM',\n 'DO',\n 'DZ',\n 'EC',\n 'EE',\n 'EG',\n 'EH',\n 'ER',\n 'ES',\n 'ET',\n 'FI',\n 'FJ',\n 'FK',\n 'FO',\n 'FR',\n 'GA',\n 'GB',\n 'GD',\n 'GE',\n 'GF',\n 'GG',\n 'GH',\n 'GI',\n 'GL',\n 'GM',\n 'GN',\n 'GP',\n 'GQ',\n 'GR',\n 'GS',\n 'GT',\n 'GU',\n 'GW',\n 'GY',\n 'HK',\n 'HN',\n 'HR',\n 'HT',\n 'HU',\n 'ID',\n 'IE',\n 'IL',\n 'IM',\n 'IN',\n 'IO',\n 'IQ',\n 'IS',\n 'IT',\n 'JE',\n 'JM',\n 'JO',\n 'JP',\n 'KE',\n 'KG',\n 'KH',\n 'KI',\n 'KM',\n 'KN',\n 'KR',\n 'KW',\n 'KY',\n 'KZ',\n 'LA',\n 'LB',\n 'LC',\n 'LI',\n 'LK',\n 'LR',\n 'LS',\n 'LT',\n 'LU',\n 'LV',\n 'LY',\n 'MA',\n 'MC',\n 'MD',\n 'ME',\n 'MF',\n 'MG',\n 'MK',\n 'ML',\n 'MM',\n 'MN',\n 'MO',\n 'MQ',\n 'MR',\n 'MS',\n 'MT',\n 'MU',\n 'MV',\n 'MW',\n 'MX',\n 'MY',\n 'MZ',\n 'NA',\n 'NC',\n 'NE',\n 'NG',\n 'NI',\n 'NL',\n 'NO',\n 'NP',\n 'NR',\n 'NU',\n 'NZ',\n 'OM',\n 'PA',\n 'PE',\n 'PF',\n 'PG',\n 'PH',\n 'PK',\n 'PL',\n 'PM',\n 'PN',\n 'PR',\n 'PS',\n 'PT',\n 'PY',\n 'QA',\n 'RE',\n 'RO',\n 'RS',\n 'RU',\n 'RW',\n 'SA',\n 'SB',\n 'SC',\n 'SD',\n 'SE',\n 'SG',\n 'SH',\n 'SI',\n 'SJ',\n 'SK',\n 'SL',\n 'SM',\n 'SN',\n 'SO',\n 'SR',\n 'SS',\n 'ST',\n 'SV',\n 'SX',\n 'SZ',\n 'TA',\n 'TC',\n 'TD',\n 'TF',\n 'TG',\n 'TH',\n 'TJ',\n 'TK',\n 'TL',\n 'TM',\n 'TN',\n 'TO',\n 'TR',\n 'TT',\n 'TV',\n 'TW',\n 'TZ',\n 'UA',\n 'UG',\n 'US',\n 'UY',\n 'UZ',\n 'VA',\n 'VC',\n 'VE',\n 'VG',\n 'VN',\n 'VU',\n 'WF',\n 'WS',\n 'XK',\n 'YE',\n 'YT',\n 'ZA',\n 'ZM',\n 'ZW',\n] as const\n\n/** The European Union's member states, for a shipping region: `countries: [...EU_COUNTRIES]`. */\nexport const EU_COUNTRIES = [\n 'AT',\n 'BE',\n 'BG',\n 'CY',\n 'CZ',\n 'DE',\n 'DK',\n 'EE',\n 'ES',\n 'FI',\n 'FR',\n 'GR',\n 'HR',\n 'HU',\n 'IE',\n 'IT',\n 'LT',\n 'LU',\n 'LV',\n 'MT',\n 'NL',\n 'PL',\n 'PT',\n 'RO',\n 'SE',\n 'SI',\n 'SK',\n] as const\n"],"mappings":";;;;AAKA,MAAM,+BAAe,IAAI,IAAI;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,GAEK,gCAAgB,IAAI,IAAI;CAAC;CAAO;CAAO;CAAO;CAAO;AAAK,CAAC,GAG3D,uCAAuB,IAAI,IAAI,CAAC,OAAO,KAAK,CAAC;;AAGnD,SAAgB,gBAAgB,UAA6B;CAC3D,IAAM,OAAO,SAAS,YAAY;CAGlC,OAFI,aAAa,IAAI,IAAI,IAAU,IAC/B,cAAc,IAAI,IAAI,IAAU,IAC7B;AACT;;AAGA,SAAgB,cAAc,UAAyB;CACrD,IAAM,OAAO,SAAS,YAAY;CAClC,OAAO,aAAa,IAAI,IAAI,KAAK,qBAAqB,IAAI,IAAI,IAAI,IAAI;AACxE;;AAGA,SAAgB,aAAa,QAAgB,UAA0B;CACrE,IAAM,OAAO,SAAS,YAAY;CAClC,IAAI,qBAAqB,IAAI,IAAI,GAAG,OAAO,KAAK,MAAM,MAAM,IAAI;CAChE,IAAM,SAAS,gBAAgB,IAAI,GAC7B,QAAQ,KAAK,MAAM,SAAS,MAAM,MAAM;CAE9C,OAAO,WAAW,IAAI,KAAK,MAAM,QAAQ,EAAE,IAAI,KAAK;AACtD;;AAGA,SAAgB,eAAe,OAAe,UAA0B;CACtE,OAAO,QAAQ,MAAM,gBAAgB,QAAQ;AAC/C;;AAGA,SAAgB,YAAY,OAAe,UAAkB,SAAS,MAAc;CAClF,OAAO,IAAI,KAAK,aAAa,QAAQ;EACnC,OAAO;EACP,UAAU,SAAS,YAAY;CACjC,CAAC,CAAC,CAAC,OAAO,eAAe,OAAO,QAAQ,CAAC;AAC3C;;AAGA,SAAgB,YAAY,QAAgB,UAAkB,SAAS,MAAc;CACnF,OAAO,IAAI,KAAK,aAAa,QAAQ;EACnC,OAAO;EACP,UAAU,SAAS,YAAY;CACjC,CAAC,CAAC,CAAC,OAAO,MAAM;AAClB;;ACtEA,MAAa,YAAY,UACvB,OAAO,SAAU,cAAY,SAAkB,CAAC,MAAM,QAAQ,KAAK,GAExD,WAAW,UAA+B,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,GAE1E,YAAY,UACvB,OAAO,SAAU,WAAW,QAAQ,MAEzB,YAAY,UACvB,OAAO,SAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ,MCLnD,4BAA4B,kuBA8OzC,GAGa,eAAe,4GA4B5B"}
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * `sanity-plugin-stripe`: the Studio's side. The site imports `sanity-plugin-stripe/react` and
3
3
  * `sanity-plugin-stripe/server` instead.
4
4
  */
5
- import { t as Availability } from "./cart-D2KxuwQz.js";
5
+ import { a as SubscriptionDelivery, h as Interval, l as Availability } from "./regions-Bfhg4QDs.js";
6
6
  import { t as EU_COUNTRIES } from "./countries-BSdN9FCR.js";
7
7
  import "sanity";
8
8
  import { ListItemBuilder, StructureBuilder } from "sanity/structure";
@@ -22,6 +22,8 @@ interface ShippingRegionInput {
22
22
  everywhereElse?: boolean;
23
23
  /** One flat rate per order, in currency units. */
24
24
  rate: number;
25
+ /** Goods adding up to this amount or more are delivered free. In currency units. */
26
+ freeFrom?: number;
25
27
  }
26
28
  interface StripeCommerceConfig {
27
29
  /** ISO 4217 code of the currency every price is in, such as `eur` or `usd`. */
@@ -41,8 +43,9 @@ interface StripeCommerceConfig {
41
43
  }
42
44
  /**
43
45
  * Selling with Stripe Checkout from the Studio: the `stripeCommerce` field (price, tax class,
44
- * on sale or sold out, variants, and stock counts kept out of the public dataset) and the shop
45
- * settings document (shipping regions, selling out at 0).
46
+ * on sale or sold out, an optional subscription, variants, and stock counts kept out of the
47
+ * public dataset) and the shop settings document (shipping regions, selling out at 0, delivery
48
+ * for subscriptions).
46
49
  *
47
50
  * ```ts
48
51
  * plugins: [structureTool({structure}), stripeCommerce({currency: 'eur'})]
@@ -52,6 +55,15 @@ interface StripeCommerceConfig {
52
55
  * put the settings in the structure with `shopSettingsListItem(S)`.
53
56
  */
54
57
  export declare const stripeCommerce: import("sanity").Plugin<StripeCommerceConfig>;
58
+ /** The subscription a product offers, as the Studio saves it. */
59
+ interface StripeSubscriptionValue {
60
+ enabled?: boolean;
61
+ /** Per delivery, in currency units. */
62
+ price?: number;
63
+ /** With `unit`: 6 and `week` is every 6 weeks. */
64
+ every?: number;
65
+ unit?: Interval['unit'];
66
+ }
55
67
  /** A variant as the Studio saves it. */
56
68
  interface StripeVariantValue {
57
69
  _key: string;
@@ -67,6 +79,7 @@ interface StripeCommerceValue {
67
79
  /** In currency units (25 means €25). */
68
80
  price?: number;
69
81
  taxClass?: string;
82
+ subscription?: StripeSubscriptionValue;
70
83
  variants?: StripeVariantValue[];
71
84
  }
72
85
  /** Schema type names. They are saved in content, so they never change. */
@@ -88,6 +101,8 @@ interface ShippingRegionValue {
88
101
  countries?: string[];
89
102
  /** Flat rate per order, in currency units. */
90
103
  rate: number;
104
+ /** Goods adding up to this amount or more are delivered free. In currency units. */
105
+ freeFrom?: number;
91
106
  }
92
107
  /**
93
108
  * The shop settings document as the Studio saves it. Anyone can read it from a public dataset,
@@ -96,6 +111,7 @@ interface ShippingRegionValue {
96
111
  interface ShopSettingsValue {
97
112
  shippingRegions?: ShippingRegionValue[];
98
113
  autoSoldOut?: boolean;
114
+ subscriptionDelivery?: SubscriptionDelivery;
99
115
  }
100
116
  /**
101
117
  * The shop settings as one item in the Studio's structure, opening the one document:
@@ -109,4 +125,4 @@ export declare function shopSettingsListItem(S: StructureBuilder, options?: {
109
125
  }): ListItemBuilder;
110
126
  /** True for the shop settings type's own list item, to leave it out of `S.documentTypeListItems()`. */
111
127
  export declare function isShopSettingsListItem(item: ListItemBuilder): boolean;
112
- export { type Availability, EU_COUNTRIES, type ShippingRegionInput, type ShippingRegionValue, type ShopSettingsValue, type StripeCommerceConfig, type StripeCommerceValue, type StripeVariantValue, type TaxClass };
128
+ export { type Availability, EU_COUNTRIES, type Interval, type ShippingRegionInput, type ShippingRegionValue, type ShopSettingsValue, type StripeCommerceConfig, type StripeCommerceValue, type StripeSubscriptionValue, type StripeVariantValue, type SubscriptionDelivery, type TaxClass };