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