@homie-rocks/studio 0.50.4 → 0.50.6
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 +36 -0
- package/README.md +1 -2
- package/bin/homie-studio.mjs +7 -7
- package/functions/FUNCTIONS.md +69 -13
- package/guides/office/SKILL.md +17 -0
- package/guides/servers/SKILL.md +17 -0
- package/guides/shop/SKILL.md +4 -3
- package/lib/build.mjs +3 -4
- package/lib/cloudflare.mjs +8 -0
- package/lib/functions-build.mjs +10 -3
- package/lib/functions-cli.mjs +9 -3
- package/lib/parts-build.mjs +1 -1
- package/lib/parts.mjs +1 -1
- package/lib/purchase-links.mjs +1 -1
- package/lib/scaffold.mjs +18 -12
- package/lib/shop.mjs +2 -30
- package/lib/template-history.json +88 -1
- package/lib/worker-config.mjs +20 -3
- package/package.json +1 -1
- package/shop/SELLING.md +1 -1
- package/shop/SHOP.md +11 -12
- package/worker/build-id.mjs +1 -1
- package/worker/canonical-json.mjs +6 -0
- package/worker/customer-mcp.mjs +1 -1
- package/worker/customer-resources.mjs +1 -1
- package/worker/function-schedule.mjs +68 -0
- package/worker/functions-schema.mjs +13 -0
- package/worker/functions.mjs +45 -9
- package/worker/index.mjs +1 -14
- package/worker/mcp-tools.mjs +7 -3
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +7 -14
- package/worker/parts-sale.mjs +1 -1
- package/worker/purchase-core.mjs +1 -1
- package/worker/purchase-crypto.mjs +1 -1
- package/worker/purchase-keys.mjs +1 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-owner.mjs +1 -1
- package/worker/purchase-routes.mjs +1 -1
- package/worker/purchase-store.mjs +1 -1
- package/worker/shop-page.mjs +5 -69
- package/worker/shop-rules.mjs +5 -33
- package/worker/shop-store.mjs +76 -56
- package/worker/shop.mjs +22 -78
- package/worker/version.mjs +1 -1
- package/worker/referrals.mjs +0 -460
package/worker/shop.mjs
CHANGED
|
@@ -16,10 +16,9 @@ import { selling } from './extensions.mjs';
|
|
|
16
16
|
* follows the studio advantage settings)
|
|
17
17
|
* GET /api/shop/mine the player's orders and what they own (the account page)
|
|
18
18
|
* GET /shop/ /shop/thanks /shop/refunds/ /shop/parent/<token> the pages (worker/shop-page.mjs)
|
|
19
|
-
* POST /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
|
|
20
19
|
*
|
|
21
20
|
* THE OFFICE (worker/office.mjs routes them): sales, refunds, disputes and payouts as links to the studio's own Stripe
|
|
22
|
-
* Dashboard, a CSV for the accountant,
|
|
21
|
+
* Dashboard, a CSV for the accountant, order records. A refund is the owner's one tap; the
|
|
23
22
|
* owner's AI (an office key) can only ASK for one, which the owner confirms with one tap. A dispute never touches the
|
|
24
23
|
* player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
|
|
25
24
|
*
|
|
@@ -37,17 +36,16 @@ import { selling } from './extensions.mjs';
|
|
|
37
36
|
import { hasStripeKey, linkConfig, linkSettings, shopMode, fullerReason, paymentLinkURL, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
|
|
38
37
|
import { players } from './players.mjs';
|
|
39
38
|
import { serversOf } from './servers.mjs';
|
|
40
|
-
import { ENTITLEMENT_KEY,
|
|
39
|
+
import { ENTITLEMENT_KEY, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
|
|
41
40
|
import {
|
|
42
41
|
orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
|
|
43
42
|
setBand, shopDataOf, spentThisMonth,
|
|
44
43
|
} from './shop-store.mjs';
|
|
45
44
|
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
46
|
-
import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
|
|
47
45
|
import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
|
|
48
46
|
import { notFoundPage } from './site.mjs';
|
|
49
47
|
|
|
50
|
-
export {
|
|
48
|
+
export { forgetPlayerShop, officeShopPage, shopDataOf };
|
|
51
49
|
|
|
52
50
|
const DAY = 86_400_000;
|
|
53
51
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -166,7 +164,7 @@ export const MISSING_WORDS = Object.freeze({
|
|
|
166
164
|
'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: approve Stripe in your browser; the command names any remaining Worker credential step).',
|
|
167
165
|
'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
|
|
168
166
|
database: 'This Worker has no database.',
|
|
169
|
-
migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and
|
|
167
|
+
migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0012_shop_lines.sql.',
|
|
170
168
|
});
|
|
171
169
|
|
|
172
170
|
/** The server a request names (the play shell says which), with its kids and beginner flags. */
|
|
@@ -183,7 +181,7 @@ async function serverFacts(env, cat, game, server) {
|
|
|
183
181
|
/** The items a game's shop shows (its own and the studio's), in shop.json's order. */
|
|
184
182
|
const itemsFor = (shop, game) => (shop?.items ?? []).filter((i) => !i.game || !game || i.game === game);
|
|
185
183
|
|
|
186
|
-
/** The account a request is signed in as, with when it was made
|
|
184
|
+
/** The account a request is signed in as, with when it was made. */
|
|
187
185
|
async function accountOf(request, env) {
|
|
188
186
|
const p = await players.of(request, env);
|
|
189
187
|
if (!p) return null;
|
|
@@ -214,7 +212,6 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
|
|
|
214
212
|
const requestAt = Date.now();
|
|
215
213
|
const path = url.pathname;
|
|
216
214
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
217
|
-
if (path === '/api/referrals/statement') return receiveStatement(request, env, url);
|
|
218
215
|
if (path === '/_homie/shop.js') return new Response(SHOP_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
219
216
|
const shopPath = path === '/shop' || path === '/shop/' || path.startsWith('/shop/') || path === '/api/shop' || path.startsWith('/api/shop/') || path === '/api/player/owns';
|
|
220
217
|
if (selling.enabled && (path.startsWith('/api/purchases/') || path.startsWith('/purchases/'))) return selling.purchaseRoutes(request, env, url, await catalogueOf());
|
|
@@ -374,7 +371,7 @@ export function sessionParams(shop, item, order, { origin, amount, player, game,
|
|
|
374
371
|
client_reference_id: player,
|
|
375
372
|
// Stripe metadata values allow 500 characters; the order holds the complete item id.
|
|
376
373
|
// https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
377
|
-
metadata: { homie: 'shop-v1', origin, studio: String(studio ?? '').length <= 500 ? String(studio ?? '') : undefined, order: order.id, item: item.id.length <= 500 ? item.id : undefined, ...(game ? { game } : {}), ...(
|
|
374
|
+
metadata: { homie: 'shop-v1', origin, studio: String(studio ?? '').length <= 500 ? String(studio ?? '') : undefined, order: order.id, item: item.id.length <= 500 ? item.id : undefined, ...(game ? { game } : {}), ...(parent ? { parent: '1' } : {}) },
|
|
378
375
|
payment_intent_data: { metadata: { homie: 'shop-v1', origin, order: order.id, item: item.id.length <= 500 ? item.id : undefined } },
|
|
379
376
|
success_url: `${origin}/shop/thanks?session_id={CHECKOUT_SESSION_ID}${back}`,
|
|
380
377
|
cancel_url: `${origin}/shop/?cancelled=1&order=${order.id}${back}`,
|
|
@@ -461,7 +458,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
461
458
|
}
|
|
462
459
|
|
|
463
460
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
464
|
-
async function startCheckout(env, url, cat, shop, item, { player, game,
|
|
461
|
+
async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
|
|
465
462
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
466
463
|
const keyless = amount > 0 && !hasStripeKey(env);
|
|
467
464
|
if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
|
|
@@ -469,21 +466,20 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
469
466
|
if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
|
|
470
467
|
if (keyless && item.price === 'choose' && lines[0].quantity !== 1) return { error: fail(400, 'quantity', 'A tip is one chosen amount; choose it on Stripe.') };
|
|
471
468
|
if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
|
|
472
|
-
|
|
473
|
-
const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
|
|
469
|
+
const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
|
|
474
470
|
const now = Date.now();
|
|
475
471
|
order.createdAt = now;
|
|
476
472
|
order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
|
|
477
473
|
// One SQL statement reserves the amount: concurrent requests cannot both spend the remaining cap.
|
|
478
|
-
const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent,
|
|
479
|
-
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0,
|
|
474
|
+
const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?10, ?13 WHERE ?5 = 0 OR ?11 IS NULL OR ?5 <= ?11 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?12) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?10))))")
|
|
475
|
+
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
|
|
480
476
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
481
477
|
const reserve = async () => {
|
|
482
478
|
const results = await env.DB.batch([reservation(),
|
|
483
479
|
...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
|
|
484
480
|
...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
|
|
485
481
|
.bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
|
|
486
|
-
env.DB.prepare('UPDATE shop_orders SET
|
|
482
|
+
env.DB.prepare('UPDATE shop_orders SET checkout_player = player WHERE id = ?1').bind(order.id),
|
|
487
483
|
]);
|
|
488
484
|
return results[0];
|
|
489
485
|
};
|
|
@@ -537,17 +533,6 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
537
533
|
return { order, url: session.url, session: session.id, reconciled };
|
|
538
534
|
}
|
|
539
535
|
|
|
540
|
-
/** The referrer this buyer brought, if it still counts: their arrival, a new account since it, in the window, proven. */
|
|
541
|
-
async function viaFor(request, env, url, shop, player) {
|
|
542
|
-
if (!shop.referrals || !(shop.referrals.rate > 0)) return null;
|
|
543
|
-
const a = await arrivalOf(request, env);
|
|
544
|
-
if (!a || a.via === url.hostname.replace(/^www\./, '')) return null;
|
|
545
|
-
if (shop.referrals.windowDays !== null && Date.now() - a.at > Math.round(shop.referrals.windowDays * DAY)) return null;
|
|
546
|
-
// New players only: an account made before the arrival was already this studio's player.
|
|
547
|
-
if (shop.referralNewPlayersOnly && player.since !== null && player.since < a.at - 60_000) return null;
|
|
548
|
-
return (await referrerOk(a.via)) ? a.via : null;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
536
|
const WAY_WORDS = Object.freeze({
|
|
552
537
|
closed: 'The shop is not open yet.', kids: 'The studio policy.kidsServer setting closes this shop on kids servers.', beginner: 'The studio server advantage settings hide this item here.',
|
|
553
538
|
owned: 'You have it already.', 'make-an-account': 'Make an account first (a passkey), so what you buy stays yours on every device.',
|
|
@@ -605,8 +590,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
605
590
|
player = guest.session.player;
|
|
606
591
|
cookies = guest.session.cookies;
|
|
607
592
|
}
|
|
608
|
-
const
|
|
609
|
-
const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, reference, requestAt });
|
|
593
|
+
const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, requestAt });
|
|
610
594
|
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
|
|
611
595
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
612
596
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
@@ -638,7 +622,6 @@ async function parentLinkRoute(request, env, url, cat, shop, ready) {
|
|
|
638
622
|
cart.push({ item: item.id, quantity });
|
|
639
623
|
}
|
|
640
624
|
const item = shop.items.find((i) => i.id === cart[0].item);
|
|
641
|
-
cart[0].via = await viaFor(request, env, url, shop, player);
|
|
642
625
|
const token = randomToken();
|
|
643
626
|
await env.DB.prepare('INSERT INTO shop_parent_links (hash, player, item, game, order_id, expires_at, cart) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)').bind(await sha256Hex(token), player.id, item.id, game, Date.now() + 7 * DAY, JSON.stringify(cart)).run();
|
|
644
627
|
return json({ ok: true, link: `${url.origin}/shop/parent/${token}`, expiresInDays: 7 });
|
|
@@ -678,7 +661,7 @@ async function parentRoute(request, env, url, cat, shop, ready, token, ctx) {
|
|
|
678
661
|
if (total > BigInt(Number.MAX_SAFE_INTEGER)) return parentPage(cat, shop, { item, lines, name: kid.name, token, said: 'The total exceeds safe integer arithmetic.' });
|
|
679
662
|
amount = Number(total);
|
|
680
663
|
}
|
|
681
|
-
const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game,
|
|
664
|
+
const r = await startCheckout(env, url, cat, shop, item, { player: { id: row.player }, game: row.game, amount, parent: true, lines });
|
|
682
665
|
if (r.error) return parentPage(cat, shop, { item, lines, name: kid.name, token, open: true, said: (await r.error.json()).message });
|
|
683
666
|
await env.DB.prepare('UPDATE shop_parent_links SET order_id = ?2 WHERE hash = ?1').bind(await sha256Hex(token), r.order.id).run();
|
|
684
667
|
if (!r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, row.player).catch((error) => logOrderError(env, r.order.id, error)));
|
|
@@ -866,7 +849,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
866
849
|
if (!order) {
|
|
867
850
|
const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
|
|
868
851
|
await env.DB.batch([
|
|
869
|
-
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent,
|
|
852
|
+
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, created_at, updated_at, session) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, ?5, ?5, ?3 FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
|
|
870
853
|
env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`),
|
|
871
854
|
env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
|
|
872
855
|
]);
|
|
@@ -880,7 +863,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
880
863
|
return true;
|
|
881
864
|
}
|
|
882
865
|
|
|
883
|
-
/** A paid checkout: the order is paid, its entitlements granted,
|
|
866
|
+
/** A paid checkout: the order is paid, its entitlements granted, its payment recorded. Idempotent. */
|
|
884
867
|
export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
885
868
|
const o = await orderBySession(env, session.id);
|
|
886
869
|
if (!o) return 'unknown-order';
|
|
@@ -939,11 +922,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
939
922
|
writes.push(env.DB.prepare("UPDATE shop_order_lines SET status = 'paid', total = ?2, snapshot = COALESCE(snapshot, ?3) WHERE id = ?1 AND status != 'refunded'").bind(line.id, gross, JSON.stringify(item)));
|
|
940
923
|
}
|
|
941
924
|
if (lineTotal !== total) throw new Error('Stripe line totals do not match order');
|
|
942
|
-
if (o.via && o.paid_at === null) writes.push(...(await lineFor(env, o, o.referral_terms ? { referrals: JSON.parse(o.referral_terms) } : shop, { now })));
|
|
943
|
-
writes.push(env.DB.prepare("UPDATE referral_lines SET original_share = share WHERE order_id = ?1 AND original_share IS NULL").bind(o.id));
|
|
944
925
|
writes.push(env.DB.prepare("UPDATE shop_orders SET status = 'paid', payment = ?2, tax = ?3, total = ?4, paid_at = ?5, updated_at = ?6 WHERE id = ?1 AND paid_at IS NULL")
|
|
945
926
|
.bind(o.id, typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, tax, total, paidAt, now));
|
|
946
|
-
// D1 batch is transactional: a failure rolls back payment
|
|
927
|
+
// D1 batch is transactional: a failure rolls back payment and every grant together.
|
|
947
928
|
// https://developers.cloudflare.com/d1/worker-api/d1-database/#batch
|
|
948
929
|
const result = await env.DB.batch(writes);
|
|
949
930
|
const paymentId = payment?.id ?? (typeof session.payment_intent === 'string' ? session.payment_intent : null);
|
|
@@ -1007,15 +988,6 @@ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []
|
|
|
1007
988
|
}
|
|
1008
989
|
writes.push(env.DB.prepare(`UPDATE entitlements SET state = CASE WHEN ?3 OR ?4 OR EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key) AND NOT EXISTS (SELECT 1 FROM shop_entitlement_lines WHERE order_id = ?1 AND key = entitlements.key AND state = 'active') THEN 'revoked' ELSE 'active' END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, full ? 1 : 0, o.status === 'lost' ? 1 : 0));
|
|
1009
990
|
writes.push(env.DB.prepare(`UPDATE entitlements SET starts_at = (SELECT MIN(starts_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active'), ends_at = CASE WHEN EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active' AND l.ends_at IS NULL) THEN NULL ELSE (SELECT MAX(ends_at) FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active') END WHERE order_id = ?1 AND ${guard} AND EXISTS (SELECT 1 FROM shop_entitlement_lines l WHERE l.order_id = ?1 AND l.key = entitlements.key AND l.state = 'active')`).bind(o.id, revision));
|
|
1010
|
-
const referral = await env.DB.prepare('SELECT * FROM referral_lines WHERE order_id = ?1').bind(o.id).first();
|
|
1011
|
-
if (referral && o.status !== 'lost') {
|
|
1012
|
-
const share = Number(referral.original_share ?? referral.share);
|
|
1013
|
-
const remaining = o.amount ? Number((2n * BigInt(share) * BigInt(Number(o.amount) - net) + BigInt(o.amount)) / (2n * BigInt(o.amount))) : 0;
|
|
1014
|
-
changed ||= Number(referral.share) !== remaining || Number(referral.net) !== Number(o.amount) - net || (referral.state === 'void') !== (remaining === 0) && referral.state !== 'settled';
|
|
1015
|
-
const delta = Number(referral.share) - remaining;
|
|
1016
|
-
writes.push(env.DB.prepare(`INSERT INTO referral_lines (order_id, via, net, rate, share, original_share, currency, state, period, hold_until, created_at) SELECT ?3, via, ?4, rate, ?5, ?5, currency, 'clawback', period, hold_until, ?6 FROM referral_lines WHERE order_id = ?1 AND state = 'settled' AND ${guard} AND ?5 != 0 ON CONFLICT DO NOTHING`).bind(o.id, revision, `${o.id}_refund_${revision}`, net - Number(o.refunded_net), delta, Date.now()));
|
|
1017
|
-
writes.push(env.DB.prepare(`UPDATE referral_lines SET share = ?3, net = ?4, state = CASE WHEN state = 'settled' THEN state WHEN ?3 = 0 THEN 'void' WHEN state = 'void' THEN 'pending' ELSE state END WHERE order_id = ?1 AND ${guard}`).bind(o.id, revision, remaining, Number(o.amount) - net));
|
|
1018
|
-
}
|
|
1019
991
|
full &&= o.status !== 'lost';
|
|
1020
992
|
changed ||= (o.status === 'refunded') !== full;
|
|
1021
993
|
if (!changed) return 'already';
|
|
@@ -1044,7 +1016,6 @@ async function disputeClosed(env, payment, status) {
|
|
|
1044
1016
|
env.DB.prepare("UPDATE shop_orders SET status = 'lost', updated_at = ?2 WHERE id = ?1 AND status IN ('paid', 'disputed')").bind(o.id, now),
|
|
1045
1017
|
env.DB.prepare("UPDATE shop_order_lines SET status = 'lost' WHERE order_id = ?1 AND status != 'refunded' AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND status = 'lost')").bind(o.id),
|
|
1046
1018
|
revokeStatement(env, o.id),
|
|
1047
|
-
...voidLineStatements(env, o.id),
|
|
1048
1019
|
]);
|
|
1049
1020
|
return 'lost';
|
|
1050
1021
|
}
|
|
@@ -1101,7 +1072,7 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
|
|
|
1101
1072
|
}
|
|
1102
1073
|
if (legacy) {
|
|
1103
1074
|
await env.DB.batch([
|
|
1104
|
-
revokeStatement(env, o.id),
|
|
1075
|
+
revokeStatement(env, o.id),
|
|
1105
1076
|
env.DB.prepare("UPDATE shop_orders SET status = 'refunded', refund = ?2, refunded_at = ?3, updated_at = ?3 WHERE id = ?1").bind(o.id, refund?.id ?? null, Date.now()),
|
|
1106
1077
|
]);
|
|
1107
1078
|
} else if (!amount) await syncRefunds(env, o.payment, o.payment ? null : o, selected.map((line) => line.id));
|
|
@@ -1146,12 +1117,6 @@ export async function performRelease(env, a, cat) {
|
|
|
1146
1117
|
return result.meta?.changes ? { ok: true, order: a.order, by: a.by, at } : { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
|
|
1147
1118
|
}
|
|
1148
1119
|
|
|
1149
|
-
export function checkSettle(body) {
|
|
1150
|
-
if (!/^[a-z0-9.-]{3,253}$/.test(String(body.via ?? ''))) return { ok: false, error: 'bad-request', message: 'via is the referrer\'s host (as the shop page shows it)' };
|
|
1151
|
-
if (!/^[a-z]{3}$/.test(body.currency ?? '')) return { ok: false, error: 'currency', message: 'Choose the currency to mark paid.' };
|
|
1152
|
-
return { ok: true, action: { op: 'shop-settle', currency: body.currency, via: String(body.via), ref: String(body.ref ?? '') } };
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
1120
|
/** The refund in plain words, for the owner's confirm page and the AI's card. */
|
|
1156
1121
|
export function describeRefund(cat, a, order = null) {
|
|
1157
1122
|
const shop = shopOf(cat);
|
|
@@ -1178,13 +1143,13 @@ export async function performRefund(env, cat, a) {
|
|
|
1178
1143
|
}
|
|
1179
1144
|
|
|
1180
1145
|
/** Everything the office's shop page shows. Money is read from this studio's D1; Stripe's own pages are linked. */
|
|
1181
|
-
export async function shopOffice(env, cat, origin,
|
|
1146
|
+
export async function shopOffice(env, cat, origin, itemCursor = 0, orderCursor = 0) {
|
|
1182
1147
|
const shop = shopOf(cat);
|
|
1183
1148
|
const ready = await readiness(env, shop);
|
|
1184
1149
|
const mode = ready.mode ?? 'test';
|
|
1185
1150
|
const out = {
|
|
1186
1151
|
ok: true, ready: ready.ready, missing: ready.missing.map((m) => ({ id: m, words: m === 'migration' ? `The shop needs ${ready.migration}; npm run deploy applies it.` : MISSING_WORDS[m] ?? m })), mode: ready.mode,
|
|
1187
|
-
shop: shop ? { policy: shop.policy, purchaseAttemptsPerMinute: shop.purchaseAttemptsPerMinute, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, nextItemsCursor: itemCursor + 100 < shop.items.length ? String(itemCursor + 100) : null, items: shop.items.slice(itemCursor, itemCursor + 100).map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.price === 'choose' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })),
|
|
1152
|
+
shop: shop ? { policy: shop.policy, purchaseAttemptsPerMinute: shop.purchaseAttemptsPerMinute, till: shop.till, currency: shop.currency, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, nextItemsCursor: itemCursor + 100 < shop.items.length ? String(itemCursor + 100) : null, items: shop.items.slice(itemCursor, itemCursor + 100).map((i) => ({ id: i.id, kind: i.kind, name: i.name, price: i.price, shown: i.price === 'choose' ? `${money(i.min, shop.currency)}+` : money(i.price, shop.currency), gives: i.gives, game: i.game ?? null })), ...(shop.broken ? { broken: shop.broken } : {}) } : null,
|
|
1188
1153
|
stripe: {
|
|
1189
1154
|
payments: dashboardLink(mode, 'payments'), refunds: dashboardLink(mode, 'refunds'), disputes: dashboardLink(mode, 'disputes'), payouts: dashboardLink(mode, 'payouts'),
|
|
1190
1155
|
balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
|
|
@@ -1192,7 +1157,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1192
1157
|
},
|
|
1193
1158
|
keyless: !hasStripeKey(env),
|
|
1194
1159
|
hook: `${origin}/api/shop/hook`,
|
|
1195
|
-
totals: null, orders: [],
|
|
1160
|
+
totals: null, orders: [],
|
|
1196
1161
|
};
|
|
1197
1162
|
if (!(await migrated(env, { complete: false }))) return out;
|
|
1198
1163
|
const since = Date.now() - 30 * DAY;
|
|
@@ -1212,8 +1177,6 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1212
1177
|
refundable: (Number(r.amount) === 0 || hasStripeKey(env)) && ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
|
|
1213
1178
|
})));
|
|
1214
1179
|
if (selling.enabled) await selling.extendOffice(env, cat, origin, out);
|
|
1215
|
-
const { books, nextCursor } = await booksOf(env, { cursor });
|
|
1216
|
-
out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
|
|
1217
1180
|
return out;
|
|
1218
1181
|
}
|
|
1219
1182
|
|
|
@@ -1221,31 +1184,12 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1221
1184
|
export async function ordersCsv(env) {
|
|
1222
1185
|
const rows = (await env.DB.prepare("SELECT * FROM shop_orders WHERE status != 'started' ORDER BY created_at").all()).results ?? [];
|
|
1223
1186
|
const cell = (v) => { const s = v === null || v === undefined ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
1224
|
-
const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', '
|
|
1187
|
+
const head = ['order', 'created', 'paid', 'refunded', 'status', 'item', 'game', 'amount', 'tax', 'total', 'currency', 'till', 'mode', 'parent', 'stripe_payment', 'stripe_refund', 'stripe_dispute', 'lines_json', 'refunded_total', 'refunded_net'];
|
|
1225
1188
|
const iso = (t) => (t ? new Date(Number(t)).toISOString() : '');
|
|
1226
|
-
const lines = await Promise.all(rows.map(async (r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.tax === null ? '' : (Number(r.tax) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.total === null ? '' : (Number(r.total) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.
|
|
1189
|
+
const lines = await Promise.all(rows.map(async (r) => [r.id, iso(r.created_at), iso(r.paid_at), iso(r.refunded_at), r.status, r.item, r.game, (Number(r.amount) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.tax === null ? '' : (Number(r.tax) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.total === null ? '' : (Number(r.total) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), r.currency, r.till, r.mode, Number(r.parent) ? 'yes' : '', r.payment, r.refund, r.dispute, JSON.stringify((await orderLines(env, r.id)).map(lineView)), (Number(r.refunded_amount ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency))), (Number(r.refunded_net ?? 0) / currencyScale(r.currency)).toFixed(Math.log10(currencyScale(r.currency)))].map(cell).join(',')));
|
|
1227
1190
|
return `${head.join(',')}\n${lines.join('\n')}${lines.length ? '\n' : ''}`;
|
|
1228
1191
|
}
|
|
1229
1192
|
|
|
1230
|
-
/** The signed statements for a period (YYYY-MM, default last month): one per referrer this studio owes. */
|
|
1231
|
-
export async function statementsOut(env, cat, origin, period, cursor = '') {
|
|
1232
|
-
const shop = shopOf(cat);
|
|
1233
|
-
const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : lastMonth();
|
|
1234
|
-
return { ok: true, period: p, ...(await statementPage(env, origin, shop?.referrals, p, cursor, { snapshot: false })) };
|
|
1235
|
-
}
|
|
1236
|
-
export async function statementsSend(env, cat, origin, period, cursor = '') {
|
|
1237
|
-
const shop = shopOf(cat);
|
|
1238
|
-
const p = /^\d{4}-\d{2}$/.test(String(period ?? '')) ? period : (await pendingStatementPeriod(env, origin)) ?? lastMonth();
|
|
1239
|
-
const { sent, nextCursor, retryAfter, failures } = await sendStatements(env, origin, shop, p, { cursor });
|
|
1240
|
-
return { ok: true, period: p, nextCursor, sent, retryAfter, failures };
|
|
1241
|
-
}
|
|
1242
|
-
export const settleReferrer = (env, a) => settle(env, a.via, a.ref, { currency: a.currency });
|
|
1243
|
-
|
|
1244
|
-
function lastMonth(now = new Date()) {
|
|
1245
|
-
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
1246
|
-
return d.toISOString().slice(0, 7);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
1193
|
/* ------------------------------------------------------------------ for the rest of the Worker */
|
|
1250
1194
|
|
|
1251
1195
|
/** The play shell's boot facts: is there a shop to show under this studio's policy. */
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.50.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.50.6';
|