Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/routes/predictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Router } from "express";
import { requireAuth } from "../middleware/requireAuth";
import { getPredictionExplanation } from "../services/predictionExplainService";
import cancelRouter from "./predictions/cancel";
import { createShareRouter } from "./predictions/share";

export const predictionsRouter = Router();
Expand All @@ -18,6 +19,7 @@ export const predictionsRouter = Router();
* Public — no authentication required.
*/
predictionsRouter.use("/", createShareRouter());
predictionsRouter.use("/", cancelRouter);

// ── Authenticated routes ──────────────────────────────────────────────────
predictionsRouter.use(requireAuth);
Expand Down
123 changes: 123 additions & 0 deletions src/routes/predictions/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { db } from '../../db';
import { predictions, markets, users } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { logger } from '../../logging';

const router = Router();

/**
* POST /api/predictions/:id/cancel
* Cancel an unresolved prediction and refund stake
*/
router.post('/:id/cancel', authenticate, async (req, res) => {
const correlationId = req.headers['x-correlation-id'] || 'unknown';
const { id } = req.params;
const userId = req.user.id;

try {
// 1. Find the prediction
const prediction = await db.query.predictions.findFirst({
where: and(
eq(predictions.id, parseInt(id)),
eq(predictions.userId, userId)
),
with: {
market: true
}
});

if (!prediction) {
return res.status(404).json({
error: 'Prediction not found',
message: 'No prediction found with this ID'
});
}

// 2. Validate market status
if (prediction.market.status === 'settled') {
return res.status(400).json({
error: 'Market already settled',
message: 'Cannot cancel prediction on a settled market'
});
}

if (prediction.market.status === 'cancelled') {
return res.status(400).json({
error: 'Market already cancelled',
message: 'Cannot cancel prediction on a cancelled market'
});
}

// 3. Validate prediction status
if (prediction.status === 'cancelled') {
return res.status(400).json({
error: 'Already cancelled',
message: 'This prediction has already been cancelled'
});
}

// 4. Process refund (immediate balance update)
const user = await db.query.users.findFirst({
where: eq(users.id, userId)
});

if (!user) {
return res.status(404).json({
error: 'User not found',
message: 'User associated with this prediction not found'
});
}

// Start transaction
await db.transaction(async (tx) => {
// Update prediction status
await tx
.update(predictions)
.set({
status: 'cancelled',
cancelledAt: new Date()
})
.where(eq(predictions.id, prediction.id));

// Refund stake to user's balance
await tx
.update(users)
.set({
balance: user.balance + prediction.stake
})
.where(eq(users.id, userId));
});

logger.info('Prediction cancelled and refunded', {
correlationId,
predictionId: prediction.id,
userId: userId,
stake: prediction.stake
});

return res.status(200).json({
message: 'Prediction cancelled and stake refunded',
prediction: {
id: prediction.id,
status: 'cancelled',
cancelledAt: new Date(),
refundAmount: prediction.stake
}
});

} catch (error) {
logger.error('Error cancelling prediction', {
correlationId,
error: error instanceof Error ? error.message : 'Unknown error'
});

return res.status(500).json({
error: 'Internal server error',
message: 'Failed to cancel prediction'
});
}
});

export default router;
87 changes: 87 additions & 0 deletions tests/routes/predictions/cancel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import request from 'supertest';
import app from '../../../src/app';
import { db } from '../../../src/db';
import { users, markets, predictions } from '../../../src/db/schema';
import { eq } from 'drizzle-orm';

describe('POST /api/predictions/:id/cancel', () => {
let token: string;
let userId: number;
let marketId: number;
let predictionId: number;

beforeEach(async () => {
// Create test user
const [user] = await db.insert(users).values({
email: 'test@example.com',
balance: 1000,
password: 'hashed'
}).returning();
userId = user.id;

// Create test market
const [market] = await db.insert(markets).values({
question: 'Will it rain?',
status: 'open'
}).returning();
marketId = market.id;

// Create test prediction
const [prediction] = await db.insert(predictions).values({
userId: userId,
marketId: marketId,
outcome: 'yes',
stake: 100,
status: 'pending'
}).returning();
predictionId = prediction.id;

token = 'test-token'; // Replace with actual token generation
});

it('should cancel prediction and refund stake', async () => {
const response = await request(app)
.post(`/api/predictions/${predictionId}/cancel`)
.set('Authorization', `Bearer ${token}`)
.expect(200);

expect(response.body.message).toBe('Prediction cancelled and stake refunded');
expect(response.body.prediction.status).toBe('cancelled');
expect(response.body.prediction.refundAmount).toBe(100);
});

it('should return 404 for non-existent prediction', async () => {
await request(app)
.post('/api/predictions/99999/cancel')
.set('Authorization', `Bearer ${token}`)
.expect(404);
});

it('should return 400 if market is settled', async () => {
await db.update(markets)
.set({ status: 'settled' })
.where(eq(markets.id, marketId));

await request(app)
.post(`/api/predictions/${predictionId}/cancel`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
});

it('should return 400 if prediction already cancelled', async () => {
await db.update(predictions)
.set({ status: 'cancelled' })
.where(eq(predictions.id, predictionId));

await request(app)
.post(`/api/predictions/${predictionId}/cancel`)
.set('Authorization', `Bearer ${token}`)
.expect(400);
});

it('should return 401 without authentication', async () => {
await request(app)
.post(`/api/predictions/${predictionId}/cancel`)
.expect(401);
});
});