diff --git a/.env.prod.example b/.env.prod.example index 1a1a52b..e1836c8 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -24,10 +24,12 @@ STARTING_OSU=25 # Payments (osu top-ups) # mock = instant confirm for local/dev (default) -# stripe = requires STRIPE_SECRET_KEY (+ webhook wiring) -PAYMENTS_PROVIDER=mock -# STRIPE_SECRET_KEY=sk_live_... -# STRIPE_WEBHOOK_SECRET=whsec_... +# stripe = Stripe Checkout + webhook (requires keys below) +PAYMENTS_PROVIDER=stripe +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLISHABLE_KEY=pk_test_... +# From Stripe CLI (`stripe listen`) or Dashboard → Webhooks → Signing secret +STRIPE_WEBHOOK_SECRET=whsec_... # Jackpot JACKPOT_ROUND_SECONDS=30 diff --git a/amelioration.md b/amelioration.md index e4001bc..909e910 100644 --- a/amelioration.md +++ b/amelioration.md @@ -1,6 +1,6 @@ # CaseOrion — Analyse & améliorations -**Date :** 2026-07-25 +**Date :** 2026-07-25 · **MAJ :** 2026-07-26 **Périmètre :** analyse complète (server + client + Docker), **sans modification de code**. **Stack :** Express / Prisma / SQLite / Socket.IO · React (Vite) · ~16k LOC client+server. @@ -9,14 +9,13 @@ ## Synthèse Le projet est une API jeu cohérente en **mono-process**, avec un effort sérieux sur l’économie haute (crédits en **strings + BigInt** via `cents.js`). -Les risques les plus graves sont : **odds duel cassées**, **paiements mock en prod**, **`db push --accept-data-loss` au boot**, **comparaisons argent via `Number()`**, et **état in-memory** (sessions, timers battles, présence). +Les risques les plus graves restants : **paiements mock en prod**, **`db push --accept-data-loss` au boot**, et **état in-memory** (sessions, timers battles, présence). | Zone | État | |------|------| | Cases / wear / keys | Solide (roll serveur, BigInt value) | | Auto-sell | Logique OK (`value < seuil`) ; UX/confusion catalogue vs instance | -| Jackpot / Battle | Agrégation BigInt correcte | -| Duels 1vX | **Supprimé** (2026-07-25) | +| Battle (jackpot only) | Un seul mode ; 1vX / coinflip / duel **retirés** | | Vault / prestige | Fonctionnel ; settle concurrent risqué | | Shop | Mock OK en dev ; dangereux si prod | | Docker | Marche en single-node ; pas scale-ready | @@ -26,18 +25,14 @@ Les risques les plus graves sont : **odds duel cassées**, **paiements mock en p ## P0 — Critique (corriger en premier) ### ~~1. Odds des duels faussées (multi-items)~~ — **résolu par suppression** -Le mode **1vX Case** (rooms custom / `duel.js`) a été **retiré** (2026-07-25). Il ne reste que le Battle MultiPlayers (`jackpot.js`). Les anciennes routes `/battle/1vx` redirigent vers `/battle`. +Le mode **1vX Case** (rooms custom / `duel.js`) a été **retiré** (2026-07-25). Il ne reste que le Battle MultiPlayers (`jackpot.js`). Les anciennes routes `/battle/1vx` et `/battle/coinflip` redirigent vers `/battle`. -### 1. Paiements mock utilisables en production -**Fichiers :** `server/src/payments.js`, `routes/shop.js`, `.env.prod.example` +### 1. Paiements mock utilisables en production — **en cours (B Stripe)** +**Fichiers :** `server/src/payments.js`, `routes/shop.js`, `index.js` (webhook raw body), `.env.prod.example` -`PAYMENTS_PROVIDER=mock` + `POST /api/shop/osu/confirm` crédite l’osu **sans paiement**. +Stripe Checkout + webhook `checkout.session.completed` branchés. En prod Docker : `PAYMENTS_PROVIDER=stripe` + `STRIPE_*` keys + `STRIPE_WEBHOOK_SECRET` + URL publique HTTPS vers `/api/shop/osu/webhook`. -| Proposition | Détail | -|-------------|--------| -| **A** | Refuser mock si `NODE_ENV=production` sauf `ALLOW_MOCK_PAYMENTS=1` | -| B | Brancher Stripe (webhook raw body + signature) | -| C | Désactiver toute la section osu en prod tant que non prêt | +Reste : désactiver mock en prod (A) si quelqu’un oublie `PAYMENTS_PROVIDER` ; bascule live keys quand le compte est vérifié. --- @@ -46,11 +41,7 @@ Le mode **1vX Case** (rooms custom / `duel.js`) a été **retiré** (2026-07-25) Peut **détruire / tronquer** des colonnes au restart. Inadapté à une vraie prod. -| Proposition | Détail | -|-------------|--------| -| **A (recommandé)** | Prisma Migrate (`migrate deploy`) ; push manuel hors boot | -| B | Garder push **sans** `--accept-data-loss` + fail loud | -| C | Job one-shot de migration séparé du `CMD` app | +| Proposition | A (recommandé) : Prisma Migrate (`migrate deploy`) ; push manuel hors boot · B : push **sans** `--accept-data-loss` + fail loud · C : job one-shot de migration séparé du `CMD` app | --- @@ -126,25 +117,30 @@ Min 6 caractères seulement. ## P3 — Qualité code / refactor -### 16. Duplication jackpot ↔ duel -Serialize, finish spin, auto-sell winner : quasi copy-paste. +### ~~16. Duplication jackpot ↔ duel~~ — **obsolète** +`duel.js` / 1vX / coinflip **retirés** ; un seul type de battle (`services/jackpot.js`). Plus de duplication cross-mode à factoriser. -| Proposition | A : module `services/battleCommon.js` · B : unifier modèle “room” (plus gros refactor) | +| Ancienne prop. | A : `services/battleCommon.js` · B : unifier modèle “room” — **non pertinent** tant qu’il n’y a qu’un mode | + +Si un 2ᵉ mode battle revient plus tard, reprendre **A** d’abord (helpers serialize / finish / auto-sell), **B** seulement si les rooms divergent vraiment. ### 17. `serializeInv` dupliqué `inventory.js` / `profile.js` (et variantes). -| Proposition | A : `serializeInventoryItem` partagé | +| Proposition | **A (retenu)** : `serializeInventoryItem` partagé (module commun serveur) | ### 18. Client — listeners `socket.on('connect')` jamais retirés -Layout, BattleHub, BattleRoom, Duel*, DropFeed → fuite de handlers au remount. +**Fichiers :** `Layout.jsx`, `BattleHub.jsx`, `BattleRoomPage.jsx`, `DropFeed.jsx` +(les pages Duel* n’existent plus) -| Proposition | A : cleanup `socket.off` dans chaque `useEffect` | +Les `useEffect` font souvent `socket.off` des events métier, mais **pas** du handler `connect` → fuite au remount / Strict Mode. -### 19. Battle hub incomplet -Routes 1vX existent ; hub n’expose que MultiPlayers + Coinflip stub. Copy “MultiPlayers” / redirects legacy. +| Proposition | **A (retenu)** : nommer le handler `onConnect` et `socket.off('connect', onConnect)` dans chaque cleanup | -| Proposition | A : cartes hub 1vX + Coinflip disabled clair · B : retirer Coinflip jusqu’à implémentation | +### ~~19. Battle hub incomplet~~ — **résolu / simplifié** +Hub = **un seul** mode Battle (jackpot MultiPlayers). 1vX et Coinflip **n’existent plus** (redirects legacy vers `/battle`). Plus de cartes stub / copy multi-modes à aligner. + +--- ### 20. Guards auth copiés partout Chaque page : loading / !user / admin redirect. @@ -167,7 +163,7 @@ State machine respin/auto-sell/announce via refs : difficile à maintenir, mais ### 23. Tests absents Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, vault). -| Proposition | A : Vitest unitaire `cents` / `wear` / `autoSell` / duel weights · B : smoke API Playwright | +| Proposition | A : Vitest unitaire `cents` / `wear` / `autoSell` / jackpot weights · B : smoke API Playwright | ### 24. Observabilité `console.error` local ; pas de métriques / tracing. @@ -197,12 +193,12 @@ Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, |---------|---------| | **Drop case** | Serveur fait foi ; luck = catalog chapters + Fortune | | **Wear → prix** | Correct (FT = ref) ; Polish/Quality biaisent vers le haut | -| **Auto-sell** | `instance < seuil` ; Yield après coup ; pas de bug “35T vendu sous 15T” si prestige max + vrai 35T instance | -| **Keys** | Progression documentée ; `keyOpenReduction` non branché | +| **Auto-sell** | `instance < seuil` ; Yield après coup ; respin + auto-sell : keepers (≥ seuil) conservés | +| **Keys** | Progression documentée ; reset keys au prestige joueur, **totalOpens conservé** | | **Vault** | Timer partagé ; risque double settle | -| **Jackpot** | Poids valeur OK | -| **Duel** | **Poids multi-items KO** | -| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/**catalog** | +| **Battle / Jackpot** | Seul mode battle ; poids valeur OK | +| **1vX / Duel / Coinflip** | **Retirés** | +| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/catalog/**opens-by-case** | | **Server prestige** | Wipe économie globale ; keep account/achievements/stats | --- @@ -210,22 +206,15 @@ Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, ## Roadmap proposée (ordre d’exécution) ``` -Semaine 1 (P0) - ├─ Fix duel toCentsBigInt + test +Semaine 1 (P0 restant) ├─ Gate mock payments en prod - ├─ Retirer reviveMoney Number(bigint) - ├─ Allowlist uploads (no SVG) └─ Plan migration Docker (stop accept-data-loss au boot) -Semaine 2 (P1) - ├─ BigInt-safe client (ItemTile, sorts, prestige shortfall, admin inputs) - ├─ Balance updates atomiques + vault settle idempotent - └─ Brancher ou supprimer prestige fields morts - -Semaine 3 (P2–P3) +Semaine 2 (P2–P3) ├─ Rate limit + CORS prod + session store - ├─ Refactor battle common + socket cleanup - ├─ Hub 1vX + tests unitaires cents/odds + ├─ serializeInventoryItem partagé + ├─ socket.off('connect', onConnect) (Layout, Battle*, DropFeed) + ├─ Tests unitaires cents / wear / autoSell / jackpot └─ Confirms UX (prestige, admin adjust) ``` @@ -247,9 +236,9 @@ Semaine 3 (P2–P3) - Audit pentest complet / dépendances CVE détaillées - Refonte UI/UX visuelle -- Implémentation Coinflip +- Réintroduction Coinflip / 1vX (non prévu) - Contenu (balancing drops / prix items) --- -*Document généré pour pilotage : aucune modification code associée. Prioriser P0 avant tout déploiement Docker “réel”.* +*Document de pilotage. Prioriser P0 restant avant tout déploiement Docker “réel”.* diff --git a/client/src/api.js b/client/src/api.js index e4cc614..5bbb477 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -92,6 +92,8 @@ export const api = { method: 'POST', body: JSON.stringify({ orderId }), }), + shopOsuOrder: (orderId) => + request(`/api/shop/osu/orders/${encodeURIComponent(orderId)}`), shopAd: () => request('/api/shop/ad', { method: 'POST' }), feed: () => request('/api/feed'), feedAnnounce: (inventoryItemIds, caseName, opts = {}) => { diff --git a/client/src/index.css b/client/src/index.css index 9685714..6829cf6 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -3103,6 +3103,22 @@ a.achievement-badge:hover .achievement-badge-name { } +.inv-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); +} + +@media (max-width: 700px) { + .inv-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + } + + .inv-grid .item-tile { + padding: 0.65rem; + min-height: 120px; + } +} + .inv-toolbar { display: flex; flex-wrap: wrap; diff --git a/client/src/pages/CasePage.jsx b/client/src/pages/CasePage.jsx index 5d15138..321aef1 100644 --- a/client/src/pages/CasePage.jsx +++ b/client/src/pages/CasePage.jsx @@ -69,9 +69,11 @@ export default function CasePage() { const finishingRef = useRef(false); const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false }); const pendingAutoSellRef = useRef([]); - /** Auto-sell ids from the discarded first roll — applied when that reel finishes. */ + /** Auto-sell ids from the first roll — applied when that reel finishes. */ const pendingPreRespinAutoSellRef = useRef([]); - /** When set, first reel pass is a discarded roll; then we restart with these final items. */ + /** Keepers from first roll (auto-sell on, value >= threshold) — merged into final loot UI. */ + const pendingKeptPreRespinRef = useRef([]); + /** When set, first reel pass is the pre-respin roll; then we restart with these final items. */ const pendingRespinFinalRef = useRef(null); /** Snapshot of first-roll drops for feed (best item announced when first reels finish). */ const pendingPreRespinAnnounceRef = useRef(null); @@ -417,8 +419,8 @@ export default function CasePage() { const heroHidden = opening || (spins.length > 0 && !allDone); - const beginReelSpins = (items) => { - setAllDrops(items); + const beginReelSpins = (items, extraAllDrops = []) => { + setAllDrops(extraAllDrops.length ? [...extraAllDrops, ...items] : items); const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS); const next = showcase.map((item, idx) => ({ key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, @@ -447,6 +449,7 @@ export default function CasePage() { pendingRespinFinalRef.current = null; pendingPreRespinAnnounceRef.current = null; pendingPreRespinAutoSellRef.current = []; + pendingKeptPreRespinRef.current = []; respinPhaseRef.current = null; try { @@ -508,6 +511,10 @@ export default function CasePage() { pendingPreRespinAutoSellRef.current = pre .filter((item) => item?.willAutoSell && item?.inventoryId) .map((item) => item.inventoryId); + pendingKeptPreRespinRef.current = + result.keptPreRespinItems?.length + ? result.keptPreRespinItems + : pre.filter((item) => item?.kept || (!item?.willAutoSell && item?.inventoryId)); pendingPreRespinAnnounceRef.current = { items: pre, caseName: resolvedCaseName || '', @@ -517,6 +524,7 @@ export default function CasePage() { beginReelSpins(pre); } else { pendingPreRespinAutoSellRef.current = []; + pendingKeptPreRespinRef.current = []; respinPhaseRef.current = null; beginReelSpins(items); } @@ -572,15 +580,21 @@ export default function CasePage() { const t = setTimeout(() => { pendingRespinFinalRef.current = null; + const kept = pendingKeptPreRespinRef.current || []; + pendingKeptPreRespinRef.current = []; respinPhaseRef.current = 'final'; + const keptNote = + kept.length > 0 + ? ` · Kept ${kept.length} item${kept.length === 1 ? '' : 's'} from first spin` + : ''; setOpenBonusMessage((msg) => msg?.includes('Respin') ? msg : msg - ? `${msg} · Respin! Re-rolling…` - : 'Respin! Re-rolling all drops…' + ? `${msg} · Respin! Re-rolling…${keptNote}` + : `Respin! Re-rolling all drops…${keptNote}` ); - beginReelSpins(finalItems); + beginReelSpins(finalItems, kept); setRespinRestarting(false); }, 550); diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index a4dae33..85f38f9 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -587,7 +587,7 @@ export default function Dashboard() { ) : ( <> -
+
{pageItems.map((inv) => ( Skill-tree bonuses
  • Account & prestige count
  • Catalog progress
  • +
  • Opens-by-case stats
  • diff --git a/client/src/pages/ShopPage.jsx b/client/src/pages/ShopPage.jsx index 2d8e274..d28ef8f 100644 --- a/client/src/pages/ShopPage.jsx +++ b/client/src/pages/ShopPage.jsx @@ -73,6 +73,67 @@ export default function ShopPage() { }; }, [user?.id, user?.role, load]); + // After Stripe Checkout redirect (?osu=success|cancel) + useEffect(() => { + if (!user || user.role === 'admin') return undefined; + const params = new URLSearchParams(window.location.search); + const osu = params.get('osu'); + if (!osu) return undefined; + + const orderId = params.get('order'); + const cleanUrl = () => { + const url = new URL(window.location.href); + url.searchParams.delete('osu'); + url.searchParams.delete('order'); + window.history.replaceState({}, '', url.pathname + url.search); + }; + + if (osu === 'cancel') { + setMessage('Payment cancelled — no osu charged'); + cleanUrl(); + return undefined; + } + + if (osu !== 'success') return undefined; + + let cancelled = false; + setMessage('Payment received — confirming osu credit…'); + cleanUrl(); + + (async () => { + for (let i = 0; i < 12; i += 1) { + if (cancelled) return; + try { + if (orderId) { + const data = await api.shopOsuOrder(orderId); + if (data.order?.status === 'paid') { + await load(); + if (!cancelled) { + setMessage(`+${data.order.osuAmount} osu added to your wallet`); + } + return; + } + } else { + await load(); + } + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 800)); + } + if (!cancelled) { + await load().catch(() => {}); + setMessage( + 'Payment submitted — if osu is missing, refresh in a few seconds (webhook pending)' + ); + } + })(); + + return () => { + cancelled = true; + }; + }, [user?.id, user?.role, load]); + const buyCredits = async (packageId) => { setBusy(true); setError(''); @@ -111,6 +172,9 @@ export default function ShopPage() { : `+${data.osuGranted} osu added to your wallet` ); await load(); + } else if (checkout.mode === 'stripe' && checkout.url) { + window.location.assign(checkout.url); + return; } else { setError('Payment provider is not ready for live checkout yet'); } @@ -179,11 +243,13 @@ export default function ShopPage() { Credits {formatCredits(user.balance)} cr
    - {payments?.mock && ( + {payments?.mock ? (

    - Dev payments: mock checkout credits osu instantly (Stripe-ready backend). + Dev payments: mock checkout credits osu instantly.

    - )} + ) : payments?.provider === 'stripe' ? ( +

    Secure checkout via Stripe.

    + ) : null}
    diff --git a/server/package-lock.json b/server/package-lock.json index 99bf2c2..e677a62 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,9 +16,9 @@ "multer": "^2.2.0", "prisma": "^6.11.1", "sharp": "^0.34.4", - "socket.io": "^4.8.3" - }, - "devDependencies": {} + "socket.io": "^4.8.3", + "stripe": "^22.3.2" + } }, "node_modules/@emnapi/runtime": { "version": "1.11.3", @@ -2154,6 +2154,23 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/stripe": { + "version": "22.3.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz", + "integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", diff --git a/server/package.json b/server/package.json index a0b799d..f8f08a5 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "multer": "^2.2.0", "prisma": "^6.11.1", "sharp": "^0.34.4", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "stripe": "^22.3.2" } } diff --git a/server/src/index.js b/server/src/index.js index a0e8b68..e8f4dd8 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -28,6 +28,8 @@ import { ensureAchievements, checkAchievements } from './achievements.js'; import { ensureCategories } from './categories.js'; import { ensureShopOsuPacks } from './shopOsu.js'; import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js'; +import { paymentsProvider } from './shopCatalog.js'; +import { handleStripeWebhook } from './payments.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const uploadsDir = path.join(__dirname, '../uploads'); @@ -92,6 +94,28 @@ const sessionMiddleware = session({ }); app.use(cors(corsOptions)); + +// Stripe webhook needs the raw body for signature verification — must run +// before express.json() for this path only. +app.post( + '/api/shop/osu/webhook', + express.raw({ type: 'application/json' }), + async (req, res) => { + try { + if (paymentsProvider() !== 'stripe') { + return res.status(404).json({ error: 'Stripe webhooks not enabled' }); + } + const signature = req.headers['stripe-signature']; + const result = await handleStripeWebhook(req.body, signature); + res.json(result); + } catch (err) { + const status = err.status || 400; + if (status >= 500) console.error(err); + res.status(status).json({ error: err.message || 'Webhook error' }); + } + } +); + app.use(express.json()); app.use(sessionMiddleware); // Serialize BigInt fields (balance / valueCents) as strings for JSON responses diff --git a/server/src/payments.js b/server/src/payments.js index 048af17..f162010 100644 --- a/server/src/payments.js +++ b/server/src/payments.js @@ -1,11 +1,23 @@ +import Stripe from 'stripe'; import { prisma } from './db.js'; import { formatFiat, paymentsProvider } from './shopCatalog.js'; import { getPurchasableOsuPack } from './shopOsu.js'; +import { checkAchievements } from './achievements.js'; + +function clientOrigin() { + return String(process.env.CLIENT_ORIGIN || 'http://localhost:5888').replace(/\/$/, ''); +} + +export function getStripe() { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) return null; + return new Stripe(key, { apiVersion: '2026-06-24.dahlia' }); +} /** * Create a pending osu top-up order. - * Mock provider completes client-side via /confirm. - * Stripe provider (when configured) would return a clientSecret. + * Mock: client confirms via /confirm. + * Stripe: redirects to hosted Checkout; webhook credits osu. */ export async function createOsuCheckout(userId, packId) { const pack = await getPurchasableOsuPack(packId); @@ -33,28 +45,76 @@ export async function createOsuCheckout(userId, packId) { }, }); + const packPayload = { + id: pack.id, + name: pack.name, + osu: pack.osu, + priceCents: pack.priceCents, + currency: pack.currency, + displayPrice: formatFiat(pack.priceCents, pack.currency), + }; + if (provider === 'stripe') { - // Placeholder for Stripe PaymentIntent wiring. - // Set PAYMENTS_PROVIDER=stripe + STRIPE_SECRET_KEY when ready. - const err = new Error( - 'Stripe checkout is configured but not wired yet — set PAYMENTS_PROVIDER=mock for local buys' - ); - err.status = 501; - throw err; + const stripe = getStripe(); + if (!stripe) { + const err = new Error('STRIPE_SECRET_KEY is not configured'); + err.status = 500; + throw err; + } + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + client_reference_id: order.id, + metadata: { + orderId: order.id, + userId: String(userId), + packId: pack.id, + }, + line_items: [ + { + quantity: 1, + price_data: { + currency: String(pack.currency || 'eur').toLowerCase(), + unit_amount: pack.priceCents, + product_data: { + name: `${pack.name} — ${pack.osu} osu`, + description: 'CaseOrion premium wallet top-up', + // Required by Stripe Managed Payments (default on new accounts). + // https://docs.stripe.com/payments/managed-payments/eligibility + tax_code: 'txcd_10000000', + }, + }, + }, + ], + success_url: `${clientOrigin()}/shop?osu=success&order=${encodeURIComponent(order.id)}`, + cancel_url: `${clientOrigin()}/shop?osu=cancel`, + }); + + await prisma.paymentOrder.update({ + where: { id: order.id }, + data: { + providerRef: session.id, + meta: JSON.stringify({ + packName: pack.name, + displayPrice: formatFiat(pack.priceCents, pack.currency), + checkoutSessionId: session.id, + }), + }, + }); + + return { + order: serializeOrder({ ...order, providerRef: session.id }), + pack: packPayload, + mode: 'stripe', + url: session.url, + confirmable: false, + }; } return { order: serializeOrder(order), - pack: { - id: pack.id, - name: pack.name, - osu: pack.osu, - priceCents: pack.priceCents, - currency: pack.currency, - displayPrice: formatFiat(pack.priceCents, pack.currency), - }, + pack: packPayload, mode: 'mock', - /** Client calls POST /api/shop/osu/confirm with this id in mock mode */ confirmable: true, }; } @@ -70,6 +130,12 @@ export async function confirmMockOsuPayment(userId, orderId) { throw err; } + if (paymentsProvider() === 'stripe' && process.env.NODE_ENV === 'production') { + const err = new Error('Mock confirm is disabled when Stripe is active'); + err.status = 400; + throw err; + } + return prisma.$transaction(async (tx) => { const order = await tx.paymentOrder.findUnique({ where: { id } }); if (!order || order.userId !== Number(userId)) { @@ -92,36 +158,133 @@ export async function confirmMockOsuPayment(userId, orderId) { throw err; } - const now = new Date(); - const updatedOrder = await tx.paymentOrder.update({ - where: { id: order.id }, - data: { status: 'paid', paidAt: now }, - }); - - const user = await tx.user.update({ - where: { id: userId }, - data: { osuBalance: { increment: order.osuAmount } }, - }); - - await tx.transaction.create({ - data: { - userId, - type: 'osu_purchase', - amount: String(order.osuAmount), - meta: JSON.stringify({ - orderId: order.id, - packId: order.packId, - priceCents: order.priceCents, - currency: order.currency, - provider: order.provider, - }), - }, - }); - - return { order: serializeOrder(updatedOrder), user, alreadyPaid: false }; + return fulfillPaidOrder(tx, order); }); } +/** Mark order paid + credit osu (idempotent). Shared by mock confirm & Stripe webhook. */ +async function fulfillPaidOrder(tx, order) { + if (order.status === 'paid') { + const user = await tx.user.findUnique({ where: { id: order.userId } }); + return { order: serializeOrder(order), user, alreadyPaid: true }; + } + + const now = new Date(); + const updatedOrder = await tx.paymentOrder.update({ + where: { id: order.id }, + data: { status: 'paid', paidAt: now }, + }); + + const user = await tx.user.update({ + where: { id: order.userId }, + data: { osuBalance: { increment: order.osuAmount } }, + }); + + await tx.transaction.create({ + data: { + userId: order.userId, + type: 'osu_purchase', + amount: String(order.osuAmount), + meta: JSON.stringify({ + orderId: order.id, + packId: order.packId, + priceCents: order.priceCents, + currency: order.currency, + provider: order.provider, + providerRef: order.providerRef || '', + }), + }, + }); + + return { order: serializeOrder(updatedOrder), user, alreadyPaid: false }; +} + +/** + * Fulfill a Stripe Checkout session (webhook). Idempotent. + */ +export async function fulfillStripeCheckoutSession(session) { + const orderId = + session?.metadata?.orderId || session?.client_reference_id || ''; + if (!orderId) { + const err = new Error('Checkout session missing order id'); + err.status = 400; + throw err; + } + + const result = await prisma.$transaction(async (tx) => { + const order = await tx.paymentOrder.findUnique({ where: { id: String(orderId) } }); + if (!order) { + const err = new Error('Order not found'); + err.status = 404; + throw err; + } + if (order.provider !== 'stripe') { + const err = new Error('Order is not a Stripe order'); + err.status = 400; + throw err; + } + if (order.status === 'paid') { + const user = await tx.user.findUnique({ where: { id: order.userId } }); + return { order: serializeOrder(order), user, alreadyPaid: true }; + } + + // Persist session id if not already stored + let working = order; + if (session?.id && order.providerRef !== session.id) { + working = await tx.paymentOrder.update({ + where: { id: order.id }, + data: { providerRef: session.id }, + }); + } + + return fulfillPaidOrder(tx, working); + }); + + if (result.user?.id && !result.alreadyPaid) { + checkAchievements(result.user.id).catch((err) => console.error('achievements', err)); + } + + return result; +} + +/** + * Verify Stripe webhook signature and handle checkout.session.completed. + * `rawBody` must be the raw Buffer/string (not parsed JSON). + */ +export async function handleStripeWebhook(rawBody, signatureHeader) { + const stripe = getStripe(); + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!stripe || !secret) { + const err = new Error('Stripe webhook is not configured'); + err.status = 500; + throw err; + } + + let event; + try { + event = stripe.webhooks.constructEvent(rawBody, signatureHeader, secret); + } catch (err) { + const e = new Error(`Webhook signature verification failed: ${err.message}`); + e.status = 400; + throw e; + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + if (session.payment_status === 'paid' || session.status === 'complete') { + await fulfillStripeCheckoutSession(session); + } + } + + return { received: true, type: event.type }; +} + +export async function getOrderForUser(userId, orderId) { + const order = await prisma.paymentOrder.findUnique({ where: { id: String(orderId || '') } }); + if (!order || order.userId !== Number(userId)) return null; + return serializeOrder(order); +} + export function serializeOrder(order) { return { id: order.id, diff --git a/server/src/prestige.js b/server/src/prestige.js index 3e67cda..c099f7a 100644 --- a/server/src/prestige.js +++ b/server/src/prestige.js @@ -111,7 +111,7 @@ export const PRESTIGE_SKILLS = { delta: 1, maxLevel: 20, title: 'Respin', - description: 'Chance to free-respin the open (shows first result, then reels restart)', + description: 'Chance to free-respin the open (shows first result, then reels restart). With auto-sell on, items above your threshold are kept', unit: '%', branch: 'drops', }, diff --git a/server/src/routes/cases.js b/server/src/routes/cases.js index d1b961c..39ae4a8 100644 --- a/server/src/routes/cases.js +++ b/server/src/routes/cases.js @@ -277,33 +277,48 @@ router.post('/:id/open', requireAuth, async (req, res) => { let { drops, createdTxIds } = await createDrops(count); - // Respin: re-roll all drops for free (replace first batch). - // preRespinItems animate the discarded roll. Auto-sell-eligible items stay in - // inventory (with inventoryId) so the client can credit them when the first - // reels finish; the rest are discarded immediately. + // Respin: free re-roll after showing the first result. + // - Auto-sell ON: junk (< threshold) stays for deferred sell; keepers (>= threshold) + // stay in inventory; second roll is extra loot. + // - Auto-sell OFF: classic replace — first roll is discarded entirely. let preRespinItems = null; + let keptPreRespinItems = []; const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0); const didRespin = respinChance > 0 && Math.random() * 100 < respinChance; if (didRespin) { - const discardIds = drops - .filter((d) => !d.willAutoSell) - .map((d) => d.inventoryId) - .filter(Boolean); + const autoSellOn = Boolean(user.autoSellEnabled); const pendingAutoSellIds = drops .filter((d) => d.willAutoSell) .map((d) => d.inventoryId) .filter(Boolean); + keptPreRespinItems = autoSellOn + ? drops.filter((d) => !d.willAutoSell && d.inventoryId) + : []; + const keepIdSet = new Set([ + ...pendingAutoSellIds, + ...keptPreRespinItems.map((d) => d.inventoryId), + ]); + const discardIds = drops + .map((d) => d.inventoryId) + .filter((id) => id && !keepIdSet.has(id)); preRespinItems = drops.map((d) => ({ ...d, - inventoryId: d.willAutoSell ? d.inventoryId : null, + inventoryId: keepIdSet.has(d.inventoryId) ? d.inventoryId : null, preRespin: true, + kept: Boolean(d.inventoryId && keepIdSet.has(d.inventoryId) && !d.willAutoSell), })); if (discardIds.length) { await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } }); } - await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } }); + // Drop open_case txs for discarded / pending-auto-sell rows; keep txs for keepers. + const deleteTxIds = drops + .map((d, i) => (keepIdSet.has(d.inventoryId) && !d.willAutoSell ? null : createdTxIds[i])) + .filter(Boolean); + if (deleteTxIds.length) { + await tx.transaction.deleteMany({ where: { id: { in: deleteTxIds } } }); + } const again = await createDrops(count); drops = again.drops; createdTxIds = again.createdTxIds; @@ -317,6 +332,7 @@ router.post('/:id/open', requireAuth, async (req, res) => { openCount: count, inventoryItemIds: drops.map((d) => d.inventoryId), preRespinPendingAutoSellIds: pendingAutoSellIds, + preRespinKeptIds: keptPreRespinItems.map((d) => d.inventoryId), }), }, }); @@ -330,7 +346,7 @@ router.post('/:id/open', requireAuth, async (req, res) => { tx, userId, caseId, - drops.map((d) => d.id) + [...keptPreRespinItems.map((d) => d.id), ...drops.map((d) => d.id)] ); const applied = applyOpens(progressRow, count, 0); @@ -367,6 +383,7 @@ router.post('/:id/open', requireAuth, async (req, res) => { caseName: c.name, items: drops, preRespinItems, + keptPreRespinItems, keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus), keysGained: applied.keysGained, catalog, @@ -386,6 +403,7 @@ router.post('/:id/open', requireAuth, async (req, res) => { count: result.items.length, items: result.items, preRespinItems: result.preRespinItems, + keptPreRespinItems: result.keptPreRespinItems, keyProgress: result.keyProgress, keysGained: result.keysGained, catalog: result.catalog, diff --git a/server/src/routes/prestige.js b/server/src/routes/prestige.js index ef2e9f5..073b8f4 100644 --- a/server/src/routes/prestige.js +++ b/server/src/routes/prestige.js @@ -114,7 +114,11 @@ router.post('/', requireAuth, async (req, res) => { const prestigeCount = (user.prestigeCount ?? 0) + 1; await tx.inventoryItem.deleteMany({ where: { userId: user.id } }); - await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } }); + // Keep lifetime opens-by-case stats; reset key progress only. + await tx.caseKeyProgress.updateMany({ + where: { userId: user.id }, + data: { keys: 1, opensSinceLastKey: 0 }, + }); await tx.transaction.create({ data: { diff --git a/server/src/routes/shop.js b/server/src/routes/shop.js index ec4a6f1..ac600b7 100644 --- a/server/src/routes/shop.js +++ b/server/src/routes/shop.js @@ -11,7 +11,11 @@ import { scaleCreditPacks, } from '../shopCatalog.js'; import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js'; -import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js'; +import { + confirmMockOsuPayment, + createOsuCheckout, + getOrderForUser, +} from '../payments.js'; const router = Router(); @@ -42,15 +46,18 @@ router.get('/', requireAuth, async (req, res) => { const packages = scaleCreditPacks(user.balance); const sectionEnabled = await isOsuTopupsSectionEnabled(); const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : []; + const provider = paymentsProvider(); res.json({ packages, osuTopups, osuTopupsEnabled: sectionEnabled && osuTopups.length > 0, payments: { - provider: paymentsProvider(), + provider, currency: 'eur', - mock: paymentsProvider() === 'mock', + mock: provider === 'mock', + publishableKey: + provider === 'stripe' ? process.env.STRIPE_PUBLISHABLE_KEY || '' : '', }, user: publicUser(user), ad: adPayload(user), @@ -123,7 +130,7 @@ router.post('/buy', requireAuth, async (req, res) => { } }); -/** Start real-money → osu checkout (mock or future Stripe). */ +/** Start real-money → osu checkout (mock or Stripe Checkout). */ router.post('/osu/checkout', requireAuth, async (req, res) => { try { const packId = String(req.body.packId || ''); @@ -155,15 +162,16 @@ router.post('/osu/confirm', requireAuth, async (req, res) => { } }); -/** - * Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe. - * Keep raw body parsing in mind when wiring Stripe. - */ -router.post('/osu/webhook', async (req, res) => { - if (paymentsProvider() !== 'stripe') { - return res.status(404).json({ error: 'Stripe webhooks not enabled' }); +/** Poll order status after Stripe redirect (webhook may lag a second). */ +router.get('/osu/orders/:id', requireAuth, async (req, res) => { + try { + const order = await getOrderForUser(req.session.userId, req.params.id); + if (!order) return res.status(404).json({ error: 'Order not found' }); + res.json({ order }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to load order' }); } - res.status(501).json({ error: 'Stripe webhook not implemented yet' }); }); router.post('/ad', requireAuth, async (req, res) => {