feat(economy): tighten credit earning + deduct on request

Credit economy changes:
- creditsPerMinute: 10 -> 2 (45min episode = 90 COOP vs 450)
- movieRequestCost: 100 -> 500 (~5.5 episodes per movie)
- tvRequestCost: 200 -> 1000 (~11 episodes per season)
- tvPerSeasonCost: 50 -> 250

Race condition fix:
- Credits now deducted immediately at request time
- No more multiple requests before approval
- On DECLINED: automatic refund with ADJUSTMENT transaction
- On APPROVED: no extra deduction (already paid)

Updated schema defaults and all code fallbacks.
This commit is contained in:
2026-04-21 15:50:18 -04:00
parent fee4d8ce1b
commit 86a8c7d89a
4 changed files with 230 additions and 203 deletions
+49 -27
View File
@@ -25,9 +25,9 @@ router.get(
const settings = await prisma.systemSettings.findFirst();
res.json({
movie: settings?.movieRequestCost || 100,
tv: settings?.tvRequestCost || 200,
tvPerSeason: settings?.tvPerSeasonCost || 50,
movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 1000,
tvPerSeason: settings?.tvPerSeasonCost || 250,
});
}),
);
@@ -55,11 +55,11 @@ router.get(
res.json({
hasWallet: true,
balance: dbBalance,
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100),
canRequestTV: dbBalance >= (settings?.tvRequestCost || 200),
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 500),
canRequestTV: dbBalance >= (settings?.tvRequestCost || 1000),
costs: {
movie: settings?.movieRequestCost || 100,
tv: settings?.tvRequestCost || 200,
movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 1000,
},
});
}),
@@ -108,11 +108,11 @@ router.post(
// Calculate cost
let cost = 0;
if (mediaType === "movie") {
cost = settings?.movieRequestCost || 100;
cost = settings?.movieRequestCost || 500;
} else if (mediaType === "tv") {
cost = settings?.tvRequestCost || 200;
cost = settings?.tvRequestCost || 1000;
if (seasons && seasons.length > 1) {
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250);
}
}
@@ -126,7 +126,7 @@ router.post(
});
}
// Create request in Overseer
// Create request in Overseer first
const overseerRequest = await overseerClient.post("/request", {
mediaType,
mediaId,
@@ -147,11 +147,37 @@ router.post(
},
});
// Deduct credits immediately to prevent race condition
const transaction = await prisma.transaction.create({
data: {
userId: user.id,
type: "SPEND",
amount: cost,
requestId: request.id,
description: `Request: ${title}`,
contentTitle: title,
},
});
await prisma.user.update({
where: { id: user.id },
data: {
totalSpent: { increment: cost },
},
});
// Emit real-time spend update
io.to(`user:${user.id}`).emit("credits_spent", {
amount: cost,
title,
transaction,
});
res.json({
success: true,
request,
cost,
message: "Request submitted. Credits will be deducted when approved.",
message: `Request submitted. ${cost} $COOP deducted.`,
});
}),
);
@@ -182,35 +208,31 @@ router.post(
data: { status },
});
// If approved, deduct credits
if (status === "APPROVED" && !request.user.totalSpent) {
// Note: Actual burning would happen here
// For now, we just record the transaction
const transaction = await prisma.transaction.create({
// Credits were already deducted at request time.
// On approval: just confirm and link transaction to request.
// On decline: refund credits back to user.
if (status === "DECLINED") {
const refundTransaction = await prisma.transaction.create({
data: {
userId: request.userId,
type: "SPEND",
type: "ADJUSTMENT",
amount: request.creditsCost,
requestId: request.id,
description: `Request: ${request.title}`,
description: `Refunded: ${request.title}`,
contentTitle: request.title,
},
});
// Update user stats
await prisma.user.update({
where: { id: request.userId },
data: {
totalSpent: { increment: request.creditsCost },
totalSpent: { decrement: request.creditsCost },
},
});
// Emit update
io.to(`user:${request.userId}`).emit("credits_spent", {
io.to(`user:${request.userId}`).emit("bonus_received", {
amount: request.creditsCost,
title: request.title,
transaction,
reason: `Refunded: ${request.title}`,
transaction: refundTransaction,
});
}