Game-Server/API/routes/payments.js
2026-03-11 00:32:45 -03:00

174 lines
7.3 KiB
JavaScript

/**
* routes/payments.js — GSO Premium Gem Store (GDD Phase 3 Monetisation)
*
* Endpoints:
* POST /api/payments/create-checkout — create Stripe Checkout session
* POST /api/payments/webhook — Stripe webhook (payment confirmation)
* GET /api/payments/products — list purchasable gem packages
*
* Requires ENV vars:
* STRIPE_SECRET_KEY — sk_live_... (or sk_test_... for development)
* STRIPE_WEBHOOK_SECRET — whsec_... (from Stripe Dashboard → Webhooks)
* CLIENT_URL — https://galaxystrike.online (for redirect URLs)
*
* Stripe is loaded lazily so the server starts even if the package isn't installed yet.
* Run: npm install stripe in the API directory before going live.
*/
const express = require('express');
const router = express.Router();
// ── Gem packages available for purchase ────────────────────────────────────────
const GEM_PACKAGES = [
{ id: 'gems_80', gems: 80, priceUSD: 0.99, label: 'Starter Pack', bonus: 0, popular: false },
{ id: 'gems_200', gems: 200, priceUSD: 1.99, label: 'Pilot Pack', bonus: 0, popular: false },
{ id: 'gems_500', gems: 500, priceUSD: 4.99, label: 'Commander Pack', bonus: 50, popular: true },
{ id: 'gems_1200', gems: 1200, priceUSD: 9.99, label: 'Admiral Pack', bonus: 200,popular: false },
{ id: 'gems_2500', gems: 2500, priceUSD: 19.99, label: 'Fleet Admiral', bonus: 500,popular: false },
{ id: 'gems_6500', gems: 6500, priceUSD: 49.99, label: 'Grand Admiral', bonus: 1500,popular: false},
];
// ── Helper: load Stripe lazily ────────────────────────────────────────────────
function getStripe() {
if (!process.env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY not configured');
// eslint-disable-next-line global-require
return require('stripe')(process.env.STRIPE_SECRET_KEY);
}
// ── GET /api/payments/products ────────────────────────────────────────────────
router.get('/products', (req, res) => {
res.json({ packages: GEM_PACKAGES });
});
// ── POST /api/payments/create-checkout ────────────────────────────────────────
// Body: { packageId, userId, username }
router.post('/create-checkout', async (req, res) => {
try {
const { packageId, userId, username } = req.body;
if (!packageId || !userId) return res.status(400).json({ error: 'packageId and userId required' });
const pkg = GEM_PACKAGES.find(p => p.id === packageId);
if (!pkg) return res.status(404).json({ error: 'Unknown package' });
const stripe = getStripe();
const totalGems = pkg.gems + pkg.bonus;
const clientUrl = process.env.CLIENT_URL || 'http://localhost:3000';
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'usd',
unit_amount: Math.round(pkg.priceUSD * 100), // cents
product_data: {
name: `${pkg.label}${totalGems} 💎 Gems`,
description: pkg.bonus > 0
? `${pkg.gems} gems + ${pkg.bonus} bonus gems (${totalGems} total)`
: `${pkg.gems} Galaxy Strike Online gems`,
metadata: { packageId: pkg.id },
},
},
quantity: 1,
}],
client_reference_id: userId,
customer_email: req.body.email || undefined,
metadata: {
userId,
username: username || 'unknown',
packageId: pkg.id,
gems: String(totalGems),
},
success_url: `${clientUrl}/payment-success?session={CHECKOUT_SESSION_ID}`,
cancel_url: `${clientUrl}/payment-cancel`,
});
res.json({ sessionId: session.id, url: session.url });
} catch (err) {
console.error('[PAYMENTS] create-checkout error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── POST /api/payments/webhook ────────────────────────────────────────────────
// Stripe sends raw body — must use express.raw() middleware for this route.
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
console.error('[PAYMENTS] STRIPE_WEBHOOK_SECRET not set — webhook rejected');
return res.status(400).send('Webhook secret not configured');
}
let event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
console.error('[PAYMENTS] Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const { userId, username, packageId, gems } = session.metadata || {};
if (!userId || !gems) {
console.error('[PAYMENTS] Webhook missing metadata', session.metadata);
return res.json({ received: true });
}
const gemCount = parseInt(gems, 10);
const amountCents = session.amount_total || 0;
console.log(`[PAYMENTS] ✅ Payment confirmed: ${username} (${userId}) — ${gemCount} gems — $${(amountCents/100).toFixed(2)}`);
// Credit gems in the database directly
try {
const PlayerData = require('../models/PlayerData');
const player = await PlayerData.findOne({ userId });
if (player) {
player.stats.gems = (player.stats.gems || 0) + gemCount;
player.markModified('stats');
await player.save();
console.log(`[PAYMENTS] Credited ${gemCount} gems to ${username}. New balance: ${player.stats.gems}`);
} else {
console.warn(`[PAYMENTS] Player not found for userId: ${userId}`);
}
} catch (dbErr) {
console.error('[PAYMENTS] DB credit error:', dbErr.message);
// Return 200 to Stripe regardless — Stripe will retry on 5xx
}
// Attempt to push live notification to game server via internal HTTP
// (Game server will push 'gems_credited' to the player's socket if online)
const gameServerUrl = process.env.GAME_SERVER_URL || 'http://localhost:3002';
try {
const http = require('http');
const body = JSON.stringify({ userId, gems: gemCount, packageId, amountCents });
const options = {
hostname: new URL(gameServerUrl).hostname,
port: new URL(gameServerUrl).port || 3002,
path: '/internal/credit-gems',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Internal-Key': process.env.INTERNAL_API_KEY || 'gso-internal',
},
};
const req2 = http.request(options);
req2.write(body);
req2.end();
} catch (notifyErr) {
// Non-fatal — player will see updated gems on next login
}
}
res.json({ received: true });
});
module.exports = router;
module.exports.GEM_PACKAGES = GEM_PACKAGES;