@homie-rocks/studio 0.24.0 → 0.24.3

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/worker/shop.mjs CHANGED
@@ -22,17 +22,23 @@
22
22
  * player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
23
23
  *
24
24
  * SECRETS (Worker secrets, put there by `homie-studio shop connect` from a page on the owner's own computer):
25
- * STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read)
26
- * and STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
25
+ * STRIPE_KEY (a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
26
+ * from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
27
+ * STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
28
+ *
29
+ * THE CATALOG (0.24.3): once `homie-studio shop catalog` has made the items' Products in Stripe (shop.json `catalog`
30
+ * names the modes), a checkout names its item's Product, so the Dashboard, Stripe's reports and Stripe's MCP see sales
31
+ * by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
32
+ * in this mode falls back to the item described inline, so a sale never fails over the catalog.
27
33
  */
28
34
  import { players } from './players.mjs';
29
35
  import { serversOf } from './servers.mjs';
30
- import { CAP_CEILING, audienceOf, bandOf, checkShop, money, wayFor } from './shop-rules.mjs';
36
+ import { CAP_CEILING, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
31
37
  import {
32
38
  bandOfPlayer, forgetPlayerShop, grantStatements, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, ownsOf, restoreStatement, revokeStatement,
33
39
  setBand, shopDataOf, spentThisMonth,
34
40
  } from './shop-store.mjs';
35
- import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, modeOf, verifyWebhook } from './stripe.mjs';
41
+ import { KEY_SHAPE, StripeError, WEBHOOK_SECRET_SHAPE, createCheckoutSession, createRefund, dashboardLink, isApprovalRequired, isMissingProduct, modeOf, productIdOf, verifyWebhook } from './stripe.mjs';
36
42
  import { arrivalOf, booksOf, lineFor, receiveStatement, referrerOk, sendStatements, settle, statementFor, statementsIn, voidLineStatements } from './referrals.mjs';
37
43
  import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
38
44
  import { notFoundPage } from './site.mjs';
@@ -270,11 +276,10 @@ async function ageRoute(request, env) {
270
276
  return json({ ok: true, asked: true });
271
277
  }
272
278
 
273
- function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio }) {
279
+ export function sessionParams(shop, item, order, { origin, amount, player, game, parent = false, studio, product = null }) {
274
280
  const managed = shop.till === 'stripe-managed';
275
- const limited = Boolean(item.days || item.ends);
276
281
  // Managed Payments needs an eligible digital-goods tax code on every product (video games, downloaded).
277
- const taxCode = item.taxCode ?? (managed ? (limited ? 'txcd_10201001' : 'txcd_10201000') : undefined);
282
+ const taxCode = item.taxCode ?? (managed ? defaultTaxCode(item) : undefined);
278
283
  const back = game ? `&game=${encodeURIComponent(game)}` : '';
279
284
  return {
280
285
  mode: 'payment',
@@ -282,7 +287,8 @@ function sessionParams(shop, item, order, { origin, amount, player, game, parent
282
287
  quantity: 1,
283
288
  price_data: {
284
289
  currency: shop.currency, unit_amount: amount, tax_behavior: 'exclusive',
285
- product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } },
290
+ // The catalog's Product (its name and tax code are Stripe's copy of shop.json's), or the item described inline.
291
+ ...(product ? { product } : { product_data: { name: item.name.slice(0, 120), ...(item.blurb ? { description: item.blurb.slice(0, 300) } : {}), ...(taxCode ? { tax_code: taxCode } : {}), metadata: { item: item.id } } }),
286
292
  },
287
293
  }],
288
294
  client_reference_id: player,
@@ -306,8 +312,18 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
306
312
  await env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11)")
307
313
  .bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, modeOf(env.STRIPE_KEY), parent ? 1 : 0, order.via, now).run();
308
314
  let session = null;
