From 004fc0b2fde5050fe93a6928253458f65a635ead Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 17 Apr 2026 14:41:41 +0200 Subject: [PATCH] feat(credits): add 2-phase debit (reserve/commit/refund) Introduces credit_reservations table + three internal endpoints so services that need to charge only after a downstream call succeeds (notably the upcoming mana-research fan-out across paid provider APIs) can reserve credits atomically, then commit on success or refund on failure. One-shot /credits/use remains for synchronous operations. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/mana-credits/CLAUDE.md | 19 ++- services/mana-credits/src/db/schema/index.ts | 1 + .../src/db/schema/reservations.ts | 38 +++++ services/mana-credits/src/lib/validation.ts | 17 ++ services/mana-credits/src/routes/internal.ts | 18 +++ services/mana-credits/src/services/credits.ts | 147 ++++++++++++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 services/mana-credits/src/db/schema/reservations.ts diff --git a/services/mana-credits/CLAUDE.md b/services/mana-credits/CLAUDE.md index 9bf3d7c73..0d3f99f7a 100644 --- a/services/mana-credits/CLAUDE.md +++ b/services/mana-credits/CLAUDE.md @@ -74,9 +74,12 @@ Gifted subscriptions have `is_gifted=true` and are skipped by the billing cron | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/internal/credits/balance/:userId` | Get user balance | -| POST | `/api/v1/internal/credits/use` | Use credits for user | -| POST | `/api/v1/internal/credits/refund` | Refund credits | +| POST | `/api/v1/internal/credits/use` | Use credits for user (one-shot debit) | +| POST | `/api/v1/internal/credits/refund` | Refund credits (unrelated to reservations) | | POST | `/api/v1/internal/credits/init` | Initialize balance | +| POST | `/api/v1/internal/credits/reserve` | 2-phase debit: reserve (body: `{ userId, amount, reason }`) → returns `{ reservationId, balance }` | +| POST | `/api/v1/internal/credits/commit` | 2-phase debit: commit (body: `{ reservationId, description? }`) → ledger entry | +| POST | `/api/v1/internal/credits/refund-reservation` | 2-phase debit: refund (body: `{ reservationId }`) → restore balance | | POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration | | GET | `/api/v1/internal/sync/status/:userId` | Sync status for server check | | POST | `/api/v1/internal/sync/charge-recurring` | Cron trigger for billing | @@ -106,7 +109,17 @@ Own database: `mana_credits` Schemas: `credits.*`, `gifts.*` -Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions, sync_subscriptions +Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, reservations, gift_codes, gift_redemptions, sync_subscriptions + +## 2-Phase Debit (Reserve/Commit/Refund) + +For services that need to charge only after a downstream call succeeds (e.g. mana-research fanning out to paid API providers), use the `/internal/credits/{reserve,commit,refund-reservation}` flow: + +1. `reserve` — atomically deducts balance, creates row in `credits.reservations` with status `reserved`. Returns `reservationId`. +2. `commit` — marks reservation `committed`, writes transaction ledger entry. +3. `refund-reservation` — marks reservation `refunded`, restores balance. + +One-shot `use` remains for synchronous operations that charge immediately. ## Credit Operations diff --git a/services/mana-credits/src/db/schema/index.ts b/services/mana-credits/src/db/schema/index.ts index 54f0401d4..8bcdc5156 100644 --- a/services/mana-credits/src/db/schema/index.ts +++ b/services/mana-credits/src/db/schema/index.ts @@ -1,3 +1,4 @@ export * from './credits'; export * from './gifts'; export * from './sync'; +export * from './reservations'; diff --git a/services/mana-credits/src/db/schema/reservations.ts b/services/mana-credits/src/db/schema/reservations.ts new file mode 100644 index 000000000..6df1b5a3d --- /dev/null +++ b/services/mana-credits/src/db/schema/reservations.ts @@ -0,0 +1,38 @@ +/** + * Credit Reservations — 2-phase debit for services that need to charge only + * after a downstream call succeeds (e.g. mana-research provider fan-out). + * + * Flow: + * reserve() — deduct balance atomically, row with status='reserved' + * commit() — finalize, row becomes 'committed', ledger entry written + * refund() — restore balance, row becomes 'refunded' + */ + +import { pgEnum, text, timestamp, integer, uuid, index } from 'drizzle-orm/pg-core'; +import { creditsSchema } from './credits'; + +export const reservationStatusEnum = pgEnum('reservation_status', [ + 'reserved', + 'committed', + 'refunded', +]); + +export const creditReservations = creditsSchema.table( + 'reservations', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + amount: integer('amount').notNull(), + reason: text('reason').notNull(), + status: reservationStatusEnum('status').default('reserved').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + }, + (t) => ({ + userIdx: index('reservations_user_id_idx').on(t.userId), + statusIdx: index('reservations_status_idx').on(t.status), + }) +); + +export type CreditReservation = typeof creditReservations.$inferSelect; +export type NewCreditReservation = typeof creditReservations.$inferInsert; diff --git a/services/mana-credits/src/lib/validation.ts b/services/mana-credits/src/lib/validation.ts index a34495d9e..875dd17c5 100644 --- a/services/mana-credits/src/lib/validation.ts +++ b/services/mana-credits/src/lib/validation.ts @@ -74,3 +74,20 @@ export const internalRedeemPendingSchema = z.object({ userId: z.string().min(1), email: z.string().email(), }); + +// ─── Reservations (2-phase debit) ────────────────────────── + +export const internalReserveSchema = z.object({ + userId: z.string().min(1), + amount: z.number().int().positive(), + reason: z.string().min(1).max(200), +}); + +export const internalCommitSchema = z.object({ + reservationId: z.string().uuid(), + description: z.string().max(500).optional(), +}); + +export const internalRefundReservationSchema = z.object({ + reservationId: z.string().uuid(), +}); diff --git a/services/mana-credits/src/routes/internal.ts b/services/mana-credits/src/routes/internal.ts index be9a89a3b..01479c1c2 100644 --- a/services/mana-credits/src/routes/internal.ts +++ b/services/mana-credits/src/routes/internal.ts @@ -11,6 +11,9 @@ import { internalRefundSchema, internalInitSchema, internalRedeemPendingSchema, + internalReserveSchema, + internalCommitSchema, + internalRefundReservationSchema, } from '../lib/validation'; export function createInternalRoutes( @@ -45,6 +48,21 @@ export function createInternalRoutes( const balance = await creditsService.initializeBalance(body.userId); return c.json(balance); }) + .post('/credits/reserve', async (c) => { + const body = internalReserveSchema.parse(await c.req.json()); + const result = await creditsService.reserve(body.userId, body.amount, body.reason); + return c.json(result); + }) + .post('/credits/commit', async (c) => { + const body = internalCommitSchema.parse(await c.req.json()); + const result = await creditsService.commitReservation(body.reservationId, body.description); + return c.json(result); + }) + .post('/credits/refund-reservation', async (c) => { + const body = internalRefundReservationSchema.parse(await c.req.json()); + const result = await creditsService.refundReservation(body.reservationId); + return c.json(result); + }) .post('/gifts/redeem-pending', async (c) => { const body = internalRedeemPendingSchema.parse(await c.req.json()); const result = await giftCodeService.redeemPendingForUser(body.userId, body.email); diff --git a/services/mana-credits/src/services/credits.ts b/services/mana-credits/src/services/credits.ts index d783249c5..2449f1d85 100644 --- a/services/mana-credits/src/services/credits.ts +++ b/services/mana-credits/src/services/credits.ts @@ -7,6 +7,7 @@ import { eq, and, desc } from 'drizzle-orm'; import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits'; +import { creditReservations } from '../db/schema/reservations'; import type { Database } from '../db/connection'; import type { StripeService } from './stripe'; import { @@ -347,4 +348,150 @@ export class CreditsService { if (!purchase) throw new NotFoundError('Purchase not found'); return purchase; } + + // ─── 2-phase debit (reserve / commit / refund) ───────────── + // Used by mana-research for provider calls that should only be charged + // after the downstream API succeeds. See services/mana-research. + + async reserve(userId: string, amount: number, reason: string) { + if (amount <= 0) throw new BadRequestError('Reservation amount must be positive'); + + return await this.db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(balances) + .where(eq(balances.userId, userId)) + .for('update') + .limit(1); + + if (!current) throw new NotFoundError('User balance not found'); + if (current.balance < amount) { + throw new InsufficientCreditsError(amount, current.balance); + } + + const newBalance = current.balance - amount; + + const updated = await tx + .update(balances) + .set({ + balance: newBalance, + version: current.version + 1, + updatedAt: new Date(), + }) + .where(and(eq(balances.userId, userId), eq(balances.version, current.version))) + .returning(); + + if (updated.length === 0) { + throw new ConflictError('Balance was modified concurrently. Please retry.'); + } + + const [reservation] = await tx + .insert(creditReservations) + .values({ userId, amount, reason, status: 'reserved' }) + .returning(); + + return { + reservationId: reservation.id, + balance: newBalance, + }; + }); + } + + async commitReservation(reservationId: string, description?: string) { + return await this.db.transaction(async (tx) => { + const [reservation] = await tx + .select() + .from(creditReservations) + .where(eq(creditReservations.id, reservationId)) + .for('update') + .limit(1); + + if (!reservation) throw new NotFoundError('Reservation not found'); + if (reservation.status !== 'reserved') { + throw new BadRequestError(`Cannot commit reservation in status: ${reservation.status}`); + } + + await tx + .update(creditReservations) + .set({ status: 'committed', resolvedAt: new Date() }) + .where(eq(creditReservations.id, reservationId)); + + const [balance] = await tx + .select() + .from(balances) + .where(eq(balances.userId, reservation.userId)) + .limit(1); + + const balanceAfter = balance?.balance ?? 0; + const balanceBefore = balanceAfter + reservation.amount; + + await tx + .update(balances) + .set({ + totalSpent: (balance?.totalSpent ?? 0) + reservation.amount, + updatedAt: new Date(), + }) + .where(eq(balances.userId, reservation.userId)); + + const [transaction] = await tx + .insert(transactions) + .values({ + userId: reservation.userId, + type: 'usage', + status: 'completed', + amount: -reservation.amount, + balanceBefore, + balanceAfter, + appId: reservation.reason.split(':')[0] || 'mana-research', + description: description ?? reservation.reason, + metadata: { reservationId: reservation.id }, + completedAt: new Date(), + }) + .returning(); + + return { success: true, transactionId: transaction.id }; + }); + } + + async refundReservation(reservationId: string) { + return await this.db.transaction(async (tx) => { + const [reservation] = await tx + .select() + .from(creditReservations) + .where(eq(creditReservations.id, reservationId)) + .for('update') + .limit(1); + + if (!reservation) throw new NotFoundError('Reservation not found'); + if (reservation.status !== 'reserved') { + throw new BadRequestError(`Cannot refund reservation in status: ${reservation.status}`); + } + + await tx + .update(creditReservations) + .set({ status: 'refunded', resolvedAt: new Date() }) + .where(eq(creditReservations.id, reservationId)); + + const [current] = await tx + .select() + .from(balances) + .where(eq(balances.userId, reservation.userId)) + .for('update') + .limit(1); + + if (!current) throw new NotFoundError('User balance not found'); + + const newBalance = current.balance + reservation.amount; + await tx + .update(balances) + .set({ + balance: newBalance, + version: current.version + 1, + updatedAt: new Date(), + }) + .where(and(eq(balances.userId, reservation.userId), eq(balances.version, current.version))); + + return { success: true, balance: newBalance }; + }); + } }