@homie-rocks/studio 0.24.2 → 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 +29 -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 +39 -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/lib/shop.mjs
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
|
|
8
8
|
* homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
|
|
9
9
|
* homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
|
|
10
|
-
* pastes the studio's restricted Stripe key
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* pastes the studio's restricted Stripe key (the one thing only they can make);
|
|
11
|
+
* with it this process makes the webhook (0.24.3), and the key and the
|
|
12
|
+
* webhook's signing secret go straight to the Worker secrets STRIPE_KEY and
|
|
13
|
+
* STRIPE_WEBHOOK_SECRET on Wrangler's standard input: never a chat, a file,
|
|
14
|
+
* an argument or a log. The page also offers Stripe Managed Payments in plain
|
|
15
|
+
* words (and in test mode tries one test checkout with it). Test keys only,
|
|
16
|
+
* unless --live (then live keys only).
|
|
17
|
+
* homie-studio shop catalog [--have <file>|-] [--mode test|live] the items as Products in Stripe, made through
|
|
18
|
+
* Stripe's own MCP (lib/shop-catalog.mjs): the read to make, then the writes
|
|
15
19
|
* homie-studio shop disconnect removes both secrets (the shop closes at once)
|
|
16
20
|
* homie-studio shop orders the latest orders (never a card or an email)
|
|
17
21
|
* homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
|
|
@@ -28,8 +32,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
32
|
import { askedFor, withKey } from './office.mjs';
|
|
29
33
|
import { runner } from './cloudflare.mjs';
|
|
30
34
|
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
31
|
-
import { SHOP_FILE, audienceOf, checkShop, money } from '../worker/shop-rules.mjs';
|
|
32
|
-
import {
|
|
35
|
+
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
36
|
+
import {
|
|
37
|
+
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
|
38
|
+
stripeCall, updateWebhookEndpoint,
|
|
39
|
+
} from '../worker/stripe.mjs';
|
|
33
40
|
|
|
34
41
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
42
|
const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
|
|
@@ -44,6 +51,7 @@ export const KEY_PERMISSIONS = Object.freeze([
|
|
|
44
51
|
['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
|
|
45
52
|
['PaymentIntents', 'Read', 'which payment an order was'],
|
|
46
53
|
['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
|
|
54
|
+
['Webhook Endpoints', 'Write', 'only so this page makes the webhook for you; you can set it back to None afterwards'],
|
|
47
55
|
]);
|
|
48
56
|
|
|
49
57
|
/** shop.json read and checked with the studio's games and audience: { ok, shop, errors, warnings, file }. */
|
|
@@ -126,46 +134,105 @@ export async function shopStatements(root, { url, period, send = false } = {}) {
|
|
|
126
134
|
|
|
127
135
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
128
136
|
|
|
129
|
-
/**
|
|
137
|
+
/** Where Stripe sends the shop's events, on the studio's live site. */
|
|
138
|
+
export const hookUrlOf = (site) => `${String(site).replace(/\/+$/, '')}/api/shop/hook`;
|
|
139
|
+
const HOOK_MARK = 'shop-v1';
|
|
140
|
+
|
|
141
|
+
/** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, one field, one button. */
|
|
130
142
|
export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
|
|
131
143
|
const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
|
|
132
144
|
const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
|
|
133
145
|
const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
|
|
134
146
|
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Connect your shop to Stripe</title>
|
|
135
|
-
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}</style>
|
|
147
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}details{margin-top:1rem}summary{cursor:pointer}</style>
|
|
136
148
|
<h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
|
|
137
|
-
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to your studio's Worker as
|
|
138
|
-
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe
|
|
139
|
-
<h2>1. In Stripe:
|
|
140
|
-
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b
|
|
149
|
+
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to Stripe (one check) and to your studio's Worker as a secret, and nowhere else: not to the chat, not to a file, not to a log.</p>
|
|
150
|
+
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
|
|
151
|
+
<h2>1. In Stripe: one restricted key</h2>
|
|
152
|
+
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>. If Stripe asks what the key is for, choose your own integration, <b>not</b> "Authorizing agent access" (Stripe holds an agent key's refunds for a second approval).</li>
|
|
141
153
|
<li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
|
|
142
154
|
<li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
|
|
143
|
-
<h2>2.
|
|
144
|
-
<
|
|
145
|
-
<li>Endpoint URL: <code>${esc(site)}/api/shop/hook</code></li>
|
|
146
|
-
<li>Events: ${events}</li>
|
|
147
|
-
<li>Create it, press <b>Reveal</b> on the signing secret, and copy it (it starts with <code>whsec_</code>).</li></ol>
|
|
155
|
+
<h2>2. The webhook: this page makes it</h2>
|
|
156
|
+
<p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${HOOK_EVENTS.length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
|
|
148
157
|
<form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
|
|
149
|
-
<h2>3. Paste
|
|
158
|
+
<h2>3. Paste the key here</h2>
|
|
150
159
|
<label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
|
|
151
|
-
<
|
|
160
|
+
<details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
|
|
161
|
+
<ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
|
|
162
|
+
<li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
|
|
163
|
+
<li>Create it, press <b>Reveal</b> on the signing secret, and paste it here (it starts with <code>whsec_</code>).</li></ol>
|
|
164
|
+
<label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
|
|
152
165
|
<h2>4. Who is the seller?</h2>
|
|
153
166
|
<fieldset>
|
|
154
167
|
<label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Stripe Tax is on: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
|
|
155
|
-
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves)
|
|
168
|
+
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).${test ? ' In test mode this page tries one test checkout with Managed Payments (expired at once) and says whether Stripe takes it.' : ''}</span></label>
|
|
156
169
|
</fieldset>
|
|
157
170
|
<button>Save to my Worker</button></form>
|
|
158
171
|
<p><small>This page works once and closes in ten minutes. You can change the seller later in shop.json ("till"). This is not legal or tax advice: an accountant can tell you where you must register.</small></p>`;
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
const said = (status, text) => ({ status, text });
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The webhook, made on the owner's computer with the key they just pasted: Stripe answers the new endpoint's signing
|
|
178
|
+
* secret once, and it goes straight to the Worker secret. Never printed, never returned. Older endpoints this kit made
|
|
179
|
+
* for the same address are turned off (not deleted) once the new secret is saved. { ok, id, secret, older } or
|
|
180
|
+
* { ok: false, page } (what the page tells the owner).
|
|
181
|
+
*/
|
|
182
|
+
async function makeWebhook(env, site, { fetcher }) {
|
|
183
|
+
const url = hookUrlOf(site);
|
|
184
|
+
let older = [];
|
|
185
|
+
try {
|
|
186
|
+
const list = await listWebhookEndpoints(env, { fetcher });
|
|
187
|
+
older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
|
|
190
|
+
return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
|
|
191
|
+
}
|
|
192
|
+
let made = null;
|
|
193
|
+
try {
|
|
194
|
+
made = await createWebhookEndpoint(env, {
|
|
195
|
+
url, enabled_events: [...HOOK_EVENTS], api_version: STRIPE_VERSION,
|
|
196
|
+
description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
|
|
197
|
+
}, { fetcher });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
|
|
200
|
+
return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
|
|
201
|
+
}
|
|
202
|
+
const secret = typeof made?.secret === 'string' ? made.secret : '';
|
|
203
|
+
if (!WEBHOOK_SECRET_SHAPE.test(secret) || typeof made?.id !== 'string') return { ok: false, page: said(502, 'Stripe made a webhook but did not hand back its signing secret. Nothing was saved. Make the webhook yourself and paste its secret under "I made the webhook myself".') };
|
|
204
|
+
return { ok: true, id: made.id, secret, older };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Managed Payments, tried once in TEST mode before the first buyer: a test Checkout Session with
|
|
209
|
+
* `managed_payments[enabled]`, expired at once. Stripe's answer says whether Managed Payments is on for this account
|
|
210
|
+
* (there is no API to read it). Never in live mode.
|
|
211
|
+
*/
|
|
212
|
+
async function probeManaged(env, shop, { fetcher }) {
|
|
213
|
+
const item = shop?.items?.find((i) => i.kind !== 'tip') ?? null;
|
|
214
|
+
try {
|
|
215
|
+
const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
|
|
216
|
+
mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
|
|
217
|
+
line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
|
|
218
|
+
managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
|
|
219
|
+
}, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
|
|
220
|
+
if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
|
|
221
|
+
return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
161
227
|
/**
|
|
162
|
-
* `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key
|
|
163
|
-
* to `wrangler secret put` on its standard input
|
|
164
|
-
*
|
|
228
|
+
* `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
|
|
229
|
+
* read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
|
|
230
|
+
* which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
|
|
165
231
|
*/
|
|
166
232
|
export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
|
|
167
233
|
const studio = readStudio(root);
|
|
168
|
-
const site = siteUrl(root)
|
|
234
|
+
const site = siteUrl(root);
|
|
235
|
+
if (!site) return { ok: false, command: 'shop connect', needs: 'deploy', why: 'the studio has no live address yet, and Stripe needs one to send payments to (the webhook): run npm run deploy first, then this' };
|
|
169
236
|
const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
|
|
170
237
|
const w = runner(root, env);
|
|
171
238
|
const shopNow = readShop(root);
|
|
@@ -174,6 +241,7 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
174
241
|
const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
|
|
175
242
|
return await new Promise((done) => {
|
|
176
243
|
let finished = false;
|
|
244
|
+
let busy = false;
|
|
177
245
|
const server = createServer((req, res) => {
|
|
178
246
|
if (req.method === 'GET' && req.url === `/${nonce}`) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(page); return; }
|
|
179
247
|
if (req.method === 'POST' && req.url === '/key') {
|
|
@@ -182,43 +250,66 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
182
250
|
req.on('end', async () => {
|
|
183
251
|
const form = new URLSearchParams(body);
|
|
184
252
|
const key = String(form.get('key') ?? '').trim();
|
|
185
|
-
|
|
253
|
+
let hook = String(form.get('hook') ?? '').trim();
|
|
186
254
|
const chosen = form.get('till') === 'stripe-managed' ? 'stripe-managed' : 'stripe';
|
|
187
|
-
const say = (status, text) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
188
|
-
if (form.get('n') !== nonce || finished) { say(403, 'This page was used already.'); return; }
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
255
|
+
const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
256
|
+
if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
|
|
257
|
+
if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
|
|
258
|
+
if (/^sk_live_/.test(key)) { say(said(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the permissions on the page. Go back and make one.')); return; }
|
|
259
|
+
if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(said(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.')); return; }
|
|
191
260
|
// Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
|
|
192
|
-
if (modeOf(key) !== (live ? 'live' : 'test')) { say(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).'); return; }
|
|
193
|
-
if (!WEBHOOK_SECRET_SHAPE.test(hook)) { say(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again.'); return; }
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
261
|
+
if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
|
|
262
|
+
if (hook && !WEBHOOK_SECRET_SHAPE.test(hook)) { say(said(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again, or leave it empty and this page makes the webhook.')); return; }
|
|
263
|
+
busy = true;
|
|
264
|
+
try {
|
|
265
|
+
const sk = { STRIPE_KEY: key, STRIPE_API_BASE: process.env.STRIPE_API_BASE };
|
|
266
|
+
// One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
|
|
267
|
+
// except to Stripe itself.
|
|
268
|
+
if (verify) {
|
|
269
|
+
try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
|
|
270
|
+
say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
let webhook = { made: false };
|
|
274
|
+
if (!hook) {
|
|
275
|
+
const m = await makeWebhook(sk, site, { fetcher });
|
|
276
|
+
if (!m.ok) { say(m.page); return; }
|
|
277
|
+
hook = m.secret;
|
|
278
|
+
webhook = { made: true, id: m.id, older: m.older };
|
|
279
|
+
}
|
|
280
|
+
finished = true;
|
|
281
|
+
const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
|
|
282
|
+
const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
|
|
283
|
+
hook = '';
|
|
284
|
+
const okay = a.code === 0 && b.code === 0;
|
|
285
|
+
// The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
|
|
286
|
+
// not deleted: the owner can turn one back on in Stripe).
|
|
287
|
+
let disabled = 0;
|
|
288
|
+
if (okay && webhook.made) {
|
|
289
|
+
for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
|
|
290
|
+
}
|
|
291
|
+
const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
|
|
292
|
+
let tillNote = '';
|
|
293
|
+
if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
|
|
294
|
+
try {
|
|
295
|
+
const raw = JSON.parse(readFileSync(shopNow.file, 'utf8'));
|
|
296
|
+
raw.till = chosen;
|
|
297
|
+
writeFileSync(shopNow.file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
298
|
+
tillNote = ` shop.json now says "till": "${chosen}" (deploy to use it).`;
|
|
299
|
+
} catch { tillNote = ''; }
|
|
300
|
+
}
|
|
301
|
+
const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
|
|
302
|
+
say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
|
|
303
|
+
server.close();
|
|
304
|
+
done(okay
|
|
305
|
+
? {
|
|
306
|
+
ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
|
|
307
|
+
webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
|
|
308
|
+
...(check ? { managedPayments: check } : {}),
|
|
309
|
+
message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
|
|
310
|
+
}
|
|
311
|
+
: { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
|
|
312
|
+
} finally { busy = false; }
|
|
222
313
|
});
|
|
223
314
|
return;
|
|
224
315
|
}
|
|
@@ -1078,6 +1078,45 @@
|
|
|
1078
1078
|
}
|
|
1079
1079
|
}
|
|
1080
1080
|
}
|
|
1081
|
+
},
|
|
1082
|
+
"0.24.3": {
|
|
1083
|
+
"files": {
|
|
1084
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1085
|
+
"README.md": "684b3cca8001673c",
|
|
1086
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1087
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1088
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1089
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1090
|
+
"posts/README.md": "37841aae476bd540",
|
|
1091
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1092
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1093
|
+
},
|
|
1094
|
+
"sections": {
|
|
1095
|
+
"AGENTS.md": {
|
|
1096
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1097
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1098
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1099
|
+
"## The site": "ab922bb6bde23293",
|
|
1100
|
+
"## Making games": "a8a893eeada33bf5",
|
|
1101
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1102
|
+
"## Art direction and models": "124772c374de7390",
|
|
1103
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1104
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
1105
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1106
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1107
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1108
|
+
"## Beta": "9d02638207a26e73"
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
"named": {
|
|
1112
|
+
"Homie Arcade": {
|
|
1113
|
+
"sections": {
|
|
1114
|
+
"AGENTS.md": {
|
|
1115
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1081
1120
|
}
|
|
1082
1121
|
}
|
|
1083
1122
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.3",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/shop/SELLING.md
CHANGED
|
@@ -74,6 +74,22 @@ referral share you published in `shop.json` (10% if you kept the suggestion; 0 i
|
|
|
74
74
|
shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
|
|
75
75
|
yourself.** Nothing moves through Homie.
|
|
76
76
|
|
|
77
|
+
## What you do, and what your AI does
|
|
78
|
+
|
|
79
|
+
Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
|
|
80
|
+
makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
|
|
81
|
+
your Stripe. What stays yours, in Stripe's own pages:
|
|
82
|
+
|
|
83
|
+
- making the Stripe account, and later activating it (your business details, bank account and identity checks);
|
|
84
|
+
- one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
|
|
85
|
+
- one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
|
|
86
|
+
- approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
|
|
87
|
+
in your office.
|
|
88
|
+
|
|
89
|
+
If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
|
|
90
|
+
Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
|
|
91
|
+
restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
|
|
92
|
+
|
|
77
93
|
## Before the first real sale
|
|
78
94
|
|
|
79
95
|
1. Make (or pick) your Stripe account, verify it, and add a bank account.
|
package/shop/SHOP.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# The shop: selling in a studio's games with the studio's own Stripe
|
|
2
2
|
|
|
3
|
-
`@homie-rocks/studio` 0.24.0. A studio sells items for its games
|
|
3
|
+
`@homie-rocks/studio` 0.24.0, set up with Stripe's own agent tools since 0.24.3. A studio sells items for its games
|
|
4
|
+
through **its own Stripe account**. The studio
|
|
4
5
|
is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
|
|
5
6
|
no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
|
|
6
7
|
note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
|
|
@@ -40,6 +41,7 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
|
|
|
40
41
|
| `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
|
|
41
42
|
| `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
|
|
42
43
|
| `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
|
|
44
|
+
| `catalog` | where the items are Products in Stripe: `["test"]`, `["test", "live"]`. `homie-studio shop catalog` writes it once Stripe's catalog matches; the Worker's checkouts then name each item's Product (0.24.3). |
|
|
43
45
|
|
|
44
46
|
`homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
|
|
45
47
|
crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
|
|
@@ -107,23 +109,59 @@ homie.rocks: not involved at any step.
|
|
|
107
109
|
- Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
|
|
108
110
|
acknowledgement) and that an unused item can still be refunded.
|
|
109
111
|
|
|
110
|
-
## Setting it up (
|
|
112
|
+
## Setting it up (the AI does the commands; the owner only uses Stripe's pages and one page here)
|
|
113
|
+
|
|
114
|
+
The AI works with **Stripe's own agent tools** (0.24.3): Stripe's agent plugin (`npm install -g @stripe/cli@latest &&
|
|
115
|
+
stripe agent setup`: Stripe's MCP server at `https://mcp.stripe.com` and Stripe's skills, for Claude Code and Codex) or
|
|
116
|
+
Stripe's connector in the Claude app. The owner signs it in once on Stripe's consent page and gives it **a sandbox
|
|
117
|
+
first**. An agent that cannot do OAuth uses an **Agent key** (a restricted key Stripe tags "Agent") from its
|
|
118
|
+
environment; from **2026-10-31** Stripe's MCP refuses full secret keys and restricted keys without that tag. The shop's
|
|
119
|
+
own key, in the Worker, is a plain restricted key and is not affected.
|
|
120
|
+
|
|
121
|
+
What only the owner does: make the Stripe account and (for live) activate it: business details, bank, identity, and,
|
|
122
|
+
for Managed Payments, its terms and Stripe's eligibility review; approve Stripe's sign-in page; make **one restricted
|
|
123
|
+
key** in Stripe's Dashboard (Stripe has no API that makes API keys) and paste it on the connect page; approve any
|
|
124
|
+
write Stripe sends to them for confirmation (a refund through Stripe's MCP, for one).
|
|
111
125
|
|
|
112
126
|
1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
|
|
113
127
|
`npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
|
|
114
|
-
2.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
2. **The catalog**, through Stripe's MCP: `homie-studio shop catalog` names the one read to make
|
|
129
|
+
(`stripe_api_read GET /v1/products`, expanding each default price); `homie-studio shop catalog --have <the saved
|
|
130
|
+
answer>` lists exactly the `stripe_api_write` calls still needed: each item a Product with the id
|
|
131
|
+
`homie_<studio slug>_<item id>` (the same in a sandbox and live), its name, blurb, a tax code (video games,
|
|
132
|
+
downloaded: `txcd_10201000`, or `txcd_10201001` for an item that lasts days or ends on a date; eligible for
|
|
133
|
+
Managed Payments) and metadata naming the studio and the item, with a default Price of shop.json's amount (a tip:
|
|
134
|
+
the Product only). A changed price is a new Price made the default; an item gone from shop.json is archived, never
|
|
135
|
+
deleted; a product the studio did not make is never named. When Stripe matches, it says so and writes
|
|
136
|
+
`"catalog": ["test"]` into shop.json (commit, deploy). shop.json's checked price is always what is charged; the
|
|
137
|
+
checkout names the Product so Stripe's Dashboard, reports and MCP see sales by product, and a Product missing in
|
|
138
|
+
this mode falls back to the item described inline.
|
|
139
|
+
3. **Tax**, read through Stripe's MCP: `GET /v1/tax/settings` (with the studio as the seller, Stripe Tax needs
|
|
140
|
+
`status: active`, the business address, or checkouts fail) and `GET /v1/tax/registrations` (none: Stripe Tax
|
|
141
|
+
collects nothing anywhere until the owner registers somewhere). Reading only; the owner and their accountant decide.
|
|
142
|
+
4. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes). The
|
|
143
|
+
owner makes one restricted key with **Checkout Sessions: Write, Charges: Write (refunds), PaymentIntents: Read,
|
|
144
|
+
Disputes: Read, Webhook Endpoints: Write**, not an Agent key, and pastes it. With it the page reads once (a typo is
|
|
145
|
+
caught here), **makes the webhook** at `https://<site>/api/shop/hook` for `checkout.session.completed`,
|
|
146
|
+
`checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`,
|
|
147
|
+
`charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed` (API version
|
|
148
|
+
`2025-03-31.basil`, metadata `homie: shop-v1`), and puts the key and the webhook's signing secret, which Stripe hands
|
|
149
|
+
back once, straight into the Worker secrets `STRIPE_KEY` and `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input.
|
|
150
|
+
An older endpoint the kit made for the same address is turned off, never deleted. The page offers the seller
|
|
151
|
+
choice (the studio, or Stripe Managed Payments, with what each costs and does) and, in test mode with Managed
|
|
152
|
+
Payments, tries one test checkout with it (expired at once) and says whether Stripe took it. Afterwards the owner
|
|
153
|
+
may set the key's Webhook Endpoints back to None (recommended; the shop never needs it again). A key without
|
|
154
|
+
Webhook Endpoints: the page takes a webhook signing secret the owner made and revealed in Stripe instead. Test keys
|
|
155
|
+
only, unless `--live`.
|
|
156
|
+
5. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
|
|
157
|
+
refund it from `/_studio/office/shop`. Through Stripe's MCP, `GET /v1/webhook_endpoints` shows the endpoint the
|
|
158
|
+
page made (its `we_…` id): if it is not there, the MCP is signed in to another account or sandbox than the key.
|
|
159
|
+
6. **Live**, only when the owner says so: they activate the account, give Stripe's MCP access to the live account,
|
|
160
|
+
`shop catalog --mode live` makes the live catalog the same way, and `shop connect --live` takes a live key.
|
|
161
|
+
|
|
162
|
+
Never ask for a key in a chat, never put one in a file, an argument, a log or a commit. **Never make a webhook endpoint
|
|
163
|
+
or event destination through Stripe's MCP**: its answer carries the signing secret into the conversation (in Claude
|
|
164
|
+
Code the Homie mod refuses the call). Never use a Stripe CLI login or a key found on the computer.
|
|
127
165
|
|
|
128
166
|
## The office
|
|
129
167
|
|
|
@@ -133,6 +171,15 @@ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral
|
|
|
133
171
|
refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
|
|
134
172
|
with one tap; so is marking a referrer paid.
|
|
135
173
|
|
|
174
|
+
A refund made anywhere else (in Stripe's Dashboard, or through Stripe's MCP after the owner approved Stripe's own
|
|
175
|
+
confirmation link) reaches the shop as `refund.created` / `charge.refunded`, and the item leaves the player's account
|
|
176
|
+
the same way. If the Worker's key is an Agent key, Stripe holds the Worker's refunds for a person's approval
|
|
177
|
+
(`approval_required`): the office answers 202 "held", nothing changes until someone approves it in Stripe (Settings,
|
|
178
|
+
Approvals), and the webhook finishes it. Reconnect with a plain restricted key to get the one tap back.
|
|
179
|
+
|
|
180
|
+
"How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
|
|
181
|
+
read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
|
|
182
|
+
|
|
136
183
|
## Referrals
|
|
137
184
|
|
|
138
185
|
- A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
|
|
@@ -150,5 +197,7 @@ with one tap; so is marking a referrer paid.
|
|
|
150
197
|
## Testing without money
|
|
151
198
|
|
|
152
199
|
The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
|
|
153
|
-
against Stripe's own API description) and sign webhooks with a test secret.
|
|
200
|
+
against Stripe's own API description) and sign webhooks with a test secret. stripe-mock also takes every write
|
|
201
|
+
`shop catalog` lists, a checkout that names its Product, and the webhook the connect page makes. Stripe's MCP is
|
|
202
|
+
never called by the tests: `shop catalog --have` reads an answer in the shapes Stripe's API documents. `STRIPE_API_BASE` (a loopback address
|
|
154
203
|
only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
|
package/worker/office.mjs
CHANGED
|
@@ -1213,7 +1213,8 @@ async function api(request, env, url, cat) {
|
|
|
1213
1213
|
}
|
|
1214
1214
|
}
|
|
1215
1215
|
const result = await perform(env, cat, action);
|
|
1216
|
-
|
|
1216
|
+
// A refund Stripe holds for a person's approval (an Agent-tagged key) is accepted, not refused: it happens there.
|
|
1217
|
+
return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.held ? 202 : result.error === 'no-player' ? 404 : 400);
|
|
1217
1218
|
}
|
|
1218
1219
|
|
|
1219
1220
|
/** /_studio/office, /_studio/confirm/<ask> and /_studio/api/*; null for any other path. */
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -206,16 +206,32 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// Where `homie-studio shop catalog` made the items' Products in Stripe (0.24.3): the Worker's checkouts then name them.
|
|
210
|
+
let catalog = [];
|
|
211
|
+
if (raw.catalog !== undefined && raw.catalog !== null) {
|
|
212
|
+
if (!Array.isArray(raw.catalog) || raw.catalog.some((m) => m !== 'test' && m !== 'live')) err('catalog', 'catalog lists where the items are Products in Stripe: ["test"], ["test", "live"] (homie-studio shop catalog writes it)');
|
|
213
|
+
else catalog = [...new Set(raw.catalog)].sort();
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
const kids = audience === 'kids';
|
|
210
217
|
const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
|
|
211
218
|
if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off whatever shop.json says).' });
|
|
212
219
|
const shop = {
|
|
213
220
|
v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
|
|
214
|
-
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}),
|
|
221
|
+
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
|
|
215
222
|
};
|
|
216
223
|
return { ok: errors.length === 0, shop, errors, warnings };
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The Stripe product tax code an item gets when shop.json names none: video games, downloaded, permanent rights
|
|
228
|
+
* (txcd_10201000), or limited rights for an item that lasts a number of days or ends on a date (txcd_10201001).
|
|
229
|
+
* Managed Payments needs one of its eligible codes on every product; these two are.
|
|
230
|
+
*/
|
|
231
|
+
export function defaultTaxCode(item) {
|
|
232
|
+
return item?.taxCode ?? (item?.days || item?.ends ? 'txcd_10201001' : 'txcd_10201000');
|
|
233
|
+
}
|
|
234
|
+
|
|
219
235
|
/** studio.json "audience": "general" (the default), "teens" or "kids". */
|
|
220
236
|
export function audienceOf(studio) {
|
|
221
237
|
const a = studio?.audience;
|