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
+4 -4
View File
@@ -152,14 +152,14 @@ model SystemSettings {
id String @id @default(cuid()) id String @id @default(cuid())
// Minting settings // Minting settings
creditsPerMinute Int @default(10) @map("credits_per_minute") creditsPerMinute Int @default(2) @map("credits_per_minute")
minWatchPercent Int @default(80) @map("min_watch_percent") minWatchPercent Int @default(80) @map("min_watch_percent")
minWatchMinutes Int @default(5) @map("min_watch_minutes") minWatchMinutes Int @default(5) @map("min_watch_minutes")
// Spending settings // Spending settings
movieRequestCost Int @default(100) @map("movie_request_cost") movieRequestCost Int @default(500) @map("movie_request_cost")
tvRequestCost Int @default(200) @map("tv_request_cost") tvRequestCost Int @default(1000) @map("tv_request_cost")
tvPerSeasonCost Int @default(50) @map("tv_per_season_cost") tvPerSeasonCost Int @default(250) @map("tv_per_season_cost")
// Multipliers // Multipliers
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2) newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
+1 -1
View File
@@ -320,7 +320,7 @@ async function backfillUserHistory(user: {
// Get system settings for credit calculation // Get system settings for credit calculation
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 10; const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80; const minWatchPercent = settings?.minWatchPercent || 80;
const minWatchMinutes = settings?.minWatchMinutes || 5; const minWatchMinutes = settings?.minWatchMinutes || 5;
+49 -27
View File
@@ -25,9 +25,9 @@ router.get(
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
res.json({ res.json({
movie: settings?.movieRequestCost || 100, movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 200, tv: settings?.tvRequestCost || 1000,
tvPerSeason: settings?.tvPerSeasonCost || 50, tvPerSeason: settings?.tvPerSeasonCost || 250,
}); });
}), }),
); );
@@ -55,11 +55,11 @@ router.get(
res.json({ res.json({
hasWallet: true, hasWallet: true,
balance: dbBalance, balance: dbBalance,
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100), canRequestMovie: dbBalance >= (settings?.movieRequestCost || 500),
canRequestTV: dbBalance >= (settings?.tvRequestCost || 200), canRequestTV: dbBalance >= (settings?.tvRequestCost || 1000),
costs: { costs: {
movie: settings?.movieRequestCost || 100, movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 200, tv: settings?.tvRequestCost || 1000,
}, },
}); });
}), }),
@@ -108,11 +108,11 @@ router.post(
// Calculate cost // Calculate cost
let cost = 0; let cost = 0;
if (mediaType === "movie") { if (mediaType === "movie") {
cost = settings?.movieRequestCost || 100; cost = settings?.movieRequestCost || 500;
} else if (mediaType === "tv") { } else if (mediaType === "tv") {
cost = settings?.tvRequestCost || 200; cost = settings?.tvRequestCost || 1000;
if (seasons && seasons.length > 1) { 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", { const overseerRequest = await overseerClient.post("/request", {
mediaType, mediaType,
mediaId, 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({ res.json({
success: true, success: true,
request, request,
cost, cost,
message: "Request submitted. Credits will be deducted when approved.", message: `Request submitted. ${cost} $COOP deducted.`,
}); });
}), }),
); );
@@ -182,35 +208,31 @@ router.post(
data: { status }, data: { status },
}); });
// If approved, deduct credits // Credits were already deducted at request time.
if (status === "APPROVED" && !request.user.totalSpent) { // On approval: just confirm and link transaction to request.
// Note: Actual burning would happen here // On decline: refund credits back to user.
// For now, we just record the transaction if (status === "DECLINED") {
const refundTransaction = await prisma.transaction.create({
const transaction = await prisma.transaction.create({
data: { data: {
userId: request.userId, userId: request.userId,
type: "SPEND", type: "ADJUSTMENT",
amount: request.creditsCost, amount: request.creditsCost,
requestId: request.id, description: `Refunded: ${request.title}`,
description: `Request: ${request.title}`,
contentTitle: request.title, contentTitle: request.title,
}, },
}); });
// Update user stats
await prisma.user.update({ await prisma.user.update({
where: { id: request.userId }, where: { id: request.userId },
data: { data: {
totalSpent: { increment: request.creditsCost }, totalSpent: { decrement: request.creditsCost },
}, },
}); });
// Emit update io.to(`user:${request.userId}`).emit("bonus_received", {
io.to(`user:${request.userId}`).emit("credits_spent", {
amount: request.creditsCost, amount: request.creditsCost,
title: request.title, reason: `Refunded: ${request.title}`,
transaction, transaction: refundTransaction,
}); });
} }
+176 -171
View File
@@ -1,204 +1,209 @@
import { Router } from 'express'; import crypto from "crypto";
import { prisma } from '../utils/prisma'; import { Router } from "express";
import { mintTokens } from '../services/solana'; import { io } from "../index";
import { asyncHandler } from '../middleware/errorHandler'; import { asyncHandler } from "../middleware/errorHandler";
import { io } from '../index'; import { mintTokens } from "../services/solana";
import crypto from 'crypto'; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || ''; const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
// Verify webhook signature // Verify webhook signature
function verifyWebhookSignature(payload: string, signature: string): boolean { function verifyWebhookSignature(payload: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set
const expected = crypto const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET) .createHmac("sha256", WEBHOOK_SECRET)
.update(payload) .update(payload)
.digest('hex'); .digest("hex");
return crypto.timingSafeEqual( return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
Buffer.from(signature),
Buffer.from(expected)
);
} }
// Tautulli webhook endpoint // Tautulli webhook endpoint
router.post('/tautulli', router.post(
asyncHandler(async (req, res) => { "/tautulli",
const webhookSignature = req.headers['x-tautulli-signature'] as string; asyncHandler(async (req, res) => {
const payload = JSON.stringify(req.body); const webhookSignature = req.headers["x-tautulli-signature"] as string;
const payload = JSON.stringify(req.body);
// Verify signature if configured
if (WEBHOOK_SECRET && webhookSignature && !verifyWebhookSignature(payload, webhookSignature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.body; // Verify signature if configured
if (
// Only process watched events WEBHOOK_SECRET &&
if (event.action !== 'watched') { webhookSignature &&
return res.json({ message: 'Event type not processed' }); !verifyWebhookSignature(payload, webhookSignature)
} ) {
return res.status(401).json({ error: "Invalid signature" });
}
// Validate required fields const event = req.body;
if (!event.user_id || !event.rating_key || !event.session_key) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Find user by Plex ID // Only process watched events
const user = await prisma.user.findUnique({ if (event.action !== "watched") {
where: { plexId: event.user_id.toString() } return res.json({ message: "Event type not processed" });
}); }
if (!user) { // Validate required fields
console.log(`User not found for Plex ID: ${event.user_id}`); if (!event.user_id || !event.rating_key || !event.session_key) {
return res.status(404).json({ error: 'User not found' }); return res.status(400).json({ error: "Missing required fields" });
} }
if (!user.walletAddress) { // Find user by Plex ID
console.log(`User ${user.plexUsername} has no wallet`); const user = await prisma.user.findUnique({
return res.status(400).json({ error: 'User has no wallet' }); where: { plexId: event.user_id.toString() },
} });
// Check for duplicate events if (!user) {
const existing = await prisma.watchEvent.findUnique({ console.log(`User not found for Plex ID: ${event.user_id}`);
where: { sessionId: event.session_key.toString() } return res.status(404).json({ error: "User not found" });
}); }
if (existing) { if (!user.walletAddress) {
return res.json({ message: 'Event already processed' }); console.log(`User ${user.plexUsername} has no wallet`);
} return res.status(400).json({ error: "User has no wallet" });
}
// Get system settings // Check for duplicate events
const settings = await prisma.systemSettings.findFirst(); const existing = await prisma.watchEvent.findUnique({
const creditsPerMinute = settings?.creditsPerMinute || 10; where: { sessionId: event.session_key.toString() },
const minWatchPercent = settings?.minWatchPercent || 80; });
const minWatchMinutes = settings?.minWatchMinutes || 5;
// Calculate watch duration if (existing) {
const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60); return res.json({ message: "Event already processed" });
const percentComplete = event.percent_complete || 0; }
// Validate minimum requirements // Get system settings
if (percentComplete < minWatchPercent) { const settings = await prisma.systemSettings.findFirst();
return res.json({ const creditsPerMinute = settings?.creditsPerMinute || 2;
message: 'Watch percentage too low', const minWatchPercent = settings?.minWatchPercent || 80;
percentComplete, const minWatchMinutes = settings?.minWatchMinutes || 5;
required: minWatchPercent
});
}
if (watchDurationMinutes < minWatchMinutes) { // Calculate watch duration
return res.json({ const watchDurationMinutes = Math.floor(
message: 'Watch duration too short', (event.stopped - event.started) / 60,
watchDurationMinutes, );
required: minWatchMinutes const percentComplete = event.percent_complete || 0;
});
}
// Calculate credits // Validate minimum requirements
let creditsEarned = watchDurationMinutes * creditsPerMinute; if (percentComplete < minWatchPercent) {
return res.json({
// Apply multipliers message: "Watch percentage too low",
if (settings?.newReleaseMultiplier && event.is_new) { percentComplete,
creditsEarned = Math.floor(creditsEarned * Number(settings.newReleaseMultiplier)); required: minWatchPercent,
} });
}
if (settings?.bonusMultiplierActive) {
creditsEarned = Math.floor(creditsEarned * Number(settings.bonusMultiplier));
}
// Create watch event record if (watchDurationMinutes < minWatchMinutes) {
const watchEvent = await prisma.watchEvent.create({ return res.json({
data: { message: "Watch duration too short",
userId: user.id, watchDurationMinutes,
sessionId: event.session_key.toString(), required: minWatchMinutes,
ratingKey: event.rating_key.toString(), });
contentType: event.media_type, }
title: event.title,
grandparentTitle: event.grandparent_title,
duration: event.stopped - event.started,
percentComplete: Math.floor(percentComplete),
creditsEarned,
watchedAt: new Date(event.stopped * 1000)
}
});
// Mint tokens on Solana // Calculate credits
const signature = await mintTokens( let creditsEarned = watchDurationMinutes * creditsPerMinute;
user.walletAddress,
creditsEarned,
{
sessionId: event.session_key.toString(),
contentTitle: event.title,
watchDurationMinutes
}
);
if (signature) { // Apply multipliers
// Create transaction record if (settings?.newReleaseMultiplier && event.is_new) {
const transaction = await prisma.transaction.create({ creditsEarned = Math.floor(
data: { creditsEarned * Number(settings.newReleaseMultiplier),
userId: user.id, );
type: 'EARN', }
amount: creditsEarned,
watchEventId: watchEvent.id,
solanaSignature: signature,
description: `Watched ${event.title}`,
contentTitle: event.title
}
});
// Update user stats if (settings?.bonusMultiplierActive) {
await prisma.user.update({ creditsEarned = Math.floor(
where: { id: user.id }, creditsEarned * Number(settings.bonusMultiplier),
data: { );
totalEarned: { increment: creditsEarned }, }
watchTimeMinutes: { increment: watchDurationMinutes }
}
});
// Mark watch event as processed // Create watch event record
await prisma.watchEvent.update({ const watchEvent = await prisma.watchEvent.create({
where: { id: watchEvent.id }, data: {
data: { isProcessed: true } userId: user.id,
}); sessionId: event.session_key.toString(),
ratingKey: event.rating_key.toString(),
contentType: event.media_type,
title: event.title,
grandparentTitle: event.grandparent_title,
duration: event.stopped - event.started,
percentComplete: Math.floor(percentComplete),
creditsEarned,
watchedAt: new Date(event.stopped * 1000),
},
});
// Emit real-time update via WebSocket // Mint tokens on Solana
io.to(`user:${user.id}`).emit('credits_earned', { const signature = await mintTokens(user.walletAddress, creditsEarned, {
amount: creditsEarned, sessionId: event.session_key.toString(),
title: event.title, contentTitle: event.title,
transaction: { watchDurationMinutes,
id: transaction.id, });
type: 'EARN',
amount: creditsEarned,
contentTitle: event.title,
createdAt: transaction.createdAt
}
});
res.json({ if (signature) {
success: true, // Create transaction record
creditsEarned, const transaction = await prisma.transaction.create({
solanaSignature: signature, data: {
message: `Minted ${creditsEarned} COOP for watching ${event.title}` userId: user.id,
}); type: "EARN",
} else { amount: creditsEarned,
res.status(500).json({ error: 'Failed to mint tokens' }); watchEventId: watchEvent.id,
} solanaSignature: signature,
}) description: `Watched ${event.title}`,
contentTitle: event.title,
},
});
// Update user stats
await prisma.user.update({
where: { id: user.id },
data: {
totalEarned: { increment: creditsEarned },
watchTimeMinutes: { increment: watchDurationMinutes },
},
});
// Mark watch event as processed
await prisma.watchEvent.update({
where: { id: watchEvent.id },
data: { isProcessed: true },
});
// Emit real-time update via WebSocket
io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsEarned,
title: event.title,
transaction: {
id: transaction.id,
type: "EARN",
amount: creditsEarned,
contentTitle: event.title,
createdAt: transaction.createdAt,
},
});
res.json({
success: true,
creditsEarned,
solanaSignature: signature,
message: `Minted ${creditsEarned} COOP for watching ${event.title}`,
});
} else {
res.status(500).json({ error: "Failed to mint tokens" });
}
}),
); );
// Test webhook endpoint // Test webhook endpoint
router.post('/test', router.post(
asyncHandler(async (req, res) => { "/test",
res.json({ asyncHandler(async (req, res) => {
message: 'Webhook endpoint working', res.json({
timestamp: new Date().toISOString(), message: "Webhook endpoint working",
body: req.body timestamp: new Date().toISOString(),
}); body: req.body,
}) });
}),
); );
export { router as webhookRouter }; export { router as webhookRouter };