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) => {