315
+ const mode = modeOf(env.STRIPE_KEY);
316
+ const product = Array.isArray(shop.catalog) && shop.catalog.includes(mode) ? productIdOf(cat.studio?.slug, item.id) : null;
317
+ const params = (p) => sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name, product: p });
309
318
  try {
310
- session = await createCheckoutSession(env, sessionParams(shop, item, order, { origin: url.origin, amount, player: player.id, game, parent, studio: cat.studio?.name }), { idempotencyKey: `checkout-${order.id}` });
319
+ try {
320
+ session = await createCheckoutSession(env, params(product), { idempotencyKey: `checkout-${order.id}` });
321
+ } catch (error) {
322
+ // shop.json says the catalog is made in this mode, but Stripe has no such Product (made in the sandbox only, or
323
+ // archived by hand): the item described inline still sells, and `homie-studio shop catalog` says what to make.
324
+ if (!product || !isMissingProduct(error)) throw error;
325
+ session = await createCheckoutSession(env, params(null), { idempotencyKey: `checkout-${order.id}-inline` });
326
+ }
311
327
  } catch (error) {
312
328
  await env.DB.prepare("UPDATE shop_orders SET status = 'failed', note = ?2, updated_at = ?3 WHERE id = ?1").bind(order.id, String(error?.code ?? 'stripe').slice(0, 60), Date.now()).run();
313
329
  const why = error instanceof StripeError ? error.message : 'Stripe did not answer';
@@ -441,6 +457,7 @@ async function selfRefundRoute(request, env, shop, ready) {
441
457
  if (!refundableNow(orderView(o), Number(used?.n) ? [{ item: o.item, used: true }] : [], shop)) return fail(403, 'not-refundable', `Only an unused item, within ${shop.refundDays} days. Ask the studio for anything else.`);
442
458
  if (!KEY_SHAPE.test(String(env.STRIPE_KEY ?? ''))) return fail(503, 'closed', MISSING_WORDS['stripe-key']);
443
459
  const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player' });
460
+ if (r.held) return json({ ok: false, held: true, error: 'held', message: 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
444
461
  return json(r, r.ok ? 200 : 502);
445
462
  }
446
463
 
@@ -553,6 +570,9 @@ async function disputeClosed(env, payment, status) {
553
570
  return 'kept';
554
571
  }
555
572
 
573
+ /** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
574
+ export const REFUND_HELD = 'Stripe is holding this refund until a person approves it in Stripe (Settings, Approvals, Requests): the shop\'s Stripe key is marked as an Agent key, and Stripe asks a person before an agent\'s refund. Approve it there; Stripe then refunds, and the item leaves the player\'s account when Stripe says so. To refund with one tap again, connect the shop with a plain restricted key (homie-studio shop connect), not one made for "Authorizing agent access".';
575
+
556
576
  /** Refund one order in full with Stripe, then take back that one item. The webhook confirms the same (idempotent). */
557
577
  export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner' } = {}) {
558
578
  if (o.status !== 'paid') return { ok: false, error: 'state', message: o.status === 'disputed' ? 'This order is disputed: the card network decides it now (answer it in Stripe).' : `This order is ${o.status}, not paid.` };
@@ -561,6 +581,9 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
561
581
  try {
562
582
  refund = await createRefund(env, { payment_intent: o.payment, reason: ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason) ? reason : 'requested_by_customer', metadata: { order: o.id, by } }, { idempotencyKey: `refund-${o.id}` });
563
583
  } catch (error) {
584
+ // An Agent-tagged key: Stripe holds an agent's refund for a person's approval. Nothing happened yet; once someone
585
+ // approves it in Stripe, Stripe refunds and the webhook (refund.created, charge.refunded) takes the item back.
586
+ if (isApprovalRequired(error)) return { ok: false, error: 'approval', held: true, message: REFUND_HELD };
564
587
  return { ok: false, error: 'stripe', message: `Stripe did not refund it (${error instanceof StripeError ? error.message : 'no answer'}). Nothing changed.` };
565
588
  }
566
589
  const now = Date.now();
package/worker/stripe.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  *
5
5
  * createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page; the TV never calls it)
6
6
  * createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
7
+ * webhook endpoints list, create and turn off: only `homie-studio shop connect` calls these, on the
8
+ * owner's computer with the key the owner pasted there (0.24.3), so a new
9
+ * endpoint's signing secret goes straight to the Worker secret
10
+ * productIdOf(slug, item) the catalog Product a shop item is (`homie-studio shop catalog`, 0.24.3)
7
11
  * verifyWebhook(payload, header, secret) the `Stripe-Signature` header: t=<seconds>,v1=<hex HMAC-SHA256 of
8
12
  * "<t>.<payload>"> with the endpoint's whsec_ secret, within 5 minutes
9
13
  *
@@ -54,9 +58,30 @@ export class StripeError extends Error {
54
58
  this.status = status;
55
59
  this.code = e.code ?? e.type ?? 'stripe';
56
60
  this.type = e.type ?? null;
61
+ this.param = typeof e.param === 'string' ? e.param.slice(0, 120) : null;
57
62
  }
58
63
  }
