mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
2df9ecdcaa
commit
004fc0b2fd
6 changed files with 237 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './credits';
|
||||
export * from './gifts';
|
||||
export * from './sync';
|
||||
export * from './reservations';
|
||||
|
|
|
|||
38
services/mana-credits/src/db/schema/reservations.ts
Normal file
38
services/mana-credits/src/db/schema/reservations.ts
Normal 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;
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue