@homie-rocks/studio 0.33.1 → 0.34.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/CHANGELOG.md +41 -1
- package/README.md +6 -6
- package/lib/build.mjs +3 -3
- package/lib/cloudflare.mjs +3 -2
- package/lib/scaffold.mjs +37 -38
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop.mjs +13 -14
- package/lib/template-history.json +40 -0
- package/package.json +1 -1
- package/shop/SELLING.md +14 -92
- package/shop/SHOP.md +147 -274
- package/shop/shop.ts +20 -5
- package/worker/index.mjs +7 -1
- package/worker/office.mjs +1 -1
- package/worker/players.mjs +1 -1
- package/worker/referrals.mjs +9 -12
- package/worker/shop-page.mjs +91 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +552 -212
- package/worker/stripe.mjs +21 -12
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,49 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.34.0] - 2026-10-08
|
|
21
|
+
|
|
22
|
+
**Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64)
|
|
23
|
+
|
|
24
|
+
A plain shop: nothing is imposed unless you write it. A shop is open to guests by default, the protective rules 0.33.0 started with are a preset you choose, and a player can put several items in a cart and pay once.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **A cart.** A cart holds several items and quantities, free and paid lines together, and is paid in one Stripe Checkout Session. `shop.add(item, quantity, amount?)` and `shop.checkout()` build and pay for a cart; `shop.buy(item)` still buys one thing directly. Each line keeps the item as it was defined at the time of sale, grants exactly once, and can be refunded on its own in the office.
|
|
29
|
+
- A cart with only free lines grants its items without going to Stripe.
|
|
30
|
+
- `checkoutMinutes` in `shop.json` sets how long a checkout stays open, within Stripe's bounds of 30 to 1440 minutes.
|
|
31
|
+
- The shop accepts a restricted (`rk_`) or a full secret (`sk_`) Stripe key. A preview you give its own database and both Stripe secrets can sell; previews normally have no database.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **Your shop is open by default.** 0.33.0 started every shop on the protective preset. Now a shop that names no preset has no policy: guests can buy, and buy again, without an age question or a parent step, from any page. You choose your items, wording, kinds, prices, dates, caps, refund windows and referral terms. `shop init` writes no policy.
|
|
36
|
+
- **Every policy is optional.** The account, age, kids-server, advantage, repeat-purchase, used-item refund and TV rules apply only when you write them, one by one or with `"preset": "protective"` or `"preset": "adults-only"`. Stripe Tax and the withdrawal acknowledgement are optional too. The toolkit no longer scans your items' wording in any preset. Flood protection stays, with rates you can change.
|
|
37
|
+
- **A player cannot refund a tip.** A refund window you set covers items, including used ones unless you choose otherwise. You can refund any order or any line from your office.
|
|
38
|
+
- A checkout now stays open for Stripe's default of 24 hours instead of 31 minutes.
|
|
39
|
+
- Shop and owned-item pages now ask Stripe about unfinished payments in the background (up to three at a time), instead of only when a purchase was refused by your spending cap. A paid order nobody came back to is found after a minute, with or without a cap, and by your Worker's scheduled handler if you set one.
|
|
40
|
+
- With a spending cap, a refund of one line gives back that line's amount. Before, a partial refund kept the whole price counted.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **The webhook signing secret is required.** The shop is ready only with both the Stripe key and the webhook signing secret, whichever kind of key you use, and the shop's setup steps and readiness name whichever is missing. Payment is confirmed by Stripe's signed event, not only by the buyer's return to your site.
|
|
45
|
+
- Cancelling or replacing a checkout asks Stripe to expire the open session before freeing its place under your spending cap. A checkout with no Stripe session frees its place after the checkout lifetime plus one minute.
|
|
46
|
+
- Refunds follow Stripe's own list of refunds and their statuses. A refund that names a line affects only that line, a pending refund keeps the item, and a failed refund gives it back. A partial refund made in the Stripe Dashboard lowers the order's balance, cap and referral totals without guessing a line. A refund of the whole order takes back the whole order, and a refund of a paid line leaves free lines in the same order alone. A refund event that arrives before its payment was recorded records the payment first.
|
|
47
|
+
- An order in dispute cannot be refunded until Stripe resolves the dispute, and a lost dispute keeps the refunds made before it. Every recorded payment is checked against Stripe's refunds and dispute state.
|
|
48
|
+
- A payment, its items and its referral share are recorded together, including a verified payment that arrives late. A paid order that is missing an item is repaired once.
|
|
49
|
+
- Shop errors no longer show Stripe keys or secrets. Deleting an account clears the checkout's buyer too. An older order whose item name is missing falls back to the catalog; an item with no definition gives an answer that can be retried and a note in the office.
|
|
50
|
+
- Checkout from inside a game keeps the running game in its tab and explains a blocked tab or blocked cookies. When a new tab cannot open, game and TV checkout use the current tab and return to the game or TV page.
|
|
51
|
+
- Signed Stripe events that are not about your shop are ignored. Optional cron triggers survive a deploy.
|
|
52
|
+
|
|
53
|
+
### Upgrade notes
|
|
54
|
+
|
|
55
|
+
- **To keep the 0.33.0 behaviour, write one line.** A `shop.json` that names no preset was protective in 0.33.0 and is open in 0.34.0. To keep the account and age rules you had, write `"policy": { "preset": "protective" }` in `shop.json`. Settings you wrote yourself stay as written. The wording scans are gone in every preset.
|
|
56
|
+
- To keep Stripe Tax, write `"automaticTax": true`. To count referrals for new players only, write `"referralNewPlayersOnly": true`. To keep the shorter checkout, write `"checkoutMinutes": 31`.
|
|
57
|
+
- **Deploy applies one new database step, `0012_shop_lines.sql`.** It only adds: your orders become one-line orders, and nothing is removed, so the 0.33.0 Worker keeps working while you deploy and if you roll back. Until the step has run, owned items and office refunds work and checkout says which step is missing.
|
|
58
|
+
- **The webhook signing secret is required, with either kind of key.** A shop with a key and no signing secret reports not ready and does not sell. `homie-studio shop connect` makes the webhook and stores the secret, or takes one you made yourself.
|
|
59
|
+
|
|
20
60
|
## [0.33.1] - 2026-10-08
|
|
21
61
|
|
|
22
|
-
**Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65)
|
|
62
|
+
**Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65) · [release-2026-10-08-studio-0.33.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.33.1)
|
|
23
63
|
|
|
24
64
|
A fix for studios upgrading to 0.33.0, found by upgrading one of Homie's own.
|
|
25
65
|
|
package/README.md
CHANGED
|
@@ -452,9 +452,9 @@ npx homie-studio lounge mod <player id> # a mode
|
|
|
452
452
|
A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
|
|
453
453
|
straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
|
|
454
454
|
currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
|
|
455
|
-
settings, with no Homie ceiling.
|
|
456
|
-
|
|
457
|
-
The studio
|
|
455
|
+
settings, with no Homie ceiling. The default is open: guests can buy repeatedly without an age question,
|
|
456
|
+
from any page, with any wording. `protective` and `adults-only` are optional presets; neither scans content.
|
|
457
|
+
The studio chooses its settings in shop.json; [shop/SHOP.md](shop/SHOP.md) explains them.
|
|
458
458
|
|
|
459
459
|
```sh
|
|
460
460
|
npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
|
|
@@ -473,10 +473,10 @@ connect page, which makes the webhook with it, so its signing secret goes straig
|
|
|
473
473
|
chat. A webhook is never made through Stripe's MCP (its answer carries the secret). From 2026-10-31 Stripe's MCP takes
|
|
474
474
|
only OAuth or Agent-tagged keys; the shop's own key is a plain restricted key and is not affected.
|
|
475
475
|
|
|
476
|
-
In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`); a
|
|
477
|
-
supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), Stripe Tax
|
|
476
|
+
In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`, `add`, `checkout`, `buy`); a
|
|
477
|
+
supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), optional Stripe Tax, or Stripe Managed
|
|
478
478
|
Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
|
|
479
|
-
office; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
|
|
479
|
+
office, per order or line; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
|
|
480
480
|
referrer). The guide: `shop/SHOP.md`.
|
|
481
481
|
|
|
482
482
|
## Setup status
|
package/lib/build.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
|
|
19
19
|
* game.json files, the music/ and videos/ manifests (media/MEDIA.md),
|
|
20
20
|
* posts/*.md, the studio's site/ folder (site/SITE.md) and shop.json (checked
|
|
21
|
-
* against
|
|
21
|
+
* against studio settings, Stripe constraints and arithmetic correctness)
|
|
22
22
|
* site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
|
|
23
23
|
* site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
|
|
24
24
|
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
@@ -489,7 +489,7 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
489
489
|
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
|
|
490
490
|
const esbuild = await studioEsbuild(root);
|
|
491
491
|
const studio = readStudio(root);
|
|
492
|
-
// The shop first (shop/SHOP.md):
|
|
492
|
+
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
493
493
|
const shop = shopForBuild(root, { log });
|
|
494
494
|
const live = join(root, 'site', 'dist');
|
|
495
495
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
@@ -677,7 +677,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
677
677
|
: studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
|
|
678
678
|
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
679
679
|
...(studio.template === true ? { template: true } : {}),
|
|
680
|
-
// studio.json "audience": "kids" (a studio
|
|
680
|
+
// studio.json "audience": "kids" (a studio audience label; shop policies are optional) or "teens"; and "referrals": false
|
|
681
681
|
// (this studio takes no referral statements as a referrer).
|
|
682
682
|
...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
|
|
683
683
|
...(studio.referrals === false ? { referrals: false } : {}),
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -238,6 +238,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
238
238
|
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
239
239
|
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
240
240
|
let routes = keptRoutes(root, studio);
|
|
241
|
+
const triggers = readConfig(root)?.triggers;
|
|
241
242
|
const who = whoami(root);
|
|
242
243
|
if (!who) {
|
|
243
244
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -352,7 +353,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
352
353
|
const r2 = storage;
|
|
353
354
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
354
355
|
|
|
355
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
|
|
356
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
356
357
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
357
358
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
358
359
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -360,7 +361,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
360
361
|
step('D1 migrations applied');
|
|
361
362
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
362
363
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
363
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
|
|
364
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
364
365
|
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
365
366
|
}
|
|
366
367
|
|
package/lib/scaffold.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs
|
|
|
29
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
31
|
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
32
|
-
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE } from '../worker/shop-store.mjs';
|
|
32
|
+
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE, SHOP_LINES, SHOP_LINES_FILE } from '../worker/shop-store.mjs';
|
|
33
33
|
import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
|
|
34
34
|
import { themeFile } from './site.mjs';
|
|
35
35
|
import { rulesIndex } from './studio.mjs';
|
|
@@ -64,7 +64,7 @@ export function slugify(name) {
|
|
|
64
64
|
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
65
65
|
* the binding names and keep it linked.
|
|
66
66
|
*/
|
|
67
|
-
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
|
|
67
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
|
|
68
68
|
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
69
69
|
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
70
70
|
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
@@ -85,6 +85,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
85
85
|
// every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
|
|
86
86
|
// (lib/routes.mjs). A studio without routes gets no key at all.
|
|
87
87
|
...(routes?.length ? { routes } : {}),
|
|
88
|
+
...(triggers ? { triggers } : {}),
|
|
88
89
|
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
89
90
|
durable_objects: { bindings: rooms },
|
|
90
91
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
@@ -433,41 +434,30 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
433
434
|
|
|
434
435
|
## Selling things (the shop)
|
|
435
436
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
-
|
|
450
|
-
|
|
451
|
-
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
Stripe sent to the owner for confirmation until they approved it.
|
|
461
|
-
- **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
|
|
462
|
-
prints a 127.0.0.1 link; the owner pastes one restricted key there, the page makes the webhook with it, and the
|
|
463
|
-
key and the webhook's secret go straight to the Worker secrets. Test keys only unless \`--live\`. Never ask for a
|
|
464
|
-
key in the chat; never write one anywhere.
|
|
465
|
-
- **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
|
|
466
|
-
\`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
|
|
467
|
-
account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
|
|
468
|
-
- **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
|
|
469
|
-
player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
|
|
470
|
-
signs them; the referrer invoices the studio. Nothing moves through Homie.
|
|
437
|
+
Your shop, your choices. The studio sells through its own Stripe account and is responsible for the law
|
|
438
|
+
where it sells and for Stripe's terms. Homie takes no cut.
|
|
439
|
+
|
|
440
|
+
- \`shop.json\` lists items, wording, kinds, prices, quantities and entitlements. Optional settings include
|
|
441
|
+
sale dates, tips, currency, \`automaticTax\`, refund windows, spending caps and referral terms.
|
|
442
|
+
- The default is open: guests can buy repeatedly, without age questions, from any page. Signing in keeps
|
|
443
|
+
guest purchases. \`shop init --supporter\` writes an example item without a policy, cap or refund window.
|
|
444
|
+
- Choose \`policy.preset: "protective"\` or \`"adults-only"\` to enable an account and age policy. Omitted
|
|
445
|
+
presets, including in existing files, use the open default. Individual overrides are in SHOP.md.
|
|
446
|
+
- \`createShop()\` from \`@homie-rocks/studio/shop\` supplies \`open\`, \`has\`, \`entitlements\`, \`used\`, and \`change\`.
|
|
447
|
+
\`shop.add(item, quantity)\` builds a cart; \`shop.checkout()\` pays; \`shop.buy(item)\` buys directly.
|
|
448
|
+
- A cart has independent lines and quantities. The office can refund a whole order or line. The optional
|
|
449
|
+
player refund window covers items; a tip is never taken back by the player.
|
|
450
|
+
- Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
|
|
451
|
+
new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
|
|
452
|
+
- \`shop check\` validates settings; \`shop connect\` provides a local page for credentials, stored as Worker
|
|
453
|
+
secrets. Stripe's agent tools can manage Products through \`shop catalog\`; the connect page creates the
|
|
454
|
+
webhook so its secret stays out of chat. Both the key and signing secret are part of working payment setup.
|
|
455
|
+
Stripe's approval links belong to the owner. \`checkoutMinutes\` is optional (Stripe: 30 to 1440 minutes,
|
|
456
|
+
default 1440; values below 31 use 31 for transport margin); cancelling a named checkout, or replacing one at least a minute old, expires the open session on Stripe's confirmation.
|
|
457
|
+
- \`shop orders\` and \`/_studio/office/shop\` show sales. \`shop refund <order>\` asks the owner to confirm.
|
|
458
|
+
\`shop statements\` records optional referral shares; studios pay their referrers directly.
|
|
459
|
+
|
|
460
|
+
The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
|
|
471
461
|
|
|
472
462
|
## Continuing a build from the Claude app
|
|
473
463
|
|
|
@@ -730,6 +720,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
730
720
|
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
731
721
|
[`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
|
|
732
722
|
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
723
|
+
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
733
724
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
734
725
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
735
726
|
'.claude/skills/.gitkeep': '',
|
|
@@ -852,6 +843,14 @@ function ensureShopStatements(root) {
|
|
|
852
843
|
return `site/migrations/${SHOP_STATEMENTS_FILE}`;
|
|
853
844
|
}
|
|
854
845
|
|
|
846
|
+
function ensureShopLines(root) {
|
|
847
|
+
const file = join(root, 'site', 'migrations', SHOP_LINES_FILE);
|
|
848
|
+
if (existsSync(file)) return null;
|
|
849
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
850
|
+
writeFileSync(file, SHOP_LINES);
|
|
851
|
+
return `site/migrations/${SHOP_LINES_FILE}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
855
854
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
856
855
|
/** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
|
|
857
856
|
function ensureChatMigration(root) {
|
|
@@ -872,7 +871,7 @@ function ensureLoungeMigration(root) {
|
|
|
872
871
|
}
|
|
873
872
|
|
|
874
873
|
export function ensureMigrations(root) {
|
|
875
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
|
|
874
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
|
|
876
875
|
}
|
|
877
876
|
|
|
878
877
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shop-catalog.mjs
CHANGED
|
@@ -31,11 +31,11 @@ export async function wantedProducts(shop, slug) {
|
|
|
31
31
|
return Promise.all((shop?.items ?? []).map(async (it) => {
|
|
32
32
|
const id = await productIdOf(slug, it.id);
|
|
33
33
|
return {
|
|
34
|
-
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
|
|
34
|
+
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: it.taxCode ?? (shop.till === 'stripe-managed' ? defaultTaxCode(it) : null),
|
|
35
35
|
// Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
36
36
|
metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
|
|
37
|
-
price: it.
|
|
38
|
-
shown: it.
|
|
37
|
+
price: it.price === 'choose' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
|
|
38
|
+
shown: it.price === 'choose' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
|
|
39
39
|
};
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
@@ -96,7 +96,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
|
|
|
96
96
|
if (!p) {
|
|
97
97
|
writes.push({
|
|
98
98
|
tool: 'stripe_api_write', method: 'POST', path: '/v1/products', why: `make ${w.name} (${w.shown})`,
|
|
99
|
-
params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), tax_code: w.tax_code, metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
|
|
99
|
+
params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), ...(w.tax_code ? { tax_code: w.tax_code } : {}), metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
|
|
100
100
|
});
|
|
101
101
|
continue;
|
|
102
102
|
}
|
package/lib/shop.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
32
|
import { askedFor, withKey } from './office.mjs';
|
|
33
33
|
import { runner } from './cloudflare.mjs';
|
|
34
34
|
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
35
|
-
import {
|
|
35
|
+
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
36
36
|
import {
|
|
37
37
|
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
|
38
38
|
stripeCall, updateWebhookEndpoint,
|
|
@@ -43,7 +43,7 @@ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
|
|
|
43
43
|
/** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
|
|
44
44
|
export const HOOK_EVENTS = Object.freeze([
|
|
45
45
|
'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
|
|
46
|
-
'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
|
|
46
|
+
'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
|
|
47
47
|
]);
|
|
48
48
|
/** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
|
|
49
49
|
export const KEY_PERMISSIONS = Object.freeze([
|
|
@@ -77,10 +77,10 @@ export function shopForBuild(root, { log = () => {} } = {}) {
|
|
|
77
77
|
export function shopCheck(root) {
|
|
78
78
|
const r = readShop(root);
|
|
79
79
|
if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
|
|
80
|
-
return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.
|
|
80
|
+
return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.price === 'choose' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** The Supporter pack the kit suggests first:
|
|
83
|
+
/** The Supporter pack the kit suggests first: a badge on the profile and in rooms; the studio can replace or edit it. */
|
|
84
84
|
export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
|
|
85
85
|
return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
|
|
86
86
|
}
|
|
@@ -91,7 +91,6 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
|
|
|
91
91
|
const studio = readStudio(root);
|
|
92
92
|
const shop = {
|
|
93
93
|
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
|
|
94
|
-
policy: { preset: 'protective', ...POLICY_PRESETS.protective },
|
|
95
94
|
items: supporter ? [supporterItem({ price: Number(price) })] : [],
|
|
96
95
|
};
|
|
97
96
|
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
@@ -99,7 +98,7 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
|
|
|
99
98
|
writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
|
|
100
99
|
const wrote = [SHOP_FILE];
|
|
101
100
|
if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
|
|
102
|
-
return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key
|
|
101
|
+
return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key and webhook signing secret are in)', 'homie-studio shop connect (the owner pastes a Stripe key into a page on this computer)'] };
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
/** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
|
|
@@ -162,8 +161,9 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
162
161
|
<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>
|
|
163
162
|
<h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
|
|
164
163
|
<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>
|
|
165
|
-
<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>
|
|
166
|
-
<h2>1. In Stripe:
|
|
164
|
+
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code> or <code>sk_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>
|
|
165
|
+
<h2>1. In Stripe: a key for this shop</h2>
|
|
166
|
+
<p>You can use a restricted (<code>rk_</code>) or full secret (<code>sk_</code>) key. To choose a restricted key with just the permissions this shop uses:</p>
|
|
167
167
|
<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>
|
|
168
168
|
<li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
|
|
169
169
|
<li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
|
|
@@ -171,7 +171,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
171
171
|
<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>
|
|
172
172
|
<form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
|
|
173
173
|
<h2>3. Paste the key here</h2>
|
|
174
|
-
<label for="key">
|
|
174
|
+
<label for="key">Stripe key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
|
|
175
175
|
<details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
|
|
176
176
|
<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>
|
|
177
177
|
<li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
|
|
@@ -179,7 +179,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
179
179
|
<label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
|
|
180
180
|
<h2>4. Who is the seller?</h2>
|
|
181
181
|
<fieldset>
|
|
182
|
-
<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
|
|
182
|
+
<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). Set automaticTax: true in shop.json to enable Stripe Tax: 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>
|
|
183
183
|
<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>
|
|
184
184
|
</fieldset>
|
|
185
185
|
<button>Save to my Worker</button></form>
|
|
@@ -225,7 +225,7 @@ async function makeWebhook(env, site, { fetcher }) {
|
|
|
225
225
|
* (there is no API to read it). Never in live mode.
|
|
226
226
|
*/
|
|
227
227
|
async function probeManaged(env, shop, { fetcher }) {
|
|
228
|
-
const item = shop?.items?.find((i) => i.
|
|
228
|
+
const item = shop?.items?.find((i) => i.price !== 'choose') ?? null;
|
|
229
229
|
try {
|
|
230
230
|
const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
|
|
231
231
|
mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
|
|
@@ -270,10 +270,9 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
270
270
|
const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
271
271
|
if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
|
|
272
272
|
if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
|
|
273
|
-
if (
|
|
274
|
-
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; }
|
|
273
|
+
if (!KEY_SHAPE.test(key)) { say(said(400, 'That does not look like a Stripe key (rk_test_…, rk_live_…, sk_test_… or sk_live_…). Go back and try again.')); return; }
|
|
275
274
|
// Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
|
|
276
|
-
if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE
|
|
275
|
+
if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE Stripe key (rk_live_… or sk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_… or sk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
|
|
277
276
|
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; }
|
|
278
277
|
busy = true;
|
|
279
278
|
try {
|
|
@@ -1822,6 +1822,46 @@
|
|
|
1822
1822
|
}
|
|
1823
1823
|
}
|
|
1824
1824
|
}
|
|
1825
|
+
},
|
|
1826
|
+
"0.34.0": {
|
|
1827
|
+
"files": {
|
|
1828
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1829
|
+
"README.md": "684b3cca8001673c",
|
|
1830
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1831
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1832
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1833
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1834
|
+
"posts/README.md": "37841aae476bd540",
|
|
1835
|
+
"site/README.md": "732894273a32dc3f",
|
|
1836
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1837
|
+
},
|
|
1838
|
+
"sections": {
|
|
1839
|
+
"AGENTS.md": {
|
|
1840
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1841
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1842
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
1843
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1844
|
+
"## Making games": "2da7fa74c8884834",
|
|
1845
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1846
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1847
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1848
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1849
|
+
"## Selling things (the shop)": "57c04f6526e16890",
|
|
1850
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1851
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1852
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1853
|
+
"## Beta": "9d02638207a26e73"
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
"named": {
|
|
1857
|
+
"Homie Arcade": {
|
|
1858
|
+
"sections": {
|
|
1859
|
+
"AGENTS.md": {
|
|
1860
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1825
1865
|
}
|
|
1826
1866
|
}
|
|
1827
1867
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
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
|
@@ -1,97 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Your shop, your choices
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
unsure about.
|
|
3
|
+
You sell with your own Stripe account. Money goes directly to you; Homie takes no cut.
|
|
4
|
+
The studio is the seller and is responsible for the law where it sells and for Stripe's terms.
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
Your `shop.json` chooses items, wording, prices, currency, tax integration, sale dates, entitlements,
|
|
7
|
+
referral terms, optional spending caps and optional refund windows. Nothing requires an account or age
|
|
8
|
+
question by default. Guests can buy repeatedly and keep their purchases when signing in later.
|
|
8
9
|
|
|
9
|
-
You
|
|
10
|
-
|
|
11
|
-
requests, you deal with card disputes, and the tax on your sales is yours to handle.
|
|
10
|
+
You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
|
|
11
|
+
Without a preset the shop is open. SHOP.md describes each setting and the game cart API.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
A cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
|
|
14
|
+
A player can refund items within a window you set; a tip stays given unless you refund it.
|
|
15
|
+
Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
|
|
16
|
+
Managed Payments is another selectable integration. Your office links Stripe's own pages.
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
| Prices, what you sell | yes | | |
|
|
18
|
-
| Taking the card, the receipt email | | yes | |
|
|
19
|
-
| Refunds | yes: one tap in your office | carries them out | |
|
|
20
|
-
| Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
|
|
21
|
-
| Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
|
|
22
|
-
| Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
|
|
23
|
-
| Who may buy | your editable shop policy; protective defaults | provider terms | supplies presets |
|
|
24
|
-
|
|
25
|
-
## Refunds
|
|
26
|
-
|
|
27
|
-
- A player can refund an **unused** item themselves within the optional refund window you set from their
|
|
28
|
-
account page. Set `policy.refundUsedItems` to include used items. Without a window, buyers ask you for a refund.
|
|
29
|
-
Fewer people turn to card disputes when a refund is easy.
|
|
30
|
-
- Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
|
|
31
|
-
can never do it by itself.
|
|
32
|
-
- Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
|
|
33
|
-
|
|
34
|
-
## Disputes (chargebacks)
|
|
35
|
-
|
|
36
|
-
- A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
|
|
37
|
-
you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
|
|
38
|
-
sale, so consider your costs when setting prices. Homie imposes no price or earnings ceiling.
|
|
39
|
-
- **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
|
|
40
|
-
that one item is taken back, as a refund would.
|
|
41
|
-
|
|
42
|
-
## Tax
|
|
43
|
-
|
|
44
|
-
You choose who the seller is when you connect your Stripe (`homie-studio shop connect` shows both):
|
|
45
|
-
|
|
46
|
-
- **You are the seller** (`"till": "stripe"`): Stripe Tax is on. It works out and collects tax **only where you
|
|
47
|
-
have told Stripe you are registered**, and it does not file your returns. Some places expect a foreign seller
|
|
48
|
-
of digital goods to register from the first sale (the EU and the UK do). Cheapest; the most work.
|
|
49
|
-
- **Stripe is the seller** (`"till": "stripe-managed"`, Stripe Managed Payments): **3.5% more** a sale. Stripe's
|
|
50
|
-
Link, LLC becomes the seller of record and registers, collects, files and pays sales tax and VAT in 80+
|
|
51
|
-
countries, runs fraud checks and answers disputes for you. You still carry the money of a lost dispute, and
|
|
52
|
-
Stripe may refund a buyer within 60 days. You turn it on in Stripe first (after Stripe's eligibility review).
|
|
53
|
-
|
|
54
|
-
## Kids
|
|
55
|
-
|
|
56
|
-
Your shop starts with the `protective` preset in shop.json. It requires an account and a neutral age question,
|
|
57
|
-
blocks purchases under 13, uses a parent's checkout for teens, closes shops on kids servers and kids studios,
|
|
58
|
-
hides advantages on beginner and kids servers, refuses paid randomness, countdowns and virtual-currency items,
|
|
59
|
-
and sends TV shoppers to a phone. Existing shops without a policy keep these protections.
|
|
60
|
-
|
|
61
|
-
These are **your settings**: read and edit `policy` in shop.json. `adults-only` also blocks teens;
|
|
62
|
-
`custom` enables all policies. You can override individual rules without changing the others. SHOP.md lists
|
|
63
|
-
every setting. No spending cap or refund window applies unless you set one; existing written values stay yours.
|
|
64
|
-
|
|
65
|
-
As the seller, you are responsible for the law and your payment provider's terms wherever you sell, including
|
|
66
|
-
rules protecting children. Choosing a preset does not establish compliance. This is information, not legal advice.
|
|
67
|
-
|
|
68
|
-
## Referrals
|
|
69
|
-
|
|
70
|
-
If another studio (or homie.rocks, or a creator) sends you a new player who buys something, you owe them the
|
|
71
|
-
referral share you published in `shop.json` (10% if you kept the suggestion; 0 if you turn it off). Your office
|
|
72
|
-
shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
|
|
73
|
-
yourself.** Nothing moves through Homie.
|
|
74
|
-
|
|
75
|
-
## What you do, and what your AI does
|
|
76
|
-
|
|
77
|
-
Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
|
|
78
|
-
makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
|
|
79
|
-
your Stripe. What stays yours, in Stripe's own pages:
|
|
80
|
-
|
|
81
|
-
- making the Stripe account, and later activating it (your business details, bank account and identity checks);
|
|
82
|
-
- one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
|
|
83
|
-
- one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
|
|
84
|
-
- approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
|
|
85
|
-
in your office.
|
|
86
|
-
|
|
87
|
-
If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
|
|
88
|
-
Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
|
|
89
|
-
restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
|
|
90
|
-
|
|
91
|
-
## Before the first real sale
|
|
92
|
-
|
|
93
|
-
1. Make (or pick) your Stripe account, verify it, and add a bank account.
|
|
94
|
-
2. Test everything in **test mode** first: buy your own item with a Stripe test card, refund it from the office.
|
|
95
|
-
3. Fill in your public business details in Stripe (name, support email, the refund page `/shop/refunds/`).
|
|
96
|
-
4. Talk to an accountant about where you must register for tax, or choose Managed Payments.
|
|
97
|
-
5. Then, and only then, connect a live key (`homie-studio shop connect --live`).
|
|
18
|
+
`homie-studio shop connect` provides a local page for the key and webhook signing secret; both are part of payment setup, credentials stay in Worker secrets.
|
|
19
|
+
The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
|