@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/CHANGELOG.md +53 -1
- package/README.md +34 -3
- package/bin/homie-studio.mjs +44 -6
- package/lib/cloudflare.mjs +11 -3
- package/lib/doctor.mjs +50 -5
- package/lib/projects-env.mjs +68 -0
- package/lib/projects.mjs +311 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/shop-catalog.mjs +163 -0
- package/lib/shop.mjs +151 -60
- package/lib/template-history.json +78 -0
- package/package.json +1 -1
- package/shop/SELLING.md +16 -0
- package/shop/SHOP.md +65 -16
- package/worker/office.mjs +2 -1
- package/worker/shop-rules.mjs +17 -1
- package/worker/shop.mjs +32 -9
- package/worker/stripe.mjs +30 -0
- package/worker/version.mjs +1 -1
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
|
-
*
|
|
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 ? (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.24.3';
|