@hachej/boring-core 0.1.42 → 0.1.44

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.
@@ -24,12 +24,16 @@ import {
24
24
  requireWorkspaceMember,
25
25
  validateConfig,
26
26
  validatePasswordStrength
27
- } from "../chunk-GZVKZD4P.js";
27
+ } from "../chunk-UM5SHYIS.js";
28
28
  import {
29
+ InsufficientCreditError,
30
+ PostgresMeteringStore,
29
31
  createDatabase,
30
32
  runMigrations
31
- } from "../chunk-C3YMOITB.js";
32
- import "../chunk-H5KU6R6Y.js";
33
+ } from "../chunk-I56OTSPB.js";
34
+ import {
35
+ ERROR_CODES
36
+ } from "../chunk-LIBHVT7V.js";
33
37
  import "../chunk-MLKGABMK.js";
34
38
 
35
39
  // src/server/security/safeRedirect.ts
@@ -102,21 +106,1476 @@ function createFsProvisioner(opts) {
102
106
  }
103
107
  };
104
108
  }
109
+
110
+ // src/server/credits/pricing.ts
111
+ var CONSERVATIVE_DEFAULT_RATE = { inputPerMillion: 3, outputPerMillion: 15 };
112
+ var DEFAULT_MODEL_RATES = [
113
+ // Kimi K2 (public token pricing) for Ollama-hosted demo parity.
114
+ [/kimi-k2/i, { inputPerMillion: 0.6, outputPerMillion: 2.5 }],
115
+ // Claude (public API list prices) as a fallback if ever routed there. Opus is
116
+ // listed FIRST and matches any opus SKU shape (3-opus / opus-4 / 4-opus) so an
117
+ // expensive Opus id is never undercharged at the Sonnet rate. The Sonnet entry
118
+ // deliberately does NOT include a bare "4" (which would catch claude-4-opus).
119
+ [/claude-(?:[0-9.-]*opus|opus[0-9.-]*)/i, { inputPerMillion: 15, outputPerMillion: 75 }],
120
+ [/claude-(?:sonnet|3-5-sonnet|3-7-sonnet|sonnet-4|4-sonnet|4-5-sonnet)/i, { inputPerMillion: 3, outputPerMillion: 15 }],
121
+ [/claude-3-haiku/i, { inputPerMillion: 0.25, outputPerMillion: 1.25 }]
122
+ ];
123
+ function clampTokens(value) {
124
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
125
+ }
126
+ function effectiveRateTable(config) {
127
+ return config.rates ? [...config.rates, ...DEFAULT_MODEL_RATES] : DEFAULT_MODEL_RATES;
128
+ }
129
+ function rateForModel(modelId, config) {
130
+ return effectiveRateTable(config).find(([pattern]) => pattern.test(modelId))?.[1] ?? null;
131
+ }
132
+ function maxEffectiveRate(config) {
133
+ return maxRateOver(effectiveRateTable(config), config);
134
+ }
135
+ function maxServedRate(config) {
136
+ return maxRateOver(config.rates ?? [], config);
137
+ }
138
+ function maxRateOver(table, config) {
139
+ const fallback = config.defaultRate ?? CONSERVATIVE_DEFAULT_RATE;
140
+ let inputPerMillion = fallback.inputPerMillion;
141
+ let outputPerMillion = fallback.outputPerMillion;
142
+ for (const [, rate] of table) {
143
+ inputPerMillion = Math.max(inputPerMillion, rate.inputPerMillion);
144
+ outputPerMillion = Math.max(outputPerMillion, rate.outputPerMillion);
145
+ }
146
+ return { inputPerMillion, outputPerMillion };
147
+ }
148
+ function estimateProviderCost(modelId, inputTokens, outputTokens, config) {
149
+ const matched = rateForModel(modelId, config);
150
+ const rate = matched ?? config.defaultRate ?? maxEffectiveRate(config);
151
+ const units = inputTokens / 1e6 * rate.inputPerMillion + outputTokens / 1e6 * rate.outputPerMillion;
152
+ return { units, usedDefault: matched === null };
153
+ }
154
+ function usageToCredits(usage, model, config) {
155
+ const inputTokens = clampTokens(usage.inputTokens);
156
+ const outputTokens = clampTokens(usage.outputTokens);
157
+ const cacheReadTokens = clampTokens(usage.cacheReadTokens);
158
+ const cacheWriteTokens = clampTokens(usage.cacheWriteTokens);
159
+ const modelId = [model.provider, model.id].filter(Boolean).join("/") || "unknown";
160
+ const reported = usage.providerReportedCost;
161
+ const reportedUnits = typeof reported === "number" && Number.isFinite(reported) && reported > 0 ? reported : 0;
162
+ const estimated = estimateProviderCost(
163
+ modelId,
164
+ inputTokens + cacheReadTokens + cacheWriteTokens,
165
+ outputTokens,
166
+ config
167
+ );
168
+ const useReported = config.preferProviderReportedCost === true && reportedUnits > 0;
169
+ const providerUnits = useReported ? reportedUnits : estimated.units;
170
+ const margin = Number.isFinite(config.margin) && config.margin > 0 ? config.margin : 1;
171
+ const providerCostMicros = Math.ceil(providerUnits * config.creditMicrosPerUnit);
172
+ const billedCreditMicros = Math.ceil(providerUnits * margin * config.creditMicrosPerUnit);
173
+ return {
174
+ inputTokens,
175
+ outputTokens,
176
+ cacheReadTokens,
177
+ cacheWriteTokens,
178
+ providerCostMicros,
179
+ billedCreditMicros,
180
+ // Only "default-priced" when token pricing was used AND it fell back to the
181
+ // default rate (a trusted reported cost or a matched rate is authoritative).
182
+ pricedFromDefault: !useReported && estimated.usedDefault
183
+ };
184
+ }
185
+
186
+ // src/server/credits/creditsService.ts
187
+ function validatePricingConfig(p) {
188
+ if (!Number.isSafeInteger(p.creditMicrosPerUnit) || p.creditMicrosPerUnit <= 0) {
189
+ throw new Error("credits pricing.creditMicrosPerUnit must be a positive safe integer");
190
+ }
191
+ if (!Number.isFinite(p.margin) || p.margin < 1) {
192
+ throw new Error("credits pricing.margin must be a finite number >= 1 (never bill below provider cost)");
193
+ }
194
+ const checkRate = (rate, label) => {
195
+ if (!Number.isFinite(rate.inputPerMillion) || rate.inputPerMillion <= 0 || !Number.isFinite(rate.outputPerMillion) || rate.outputPerMillion <= 0) {
196
+ throw new Error(`credits pricing ${label} rate must have positive input/output rates`);
197
+ }
198
+ };
199
+ for (const [, rate] of p.rates ?? []) checkRate(rate, "configured");
200
+ if (p.defaultRate) checkRate(p.defaultRate, "default");
201
+ }
202
+ var SIGNUP_GRANT_REASON = "signup_grant";
203
+ var DEFAULT_CREDITS_CONFIG = {
204
+ enabled: true,
205
+ signupGrantMicros: 2e6,
206
+ // €2
207
+ signupGrantExpiresAfterDays: null,
208
+ // €1 hold — covers a worst-case single run so a run rarely overshoots its hold.
209
+ runReservationMicros: 1e6,
210
+ reservationTtlSeconds: 2 * 60 * 60,
211
+ minBalanceMicros: 5e4,
212
+ // €0.05
213
+ // No explicit defaultRate ⇒ an unmatched model bills at the highest effective
214
+ // rate (fail closed), not a cheap fallback.
215
+ pricing: { margin: 1.3, creditMicrosPerUnit: 1e6 }
216
+ };
217
+ var CreditExhaustedError = class extends Error {
218
+ statusCode = 402;
219
+ code = "PAYMENT_REQUIRED";
220
+ details;
221
+ constructor(balance) {
222
+ super("You're out of credits. Top up your balance to keep going.");
223
+ this.name = "CreditExhaustedError";
224
+ this.details = { balance };
225
+ }
226
+ };
227
+ function disabledBalance(userId) {
228
+ return {
229
+ enabled: false,
230
+ userId,
231
+ grantedMicros: 0,
232
+ usedMicros: 0,
233
+ activeReservedMicros: 0,
234
+ remainingMicros: Number.MAX_SAFE_INTEGER,
235
+ availableMicros: Number.MAX_SAFE_INTEGER,
236
+ debtMicros: 0,
237
+ currency: "credits"
238
+ };
239
+ }
240
+ var CreditsService = class {
241
+ constructor(store, config = DEFAULT_CREDITS_CONFIG, log) {
242
+ this.store = store;
243
+ this.config = config;
244
+ this.log = log;
245
+ if (!config.enabled) return;
246
+ validatePricingConfig(config.pricing);
247
+ const posInt = (n) => Number.isSafeInteger(n) && n > 0;
248
+ const nonNegInt = (n) => Number.isSafeInteger(n) && n >= 0;
249
+ if (!nonNegInt(config.signupGrantMicros)) throw new Error("credits: signupGrantMicros must be a non-negative safe integer");
250
+ if (!posInt(config.runReservationMicros)) throw new Error("credits: runReservationMicros must be a positive safe integer");
251
+ if (!nonNegInt(config.minBalanceMicros)) throw new Error("credits: minBalanceMicros must be a non-negative safe integer");
252
+ if (!posInt(config.reservationTtlSeconds)) throw new Error("credits: reservationTtlSeconds must be a positive safe integer");
253
+ if (!Number.isSafeInteger(config.runReservationMicros + config.minBalanceMicros)) {
254
+ throw new Error("credits: runReservationMicros + minBalanceMicros exceeds the safe integer range");
255
+ }
256
+ if (config.signupGrantExpiresAfterDays !== null) {
257
+ throw new Error("credits: signupGrantExpiresAfterDays is not supported yet (an expiring grant turns a partly-spent trial into debt); use null");
258
+ }
259
+ }
260
+ store;
261
+ config;
262
+ log;
263
+ /** Users whose signup grant was ensured this process; avoids an INSERT per balance poll. */
264
+ signupGrantedUsers = /* @__PURE__ */ new Set();
265
+ /** Idempotently grant the free starter credits (call from the post-signup hook
266
+ * and lazily on first balance/reserve). The grant NEVER expires: an expiring
267
+ * grant would drop from grantedMicros on expiry while spent usage stayed, turning
268
+ * a partly-spent trial into debt. (Proper expiry must cap/allocate usage against
269
+ * the promo balance — a tracked follow-up; the expiry config is rejected up front.) */
270
+ async grantSignupCredits(userId) {
271
+ if (!this.config.enabled || this.config.signupGrantMicros <= 0) return;
272
+ if (this.signupGrantedUsers.has(userId)) return;
273
+ await this.store.grantOnce({
274
+ userId,
275
+ reason: SIGNUP_GRANT_REASON,
276
+ amountMicros: this.config.signupGrantMicros
277
+ });
278
+ this.signupGrantedUsers.add(userId);
279
+ }
280
+ /** Credit a completed purchase. Globally idempotent per order id (safe on
281
+ * webhook retry, and the same order can never be credited to two users). The
282
+ * optional provider identity is persisted for audit/refund reconciliation. */
283
+ async grantPurchase(userId, orderId, amountMicros, identity) {
284
+ if (!this.config.enabled) return { created: false };
285
+ const { granted } = await this.store.grantPurchaseOnce({ userId, orderId, amountMicros, ...identity });
286
+ return { created: granted };
287
+ }
288
+ /** Revoke a refunded/disputed purchase. `refundFraction` is the cumulative
289
+ * fraction of the order refunded (LS refunded_amount / total) for partial
290
+ * refunds; omit for a full refund. `allowTombstone` permits writing a pre-grant
291
+ * refund tombstone for an order not yet credited (set only when the refund
292
+ * validates as a credit order); an already-credited order is always revocable.
293
+ * Idempotent per cumulative level. */
294
+ async revokePurchase(orderId, opts = {}) {
295
+ if (!this.config.enabled) return { revoked: false };
296
+ return this.store.revokePurchase(orderId, {
297
+ refundFraction: opts.refundFraction,
298
+ allowTombstone: opts.allowTombstone,
299
+ expectedStoreId: opts.expectedStoreId,
300
+ expectedTestMode: opts.expectedTestMode,
301
+ expectedCurrency: opts.expectedCurrency
302
+ });
303
+ }
304
+ async getBalance(userId) {
305
+ if (!this.config.enabled) return disabledBalance(userId);
306
+ await this.grantSignupCredits(userId);
307
+ const balance = await this.store.getBalance(userId);
308
+ return {
309
+ enabled: true,
310
+ userId,
311
+ grantedMicros: balance.grantedMicros,
312
+ usedMicros: balance.usedMicros,
313
+ activeReservedMicros: balance.activeReservedMicros,
314
+ // remaining = granted − used (ledger). available = remaining − active holds.
315
+ remainingMicros: Math.max(0, balance.remainingMicros),
316
+ availableMicros: Math.max(0, balance.availableMicros),
317
+ // Owed when the raw ledger is negative (refund of already-spent credits).
318
+ debtMicros: Math.max(0, -balance.remainingMicros),
319
+ currency: "credits"
320
+ };
321
+ }
322
+ /** Recent credit ledger (grants/purchases + usage/refund debits) for the account
323
+ * activity view, newest first, capped (clamped 1..50 in the store). Empty when
324
+ * credits are disabled. */
325
+ async listLedger(userId, limit = 20) {
326
+ if (!this.config.enabled) return [];
327
+ return this.store.listLedger(userId, limit);
328
+ }
329
+ /** Reserve a per-run hold. Returns the reservation id; throws
330
+ * CreditExhaustedError (402) below the floor. */
331
+ async reserveRun(input) {
332
+ if (!this.config.enabled) return void 0;
333
+ await this.grantSignupCredits(input.userId);
334
+ try {
335
+ const { reservationId } = await this.store.reserve({
336
+ userId: input.userId,
337
+ workspaceId: input.workspaceId,
338
+ sessionId: input.sessionId,
339
+ runId: input.runId,
340
+ source: "pi-chat",
341
+ amountMicros: this.config.runReservationMicros,
342
+ ttlSeconds: this.config.reservationTtlSeconds,
343
+ // Must keep minBalanceMicros available AFTER placing the hold, so a run
344
+ // is admitted only when available ≥ hold + floor (matches the config doc).
345
+ minAvailableMicros: this.config.runReservationMicros + this.config.minBalanceMicros
346
+ });
347
+ return reservationId;
348
+ } catch (error) {
349
+ if (error instanceof InsufficientCreditError) {
350
+ throw new CreditExhaustedError(await this.getBalance(input.userId));
351
+ }
352
+ throw error;
353
+ }
354
+ }
355
+ /** Charge native usage, priced token→credits with margin. Returns the billed
356
+ * credit micros (0 when disabled or the usage priced to nothing) so the caller can
357
+ * decide billability from the ACTUAL charge, not raw provider fields — e.g. a
358
+ * cost-only row prices to 0 unless preferProviderReportedCost is set. */
359
+ async recordUsage(input) {
360
+ if (!this.config.enabled) return { billedMicros: 0 };
361
+ const model = { provider: input.provider, id: input.model };
362
+ const cost = usageToCredits(
363
+ {
364
+ inputTokens: input.usage.input,
365
+ outputTokens: input.usage.output,
366
+ cacheReadTokens: input.usage.cacheRead,
367
+ cacheWriteTokens: input.usage.cacheWrite,
368
+ providerReportedCost: input.usage.cost.total
369
+ },
370
+ model,
371
+ this.config.pricing
372
+ );
373
+ if (cost.pricedFromDefault) {
374
+ this.log?.("credits: model billed at default rate (no configured rate)", {
375
+ model: input.model,
376
+ provider: input.provider,
377
+ billedCostMicros: cost.billedCreditMicros
378
+ });
379
+ }
380
+ await this.store.recordUsage({
381
+ usageId: input.usageId,
382
+ userId: input.userId,
383
+ workspaceId: input.workspaceId,
384
+ sessionId: input.sessionId,
385
+ runId: input.runId,
386
+ messageId: input.messageId,
387
+ source: "pi-chat",
388
+ provider: input.provider,
389
+ model: input.model,
390
+ inputTokens: cost.inputTokens,
391
+ outputTokens: cost.outputTokens,
392
+ cacheReadTokens: cost.cacheReadTokens,
393
+ cacheWriteTokens: cost.cacheWriteTokens,
394
+ providerCostMicros: cost.providerCostMicros,
395
+ billedCostMicros: cost.billedCreditMicros,
396
+ stopReason: input.stopReason,
397
+ // reservationId tags the row to THIS run attempt so the fallback top-up can
398
+ // scope to the current reservation (runId is reused on client-nonce replay).
399
+ metadata: { currency: "credits", ...input.reservationId ? { reservationId: input.reservationId } : {} }
400
+ });
401
+ return { billedMicros: cost.billedCreditMicros };
402
+ }
403
+ async settleRun(userId, runId, reservationId) {
404
+ if (!this.config.enabled) return;
405
+ await this.store.finishReservation(reservationId ? { reservationId } : { runId, userId }, "settled");
406
+ }
407
+ async releaseRun(userId, runId, reservationId) {
408
+ if (!this.config.enabled) return;
409
+ await this.store.finishReservation(reservationId ? { reservationId } : { runId, userId }, "released");
410
+ }
411
+ /**
412
+ * Fail-closed billing for a completed run whose usage write failed: a run that
413
+ * already executed must never go free. Charge the per-run hold (worst-case)
414
+ * as a conservative, idempotent debit, then settle the reservation. Tagged
415
+ * source 'pi-chat-fallback' so it's reconcilable against the missing real
416
+ * usage row. Over-charges rather than risk free usage.
417
+ */
418
+ async chargeFallbackUsage(input) {
419
+ if (!this.config.enabled) return;
420
+ const key = input.reservationId ?? input.runId;
421
+ const metaKind = input.kind === "no_billable_usage" ? "no_billable_usage_fallback" : "usage_write_failed_fallback";
422
+ if (input.reservationId) {
423
+ await this.store.markReservationFallbackCharge(input.userId, input.reservationId);
424
+ }
425
+ const alreadyBilled = input.reservationId ? await this.store.billedMicrosForReservation(input.userId, input.reservationId) : await this.store.billedMicrosForRun(input.userId, input.runId);
426
+ const topUp = Math.max(0, this.config.runReservationMicros - alreadyBilled);
427
+ if (topUp > 0) {
428
+ await this.store.recordUsage({
429
+ usageId: `usage-fallback:${key}`,
430
+ userId: input.userId,
431
+ runId: input.runId,
432
+ source: "pi-chat-fallback",
433
+ billedCostMicros: topUp,
434
+ providerCostMicros: 0,
435
+ metadata: { kind: metaKind, reservationId: input.reservationId ?? null, alreadyBilledMicros: alreadyBilled, currency: "credits" }
436
+ });
437
+ }
438
+ await this.store.finishReservation(
439
+ input.reservationId ? { reservationId: input.reservationId } : { runId: input.runId, userId: input.userId },
440
+ "settled"
441
+ );
442
+ this.log?.("credits: fallback hold charge \u2014 topped up to the hold and settled (reconcile against missing/non-billable usage)", {
443
+ kind: metaKind,
444
+ runId: input.runId,
445
+ reservationId: input.reservationId,
446
+ alreadyBilledMicros: alreadyBilled,
447
+ topUpMicros: topUp
448
+ });
449
+ }
450
+ };
451
+
452
+ // src/server/credits/meteringSink.ts
453
+ function authRequiredError() {
454
+ return Object.assign(new Error("authentication required"), { statusCode: 401, code: "UNAUTHORIZED" });
455
+ }
456
+ function createCreditsMeteringSink(getService) {
457
+ return {
458
+ async reserveRun(input) {
459
+ const service = getService();
460
+ if (!service.config.enabled) return {};
461
+ if (!input.userId) throw authRequiredError();
462
+ const reservationId = await service.reserveRun({
463
+ userId: input.userId,
464
+ workspaceId: input.workspaceId,
465
+ sessionId: input.sessionId,
466
+ runId: input.runId
467
+ });
468
+ return { reservationId };
469
+ },
470
+ async recordUsage(input) {
471
+ if (!input.userId) return { billedMicros: 0 };
472
+ return getService().recordUsage({
473
+ usageId: input.usageId,
474
+ userId: input.userId,
475
+ workspaceId: input.workspaceId,
476
+ sessionId: input.sessionId,
477
+ runId: input.runId,
478
+ messageId: input.messageId,
479
+ reservationId: input.reservationId,
480
+ provider: input.model?.provider,
481
+ model: input.model?.id,
482
+ usage: input.usage,
483
+ stopReason: input.stopReason
484
+ });
485
+ },
486
+ async settleRun(input) {
487
+ if (!input.userId) return;
488
+ await getService().settleRun(input.userId, input.runId, input.reservationId);
489
+ },
490
+ async releaseRun(input) {
491
+ if (!input.userId) return;
492
+ if (input.reason === "usage-write-failed" || input.reason === "fallback-hold-charge") {
493
+ await getService().chargeFallbackUsage({
494
+ userId: input.userId,
495
+ runId: input.runId,
496
+ reservationId: input.reservationId,
497
+ // Carry the cause through to the ledger metadata/log for honest audit.
498
+ kind: input.reason === "usage-write-failed" ? "usage_write_failed" : "no_billable_usage"
499
+ });
500
+ return;
501
+ }
502
+ await getService().releaseRun(input.userId, input.runId, input.reservationId);
503
+ }
504
+ };
505
+ }
506
+
507
+ // src/server/credits/lemonSqueezy.ts
508
+ import { createHmac, timingSafeEqual } from "crypto";
509
+ function verifyLemonSqueezySignature(rawBody, signatureHeader, secret) {
510
+ if (!signatureHeader || !secret) return false;
511
+ const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
512
+ const a = Buffer.from(expected, "utf8");
513
+ const b = Buffer.from(signatureHeader, "utf8");
514
+ if (a.length !== b.length) return false;
515
+ return timingSafeEqual(a, b);
516
+ }
517
+ function signUserAttribution(userId, secret) {
518
+ return createHmac("sha256", secret).update(`credit-user:${userId}`).digest("hex");
519
+ }
520
+ function verifyUserAttribution(userId, token, secret) {
521
+ if (!userId || !token) return false;
522
+ const actual = Buffer.from(token, "utf8");
523
+ const secrets = typeof secret === "string" ? [secret] : secret;
524
+ return secrets.some((s) => {
525
+ if (!s) return false;
526
+ const expected = Buffer.from(signUserAttribution(userId, s), "utf8");
527
+ if (expected.length !== actual.length) return false;
528
+ return timingSafeEqual(expected, actual);
529
+ });
530
+ }
531
+ function asRecord(value) {
532
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
533
+ }
534
+ function asString(value) {
535
+ return typeof value === "string" && value.length > 0 ? value : void 0;
536
+ }
537
+ function asNumber(value) {
538
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
539
+ }
540
+ function asOptionalNumber(value) {
541
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
542
+ }
543
+ function asOptionalId(value) {
544
+ if (value === null || value === void 0) return void 0;
545
+ const s = String(value);
546
+ return s.length > 0 ? s : void 0;
547
+ }
548
+ function parseLemonSqueezyOrder(payload) {
549
+ const root = asRecord(payload);
550
+ const meta = asRecord(root?.meta);
551
+ const data = asRecord(root?.data);
552
+ const attrs = asRecord(data?.attributes);
553
+ const eventName = asString(meta?.event_name);
554
+ const orderId = asString(data?.id);
555
+ if (!eventName || !orderId || !attrs) return null;
556
+ const customData = asRecord(meta?.custom_data);
557
+ const firstItem = asRecord(attrs.first_order_item);
558
+ return {
559
+ eventName,
560
+ orderId,
561
+ userId: asString(customData?.user_id),
562
+ userAttributionToken: asString(customData?.uat),
563
+ userEmail: asString(attrs.user_email),
564
+ status: asString(attrs.status),
565
+ testMode: typeof attrs.test_mode === "boolean" ? attrs.test_mode : void 0,
566
+ storeId: asOptionalId(attrs.store_id),
567
+ currency: asString(attrs.currency),
568
+ subtotalCents: asOptionalNumber(attrs.subtotal),
569
+ discountTotalCents: asOptionalNumber(attrs.discount_total),
570
+ totalCents: asOptionalNumber(attrs.total),
571
+ taxCents: asNumber(attrs.tax),
572
+ refunded: attrs.refunded === true,
573
+ refundedAmountCents: asNumber(attrs.refunded_amount),
574
+ variantId: asOptionalId(firstItem?.variant_id),
575
+ quantity: (() => {
576
+ const q = asNumber(firstItem?.quantity);
577
+ return Number.isInteger(q) && q > 0 ? q : 1;
578
+ })(),
579
+ productName: asString(firstItem?.product_name)
580
+ };
581
+ }
582
+ async function handleLemonSqueezyWebhook(rawBody, signatureHeader, options) {
583
+ if (!verifyLemonSqueezySignature(rawBody, signatureHeader, options.secret)) {
584
+ return { status: 401, body: { ok: false, reason: "invalid_signature" } };
585
+ }
586
+ let payload;
587
+ try {
588
+ payload = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"));
589
+ } catch {
590
+ return { status: 400, body: { ok: false, reason: "invalid_json" } };
591
+ }
592
+ const order = parseLemonSqueezyOrder(payload);
593
+ if (!order) {
594
+ return { status: 400, body: { ok: false, reason: "unparseable_order" } };
595
+ }
596
+ const refundEvents = options.refundEvents ?? ["order_refunded"];
597
+ if (refundEvents.includes(order.eventName)) {
598
+ if (options.isRefundForOurStore && !options.isRefundForOurStore(order)) {
599
+ options.log?.("lemonsqueezy refund payload is not for our store/mode/currency \u2014 ignoring", {
600
+ orderId: order.orderId,
601
+ storeId: order.storeId,
602
+ currency: order.currency,
603
+ testMode: order.testMode
604
+ });
605
+ return { status: 200, body: { ok: true, reason: "refund_not_our_store", orderId: order.orderId } };
606
+ }
607
+ const { revoked } = await options.onRefund(order);
608
+ options.log?.("lemonsqueezy refund processed", { orderId: order.orderId, revoked });
609
+ return { status: 200, body: { ok: true, reason: revoked ? "refund_revoked" : "refund_noop", orderId: order.orderId } };
610
+ }
611
+ const creditable = options.creditableEvents ?? ["order_created"];
612
+ if (!creditable.includes(order.eventName)) {
613
+ return { status: 200, body: { ok: true, reason: "ignored_event", orderId: order.orderId } };
614
+ }
615
+ if (order.status !== "paid") {
616
+ const statusMissing = !order.status;
617
+ const looksLikeOurCreditOrder = options.isCreditOrder(order) || Boolean(options.isOurStoreOrder?.(order)) || Boolean(options.isUnverifiedCreditOrder?.(order));
618
+ if (statusMissing && looksLikeOurCreditOrder) {
619
+ options.log?.("lemonsqueezy recognized credit order has a missing/unparseable status \u2014 failing loud so a possibly-paid order is not dropped", {
620
+ orderId: order.orderId,
621
+ variantId: order.variantId,
622
+ currency: order.currency,
623
+ testMode: order.testMode
624
+ });
625
+ return { status: 500, body: { ok: false, reason: "order_status_missing", orderId: order.orderId } };
626
+ }
627
+ return { status: 200, body: { ok: true, reason: `order_status_${order.status ?? "unknown"}`, orderId: order.orderId } };
628
+ }
629
+ if (!options.isCreditOrder(order)) {
630
+ if (options.isUnverifiedCreditOrder?.(order)) {
631
+ options.log?.("lemonsqueezy paid order for a known credit variant has incomplete store/mode/currency identity \u2014 not crediting, retrying", {
632
+ orderId: order.orderId,
633
+ variantId: order.variantId,
634
+ currency: order.currency,
635
+ testMode: order.testMode,
636
+ storeId: order.storeId
637
+ });
638
+ return { status: 500, body: { ok: false, reason: "unverified_credit_order", orderId: order.orderId } };
639
+ }
640
+ if (options.isOurStoreOrder?.(order)) {
641
+ options.log?.("lemonsqueezy paid order on our store has an unrecognized credit variant \u2014 not crediting", {
642
+ orderId: order.orderId,
643
+ variantId: order.variantId,
644
+ currency: order.currency,
645
+ testMode: order.testMode,
646
+ storeId: order.storeId
647
+ });
648
+ return { status: 500, body: { ok: false, reason: "unrecognized_credit_variant", orderId: order.orderId } };
649
+ }
650
+ options.log?.("lemonsqueezy paid order is not a recognized credit pack", {
651
+ orderId: order.orderId,
652
+ variantId: order.variantId,
653
+ currency: order.currency,
654
+ testMode: order.testMode
655
+ });
656
+ return { status: 200, body: { ok: true, reason: "not_a_credit_order", orderId: order.orderId } };
657
+ }
658
+ if (options.attributionSecret !== void 0) {
659
+ const provided = typeof options.attributionSecret === "string" ? [options.attributionSecret] : options.attributionSecret;
660
+ const attributionSecrets = provided.filter((s) => typeof s === "string" && s.length > 0);
661
+ const effectiveSecrets = attributionSecrets.length > 0 ? attributionSecrets : [options.secret];
662
+ if (!verifyUserAttribution(order.userId, order.userAttributionToken, effectiveSecrets)) {
663
+ options.log?.("lemonsqueezy order user attribution token invalid/missing \u2014 not crediting", { orderId: order.orderId });
664
+ return { status: 500, body: { ok: false, reason: "untrusted_attribution", orderId: order.orderId } };
665
+ }
666
+ }
667
+ const userId = (options.resolveUserId ?? ((o) => o.userId))(order);
668
+ if (!userId) {
669
+ options.log?.("lemonsqueezy PAID credit order missing user id \u2014 not crediting; returning 500 so LS retries", { orderId: order.orderId });
670
+ return { status: 500, body: { ok: false, reason: "missing_user_id", orderId: order.orderId } };
671
+ }
672
+ if (options.userExists && !await options.userExists(userId)) {
673
+ options.log?.("lemonsqueezy credit order for a non-existent (deleted) user \u2014 not crediting (no PII resurrection)", { orderId: order.orderId, userId });
674
+ return { status: 200, body: { ok: true, reason: "user_not_found", orderId: order.orderId } };
675
+ }
676
+ const amountMicros = options.creditsForOrder(order);
677
+ if (!Number.isSafeInteger(amountMicros) || amountMicros <= 0) {
678
+ options.log?.("lemonsqueezy recognized credit order resolved to non-positive credits \u2014 config bug", { orderId: order.orderId, amountMicros });
679
+ return { status: 500, body: { ok: false, reason: "no_credit_amount", orderId: order.orderId } };
680
+ }
681
+ if (typeof options.creditMicrosPerUnit === "number" && options.creditMicrosPerUnit > 0) {
682
+ const { subtotalCents, discountTotalCents, totalCents, taxCents } = order;
683
+ if (subtotalCents === void 0 || discountTotalCents === void 0 || totalCents === void 0) {
684
+ options.log?.("lemonsqueezy order is MISSING a required money field (subtotal/discount/total) \u2014 not granting", {
685
+ orderId: order.orderId,
686
+ subtotalCents,
687
+ discountTotalCents,
688
+ totalCents
689
+ });
690
+ return { status: 500, body: { ok: false, reason: "invalid_money_fields", orderId: order.orderId } };
691
+ }
692
+ const netFromSubtotal = subtotalCents - discountTotalCents;
693
+ const netFromTotal = totalCents - taxCents;
694
+ const moneySane = subtotalCents >= 0 && discountTotalCents >= 0 && totalCents > 0 && Number.isFinite(taxCents) && taxCents >= 0 && subtotalCents >= discountTotalCents && totalCents >= taxCents && netFromSubtotal <= netFromTotal + 1;
695
+ if (!moneySane) {
696
+ options.log?.("lemonsqueezy order has inconsistent money fields \u2014 not granting", {
697
+ orderId: order.orderId,
698
+ subtotalCents,
699
+ discountTotalCents,
700
+ totalCents,
701
+ taxCents
702
+ });
703
+ return { status: 500, body: { ok: false, reason: "invalid_money_fields", orderId: order.orderId } };
704
+ }
705
+ const oneCentMicros = options.creditMicrosPerUnit / 100;
706
+ const netPaidMicros = Math.max(0, netFromSubtotal) * oneCentMicros;
707
+ if (netPaidMicros + oneCentMicros <= amountMicros) {
708
+ options.log?.("lemonsqueezy order underpaid for the credits it maps to \u2014 not granting", {
709
+ orderId: order.orderId,
710
+ amountMicros,
711
+ netPaidMicros,
712
+ subtotalCents: order.subtotalCents,
713
+ discountTotalCents: order.discountTotalCents
714
+ });
715
+ return { status: 500, body: { ok: false, reason: "underpaid_order", orderId: order.orderId } };
716
+ }
717
+ }
718
+ const { created } = await options.grant(
719
+ {
720
+ userId,
721
+ orderId: order.orderId,
722
+ reason: `purchase:${order.orderId}`,
723
+ amountMicros
724
+ },
725
+ order
726
+ );
727
+ return { status: 200, body: { ok: true, orderId: order.orderId, created } };
728
+ }
729
+
730
+ // src/server/credits/lemonSqueezyCheckout.ts
731
+ var LS_API = "https://api.lemonsqueezy.com/v1/checkouts";
732
+ function buildCheckoutRequestBody(input) {
733
+ const numericVariant = Number(input.variantId);
734
+ if (!Number.isInteger(numericVariant) || numericVariant <= 0) {
735
+ throw new Error(`Lemon Squeezy variantId must be a positive integer, got "${input.variantId}"`);
736
+ }
737
+ const enabledVariants = [numericVariant];
738
+ return {
739
+ data: {
740
+ type: "checkouts",
741
+ attributes: {
742
+ ...input.testMode !== void 0 ? { test_mode: input.testMode } : {},
743
+ checkout_data: {
744
+ ...input.email ? { email: input.email } : {},
745
+ // Lock the purchased quantity to exactly 1 so a buyer can't pay for N
746
+ // packs and receive one pack's credits (the webhook credits the fixed
747
+ // per-variant value; this keeps order quantity == 1).
748
+ variant_quantities: [{ variant_id: numericVariant, quantity: 1 }],
749
+ // Custom data is echoed back on the order webhook as meta.custom_data.
750
+ // uat binds the user id to this server-created checkout (verified by the
751
+ // webhook), so a buyer can't credit an arbitrary account via a crafted URL.
752
+ custom: {
753
+ user_id: input.userId,
754
+ ...input.attributionSecret ? { uat: signUserAttribution(input.userId, input.attributionSecret) } : {}
755
+ }
756
+ },
757
+ // Disable discount codes: credits are granted on the net pre-tax amount,
758
+ // and a discount must never let a buyer pay less than the credited value.
759
+ checkout_options: { discount: false },
760
+ // Lock the checkout to EXACTLY the server-selected variant — without
761
+ // enabled_variants, LS may let the buyer switch to another variant of the
762
+ // product, which would then mis-credit or not credit at all.
763
+ product_options: {
764
+ enabled_variants: enabledVariants,
765
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
766
+ }
767
+ },
768
+ relationships: {
769
+ store: { data: { type: "stores", id: input.storeId } },
770
+ variant: { data: { type: "variants", id: input.variantId } }
771
+ }
772
+ }
773
+ };
774
+ }
775
+ function extractCheckoutUrl(payload) {
776
+ if (typeof payload !== "object" || payload === null) return null;
777
+ const data = payload.data;
778
+ const url = data?.attributes?.url;
779
+ return typeof url === "string" && url.length > 0 ? url : null;
780
+ }
781
+ async function createLemonSqueezyCheckout(input, fetchImpl = fetch) {
782
+ const res = await fetchImpl(LS_API, {
783
+ method: "POST",
784
+ headers: {
785
+ Authorization: `Bearer ${input.apiKey}`,
786
+ Accept: "application/vnd.api+json",
787
+ "Content-Type": "application/vnd.api+json"
788
+ },
789
+ body: JSON.stringify(buildCheckoutRequestBody(input))
790
+ });
791
+ if (!res.ok) {
792
+ const detail = await res.text().catch(() => "");
793
+ throw new Error(`lemon squeezy checkout failed (${res.status}): ${detail.slice(0, 300)}`);
794
+ }
795
+ const url = extractCheckoutUrl(await res.json());
796
+ if (!url) throw new Error("lemon squeezy checkout returned no url");
797
+ return { url };
798
+ }
799
+
800
+ // src/server/credits/stripe.ts
801
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
802
+ var DEFAULT_TOLERANCE_SECONDS = 300;
803
+ function signStripeAttribution(userId, packId, secret) {
804
+ return createHmac2("sha256", secret).update(`credit:${userId}:${packId}`).digest("hex");
805
+ }
806
+ function verifyStripeAttribution(userId, packId, token, secret) {
807
+ if (!userId || !packId || !token) return false;
808
+ const actual = Buffer.from(token, "utf8");
809
+ const secrets = typeof secret === "string" ? [secret] : secret;
810
+ return secrets.some((s) => {
811
+ if (!s) return false;
812
+ const expected = Buffer.from(signStripeAttribution(userId, packId, s), "utf8");
813
+ if (expected.length !== actual.length) return false;
814
+ return timingSafeEqual2(expected, actual);
815
+ });
816
+ }
817
+ function parseSignatureHeader(header) {
818
+ let t = null;
819
+ const v1 = [];
820
+ for (const part of header.split(",")) {
821
+ const idx = part.indexOf("=");
822
+ if (idx <= 0) continue;
823
+ const key = part.slice(0, idx).trim();
824
+ const value = part.slice(idx + 1).trim();
825
+ if (key === "t") {
826
+ const n = Number(value);
827
+ if (Number.isFinite(n)) t = n;
828
+ } else if (key === "v1" && value) {
829
+ v1.push(value);
830
+ }
831
+ }
832
+ return { t, v1 };
833
+ }
834
+ function timingSafeHexEqual(aHex, bHex) {
835
+ const a = Buffer.from(aHex, "utf8");
836
+ const b = Buffer.from(bHex, "utf8");
837
+ if (a.length !== b.length) return false;
838
+ return timingSafeEqual2(a, b);
839
+ }
840
+ function verifyStripeSignature(rawBody, signatureHeader, secret, opts = {}) {
841
+ if (!signatureHeader || !secret) return false;
842
+ const { t, v1 } = parseSignatureHeader(signatureHeader);
843
+ if (t === null || v1.length === 0) return false;
844
+ const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
845
+ if (tolerance > 0) {
846
+ const nowSec = (opts.now ?? Date.now()) / 1e3;
847
+ if (Math.abs(nowSec - t) > tolerance) return false;
848
+ }
849
+ const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
850
+ const expected = createHmac2("sha256", secret).update(`${t}.${body}`).digest("hex");
851
+ return v1.some((sig) => timingSafeHexEqual(expected, sig));
852
+ }
853
+ function asRecord2(value) {
854
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
855
+ }
856
+ function asString2(value) {
857
+ return typeof value === "string" && value.length > 0 ? value : void 0;
858
+ }
859
+ function asOptionalNumber2(value) {
860
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
861
+ }
862
+ function asId(value) {
863
+ if (typeof value === "string") return value.length > 0 ? value : void 0;
864
+ const id = asRecord2(value)?.id;
865
+ return typeof id === "string" && id.length > 0 ? id : void 0;
866
+ }
867
+ function stripeNetPaidMinor(order) {
868
+ const sub = order.amountSubtotalMinor;
869
+ const total = order.amountTotalMinor;
870
+ const disc = order.amountDiscountMinor;
871
+ const tax = order.amountTaxMinor;
872
+ if (typeof sub !== "number" || typeof total !== "number" || typeof disc !== "number" || typeof tax !== "number") return null;
873
+ const netFromSubtotal = sub - disc;
874
+ const netFromTotal = total - tax;
875
+ const sane = sub >= 0 && disc >= 0 && total >= 0 && tax >= 0 && sub >= disc && total >= tax && netFromSubtotal <= netFromTotal + 1;
876
+ if (!sane) return null;
877
+ return Math.max(0, netFromSubtotal);
878
+ }
879
+ function parseStripeEvent(payload) {
880
+ const root = asRecord2(payload);
881
+ const eventType = asString2(root?.type);
882
+ const object = asRecord2(asRecord2(root?.data)?.object);
883
+ if (!eventType || !object) return null;
884
+ if (eventType.startsWith("checkout.session.")) {
885
+ const meta = asRecord2(object.metadata);
886
+ const totals = asRecord2(object.total_details);
887
+ return {
888
+ eventType,
889
+ paymentIntentId: asId(object.payment_intent),
890
+ sessionId: asString2(object.id),
891
+ userId: asString2(meta?.user_id) ?? asString2(object.client_reference_id),
892
+ userAttributionToken: asString2(meta?.uat),
893
+ paymentStatus: asString2(object.payment_status),
894
+ livemode: typeof object.livemode === "boolean" ? object.livemode : void 0,
895
+ currency: asString2(object.currency)?.toLowerCase(),
896
+ amountSubtotalMinor: asOptionalNumber2(object.amount_subtotal),
897
+ amountTotalMinor: asOptionalNumber2(object.amount_total),
898
+ amountDiscountMinor: asOptionalNumber2(totals?.amount_discount),
899
+ amountTaxMinor: asOptionalNumber2(totals?.amount_tax),
900
+ packId: asString2(meta?.pack_id)
901
+ };
902
+ }
903
+ if (eventType === "charge.refunded" || eventType === "charge.dispute.created") {
904
+ return {
905
+ eventType,
906
+ paymentIntentId: asId(object.payment_intent),
907
+ currency: asString2(object.currency)?.toLowerCase(),
908
+ livemode: typeof object.livemode === "boolean" ? object.livemode : void 0,
909
+ amountMinor: asOptionalNumber2(object.amount),
910
+ amountRefundedMinor: asOptionalNumber2(object.amount_refunded)
911
+ };
912
+ }
913
+ return { eventType };
914
+ }
915
+ async function handleStripeWebhook(rawBody, signatureHeader, options) {
916
+ if (!verifyStripeSignature(rawBody, signatureHeader, options.secret, { now: options.now, toleranceSeconds: options.toleranceSeconds })) {
917
+ return { status: 401, body: { ok: false, reason: "invalid_signature" } };
918
+ }
919
+ let payload;
920
+ try {
921
+ payload = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"));
922
+ } catch {
923
+ return { status: 400, body: { ok: false, reason: "invalid_json" } };
924
+ }
925
+ const order = parseStripeEvent(payload);
926
+ if (!order) return { status: 400, body: { ok: false, reason: "unparseable_event" } };
927
+ const refundEvents = options.refundEvents ?? ["charge.refunded", "charge.dispute.created"];
928
+ if (refundEvents.includes(order.eventType)) {
929
+ if (options.isRefundForOurStore && !options.isRefundForOurStore(order)) {
930
+ options.log?.("stripe refund not for our mode/currency \u2014 ignoring", { paymentIntentId: order.paymentIntentId, currency: order.currency, livemode: order.livemode });
931
+ return { status: 200, body: { ok: true, reason: "refund_not_our_store", orderId: order.paymentIntentId } };
932
+ }
933
+ if (!order.paymentIntentId) {
934
+ options.log?.("stripe refund/dispute for our store has no payment_intent to map to a grant \u2014 failing loud", { eventType: order.eventType, currency: order.currency, livemode: order.livemode });
935
+ return { status: 500, body: { ok: false, reason: "refund_unmappable" } };
936
+ }
937
+ const { revoked } = await options.onRefund(order);
938
+ options.log?.("stripe refund processed", { paymentIntentId: order.paymentIntentId, revoked });
939
+ return { status: 200, body: { ok: true, reason: revoked ? "refund_revoked" : "refund_noop", orderId: order.paymentIntentId } };
940
+ }
941
+ const creditable = options.creditableEvents ?? ["checkout.session.completed", "checkout.session.async_payment_succeeded"];
942
+ if (!creditable.includes(order.eventType)) {
943
+ return { status: 200, body: { ok: true, reason: "ignored_event" } };
944
+ }
945
+ if (order.paymentStatus !== "paid") {
946
+ const statusMissing = !order.paymentStatus;
947
+ const looksOurs = options.isCreditOrder(order) || Boolean(options.isOurStoreOrder?.(order)) || Boolean(options.isUnverifiedCreditOrder?.(order));
948
+ if (statusMissing && looksOurs) {
949
+ options.log?.("stripe recognized credit session missing payment_status \u2014 failing loud", { sessionId: order.sessionId, paymentIntentId: order.paymentIntentId });
950
+ return { status: 500, body: { ok: false, reason: "payment_status_missing", orderId: order.paymentIntentId } };
951
+ }
952
+ return { status: 200, body: { ok: true, reason: `payment_status_${order.paymentStatus ?? "unknown"}`, orderId: order.paymentIntentId } };
953
+ }
954
+ if (!options.isCreditOrder(order)) {
955
+ if (options.isUnverifiedCreditOrder?.(order)) {
956
+ options.log?.("stripe paid session for a known pack has incomplete mode/currency identity \u2014 not crediting, retrying", { paymentIntentId: order.paymentIntentId, packId: order.packId, currency: order.currency, livemode: order.livemode });
957
+ return { status: 500, body: { ok: false, reason: "unverified_credit_order", orderId: order.paymentIntentId } };
958
+ }
959
+ if (options.isOurStoreOrder?.(order)) {
960
+ options.log?.("stripe paid session on our account has an unrecognized pack \u2014 not crediting", { paymentIntentId: order.paymentIntentId, packId: order.packId });
961
+ return { status: 500, body: { ok: false, reason: "unrecognized_credit_pack", orderId: order.paymentIntentId } };
962
+ }
963
+ return { status: 200, body: { ok: true, reason: "not_a_credit_order", orderId: order.paymentIntentId } };
964
+ }
965
+ if (options.attributionSecret !== void 0) {
966
+ const provided = typeof options.attributionSecret === "string" ? [options.attributionSecret] : options.attributionSecret;
967
+ const secrets = provided.filter((s) => typeof s === "string" && s.length > 0);
968
+ if (secrets.length === 0 || !verifyStripeAttribution(order.userId, order.packId, order.userAttributionToken, secrets)) {
969
+ options.log?.("stripe paid known-pack session has an invalid/missing attribution token \u2014 not crediting", { paymentIntentId: order.paymentIntentId, packId: order.packId });
970
+ return { status: 500, body: { ok: false, reason: "untrusted_attribution", orderId: order.paymentIntentId } };
971
+ }
972
+ }
973
+ if (!order.paymentIntentId) {
974
+ options.log?.("stripe paid credit session missing payment_intent \u2014 cannot key the grant, retrying", { sessionId: order.sessionId });
975
+ return { status: 500, body: { ok: false, reason: "missing_payment_intent", orderId: order.sessionId } };
976
+ }
977
+ const userId = (options.resolveUserId ?? ((o) => o.userId))(order);
978
+ if (!userId) {
979
+ options.log?.("stripe PAID credit order missing user id \u2014 not crediting; 500 so Stripe retries", { paymentIntentId: order.paymentIntentId });
980
+ return { status: 500, body: { ok: false, reason: "missing_user_id", orderId: order.paymentIntentId } };
981
+ }
982
+ if (options.userExists && !await options.userExists(userId)) {
983
+ options.log?.("stripe credit order for a non-existent (deleted) user \u2014 not crediting (no PII resurrection)", { paymentIntentId: order.paymentIntentId, userId });
984
+ return { status: 200, body: { ok: true, reason: "user_not_found", orderId: order.paymentIntentId } };
985
+ }
986
+ const amountMicros = options.creditsForOrder(order);
987
+ if (!Number.isSafeInteger(amountMicros) || amountMicros <= 0) {
988
+ options.log?.("stripe recognized credit order resolved to non-positive credits \u2014 config bug", { paymentIntentId: order.paymentIntentId, amountMicros });
989
+ return { status: 500, body: { ok: false, reason: "no_credit_amount", orderId: order.paymentIntentId } };
990
+ }
991
+ if (typeof options.creditMicrosPerUnit === "number" && options.creditMicrosPerUnit > 0) {
992
+ const netPaidMinor = stripeNetPaidMinor(order);
993
+ if (netPaidMinor === null) {
994
+ options.log?.("stripe order has missing/inconsistent money fields \u2014 not granting", { paymentIntentId: order.paymentIntentId, amountSubtotalMinor: order.amountSubtotalMinor, amountTotalMinor: order.amountTotalMinor, amountDiscountMinor: order.amountDiscountMinor, amountTaxMinor: order.amountTaxMinor });
995
+ return { status: 500, body: { ok: false, reason: "invalid_money_fields", orderId: order.paymentIntentId } };
996
+ }
997
+ const oneUnitMinorMicros = options.creditMicrosPerUnit / 100;
998
+ const netPaidMicros = netPaidMinor * oneUnitMinorMicros;
999
+ if (netPaidMicros + oneUnitMinorMicros <= amountMicros) {
1000
+ options.log?.("stripe order underpaid for the credits it maps to \u2014 not granting", { paymentIntentId: order.paymentIntentId, amountMicros, netPaidMicros, netPaidMinor });
1001
+ return { status: 500, body: { ok: false, reason: "underpaid_order", orderId: order.paymentIntentId } };
1002
+ }
1003
+ }
1004
+ const { created } = await options.grant(
1005
+ { userId, orderId: order.paymentIntentId, reason: `purchase:${order.paymentIntentId}`, amountMicros },
1006
+ order
1007
+ );
1008
+ return { status: 200, body: { ok: true, orderId: order.paymentIntentId, created } };
1009
+ }
1010
+
1011
+ // src/server/credits/stripeCheckout.ts
1012
+ var STRIPE_CHECKOUT_API = "https://api.stripe.com/v1/checkout/sessions";
1013
+ function appendCheckoutMarker(url, value) {
1014
+ const sep = url.includes("?") ? "&" : "?";
1015
+ return `${url}${sep}checkout=${value}`;
1016
+ }
1017
+ function buildStripeCheckoutForm(input) {
1018
+ if (!/^price_[A-Za-z0-9]+$/.test(input.priceId)) {
1019
+ throw new Error(`Stripe priceId must look like "price_\u2026", got "${input.priceId}"`);
1020
+ }
1021
+ if (!input.packId) throw new Error("Stripe checkout requires a packId");
1022
+ const params = [
1023
+ ["mode", "payment"],
1024
+ ["line_items[0][price]", input.priceId],
1025
+ ["line_items[0][quantity]", "1"],
1026
+ // Lock quantity: buyer can't change it on the hosted page (so a fixed pack's
1027
+ // amount == price; a custom pack collects its amount via custom_unit_amount).
1028
+ ["line_items[0][adjustable_quantity][enabled]", "false"],
1029
+ // Disable Adaptive Pricing: it can localize the session into another currency,
1030
+ // which our webhook's strict currency gate would then reject — leaving the buyer
1031
+ // charged but uncredited. Pin the currency to the configured one.
1032
+ ["adaptive_pricing[enabled]", "false"],
1033
+ // Buyer attribution (server-set). Both client_reference_id and metadata carry it
1034
+ // so the webhook can read it off the session regardless of Stripe shape changes.
1035
+ ["client_reference_id", input.userId],
1036
+ ["metadata[user_id]", input.userId],
1037
+ ["metadata[pack_id]", input.packId],
1038
+ // Mirror onto the PaymentIntent so a refund (charge→payment_intent) can be traced
1039
+ // back for audit even though we key revocation by the payment_intent id itself.
1040
+ ["payment_intent_data[metadata][user_id]", input.userId],
1041
+ ["payment_intent_data[metadata][pack_id]", input.packId]
1042
+ ];
1043
+ if (input.attributionSecret) {
1044
+ params.push(["metadata[uat]", signStripeAttribution(input.userId, input.packId, input.attributionSecret)]);
1045
+ }
1046
+ if (input.email) params.push(["customer_email", input.email]);
1047
+ if (input.redirectUrl) {
1048
+ params.push(["success_url", appendCheckoutMarker(input.redirectUrl, "return")]);
1049
+ params.push(["cancel_url", appendCheckoutMarker(input.redirectUrl, "cancelled")]);
1050
+ }
1051
+ return params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
1052
+ }
1053
+ function extractCheckoutUrl2(payload) {
1054
+ if (typeof payload !== "object" || payload === null) return null;
1055
+ const url = payload.url;
1056
+ return typeof url === "string" && url.length > 0 ? url : null;
1057
+ }
1058
+ async function createStripeCheckout(input, fetchImpl = fetch) {
1059
+ const res = await fetchImpl(STRIPE_CHECKOUT_API, {
1060
+ method: "POST",
1061
+ headers: {
1062
+ Authorization: `Bearer ${input.apiKey}`,
1063
+ "Content-Type": "application/x-www-form-urlencoded"
1064
+ // Idempotency on retried checkout *creation* is not required for money-safety
1065
+ // (only paid orders mint credits, deduped by payment_intent), so it's omitted.
1066
+ },
1067
+ body: buildStripeCheckoutForm(input)
1068
+ });
1069
+ if (!res.ok) {
1070
+ const detail = await res.text().catch(() => "");
1071
+ throw new Error(`stripe checkout failed (${res.status}): ${detail.slice(0, 300)}`);
1072
+ }
1073
+ const url = extractCheckoutUrl2(await res.json());
1074
+ if (!url) throw new Error("stripe checkout returned no url");
1075
+ return { url };
1076
+ }
1077
+
1078
+ // src/server/credits/routes.ts
1079
+ var NON_TWO_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
1080
+ // 0-decimal
1081
+ "BIF",
1082
+ "CLP",
1083
+ "DJF",
1084
+ "GNF",
1085
+ "JPY",
1086
+ "KMF",
1087
+ "KRW",
1088
+ "MGA",
1089
+ "PYG",
1090
+ "RWF",
1091
+ "UGX",
1092
+ "VND",
1093
+ "VUV",
1094
+ "XAF",
1095
+ "XOF",
1096
+ "XPF",
1097
+ // 3-decimal
1098
+ "BHD",
1099
+ "IQD",
1100
+ "JOD",
1101
+ "KWD",
1102
+ "LYD",
1103
+ "OMR",
1104
+ "TND"
1105
+ ]);
1106
+ function buildCreditPacks(ls, locale) {
1107
+ const checkout = ls.checkout;
1108
+ if (!checkout) return [];
1109
+ const credits = ls.creditMicrosByVariant ?? {};
1110
+ const currency = ls.requireCurrency ?? "EUR";
1111
+ const packs = [];
1112
+ for (const [packId, variantId] of Object.entries(checkout.variants)) {
1113
+ const major = Number(packId);
1114
+ const creditMicros = credits[variantId];
1115
+ if (!Number.isFinite(major) || major <= 0 || typeof creditMicros !== "number" || creditMicros <= 0) continue;
1116
+ const priceMinor = Math.round(major * 100);
1117
+ packs.push({
1118
+ id: packId,
1119
+ creditMicros,
1120
+ priceMinor,
1121
+ currency,
1122
+ label: new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }).format(major),
1123
+ isDefault: packId === checkout.defaultPack
1124
+ });
1125
+ }
1126
+ return packs;
1127
+ }
1128
+ function buildStripePacks(stripe, locale) {
1129
+ const checkout = stripe.checkout;
1130
+ if (!checkout) return [];
1131
+ const credits = stripe.creditMicrosByPack ?? {};
1132
+ const currency = (stripe.requireCurrency ?? "EUR").toUpperCase();
1133
+ const packs = [];
1134
+ for (const [packId] of Object.entries(checkout.variants)) {
1135
+ const major = Number(packId);
1136
+ const creditMicros = credits[packId];
1137
+ if (!Number.isFinite(major) || major <= 0 || typeof creditMicros !== "number" || creditMicros <= 0) continue;
1138
+ packs.push({
1139
+ id: packId,
1140
+ creditMicros,
1141
+ priceMinor: Math.round(major * 100),
1142
+ currency,
1143
+ label: new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }).format(major),
1144
+ isDefault: packId === checkout.defaultPack
1145
+ });
1146
+ }
1147
+ if (stripe.customPack && checkout.customPriceId) {
1148
+ packs.push({
1149
+ id: stripe.customPack.id,
1150
+ creditMicros: 0,
1151
+ priceMinor: stripe.customPack.minMinor,
1152
+ currency,
1153
+ label: "Custom amount",
1154
+ isDefault: stripe.customPack.id === checkout.defaultPack,
1155
+ custom: true
1156
+ });
1157
+ }
1158
+ return packs;
1159
+ }
1160
+ function defaultGetUserId(request) {
1161
+ const user = request.user;
1162
+ return typeof user?.id === "string" && user.id ? user.id : void 0;
1163
+ }
1164
+ function registerCreditsRoutes(app, options) {
1165
+ const getUserId = options.getUserId ?? defaultGetUserId;
1166
+ const balancePath = options.balancePath ?? "/api/credits/balance";
1167
+ if (options.lemonSqueezy && options.stripe) {
1168
+ throw new Error("credits: configure at most one purchase provider (lemonSqueezy OR stripe), not both");
1169
+ }
1170
+ const stripeReady = Boolean(options.stripe?.checkout && options.stripe?.webhookSecret);
1171
+ const checkoutEnabled = Boolean(options.lemonSqueezy?.checkout) || stripeReady;
1172
+ const packs = options.lemonSqueezy ? buildCreditPacks(options.lemonSqueezy) : stripeReady ? buildStripePacks(options.stripe) : [];
1173
+ app.get(balancePath, async (request, reply) => {
1174
+ const userId = getUserId(request);
1175
+ if (!userId) {
1176
+ return reply.code(401).send({ error: { code: ERROR_CODES.UNAUTHORIZED, message: "authentication required" } });
1177
+ }
1178
+ return reply.send({
1179
+ ...await options.service.getBalance(userId),
1180
+ checkoutEnabled,
1181
+ ...packs.length > 0 ? { packs } : {}
1182
+ });
1183
+ });
1184
+ const historyPath = options.historyPath ?? "/api/credits/history";
1185
+ app.get(historyPath, async (request, reply) => {
1186
+ const userId = getUserId(request);
1187
+ if (!userId) {
1188
+ return reply.code(401).send({ error: { code: ERROR_CODES.UNAUTHORIZED, message: "authentication required" } });
1189
+ }
1190
+ const raw = request.query?.limit;
1191
+ const parsed = typeof raw === "string" ? Number.parseInt(raw, 10) : typeof raw === "number" ? raw : NaN;
1192
+ const limit = Number.isFinite(parsed) ? Math.min(50, Math.max(1, Math.trunc(parsed))) : 20;
1193
+ return reply.send({ entries: await options.service.listLedger(userId, limit) });
1194
+ });
1195
+ const stripeOpts = options.stripe;
1196
+ if (stripeOpts) {
1197
+ if (!options.service.config.enabled) {
1198
+ throw new Error("credits: cannot register Stripe checkout/webhook with a disabled credits service (paid orders would be acknowledged without crediting)");
1199
+ }
1200
+ registerStripeRoutes(app, options.service, getUserId, stripeOpts, options.log);
1201
+ return;
1202
+ }
1203
+ const ls = options.lemonSqueezy;
1204
+ if (!ls) return;
1205
+ if (!options.service.config.enabled) {
1206
+ throw new Error("credits: cannot register Lemon Squeezy checkout/webhook with a disabled credits service (paid orders would be acknowledged without crediting)");
1207
+ }
1208
+ if (ls.creditVariantIds.length === 0) {
1209
+ throw new Error("credits: Lemon Squeezy webhook requires a non-empty creditVariantIds (else every paid order is acknowledged without crediting)");
1210
+ }
1211
+ const rawAttributionSecrets = ls.attributionSecret === void 0 ? [ls.webhookSecret] : typeof ls.attributionSecret === "string" ? [ls.attributionSecret] : ls.attributionSecret;
1212
+ const filteredAttributionSecrets = rawAttributionSecrets.filter((s) => typeof s === "string" && s.length > 0);
1213
+ const attributionSecrets = filteredAttributionSecrets.length > 0 ? filteredAttributionSecrets : [ls.webhookSecret];
1214
+ const attributionSigningSecret = attributionSecrets[0];
1215
+ if (ls.checkout) {
1216
+ const checkout = ls.checkout;
1217
+ const checkoutCreditVariants = new Set(ls.creditVariantIds);
1218
+ for (const [packId, variantId] of Object.entries(checkout.variants)) {
1219
+ if (!checkoutCreditVariants.has(variantId)) {
1220
+ throw new Error(`credits: checkout pack "${packId}" maps to variant "${variantId}", which is not a configured credit variant (creditVariantIds) \u2014 a paid checkout for it would not be credited by the webhook`);
1221
+ }
1222
+ const micros = ls.creditMicrosByVariant?.[variantId];
1223
+ if (typeof micros !== "number" || !Number.isSafeInteger(micros) || micros <= 0) {
1224
+ throw new Error(`credits: checkout pack "${packId}" variant "${variantId}" has no positive creditMicrosByVariant entry \u2014 a paid checkout for it could not be credited`);
1225
+ }
1226
+ }
1227
+ if (!(checkout.defaultPack in checkout.variants)) {
1228
+ throw new Error(`credits: checkout defaultPack "${checkout.defaultPack}" is not one of the configured checkout variants`);
1229
+ }
1230
+ if (ls.expectedStoreId !== void 0 && checkout.storeId !== ls.expectedStoreId) {
1231
+ throw new Error(`credits: checkout storeId "${checkout.storeId}" does not match the webhook's expectedStoreId "${ls.expectedStoreId}" \u2014 orders from this checkout would not be credited`);
1232
+ }
1233
+ if (checkout.testMode !== ls.expectedTestMode) {
1234
+ throw new Error(`credits: checkout testMode (${checkout.testMode}) must be set and match the webhook's expectedTestMode (${ls.expectedTestMode}) \u2014 otherwise orders from this checkout would be classified in the wrong mode and not credited`);
1235
+ }
1236
+ app.post(ls.checkoutPath ?? "/api/credits/checkout", async (request, reply) => {
1237
+ const userId = getUserId(request);
1238
+ if (!userId) {
1239
+ return reply.code(401).send({ error: { code: ERROR_CODES.UNAUTHORIZED, message: "authentication required" } });
1240
+ }
1241
+ const pack = request.body?.pack;
1242
+ if (pack !== void 0 && pack !== null && (typeof pack !== "string" || !(pack in checkout.variants))) {
1243
+ return reply.code(400).send({ error: { code: ERROR_CODES.INVALID_PACK, message: "unknown credit pack" } });
1244
+ }
1245
+ const packId = typeof pack === "string" && pack in checkout.variants ? pack : checkout.defaultPack;
1246
+ const variantId = checkout.variants[packId];
1247
+ if (!variantId) {
1248
+ return reply.code(400).send({ error: { code: ERROR_CODES.INVALID_PACK, message: "unknown credit pack" } });
1249
+ }
1250
+ const email = request.user?.email;
1251
+ try {
1252
+ const { url } = await createLemonSqueezyCheckout({
1253
+ apiKey: checkout.apiKey,
1254
+ storeId: checkout.storeId,
1255
+ variantId,
1256
+ userId,
1257
+ // Sign the attribution token with the (current) attribution secret so the
1258
+ // webhook can verify the buyer id came from this server-created checkout.
1259
+ attributionSecret: attributionSigningSecret,
1260
+ email: typeof email === "string" ? email : void 0,
1261
+ redirectUrl: checkout.redirectUrl,
1262
+ testMode: checkout.testMode
1263
+ });
1264
+ return reply.send({ url });
1265
+ } catch (error) {
1266
+ options.log?.("credits: checkout creation failed", { error: String(error) });
1267
+ return reply.code(502).send({ error: { code: ERROR_CODES.CHECKOUT_FAILED, message: "could not create checkout" } });
1268
+ }
1269
+ });
1270
+ }
1271
+ const webhookPath = ls.webhookPath ?? "/api/credits/webhooks/lemonsqueezy";
1272
+ const creditMicrosPerUnit = options.service.config.pricing.creditMicrosPerUnit;
1273
+ const purchaseKey = (order) => `ls:${ls.expectedStoreId ?? "default"}:${ls.expectedTestMode ? "test" : "live"}:${order.orderId}`;
1274
+ const variantCredits = ls.creditMicrosByVariant ?? {};
1275
+ if (!ls.creditMicrosByVariant) {
1276
+ throw new Error("credits: creditMicrosByVariant is required for the Lemon Squeezy webhook (fixed per-variant credit values; no order-amount fallback)");
1277
+ }
1278
+ for (const variantId of ls.creditVariantIds) {
1279
+ const value = variantCredits[variantId];
1280
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
1281
+ throw new Error(`credits: creditMicrosByVariant must be a positive safe integer for credit variant "${variantId}"`);
1282
+ }
1283
+ }
1284
+ const creditsForOrder = (order) => {
1285
+ const perUnit = order.variantId !== void 0 ? variantCredits[order.variantId] ?? 0 : 0;
1286
+ return perUnit * Math.max(1, order.quantity);
1287
+ };
1288
+ const creditVariantIds = new Set(ls.creditVariantIds);
1289
+ const requireCurrency = (ls.requireCurrency ?? "EUR").toUpperCase();
1290
+ const isOurStoreOrder = (order) => {
1291
+ if (!order.currency || order.currency.toUpperCase() !== requireCurrency) return false;
1292
+ if (order.testMode !== ls.expectedTestMode) return false;
1293
+ if (ls.expectedStoreId && order.storeId !== ls.expectedStoreId) return false;
1294
+ return true;
1295
+ };
1296
+ const isRefundForOurStore = (order) => {
1297
+ if (order.currency != null && order.currency.toUpperCase() !== requireCurrency) return false;
1298
+ if (order.testMode != null && order.testMode !== ls.expectedTestMode) return false;
1299
+ if (order.storeId != null && ls.expectedStoreId != null && order.storeId !== ls.expectedStoreId) return false;
1300
+ return true;
1301
+ };
1302
+ const isCreditVariant = (order) => order.variantId != null && creditVariantIds.has(order.variantId);
1303
+ const isCreditOrder = (order) => {
1304
+ if (creditVariantIds.size === 0) return false;
1305
+ if (!isCreditVariant(order)) return false;
1306
+ return isOurStoreOrder(order);
1307
+ };
1308
+ const isUnverifiedCreditOrder = (order) => isCreditVariant(order) && !isOurStoreOrder(order) && isRefundForOurStore(order);
1309
+ const creditOnlyStore = ls.creditOnlyStore !== false;
1310
+ app.register(async (scope) => {
1311
+ scope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
1312
+ done(null, body);
1313
+ });
1314
+ scope.post(webhookPath, async (request, reply) => {
1315
+ const rawBody = request.body;
1316
+ const signature = request.headers["x-signature"];
1317
+ const result = await handleLemonSqueezyWebhook(
1318
+ rawBody,
1319
+ typeof signature === "string" ? signature : Array.isArray(signature) ? signature[0] : void 0,
1320
+ {
1321
+ secret: ls.webhookSecret,
1322
+ creditsForOrder,
1323
+ // Don't resurrect a deleted user's PII via a stale order_created webhook.
1324
+ userExists: ls.userExists,
1325
+ isCreditOrder,
1326
+ // Credit-only store (the DEFAULT): an unknown-variant PAID order on our store
1327
+ // is a pack misconfiguration → retryable 500. Use the LENIENT identity check
1328
+ // (isRefundForOurStore: missing store/mode/currency still counts as ours; only
1329
+ // a PRESENT contradiction is exempt) so a misconfigured new pack whose payload
1330
+ // omits store_id isn't silently 200-dropped. Mixed store (explicit
1331
+ // creditOnlyStore: false): omit this predicate so the handler 200-ignores an
1332
+ // unknown variant (a different product — no infinite retry on legit sales).
1333
+ isOurStoreOrder: creditOnlyStore ? isRefundForOurStore : void 0,
1334
+ // Known credit variant with incomplete identity → retryable 500, not a
1335
+ // silent 200 drop (a paid pack we can't safely attribute must fail loud).
1336
+ // Fires regardless of creditOnlyStore (it's a recognized pack either way).
1337
+ isUnverifiedCreditOrder,
1338
+ // Lenient: a refund whose payload omits store/mode/currency still revokes
1339
+ // a credited order; only a present-and-mismatched field is rejected.
1340
+ isRefundForOurStore,
1341
+ // Bind buyer attribution to a server-created checkout (custom_data.uat).
1342
+ // Verify against current + previous secrets so a rotation doesn't reject
1343
+ // in-flight checkout links.
1344
+ attributionSecret: attributionSecrets,
1345
+ // Refuse to grant a fixed pack value the buyer didn't actually pay for.
1346
+ creditMicrosPerUnit,
1347
+ grant: (input, order) => options.service.grantPurchase(input.userId, purchaseKey(order), input.amountMicros, {
1348
+ storeId: order.storeId,
1349
+ testMode: order.testMode,
1350
+ currency: order.currency,
1351
+ variantId: order.variantId
1352
+ }),
1353
+ // Refunds/disputes revoke the order's credits. A PARTIAL refund passes
1354
+ // the cumulative refunded fraction (refunded_amount / total); a full
1355
+ // refund passes undefined. allowTombstone gates writing a pre-grant
1356
+ // tombstone for an order we HAVEN'T credited yet: only when the refund
1357
+ // still validates as a credit order (prevents a cross-store/mode refund
1358
+ // from tombstoning by order id). An order we already credited is always
1359
+ // revocable regardless (reconciled by order id in the store).
1360
+ onRefund: (order) => options.service.revokePurchase(purchaseKey(order), {
1361
+ // A partial refund passes the fraction refunded; a missing/zero total
1362
+ // (totalCents undefined) or refunded amount falls back to a full refund.
1363
+ refundFraction: order.refundedAmountCents > 0 && order.totalCents !== void 0 && order.totalCents > 0 ? order.refundedAmountCents / order.totalCents : void 0,
1364
+ // Tombstone an unknown order when the refund is compatible with our
1365
+ // store/mode (lenient: missing fields OK). This leniency is DELIBERATE:
1366
+ // Lemon Squeezy refund payloads routinely omit the variant id (and often
1367
+ // store/mode), so requiring strict identity here would SKIP the tombstone
1368
+ // for a refund-before-grant whose payload lacks the variant — reintroducing
1369
+ // the bug where the later order_created then grants credits for an order
1370
+ // already refunded (the refund fired first, found nothing to revoke, and
1371
+ // never re-fires). Safe because: (a) the per-store webhook secret already
1372
+ // proves the refund is from our store; (b) the composite purchase key is
1373
+ // namespaced by CONFIG store+mode (not the payload), so the tombstone lands
1374
+ // in our namespace; (c) order ids are globally unique, so a tombstone can
1375
+ // only ever net a FUTURE grant for that SAME order — exactly the intended
1376
+ // refund-before-grant behaviour, never a different legitimate order.
1377
+ allowTombstone: isRefundForOurStore(order),
1378
+ // Match the credited row against the CONFIGURED identity (not the
1379
+ // payload's maybe-missing fields). The per-store/mode webhook secret
1380
+ // already proves the refund is from our store+mode; the row's stored
1381
+ // identity equals config for legit grants, so a colliding order id
1382
+ // from another store can't reach here (HMAC) and a legit refund whose
1383
+ // payload omits store_id still revokes.
1384
+ expectedStoreId: ls.expectedStoreId,
1385
+ expectedTestMode: ls.expectedTestMode,
1386
+ expectedCurrency: requireCurrency
1387
+ }),
1388
+ log: options.log
1389
+ }
1390
+ );
1391
+ return reply.code(result.status).send(result.body);
1392
+ });
1393
+ });
1394
+ }
1395
+ function registerStripeRoutes(app, service, getUserId, stripe, log) {
1396
+ const creditMicrosPerUnit = service.config.pricing.creditMicrosPerUnit;
1397
+ const requireCurrency = (stripe.requireCurrency ?? "EUR").toUpperCase();
1398
+ if (NON_TWO_DECIMAL_CURRENCIES.has(requireCurrency)) {
1399
+ throw new Error(`credits: Stripe currency "${requireCurrency}" is not a 2-decimal currency; the credit math assumes 100 minor units per major unit. Use a 2-decimal currency (e.g. EUR/USD/GBP/CHF).`);
1400
+ }
1401
+ const expectedLiveMode = !stripe.expectedTestMode;
1402
+ const creditOnlyStore = stripe.creditOnlyStore !== false;
1403
+ const fixedPackMicros = stripe.creditMicrosByPack ?? {};
1404
+ const customPackId = stripe.customPack?.id;
1405
+ if (stripe.checkout && !stripe.webhookSecret) {
1406
+ throw new Error("credits: Stripe checkout is configured but no webhookSecret \u2014 buyers would be charged without being credited (no fulfillment webhook). Set BORING_CREDITS_STRIPE_WEBHOOK_SECRET.");
1407
+ }
1408
+ const webhookSecret = stripe.webhookSecret;
1409
+ if (!webhookSecret) return;
1410
+ const rawAttribution = stripe.attributionSecret === void 0 ? [webhookSecret] : typeof stripe.attributionSecret === "string" ? [stripe.attributionSecret] : stripe.attributionSecret;
1411
+ const attributionSecrets = rawAttribution.filter((s) => typeof s === "string" && s.length > 0);
1412
+ const effectiveAttributionSecrets = attributionSecrets.length > 0 ? attributionSecrets : [webhookSecret];
1413
+ const attributionSigningSecret = effectiveAttributionSecrets[0];
1414
+ if (stripe.checkout) {
1415
+ const checkout = stripe.checkout;
1416
+ if (!checkout.redirectUrl) {
1417
+ throw new Error("credits: Stripe checkout requires a redirect URL (BORING_CREDITS_STRIPE_REDIRECT_URL) \u2014 Stripe rejects payment-mode sessions without a success_url");
1418
+ }
1419
+ for (const [packId, priceId] of Object.entries(checkout.variants)) {
1420
+ const micros = fixedPackMicros[packId];
1421
+ if (typeof micros !== "number" || !Number.isSafeInteger(micros) || micros <= 0) {
1422
+ throw new Error(`credits: stripe checkout pack "${packId}" (price ${priceId}) has no positive creditMicrosByPack entry`);
1423
+ }
1424
+ }
1425
+ const defaultIsCustom = customPackId != null && checkout.defaultPack === customPackId && checkout.customPriceId != null;
1426
+ if (!(checkout.defaultPack in checkout.variants) && !defaultIsCustom) {
1427
+ throw new Error(`credits: stripe checkout defaultPack "${checkout.defaultPack}" is not one of the configured packs (or the custom pack)`);
1428
+ }
1429
+ if (customPackId && customPackId in checkout.variants) {
1430
+ throw new Error(`credits: stripe custom pack id "${customPackId}" collides with a fixed pack id`);
1431
+ }
1432
+ }
1433
+ const isFixedPack = (packId) => packId != null && packId in fixedPackMicros;
1434
+ const isCustomPack = (packId) => customPackId != null && packId === customPackId;
1435
+ const isKnownPack = (packId) => isFixedPack(packId) || isCustomPack(packId);
1436
+ const ratePerMinor = creditMicrosPerUnit / 100;
1437
+ const customMinMinor = stripe.customPack?.minMinor ?? 0;
1438
+ const creditsForOrder = (order) => {
1439
+ if (isCustomPack(order.packId)) {
1440
+ const net = stripeNetPaidMinor(order);
1441
+ if (net === null || net <= 0 || net < customMinMinor) return 0;
1442
+ return Math.floor(net * ratePerMinor);
1443
+ }
1444
+ if (isFixedPack(order.packId)) return fixedPackMicros[order.packId] ?? 0;
1445
+ return 0;
1446
+ };
1447
+ const isOurStoreOrder = (order) => order.livemode === expectedLiveMode && order.currency != null && order.currency.toUpperCase() === requireCurrency;
1448
+ const isRefundForOurStore = (order) => {
1449
+ if (order.livemode != null && order.livemode !== expectedLiveMode) return false;
1450
+ if (order.currency != null && order.currency.toUpperCase() !== requireCurrency) return false;
1451
+ return true;
1452
+ };
1453
+ const isCreditOrder = (order) => isKnownPack(order.packId) && isOurStoreOrder(order);
1454
+ const isUnverifiedCreditOrder = (order) => isKnownPack(order.packId) && !isOurStoreOrder(order);
1455
+ const purchaseKey = (paymentIntentId) => `stripe:${stripe.expectedTestMode ? "test" : "live"}:${paymentIntentId}`;
1456
+ if (stripe.checkout) {
1457
+ const checkout = stripe.checkout;
1458
+ app.post(stripe.checkoutPath ?? "/api/credits/checkout", async (request, reply) => {
1459
+ const userId = getUserId(request);
1460
+ if (!userId) {
1461
+ return reply.code(401).send({ error: { code: ERROR_CODES.UNAUTHORIZED, message: "authentication required" } });
1462
+ }
1463
+ const pack = request.body?.pack;
1464
+ let packId;
1465
+ let priceId;
1466
+ if (pack === void 0 || pack === null) {
1467
+ packId = checkout.defaultPack;
1468
+ priceId = isCustomPack(packId) && checkout.customPriceId ? checkout.customPriceId : checkout.variants[packId];
1469
+ } else if (typeof pack === "string" && pack in checkout.variants) {
1470
+ packId = pack;
1471
+ priceId = checkout.variants[pack];
1472
+ } else if (typeof pack === "string" && isCustomPack(pack) && checkout.customPriceId) {
1473
+ packId = pack;
1474
+ priceId = checkout.customPriceId;
1475
+ } else {
1476
+ return reply.code(400).send({ error: { code: ERROR_CODES.INVALID_PACK, message: "unknown credit pack" } });
1477
+ }
1478
+ const email = request.user?.email;
1479
+ try {
1480
+ const { url } = await createStripeCheckout({
1481
+ apiKey: checkout.apiKey,
1482
+ priceId,
1483
+ userId,
1484
+ packId,
1485
+ // Sign (user, pack) so the webhook can confirm THIS adapter created the session.
1486
+ attributionSecret: attributionSigningSecret,
1487
+ email: typeof email === "string" ? email : void 0,
1488
+ redirectUrl: checkout.redirectUrl
1489
+ });
1490
+ return reply.send({ url });
1491
+ } catch (error) {
1492
+ log?.("credits: stripe checkout creation failed", { error: String(error) });
1493
+ return reply.code(502).send({ error: { code: ERROR_CODES.CHECKOUT_FAILED, message: "could not create checkout" } });
1494
+ }
1495
+ });
1496
+ }
1497
+ const webhookPath = stripe.webhookPath ?? "/api/credits/webhooks/stripe";
1498
+ app.register(async (scope) => {
1499
+ scope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
1500
+ done(null, body);
1501
+ });
1502
+ scope.post(webhookPath, async (request, reply) => {
1503
+ const rawBody = request.body;
1504
+ const signature = request.headers["stripe-signature"];
1505
+ const result = await handleStripeWebhook(
1506
+ rawBody,
1507
+ typeof signature === "string" ? signature : Array.isArray(signature) ? signature[0] : void 0,
1508
+ {
1509
+ secret: webhookSecret,
1510
+ creditsForOrder,
1511
+ userExists: stripe.userExists,
1512
+ isCreditOrder,
1513
+ // Credit-only store (default): a paid session that's ours but an unknown pack →
1514
+ // retryable 500 (lenient identity) rather than a silent drop. Mixed store: omit.
1515
+ isOurStoreOrder: creditOnlyStore ? isRefundForOurStore : void 0,
1516
+ isUnverifiedCreditOrder,
1517
+ isRefundForOurStore,
1518
+ creditMicrosPerUnit,
1519
+ // Verify the session was created by THIS adapter (metadata.uat). Verify against
1520
+ // ALL attribution secrets (current + previous) so a rotation doesn't reject
1521
+ // in-flight checkouts. Defends a mixed Stripe account from a colliding pack_id.
1522
+ attributionSecret: effectiveAttributionSecrets,
1523
+ grant: (input, order) => service.grantPurchase(input.userId, purchaseKey(order.paymentIntentId), input.amountMicros, {
1524
+ testMode: stripe.expectedTestMode,
1525
+ currency: order.currency?.toUpperCase(),
1526
+ variantId: order.packId
1527
+ }),
1528
+ onRefund: (order) => service.revokePurchase(purchaseKey(order.paymentIntentId), {
1529
+ // charge.refunded carries amount_refunded/amount → a proportional fraction
1530
+ // for partial refunds. charge.dispute.created carries neither, so the fraction
1531
+ // is undefined ⇒ FULL revoke. That's deliberate and safe: a dispute withholds
1532
+ // the funds immediately, so revoking the whole order's credits is the
1533
+ // conservative direction (a rare partial dispute over-revokes, recoverable by
1534
+ // re-grant, vs. the worse alternative of leaving clawed-back funds credited).
1535
+ refundFraction: order.amountRefundedMinor !== void 0 && order.amountRefundedMinor > 0 && order.amountMinor !== void 0 && order.amountMinor > 0 ? order.amountRefundedMinor / order.amountMinor : void 0,
1536
+ allowTombstone: isRefundForOurStore(order),
1537
+ expectedTestMode: stripe.expectedTestMode,
1538
+ expectedCurrency: requireCurrency
1539
+ }),
1540
+ log
1541
+ }
1542
+ );
1543
+ return reply.code(result.status).send(result.body);
1544
+ });
1545
+ });
1546
+ }
105
1547
  export {
1548
+ CONSERVATIVE_DEFAULT_RATE,
1549
+ CreditExhaustedError,
1550
+ CreditsService,
1551
+ DEFAULT_CREDITS_CONFIG,
1552
+ DEFAULT_MODEL_RATES,
1553
+ InsufficientCreditError,
106
1554
  MailDeliveryError,
1555
+ PostgresMeteringStore,
1556
+ SIGNUP_GRANT_REASON,
107
1557
  WorkspaceRuntimeSandboxHandleStore,
108
1558
  authHook,
1559
+ buildCheckoutRequestBody,
109
1560
  buildRuntimeConfigPayload,
110
1561
  coreConfigSchema,
111
1562
  createAuth,
112
1563
  createCoreApp,
1564
+ createCreditsMeteringSink,
113
1565
  createDatabase,
114
1566
  createDrizzleIdempotencyStore,
115
1567
  createFsProvisioner,
116
1568
  createIdempotencyMiddleware,
1569
+ createLemonSqueezyCheckout,
117
1570
  createMailTransport,
118
1571
  createPostSignupHook,
1572
+ estimateProviderCost,
1573
+ handleLemonSqueezyWebhook,
119
1574
  loadConfig,
1575
+ maxEffectiveRate,
1576
+ maxServedRate,
1577
+ parseLemonSqueezyOrder,
1578
+ registerCreditsRoutes,
120
1579
  registerInviteRoutes,
121
1580
  registerMemberRoutes,
122
1581
  registerRoutes,
@@ -131,6 +1590,10 @@ export {
131
1590
  runCoreMigrationsFromEnv,
132
1591
  runMigrations,
133
1592
  safeRedirect,
1593
+ signUserAttribution,
1594
+ usageToCredits,
134
1595
  validateConfig,
135
- validatePasswordStrength
1596
+ validatePasswordStrength,
1597
+ verifyLemonSqueezySignature,
1598
+ verifyUserAttribution
136
1599
  };