@homie-rocks/studio 0.34.0 → 0.35.1
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 +25 -1
- package/README.md +12 -8
- package/bin/homie-studio.mjs +4 -4
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +12 -5
- package/lib/shop-catalog.mjs +20 -1
- package/lib/shop-links.mjs +93 -0
- package/lib/shop.mjs +31 -16
- package/lib/stripe-connect.mjs +146 -0
- package/lib/template-history.json +85 -1
- package/package.json +1 -1
- package/shop/SELLING.md +4 -2
- package/shop/SHOP.md +55 -17
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +5 -3
- package/worker/shop.mjs +106 -31
- package/worker/stripe.mjs +3 -3
- package/worker/version.mjs +1 -1
package/worker/shop.mjs
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* owner's AI (an office key) can only ASK for one, which the owner confirms with one tap. A dispute never touches the
|
|
22
22
|
* player's account: it changes nothing until it is decided, and a lost one is a refund of that one item.
|
|
23
23
|
*
|
|
24
|
-
* SECRETS (Worker secrets, put there by `homie-studio shop connect`
|
|
25
|
-
*
|
|
24
|
+
* SECRETS (Worker secrets, put there by `homie-studio shop connect` on the owner's own computer):
|
|
25
|
+
* Default: STRIPE_SHOP_LINKS and STRIPE_WEBHOOK_SECRET; no API key or API requests at purchase time.
|
|
26
|
+
* Optional fuller checkout: STRIPE_KEY (a secret key or a restricted key: Checkout Sessions write, Charges write for refunds, PaymentIntents and Disputes read;
|
|
26
27
|
* from 0.24.3 also Webhook Endpoints write, which only the connect page on the owner's computer uses) and
|
|
27
28
|
* STRIPE_WEBHOOK_SECRET. Never in a file, a chat, a log or a page.
|
|
28
29
|
*
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
* by product. The price is always shop.json's (the rules checked it), never one read from Stripe. A Product missing
|
|
32
33
|
* in this mode falls back to the item described inline, so a sale never fails over the catalog.
|
|
33
34
|
*/
|
|
35
|
+
import { hasStripeKey, linkConfig, linkSettings, shopMode, fullerReason, paymentLinkURL, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
|
|
34
36
|
import { players } from './players.mjs';
|
|
35
37
|
import { serversOf } from './servers.mjs';
|
|
36
38
|
import { ENTITLEMENT_KEY, referralShare, checkoutMessage, amountError, amountStep, parseAmount, currencyScale, audienceOf, bandOf, checkShop, defaultTaxCode, money, wayFor } from './shop-rules.mjs';
|
|
@@ -38,7 +40,7 @@ import {
|
|
|
38
40
|
orderLines, lineView, bandOfPlayer, forgetPlayerShop, grantStatements, migrationNeeded, migrated, newOrderId, orderById, orderByPayment, orderBySession, orderView, playerOrderStatus, ownsOf, restoreStatement, revokeStatement,
|
|
39
41
|
setBand, shopDataOf, spentThisMonth,
|
|
40
42
|
} from './shop-store.mjs';
|
|
41
|
-
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct,
|
|
43
|
+
import { redactStripe, WEBHOOK_SECRET_SHAPE, KEY_SHAPE, stripeCall, StripeError, createCheckoutSession, createRefund, expireCheckoutSession, dashboardLink, isApprovalRequired, isMissingProduct, productIdOf, verifyWebhook } from './stripe.mjs';
|
|
42
44
|
import { pendingStatementPeriod, statementFailures, arrivalOf, booksOf, linesOf, statementPage, lineFor, receiveStatement, receivedPages, referrerOk, sendStatements, settle, statementsIn, voidLineStatements } from './referrals.mjs';
|
|
43
45
|
import { SHOP_JS, officeShopPage, parentPage, refundsPage, shopPage, thanksPage } from './shop-page.mjs';
|
|
44
46
|
import { notFoundPage } from './site.mjs';
|
|
@@ -136,15 +138,19 @@ export async function readiness(env, shop) {
|
|
|
136
138
|
else if (shop.broken) missing.push('shop.json-refused');
|
|
137
139
|
else if (shop.till === 'off') missing.push('till-off');
|
|
138
140
|
else if (!shop.items.length) missing.push('items');
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
+
const paidItems = shop?.items.some((i) => i.price !== 0);
|
|
142
|
+
const links = linkConfig(env);
|
|
143
|
+
if (paidItems && !hasStripeKey(env)) {
|
|
144
|
+
if (fullerReason(shop)) missing.push('fuller-checkout');
|
|
145
|
+
else if (!links || links.settings !== linkSettings(shop) || shop.items.some((i) => i.price !== 0 && !links.items.some((l) => l.item === i.id && paymentLinkURL(l.url)))) missing.push('payment-links');
|
|
146
|
+
}
|
|
141
147
|
// Reliable fulfillment requires the webhook as well as the redirect.
|
|
142
148
|
// https://docs.stripe.com/checkout/fulfillment
|
|
143
|
-
if (!WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
|
|
149
|
+
if (paidItems && !WEBHOOK_SECRET_SHAPE.test(String(env?.STRIPE_WEBHOOK_SECRET ?? ''))) missing.push('webhook-secret');
|
|
144
150
|
let migration = null;
|
|
145
151
|
if (!env?.DB) missing.push('database');
|
|
146
152
|
else if ((migration = await migrationNeeded(env))) missing.push('migration');
|
|
147
|
-
return { ready: missing.length === 0, missing, migration, mode:
|
|
153
|
+
return { ready: missing.length === 0, missing, next: missing.length ? MISSING_WORDS[missing[0]] : null, migration, mode: paidItems ? shopMode(env) : null };
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
export const MISSING_WORDS = Object.freeze({
|
|
@@ -153,8 +159,10 @@ export const MISSING_WORDS = Object.freeze({
|
|
|
153
159
|
'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
|
|
154
160
|
'till-off': 'shop.json says "till": "off".',
|
|
155
161
|
items: 'shop.json has no items.',
|
|
156
|
-
'
|
|
157
|
-
'
|
|
162
|
+
'payment-links': 'Run homie-studio shop connect to sync your items and connect Stripe; approve its browser page if asked.',
|
|
163
|
+
'fuller-checkout': 'Your spending allowance or checkout expiry needs the fuller checkout connection. Ask your AI to connect it.',
|
|
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).',
|
|
165
|
+
'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
|
|
158
166
|
database: 'This Worker has no database.',
|
|
159
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 0011_shop_statements.sql and 0012_shop_lines.sql.',
|
|
160
168
|
});
|
|
@@ -191,7 +199,7 @@ async function itemsView(env, shop, { game, kids, beginner, player, open, cursor
|
|
|
191
199
|
const page = item ? selected : selected.slice(cursor, cursor + 100);
|
|
192
200
|
const items = page.map((it) => {
|
|
193
201
|
const owned = it.gives.length > 0 && it.gives.every((k) => owns.some((o) => o.key === k && o.item === it.id));
|
|
194
|
-
const way = wayFor(it, { open, kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
202
|
+
const way = wayFor(it, { open: open || it.price === 0 && !shop.broken && shop.till !== 'off', kids, beginner, player, band, owned, spent, cap: purchasing ? null : shop.capPerPlayerMonth, policy: shop.policy });
|
|
195
203
|
return { id: it.id, kind: it.kind, name: it.name, blurb: it.blurb ?? null, price: it.price, ...(it.price === 'choose' ? { min: it.min, max: it.max } : {}), shown: it.price === 'choose' ? `${money(it.min, shop.currency)} or more` : money(it.price, shop.currency), gives: it.gives, ...(it.badge ? { badge: it.badge } : {}), ...(it.days ? { days: it.days } : {}), ...(it.ends ? { ends: it.ends } : {}), ...(it.game ? { game: it.game } : {}), way, ...(way === 'cap' ? { retryWay: wayFor(it, { open, kids, beginner, player, band, owned, cap: null, policy: shop.policy }) } : {}) };
|
|
196
204
|
}).filter((i) => i.way !== 'beginner' && i.way !== 'kids');
|
|
197
205
|
return { band, owns, spent, items, nextCursor: !item && cursor + 100 < selected.length ? String(cursor + 100) : null };
|
|
@@ -263,7 +271,7 @@ async function listRoute(request, env, url, cat, shop, ready) {
|
|
|
263
271
|
// The studio decides which known age bands see no shop.
|
|
264
272
|
if ((v.band === 'child' && shop.policy.children === 'deny') || (v.band === 'teen' && shop.policy.teens === 'deny')) return json({ ok: true, open: false, kids: true, items: [] });
|
|
265
273
|
return json({
|
|
266
|
-
ok: true, open: ready.ready, ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
|
|
274
|
+
ok: true, keyless: !hasStripeKey(env), open: ready.ready || !ready.migration && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)) && shop.items.some((i) => i.price === 0), ...(ready.ready ? {} : { missing: ready.missing, migration: ready.migration }), mode: ready.mode,
|
|
267
275
|
currency: shop.currency, currencyScale: currencyScale(shop.currency), amountStep: amountStep(shop.currency), policy: shop.policy, till: shop.till, refundDays: shop.refundDays, cap: shop.capPerPlayerMonth, spent: v.spent,
|
|
268
276
|
player: player ? { signedIn: !player.guest, guest: player.guest, name: player.name, band: v.band } : null,
|
|
269
277
|
owns: v.owns.map((o) => o.key), items: v.items, nextCursor: v.nextCursor,
|
|
@@ -291,7 +299,7 @@ async function mineRoute(request, env, shop) {
|
|
|
291
299
|
for (const line of o.lines) {
|
|
292
300
|
if (line.kind === null) { const item = shop.items.find((i) => i.id === line.item); line.name = item?.name ?? line.name; line.kind = item?.kind ?? null; }
|
|
293
301
|
const used = await lineWasUsed(env, o.id, line);
|
|
294
|
-
line.refundable = refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
|
|
302
|
+
line.refundable = (o.amount === 0 || hasStripeKey(env)) && refundableNow({ ...line, paidAt: o.paidAt }, used ? [{ item: line.item, used: true }] : [], shop);
|
|
295
303
|
line.shown = money(line.amount, o.currency);
|
|
296
304
|
}
|
|
297
305
|
orders.push({ ...o, name: o.lines.map((l) => `${l.name} × ${l.quantity}`).join(', '), shown: money(o.amount, o.currency), refundable: o.lines.length > 0 && o.lines.every((l) => l.refundable) });
|
|
@@ -391,7 +399,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
391
399
|
if (!shop || !KEY_SHAPE.test(String(env?.STRIPE_KEY ?? '')) || !(await migrated(env))) return;
|
|
392
400
|
const now = Date.now();
|
|
393
401
|
// Filter eligibility before LIMIT. Unrecorded payments precede old paid rows needing repair.
|
|
394
|
-
const params = [
|
|
402
|
+
const params = [shopMode(env), now, Number.isFinite(before) ? before : Number.MAX_SAFE_INTEGER, ...(player ? [player] : []), ...(order ? [order] : [])];
|
|
395
403
|
const query = (status, limit) => env.DB.prepare(`SELECT * FROM shop_orders ${order ? '' : `INDEXED BY ${player ? 'shop_orders_attention_player' : 'shop_orders_attention'}`} WHERE attention = 1 AND mode = ?1
|
|
396
404
|
AND status = '${status}'
|
|
397
405
|
AND (created_at < ?3 ${expireOpen ? 'OR expires_at <= ?2' : ''}) ${player ? 'AND player = ?4' : ''} ${order ? `AND id = ?${player ? 5 : 4}` : ''}
|
|
@@ -452,6 +460,12 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
452
460
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
453
461
|
async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
|
|
454
462
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
463
|
+
const keyless = amount > 0 && !hasStripeKey(env);
|
|
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.') };
|
|
465
|
+
const link = keyless ? linkConfig(env)?.items.find((l) => l.item === item.id) : null;
|
|
466
|
+
if (keyless && (!link || !paymentLinkURL(link.url))) return { error: fail(503, 'payment-links', MISSING_WORDS['payment-links']) };
|
|
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.') };
|
|
468
|
+
if (keyless) lines = lines.map((l) => ({ ...l, item: { ...l.item, paymentLink: link } }));
|
|
455
469
|
try { if (via) referralShare(amount, shop.referrals.rate); } catch { return { error: fail(400, 'arithmetic', 'The referral share exceeds safe integer arithmetic.') }; }
|
|
456
470
|
const order = { id: newOrderId(), player: player.id, via: via ?? null, amount, currency: shop.currency };
|
|
457
471
|
const now = Date.now();
|
|
@@ -459,7 +473,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
459
473
|
order.expiresAt = Math.floor(now / 1000) + Math.max(31, shop.checkoutMinutes ?? 1440) * 60;
|
|
460
474
|
// One SQL statement reserves the amount: concurrent requests cannot both spend the remaining cap.
|
|
461
475
|
const reservation = () => env.DB.prepare("INSERT INTO shop_orders (id, player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, expires_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'started', ?9, ?10, ?11, ?11, ?14 WHERE ?5 = 0 OR ?12 IS NULL OR ?5 <= ?12 - (SELECT COALESCE(SUM(amount - refunded_net), 0) FROM shop_orders WHERE player = ?2 AND mode = ?8 AND ((status IN ('paid', 'disputed') AND paid_at >= ?13) OR (status IN ('started', 'processing') AND (session IS NOT NULL OR COALESCE(expires_at, created_at + 1860000) + 60000 > ?11))))")
|
|
462
|
-
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till,
|
|
476
|
+
.bind(order.id, player.id, item.id, game ?? item.game ?? null, amount, shop.currency, shop.till, shopMode(env), parent ? 1 : 0, order.via, now, shop.capPerPlayerMonth, Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), 1), order.expiresAt * 1000);
|
|
463
477
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
464
478
|
const reserve = async () => {
|
|
465
479
|
const results = await env.DB.batch([reservation(),
|
|
@@ -483,8 +497,12 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
483
497
|
await paid(env, shop, { id, metadata: { order: order.id }, client_reference_id: player.id, currency: shop.currency, amount_subtotal: 0, amount_total: 0, payment_status: 'no_payment_required' });
|
|
484
498
|
return { order, url: `${url.origin}/shop/thanks?session_id=${id}`, session: id, reconciled };
|
|
485
499
|
}
|
|
500
|
+
if (keyless) {
|
|
501
|
+
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
502
|
+
return { order, url: checkout.href, reconciled };
|
|
503
|
+
}
|
|
486
504
|
let session = null;
|
|
487
|
-
const mode =
|
|
505
|
+
const mode = shopMode(env);
|
|
488
506
|
try {
|
|
489
507
|
const useCatalog = Array.isArray(shop.catalog) && shop.catalog.includes(mode);
|
|
490
508
|
for (const line of lines) line.product = useCatalog ? await productIdOf(cat.studio?.slug, line.item.id) : null;
|
|
@@ -543,7 +561,8 @@ function purchaseLimited(request, shop, player, kind) {
|
|
|
543
561
|
async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
544
562
|
const b = await bodyOf(request, requestSize(shop));
|
|
545
563
|
if (b.error) return b.error;
|
|
546
|
-
|
|
564
|
+
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
|
|
565
|
+
if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
547
566
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
548
567
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
549
568
|
if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
|
|
@@ -564,7 +583,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
564
583
|
const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
|
|
565
584
|
const way = facts.kids && !shop.policy.kidsServer ? 'kids' : v.items[0]?.way ?? 'beginner';
|
|
566
585
|
if (way !== 'checkout') return fail(403, way, WAY_WORDS[way] ?? 'Not now.', { way });
|
|
567
|
-
const unit = item.price === 'choose' ? entry.amount : item.price;
|
|
586
|
+
const unit = item.price === 'choose' ? (entry.amount ?? (!hasStripeKey(env) ? item.min : undefined)) : item.price;
|
|
568
587
|
if (amountError(unit, shop.currency)) return fail(400, 'amount', amountError(unit, shop.currency));
|
|
569
588
|
if (item.price === 'choose' && (unit < item.min || (item.max !== null && unit > item.max))) return fail(400, 'amount', 'This amount is outside the studio tip range.');
|
|
570
589
|
const total = BigInt(unit) * BigInt(quantity) + BigInt(amount);
|
|
@@ -699,7 +718,7 @@ async function selfRefundRoute(request, env, shop, ready) {
|
|
|
699
718
|
const used = await lineWasUsed(env, o.id, line);
|
|
700
719
|
if (!refundableNow({ ...lineView(line), paidAt: o.paid_at }, used ? [{ item: line.item, used: true }] : [], shop)) return fail(403, 'not-refundable', 'Ask the studio for this refund. Tips are not refundable by the player; items follow the studio refund settings.');
|
|
701
720
|
}
|
|
702
|
-
if (Number(o.amount) !== 0 && !
|
|
721
|
+
if (Number(o.amount) !== 0 && !hasStripeKey(env)) return fail(409, 'ask-studio', 'Ask the studio to refund this payment in Stripe.');
|
|
703
722
|
const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player', line: b.body.line });
|
|
704
723
|
if (r.held) return json({ ...r, error: r.error === 'pending' ? 'pending' : 'held', message: r.error === 'pending' ? r.message : 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
|
|
705
724
|
return json(r, r.ok ? 200 : 502);
|
|
@@ -719,18 +738,20 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
719
738
|
const obj = ev.data?.object ?? {};
|
|
720
739
|
const needed = ['checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed'];
|
|
721
740
|
if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
|
|
722
|
-
if (typeof ev.livemode === 'boolean' &&
|
|
741
|
+
if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
|
|
723
742
|
try {
|
|
724
743
|
const sessionEvent = ev.type.startsWith('checkout.session.');
|
|
744
|
+
if (!sessionEvent) await rememberMoneyEvent(env, ev);
|
|
745
|
+
if (sessionEvent && obj.payment_link && !(await bindLinkSession(env, obj, ev.livemode ? 'live' : 'test'))) return json({ ok: true, did: 'mismatch' });
|
|
725
746
|
let known = sessionEvent ? await orderBySession(env, obj.id) : await orderByPayment(env, String(obj.payment_intent ?? ''));
|
|
726
747
|
if (!known) {
|
|
727
748
|
let metadata = obj.metadata;
|
|
728
749
|
let named = metadata?.order ? await orderById(env, metadata.order) : null;
|
|
729
750
|
// Charge metadata identifies foreign sales without a provider read. Untagged refunds/disputes
|
|
730
751
|
// need their PaymentIntent only when this database actually has an unrecorded payment.
|
|
731
|
-
if (!named && !sessionEvent && obj.payment_intent && !metadata?.order &&
|
|
752
|
+
if (hasStripeKey(env) && !named && !sessionEvent && obj.payment_intent && !metadata?.order &&
|
|
732
753
|
(!metadata?.homie || metadata.homie === 'shop-v1') && (!metadata?.origin || metadata.origin === new URL(request.url).origin) &&
|
|
733
|
-
await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(
|
|
754
|
+
await env.DB.prepare('SELECT 1 FROM shop_orders WHERE payment IS NULL AND paid_at IS NULL AND mode = ?1 LIMIT 1').bind(shopMode(env)).first()) {
|
|
734
755
|
let payment;
|
|
735
756
|
try { payment = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(obj.payment_intent)}`); }
|
|
736
757
|
catch (error) {
|
|
@@ -740,10 +761,11 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
740
761
|
metadata = payment.metadata;
|
|
741
762
|
named = metadata?.order ? await orderById(env, metadata.order) : null;
|
|
742
763
|
}
|
|
743
|
-
if (!named) return json({ ok: true, did: 'ignored' });
|
|
764
|
+
if (!named) return json({ ok: true, did: !sessionEvent && !hasStripeKey(env) ? 'remembered' : 'ignored' });
|
|
765
|
+
if (!hasStripeKey(env) && !sessionEvent) return json({ ok: true, did: 'remembered' });
|
|
744
766
|
if (sessionEvent) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
745
767
|
if (!(await migrated(env))) return fail(503, 'not-migrated', MISSING_WORDS.migration);
|
|
746
|
-
if (named.payment || named.mode !==
|
|
768
|
+
if (named.payment || named.mode !== shopMode(env)) return json({ ok: true, did: 'ignored' });
|
|
747
769
|
if (!named.session) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
748
770
|
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(named.session)}`, { expand: ['payment_intent.latest_charge'] });
|
|
749
771
|
if (session.id !== named.session || !['paid', 'no_payment_required'].includes(session.payment_status)) return fail(503, 'unknown-order', 'Payment order is not recorded yet; retry this event.');
|
|
@@ -780,6 +802,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
780
802
|
break;
|
|
781
803
|
case 'charge.dispute.created':
|
|
782
804
|
did = await disputed(env, String(obj.payment_intent ?? ''), String(obj.id ?? ''));
|
|
805
|
+
if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, obj.payment_intent, known.mode)) if (d.id === obj.id && ['lost', 'won', 'warning_closed'].includes(d.status)) did = await disputeClosed(env, obj.payment_intent, d.status);
|
|
783
806
|
break;
|
|
784
807
|
case 'charge.dispute.closed':
|
|
785
808
|
did = await disputeClosed(env, String(obj.payment_intent ?? ''), String(obj.status ?? ''));
|
|
@@ -787,6 +810,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
787
810
|
default:
|
|
788
811
|
break;
|
|
789
812
|
}
|
|
813
|
+
if (did === 'needs-keyed-recovery') return fail(503, did, 'This earlier multi-item checkout needs its original connection to recover tax per line. Reconnect it, then resend this event.');
|
|
790
814
|
if (did === 'missing-item' || did === 'unknown-order') return fail(503, did, did === 'missing-item' ? 'Restore this order item in shop.json to grant it; the office can refund its payment.' : 'Payment order is not recorded yet; retry this event.');
|
|
791
815
|
await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run();
|
|
792
816
|
if (Math.random() < 0.02) await env.DB.prepare('DELETE FROM shop_events WHERE at < ?1').bind(Date.now() - 60 * DAY).run();
|
|
@@ -797,12 +821,47 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
797
821
|
}
|
|
798
822
|
}
|
|
799
823
|
|
|
824
|
+
/** Payment Links can be reopened. Each signed Session becomes its own order; the URL reference
|
|
825
|
+
* selects a frozen local intent, never an entitlement or a buyer-supplied price. */
|
|
826
|
+
async function bindLinkSession(env, session, mode) {
|
|
827
|
+
if (!/^ord_[A-Za-z0-9]{20}$/.test(session.client_reference_id ?? '') || !/^cs_[A-Za-z0-9_]+$/.test(session.id ?? '')) return false;
|
|
828
|
+
const source = await orderById(env, session.client_reference_id);
|
|
829
|
+
if (!source || source.mode !== mode) return false;
|
|
830
|
+
const lines = await orderLines(env, source.id);
|
|
831
|
+
if (lines.length !== 1 || !lines[0].snapshot) return false;
|
|
832
|
+
const item = JSON.parse(lines[0].snapshot), link = item.paymentLink;
|
|
833
|
+
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
834
|
+
const amount = session.amount_subtotal;
|
|
835
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
|
|
836
|
+
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
837
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
|
|
838
|
+
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
839
|
+
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
|
840
|
+
await env.DB.prepare('UPDATE shop_orders SET session = ?2 WHERE id = ?1 AND session IS NULL').bind(source.id, session.id).run();
|
|
841
|
+
let order = await orderBySession(env, session.id);
|
|
842
|
+
if (!order) {
|
|
843
|
+
const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
|
|
844
|
+
await env.DB.batch([
|
|
845
|
+
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
|
|
846
|
+
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),
|
|
847
|
+
]);
|
|
848
|
+
order = await orderBySession(env, session.id);
|
|
849
|
+
}
|
|
850
|
+
if (!order) return false;
|
|
851
|
+
if (order.paid_at === null) await env.DB.batch([
|
|
852
|
+
env.DB.prepare('UPDATE shop_orders SET amount = ?2 WHERE id = ?1 AND paid_at IS NULL').bind(order.id, amount),
|
|
853
|
+
env.DB.prepare('UPDATE shop_order_lines SET quantity = ?2, unit_amount = ?3, amount = ?4 WHERE order_id = ?1 AND EXISTS (SELECT 1 FROM shop_orders WHERE id = ?1 AND paid_at IS NULL)').bind(order.id, quantity, amount / quantity, amount),
|
|
854
|
+
]);
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
800
858
|
/** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
|
|
801
859
|
async function paid(env, shop, session, paidAt = Date.now()) {
|
|
802
860
|
const o = await orderBySession(env, session.id);
|
|
803
861
|
if (!o) return 'unknown-order';
|
|
804
862
|
// The session must be the one this order opened, for this player and this price.
|
|
805
|
-
|
|
863
|
+
const linkOrder = (await orderLines(env, o.id)).some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
|
|
864
|
+
if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
|
|
806
865
|
if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
|
|
807
866
|
if (['refunded', 'lost', 'disputed'].includes(o.status)) {
|
|
808
867
|
if (o.payment) await syncRefunds(env, o.payment);
|
|
@@ -810,9 +869,9 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
810
869
|
return 'already';
|
|
811
870
|
}
|
|
812
871
|
const payment = typeof session.payment_intent === 'string'
|
|
813
|
-
? await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
|
|
872
|
+
? !hasStripeKey(env) ? { id: session.payment_intent } : await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(session.payment_intent)}`, { expand: ['latest_charge'] }) : session.payment_intent;
|
|
814
873
|
let dispute = null;
|
|
815
|
-
if (payment?.latest_charge?.disputed) {
|
|
874
|
+
if (hasStripeKey(env) && payment?.latest_charge?.disputed) {
|
|
816
875
|
const list = await stripeCall(env, 'GET', '/v1/disputes', { payment_intent: payment.id, limit: 100 });
|
|
817
876
|
if (!Array.isArray(list.data) || !list.data.length || list.has_more) throw new Error('Stripe dispute state is unavailable');
|
|
818
877
|
dispute = list.data.find((d) => d.status === 'lost') ?? list.data[0];
|
|
@@ -825,6 +884,7 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
825
884
|
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(tax) || total < 0 || tax < 0 || total !== Number(o.amount) + tax) return 'mismatch';
|
|
826
885
|
let stripeLines = null;
|
|
827
886
|
if (lines.length > 1 && tax) {
|
|
887
|
+
if (!hasStripeKey(env)) return 'needs-keyed-recovery';
|
|
828
888
|
// Use Stripe's actual tax per line, never spread tax across products with different tax codes.
|
|
829
889
|
// https://docs.stripe.com/api/checkout/sessions/line_items
|
|
830
890
|
const response = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(session.id)}/line_items`, { limit: 100, expand: ['data.price.product'] });
|
|
@@ -866,7 +926,13 @@ async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
866
926
|
await disputed(env, paymentId, dispute.id);
|
|
867
927
|
if (['lost', 'won', 'warning_closed'].includes(dispute.status)) await disputeClosed(env, paymentId, dispute.status);
|
|
868
928
|
}
|
|
869
|
-
if (paymentId)
|
|
929
|
+
if (paymentId) {
|
|
930
|
+
await syncRefunds(env, paymentId);
|
|
931
|
+
if (!hasStripeKey(env)) for (const d of await rememberedMoney(env, paymentId, o.mode)) if (d.id.startsWith('dp_')) {
|
|
932
|
+
await disputed(env, paymentId, d.id);
|
|
933
|
+
if (['lost', 'won', 'warning_closed'].includes(d.status)) await disputeClosed(env, paymentId, d.status);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
870
936
|
await env.DB.prepare('UPDATE shop_orders SET attention = 0 WHERE id = ?1').bind(o.id).run();
|
|
871
937
|
return result.at(-1)?.meta?.changes ? 'paid' : 'already';
|
|
872
938
|
}
|
|
@@ -882,7 +948,13 @@ async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
|
|
|
882
948
|
const all = await orderLines(env, o.id);
|
|
883
949
|
const refunds = new Map();
|
|
884
950
|
let cursor;
|
|
885
|
-
if (payment)
|
|
951
|
+
if (payment && !hasStripeKey(env)) {
|
|
952
|
+
for (const r of await rememberedMoney(env, payment, o.mode)) if (r.id.startsWith('re_')) {
|
|
953
|
+
if (r.currency !== o.currency) throw new Error('Refund currency mismatch');
|
|
954
|
+
refunds.set(r.id, r);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (payment && hasStripeKey(env)) do {
|
|
886
958
|
const page = await stripeCall(env, 'GET', '/v1/refunds', { payment_intent: payment, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
|
|
887
959
|
if (!Array.isArray(page.data)) throw new Error('Stripe refund list is unavailable');
|
|
888
960
|
for (const refund of page.data) refunds.set(refund.id, refund);
|
|
@@ -991,6 +1063,7 @@ export async function refundOrder(env, o, { reason = 'requested_by_customer', by
|
|
|
991
1063
|
const amount = selected.reduce((sum, l) => sum + Number(l.total ?? l.amount) - Number(l.refunded_amount ?? 0), 0);
|
|
992
1064
|
let refund = null;
|
|
993
1065
|
if (amount) {
|
|
1066
|
+
if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Refund in your Stripe Dashboard or ask your AI connected to Stripe. The signed refund event updates this shop.', url: dashboardLink(o.mode, 'payment', o.payment) };
|
|
994
1067
|
if (!o.payment) return { ok: false, error: 'no-payment', message: 'Stripe has not confirmed the payment yet.' };
|
|
995
1068
|
try {
|
|
996
1069
|
const prior = lineId ? selected[0].refund : legacy ? o.refund : o.whole_refund;
|
|
@@ -1040,6 +1113,7 @@ export function checkRelease(body) {
|
|
|
1040
1113
|
return checked.ok ? { ok: true, action: { op: 'shop-release', order: body.order } } : checked;
|
|
1041
1114
|
}
|
|
1042
1115
|
export async function performRelease(env, a, cat) {
|
|
1116
|
+
if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Stripe controls this checkout’s lifetime. Ask your connected AI to expire it in Stripe if needed.' };
|
|
1043
1117
|
const row = await orderById(env, a.order);
|
|
1044
1118
|
if (!row || !['started', 'processing'].includes(row.status)) return { ok: false, error: 'state', message: 'Only an unresolved order can be released.' };
|
|
1045
1119
|
// https://docs.stripe.com/api/checkout/sessions/expire — only open sessions can expire.
|
|
@@ -1079,7 +1153,7 @@ export function describeRefund(cat, a, order = null) {
|
|
|
1079
1153
|
export async function performRefund(env, cat, a) {
|
|
1080
1154
|
let o = await orderById(env, a.order);
|
|
1081
1155
|
if (!o) return { ok: false, error: 'order', message: 'No such order.' };
|
|
1082
|
-
if (!o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
|
|
1156
|
+
if (hasStripeKey(env) && !o.payment && o.session && o.paid_at === null && !(await migrationNeeded(env))) {
|
|
1083
1157
|
try {
|
|
1084
1158
|
const session = await stripeCall(env, 'GET', `/v1/checkout/sessions/${encodeURIComponent(o.session)}`, { expand: ['payment_intent.latest_charge'] }, { timeout: 2000 });
|
|
1085
1159
|
if (session?.id === o.session && (session.livemode === undefined || session.livemode === (o.mode === 'live')) && ['paid', 'no_payment_required'].includes(session.payment_status)) {
|
|
@@ -1104,6 +1178,7 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1104
1178
|
balance: dashboardLink(mode, 'balance'), tax: dashboardLink(mode, 'tax'), keys: dashboardLink(mode, 'keys'), webhooks: dashboardLink(mode, 'webhooks'),
|
|
1105
1179
|
...(shop?.till === 'stripe-managed' ? { managedPayments: dashboardLink(mode, 'managed-payments') } : {}),
|
|
1106
1180
|
},
|
|
1181
|
+
keyless: !hasStripeKey(env),
|
|
1107
1182
|
hook: `${origin}/api/shop/hook`,
|
|
1108
1183
|
totals: null, orders: [], referrals: { owe: [], owedToUs: [] },
|
|
1109
1184
|
};
|
|
@@ -1121,8 +1196,8 @@ export async function shopOffice(env, cat, origin, cursor = '', itemCursor = 0,
|
|
|
1121
1196
|
lines: (await orderLines(env, r.id)).map((line) => { const view = lineView(line); return { ...view, name: line.snapshot ? view.name : names.get(line.item) ?? view.name }; }),
|
|
1122
1197
|
...orderView(r), note: r.note ?? null, name: names.get(r.item) ?? r.item, shown: money(r.amount, r.currency), player: r.player ? { id: r.player, name: r.player_name ?? null } : null,
|
|
1123
1198
|
stripe: r.payment ? dashboardLink(r.mode, 'payment', r.payment) : null, dispute: r.dispute ? dashboardLink(r.mode, 'dispute', r.dispute) : null,
|
|
1124
|
-
releasable: ['started', 'processing'].includes(r.status),
|
|
1125
|
-
refundable: ['paid', 'started', 'processing', 'missing', 'released', 'expired', 'failed'].includes(r.status) && (Number(r.amount) === 0 || Boolean(r.payment) || Boolean(r.session)),
|
|
1199
|
+
releasable: hasStripeKey(env) && ['started', 'processing'].includes(r.status),
|
|
1200
|
+
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)),
|
|
1126
1201
|
})));
|
|
1127
1202
|
const { books, nextCursor } = await booksOf(env, { cursor });
|
|
1128
1203
|
out.referrals = { nextCursor, owe: books, failures: await statementFailures(env), owedToUs: await statementsIn(env), invoice: dashboardLink(mode, 'invoices') };
|
package/worker/stripe.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* createCheckoutSession(env, params) POST /v1/checkout/sessions (Stripe's hosted page)
|
|
6
6
|
* createRefund(env, params) POST /v1/refunds (only ever from the owner's tap, or a confirmed ask)
|
|
7
|
-
* webhook endpoints list, create and turn off:
|
|
7
|
+
* webhook endpoints list, create and turn off: the manual `homie-studio shop connect --manual` fallback calls these, on the
|
|
8
8
|
* owner's computer with the key the owner pasted there (0.24.3), so a new
|
|
9
9
|
* endpoint's signing secret goes straight to the Worker secret
|
|
10
10
|
* productIdOf(slug, item) the catalog Product a shop item is (`homie-studio shop catalog`, 0.24.3)
|
|
@@ -55,13 +55,13 @@ export function formEncode(params, prefix = '', out = new URLSearchParams()) {
|
|
|
55
55
|
export function redactStripe(value, env = {}) {
|
|
56
56
|
let text = String(value ?? '');
|
|
57
57
|
for (const secret of [env.STRIPE_KEY, env.STRIPE_WEBHOOK_SECRET]) if (secret) text = text.replaceAll(String(secret), '[redacted]');
|
|
58
|
-
return text.replace(/(?:
|
|
58
|
+
return text.replace(/(?:(?:sk|rk|pk|whsec|oak)_[A-Za-z0-9+/=_-]+|[A-Za-z0-9_]+_secret_[A-Za-z0-9_-]+)/g, '[redacted]');
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export class StripeError extends Error {
|
|
62
62
|
constructor(status, body) {
|
|
63
63
|
const e = body?.error ?? {};
|
|
64
|
-
super(e.message ? redactStripe(e.message).slice(0,
|
|
64
|
+
super(e.message ? redactStripe(e.message).slice(0, 400) : `Stripe answered ${status}`);
|
|
65
65
|
this.status = status;
|
|
66
66
|
this.code = redactStripe(e.code ?? e.type ?? 'stripe');
|
|
67
67
|
this.type = e.type ? redactStripe(e.type) : null;
|
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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.35.1';
|