59
64
 
65
+ /** Stripe has no such Product (a catalog Product the shop names, in a mode the catalog was never made in). */
66
+ export const isMissingProduct = (error) => error instanceof StripeError && error.code === 'resource_missing' && /product/.test(String(error.param ?? error.message));
67
+ /**
68
+ * Stripe is holding the call for a person's approval: an Agent-tagged key's refund meets Stripe's approval rules
69
+ * (docs.stripe.com/account/approvals). Nothing happened yet; it happens when someone approves it in Stripe.
70
+ */
71
+ export const isApprovalRequired = (error) => error instanceof StripeError && error.code === 'approval_required';
72
+ /** The key may not do this (a restricted key without the permission). */
73
+ export const isPermissionError = (error) => error instanceof StripeError && (error.status === 403 || error.status === 401 || /permission|restricted key/i.test(error.message));
74
+
75
+ /**
76
+ * The catalog Product a shop item is, the same id in a sandbox and in live mode: `homie_<studio slug>_<item id>`
77
+ * (Stripe takes a product id of our choosing). Null without a slug or an item id.
78
+ */
79
+ export function productIdOf(slug, item) {
80
+ const s = String(slug ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
81
+ const i = String(item ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
82
+ return s && i ? `homie_${s}_${i}` : null;
83
+ }
84
+
60
85
  /** One call with the studio's key. `idempotencyKey` makes a retried POST do the work once (Stripe keeps it 24 h). */
61
86
  export async function stripeCall(env, method, path, params = null, { idempotencyKey = null, fetcher = fetch } = {}) {
62
87
  const key = String(env?.STRIPE_KEY ?? '');
@@ -83,6 +108,11 @@ export async function stripeCall(env, method, path, params = null, { idempotency
83
108
  export const createCheckoutSession = (env, params, opts) => stripeCall(env, 'POST', '/v1/checkout/sessions', params, opts);
84
109
  export const retrieveCheckoutSession = (env, id, opts) => stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(id)}`, null, opts);
85
110
  export const createRefund = (env, params, opts) => stripeCall(env, 'POST', '/v1/refunds', params, opts);
111
+ export const expireCheckoutSession = (env, id, opts) => stripeCall(env, 'POST', `/v1/checkout/sessions/${encodeURIComponent(id)}/expire`, {}, opts);
112
+ export const listWebhookEndpoints = (env, opts) => stripeCall(env, 'GET', '/v1/webhook_endpoints', { limit: 100 }, opts);
113
+ /** The answer carries the endpoint's signing secret (`secret`), once: the caller puts it in the Worker and drops it. */
114
+ export const createWebhookEndpoint = (env, params, opts) => stripeCall(env, 'POST', '/v1/webhook_endpoints', params, opts);
115
+ export const updateWebhookEndpoint = (env, id, params, opts) => stripeCall(env, 'POST', `/v1/webhook_endpoints/${encodeURIComponent(id)}`, params, opts);
86
116
 
87
117
  /* ------------------------------------------------------------------ webhooks */
88
118
 
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.24.0';
2
+ export const STUDIO_VERSION_TAG = '0.24.3';