fix(balance): use database credits instead of on-chain balance

Balance checks were reading on-chain Solana token balance (0
during db-only mode). Now wallet and overseer routes return
user.totalEarned - user.totalSpent as the usable credit balance.
This makes credits spendable even when devnet faucet is dry.

- /wallet returns dbBalance, with onChainBalance as extra info
- /overseer/balance returns dbBalance for request eligibility
- /overseer/request checks dbBalance before approving spend
- /wallet/admin returns dbBalance
This commit is contained in:
2026-04-21 15:37:06 -04:00
parent 5dbee115ad
commit fee4d8ce1b
2 changed files with 185 additions and 181 deletions
+173 -169
View File
@@ -1,217 +1,221 @@
import { Router } from 'express'; import axios from "axios";
import axios from 'axios'; import { Router } from "express";
import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { io } from "../index";
import { prisma } from '../utils/prisma'; import { type AuthenticatedRequest, authenticate } from "../middleware/auth";
import { asyncHandler } from '../middleware/errorHandler'; import { asyncHandler } from "../middleware/errorHandler";
import { getTokenBalance } from '../services/solana'; import { prisma } from "../utils/prisma";
import { io } from '../index';
const router = Router(); const router = Router();
const OVERSEER_URL = process.env.OVERSEER_URL || ''; const OVERSEER_URL = process.env.OVERSEER_URL || "";
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ''; const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || "";
const overseerClient = axios.create({ const overseerClient = axios.create({
baseURL: `${OVERSEER_URL}/api/v1`, baseURL: `${OVERSEER_URL}/api/v1`,
headers: { headers: {
'X-Api-Key': OVERSEER_API_KEY "X-Api-Key": OVERSEER_API_KEY,
} },
}); });
// Get request costs // Get request costs
router.get('/costs', router.get(
authenticate, "/costs",
asyncHandler(async (_req: AuthenticatedRequest, res) => { authenticate,
const settings = await prisma.systemSettings.findFirst(); asyncHandler(async (_req: AuthenticatedRequest, res) => {
const settings = await prisma.systemSettings.findFirst();
res.json({ res.json({
movie: settings?.movieRequestCost || 100, movie: settings?.movieRequestCost || 100,
tv: settings?.tvRequestCost || 200, tv: settings?.tvRequestCost || 200,
tvPerSeason: settings?.tvPerSeasonCost || 50 tvPerSeason: settings?.tvPerSeasonCost || 50,
}); });
}) }),
); );
// Get user's balance and request availability // Get user's balance and request availability
router.get('/balance', router.get(
authenticate, "/balance",
asyncHandler(async (req: AuthenticatedRequest, res) => { authenticate,
const user = await prisma.user.findUnique({ asyncHandler(async (req: AuthenticatedRequest, res) => {
where: { id: req.user!.id } const user = await prisma.user.findUnique({
}); where: { id: req.user!.id },
});
if (!user?.walletAddress) { if (!user?.walletAddress) {
return res.json({ return res.json({
hasWallet: false, hasWallet: false,
balance: 0, balance: 0,
canRequest: false canRequest: false,
}); });
} }
const balance = await getTokenBalance(user.walletAddress); const settings = await prisma.systemSettings.findFirst();
const settings = await prisma.systemSettings.findFirst(); const dbBalance = user.totalEarned - user.totalSpent;
res.json({ res.json({
hasWallet: true, hasWallet: true,
balance, balance: dbBalance,
canRequestMovie: balance >= (settings?.movieRequestCost || 100), canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100),
canRequestTV: balance >= (settings?.tvRequestCost || 200), canRequestTV: dbBalance >= (settings?.tvRequestCost || 200),
costs: { costs: {
movie: settings?.movieRequestCost || 100, movie: settings?.movieRequestCost || 100,
tv: settings?.tvRequestCost || 200 tv: settings?.tvRequestCost || 200,
} },
}); });
}) }),
); );
// Search for content // Search for content
router.get('/search', router.get(
authenticate, "/search",
asyncHandler(async (req: AuthenticatedRequest, res) => { authenticate,
const { query } = req.query; asyncHandler(async (req: AuthenticatedRequest, res) => {
const { query } = req.query;
if (!query) { if (!query) {
return res.status(400).json({ error: 'Query required' }); return res.status(400).json({ error: "Query required" });
} }
const response = await overseerClient.get('/search', { const response = await overseerClient.get("/search", {
params: { query } params: { query },
}); });
res.json(response.data); res.json(response.data);
}) }),
); );
// Request content // Request content
router.post('/request', router.post(
authenticate, "/request",
asyncHandler(async (req: AuthenticatedRequest, res) => { authenticate,
const { mediaType, mediaId, title, seasons } = req.body; asyncHandler(async (req: AuthenticatedRequest, res) => {
const { mediaType, mediaId, title, seasons } = req.body;
if (!mediaType || !mediaId || !title) { if (!mediaType || !mediaId || !title) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: "Missing required fields" });
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: req.user!.id } where: { id: req.user!.id },
}); });
if (!user?.walletAddress) { if (!user?.walletAddress) {
return res.status(400).json({ error: 'Wallet required' }); return res.status(400).json({ error: "Wallet required" });
} }
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
// Calculate cost // Calculate cost
let cost = 0; let cost = 0;
if (mediaType === 'movie') { if (mediaType === "movie") {
cost = settings?.movieRequestCost || 100; cost = settings?.movieRequestCost || 100;
} else if (mediaType === 'tv') { } else if (mediaType === "tv") {
cost = settings?.tvRequestCost || 200; cost = settings?.tvRequestCost || 200;
if (seasons && seasons.length > 1) { if (seasons && seasons.length > 1) {
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50); cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
} }
} }
// Check balance // Check balance
const balance = await getTokenBalance(user.walletAddress); const dbBalance = user.totalEarned - user.totalSpent;
if (balance < cost) { if (dbBalance < cost) {
return res.status(400).json({ return res.status(400).json({
error: 'Insufficient balance', error: "Insufficient balance",
required: cost, required: cost,
current: balance current: dbBalance,
}); });
} }
// Create request in Overseer // Create request in Overseer
const overseerRequest = await overseerClient.post('/request', { const overseerRequest = await overseerClient.post("/request", {
mediaType, mediaType,
mediaId, mediaId,
...(seasons && { seasons }) ...(seasons && { seasons }),
}); });
// Create local request record // Create local request record
const request = await prisma.contentRequest.create({ const request = await prisma.contentRequest.create({
data: { data: {
userId: user.id, userId: user.id,
overseerRequestId: overseerRequest.data.id, overseerRequestId: overseerRequest.data.id,
mediaType, mediaType,
tmdbId: mediaId, tmdbId: mediaId,
title, title,
creditsCost: cost, creditsCost: cost,
status: 'PENDING', status: "PENDING",
requestedAt: new Date() requestedAt: new Date(),
} },
}); });
res.json({ res.json({
success: true, success: true,
request, request,
cost, cost,
message: 'Request submitted. Credits will be deducted when approved.' message: "Request submitted. Credits will be deducted when approved.",
}); });
}) }),
); );
// Webhook: Handle Overseer request status changes // Webhook: Handle Overseer request status changes
router.post('/webhook', router.post(
asyncHandler(async (req, res) => { "/webhook",
const { request_id, status } = req.body; asyncHandler(async (req, res) => {
const { request_id, status } = req.body;
if (!request_id || !status) { if (!request_id || !status) {
return res.status(400).json({ error: 'Missing fields' }); return res.status(400).json({ error: "Missing fields" });
} }
// Find local request // Find local request
const request = await prisma.contentRequest.findFirst({ const request = await prisma.contentRequest.findFirst({
where: { overseerRequestId: request_id }, where: { overseerRequestId: request_id },
include: { user: true } include: { user: true },
}); });
if (!request) { if (!request) {
return res.status(404).json({ error: 'Request not found' }); return res.status(404).json({ error: "Request not found" });
} }
// Update status // Update status
const updatedRequest = await prisma.contentRequest.update({ const updatedRequest = await prisma.contentRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { status } data: { status },
}); });
// If approved, deduct credits // If approved, deduct credits
if (status === 'APPROVED' && !request.user.totalSpent) { if (status === "APPROVED" && !request.user.totalSpent) {
// Note: Actual burning would happen here // Note: Actual burning would happen here
// For now, we just record the transaction // For now, we just record the transaction
const transaction = await prisma.transaction.create({ const transaction = await prisma.transaction.create({
data: { data: {
userId: request.userId, userId: request.userId,
type: 'SPEND', type: "SPEND",
amount: request.creditsCost, amount: request.creditsCost,
requestId: request.id, requestId: request.id,
description: `Request: ${request.title}`, description: `Request: ${request.title}`,
contentTitle: request.title contentTitle: request.title,
} },
}); });
// Update user stats // 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: { increment: request.creditsCost },
} },
}); });
// Emit update // Emit update
io.to(`user:${request.userId}`).emit('credits_spent', { io.to(`user:${request.userId}`).emit("credits_spent", {
amount: request.creditsCost, amount: request.creditsCost,
title: request.title, title: request.title,
transaction transaction,
}); });
} }
res.json({ success: true, request: updatedRequest }); res.json({ success: true, request: updatedRequest });
}) }),
); );
export { router as overseerRouter }; export { router as overseerRouter };
+8 -8
View File
@@ -65,13 +65,16 @@ router.get(
}); });
} }
// Get on-chain balance // Get on-chain balance (may be 0 if using DB-only credits)
const balance = await getTokenBalance(user.walletAddress); const onChainBalance = await getTokenBalance(user.walletAddress);
// Usable credits are tracked in DB (works even when blockchain is down)
const dbBalance = user.totalEarned - user.totalSpent;
res.json({ res.json({
hasWallet: true, hasWallet: true,
address: user.walletAddress, address: user.walletAddress,
balance, balance: dbBalance,
onChainBalance,
totalEarned: user.totalEarned, totalEarned: user.totalEarned,
totalSpent: user.totalSpent, totalSpent: user.totalSpent,
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`, explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`,
@@ -198,14 +201,11 @@ router.get(
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
let balance = 0; const dbBalance = user.totalEarned - user.totalSpent;
if (user.walletAddress) {
balance = await getTokenBalance(user.walletAddress);
}
res.json({ res.json({
...user, ...user,
balance, balance: dbBalance,
}); });
}), }),
); );