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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 14:41:41 +02:00
parent 2df9ecdcaa
commit 004fc0b2fd
6 changed files with 237 additions and 3 deletions

View file

@ -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

View file

@ -1,3 +1,4 @@
export * from './credits';
export * from './gifts';
export * from './sync';
export * from './reservations';

View file

@ -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;

View file

@ -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(),
});

View file

@ -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);

View file

@ -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 };
});
}
}