@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.
- package/dist/PostgresMeteringStore-CzNv6xil.d.ts +224 -0
- package/dist/app/front/index.d.ts +216 -3
- package/dist/app/front/index.js +834 -43
- package/dist/app/server/index.d.ts +3 -3
- package/dist/app/server/index.js +33 -8
- package/dist/{authHook-DUqyxueY.d.ts → authHook-CzBsMwwM.d.ts} +2 -2
- package/dist/{chunk-C3YMOITB.js → chunk-I56OTSPB.js} +649 -6
- package/dist/{chunk-H5KU6R6Y.js → chunk-LIBHVT7V.js} +5 -1
- package/dist/{chunk-GZVKZD4P.js → chunk-UM5SHYIS.js} +11 -2
- package/dist/{chunk-MLTJKZL4.js → chunk-VYXEXOCO.js} +21 -10
- package/dist/{connection-AL8KSENV.d.ts → connection-C5SiqoNc.d.ts} +1 -1
- package/dist/front/index.d.ts +15 -2
- package/dist/front/index.js +2 -2
- package/dist/server/db/index.d.ts +4 -4
- package/dist/server/db/index.js +6 -2
- package/dist/server/index.d.ts +594 -7
- package/dist/server/index.js +1467 -4
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/{types-CbMOXLBf.d.ts → types-CWtJ4kgd.d.ts} +3 -0
- package/drizzle/0011_usage_metering.sql +57 -0
- package/drizzle/0012_credit_purchases.sql +9 -0
- package/drizzle/0013_credit_purchase_lifecycle.sql +28 -0
- package/drizzle/0014_reservation_charge_on_expire.sql +7 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +4 -4
- package/dist/migrate-B4dwdtGP.d.ts +0 -8
package/dist/server/index.js
CHANGED
|
@@ -24,12 +24,16 @@ import {
|
|
|
24
24
|
requireWorkspaceMember,
|
|
25
25
|
validateConfig,
|
|
26
26
|
validatePasswordStrength
|
|
27
|
-
} from "../chunk-
|
|
27
|
+
} from "../chunk-UM5SHYIS.js";
|
|
28
28
|
import {
|
|
29
|
+
InsufficientCreditError,
|
|
30
|
+
PostgresMeteringStore,
|
|
29
31
|
createDatabase,
|
|
30
32
|
runMigrations
|
|
31
|
-
} from "../chunk-
|
|
32
|
-
import
|
|
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
|
};
|