shop stipe
This commit is contained in:
+6
-4
@@ -24,10 +24,12 @@ STARTING_OSU=25
|
|||||||
|
|
||||||
# Payments (osu top-ups)
|
# Payments (osu top-ups)
|
||||||
# mock = instant confirm for local/dev (default)
|
# mock = instant confirm for local/dev (default)
|
||||||
# stripe = requires STRIPE_SECRET_KEY (+ webhook wiring)
|
# stripe = Stripe Checkout + webhook (requires keys below)
|
||||||
PAYMENTS_PROVIDER=mock
|
PAYMENTS_PROVIDER=stripe
|
||||||
# STRIPE_SECRET_KEY=sk_live_...
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
# STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
# From Stripe CLI (`stripe listen`) or Dashboard → Webhooks → Signing secret
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
# Jackpot
|
# Jackpot
|
||||||
JACKPOT_ROUND_SECONDS=30
|
JACKPOT_ROUND_SECONDS=30
|
||||||
|
|||||||
+36
-47
@@ -1,6 +1,6 @@
|
|||||||
# CaseOrion — Analyse & améliorations
|
# 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**.
|
**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.
|
**Stack :** Express / Prisma / SQLite / Socket.IO · React (Vite) · ~16k LOC client+server.
|
||||||
|
|
||||||
@@ -9,14 +9,13 @@
|
|||||||
## Synthèse
|
## 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`).
|
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 |
|
| Zone | État |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Cases / wear / keys | Solide (roll serveur, BigInt value) |
|
| Cases / wear / keys | Solide (roll serveur, BigInt value) |
|
||||||
| Auto-sell | Logique OK (`value < seuil`) ; UX/confusion catalogue vs instance |
|
| Auto-sell | Logique OK (`value < seuil`) ; UX/confusion catalogue vs instance |
|
||||||
| Jackpot / Battle | Agrégation BigInt correcte |
|
| Battle (jackpot only) | Un seul mode ; 1vX / coinflip / duel **retirés** |
|
||||||
| Duels 1vX | **Supprimé** (2026-07-25) |
|
|
||||||
| Vault / prestige | Fonctionnel ; settle concurrent risqué |
|
| Vault / prestige | Fonctionnel ; settle concurrent risqué |
|
||||||
| Shop | Mock OK en dev ; dangereux si prod |
|
| Shop | Mock OK en dev ; dangereux si prod |
|
||||||
| Docker | Marche en single-node ; pas scale-ready |
|
| 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)
|
## P0 — Critique (corriger en premier)
|
||||||
|
|
||||||
### ~~1. Odds des duels faussées (multi-items)~~ — **résolu par suppression**
|
### ~~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
|
### 1. Paiements mock utilisables en production — **en cours (B Stripe)**
|
||||||
**Fichiers :** `server/src/payments.js`, `routes/shop.js`, `.env.prod.example`
|
**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 |
|
Reste : désactiver mock en prod (A) si quelqu’un oublie `PAYMENTS_PROVIDER` ; bascule live keys quand le compte est vérifié.
|
||||||
|-------------|--------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
Peut **détruire / tronquer** des colonnes au restart. Inadapté à une vraie prod.
|
||||||
|
|
||||||
| Proposition | Détail |
|
| 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 |
|
||||||
|-------------|--------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,25 +117,30 @@ Min 6 caractères seulement.
|
|||||||
|
|
||||||
## P3 — Qualité code / refactor
|
## P3 — Qualité code / refactor
|
||||||
|
|
||||||
### 16. Duplication jackpot ↔ duel
|
### ~~16. Duplication jackpot ↔ duel~~ — **obsolète**
|
||||||
Serialize, finish spin, auto-sell winner : quasi copy-paste.
|
`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é
|
### 17. `serializeInv` dupliqué
|
||||||
`inventory.js` / `profile.js` (et variantes).
|
`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
|
### 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
|
| Proposition | **A (retenu)** : nommer le handler `onConnect` et `socket.off('connect', onConnect)` dans chaque cleanup |
|
||||||
Routes 1vX existent ; hub n’expose que MultiPlayers + Coinflip stub. Copy “MultiPlayers” / redirects legacy.
|
|
||||||
|
|
||||||
| 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
|
### 20. Guards auth copiés partout
|
||||||
Chaque page : loading / !user / admin redirect.
|
Chaque page : loading / !user / admin redirect.
|
||||||
@@ -167,7 +163,7 @@ State machine respin/auto-sell/announce via refs : difficile à maintenir, mais
|
|||||||
### 23. Tests absents
|
### 23. Tests absents
|
||||||
Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, vault).
|
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é
|
### 24. Observabilité
|
||||||
`console.error` local ; pas de métriques / tracing.
|
`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 |
|
| **Drop case** | Serveur fait foi ; luck = catalog chapters + Fortune |
|
||||||
| **Wear → prix** | Correct (FT = ref) ; Polish/Quality biaisent vers le haut |
|
| **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 |
|
| **Auto-sell** | `instance < seuil` ; Yield après coup ; respin + auto-sell : keepers (≥ seuil) conservés |
|
||||||
| **Keys** | Progression documentée ; `keyOpenReduction` non branché |
|
| **Keys** | Progression documentée ; reset keys au prestige joueur, **totalOpens conservé** |
|
||||||
| **Vault** | Timer partagé ; risque double settle |
|
| **Vault** | Timer partagé ; risque double settle |
|
||||||
| **Jackpot** | Poids valeur OK |
|
| **Battle / Jackpot** | Seul mode battle ; poids valeur OK |
|
||||||
| **Duel** | **Poids multi-items KO** |
|
| **1vX / Duel / Coinflip** | **Retirés** |
|
||||||
| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/**catalog** |
|
| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/catalog/**opens-by-case** |
|
||||||
| **Server prestige** | Wipe économie globale ; keep account/achievements/stats |
|
| **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)
|
## Roadmap proposée (ordre d’exécution)
|
||||||
|
|
||||||
```
|
```
|
||||||
Semaine 1 (P0)
|
Semaine 1 (P0 restant)
|
||||||
├─ Fix duel toCentsBigInt + test
|
|
||||||
├─ Gate mock payments en prod
|
├─ Gate mock payments en prod
|
||||||
├─ Retirer reviveMoney Number(bigint)
|
|
||||||
├─ Allowlist uploads (no SVG)
|
|
||||||
└─ Plan migration Docker (stop accept-data-loss au boot)
|
└─ Plan migration Docker (stop accept-data-loss au boot)
|
||||||
|
|
||||||
Semaine 2 (P1)
|
Semaine 2 (P2–P3)
|
||||||
├─ 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)
|
|
||||||
├─ Rate limit + CORS prod + session store
|
├─ Rate limit + CORS prod + session store
|
||||||
├─ Refactor battle common + socket cleanup
|
├─ serializeInventoryItem partagé
|
||||||
├─ Hub 1vX + tests unitaires cents/odds
|
├─ socket.off('connect', onConnect) (Layout, Battle*, DropFeed)
|
||||||
|
├─ Tests unitaires cents / wear / autoSell / jackpot
|
||||||
└─ Confirms UX (prestige, admin adjust)
|
└─ Confirms UX (prestige, admin adjust)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -247,9 +236,9 @@ Semaine 3 (P2–P3)
|
|||||||
|
|
||||||
- Audit pentest complet / dépendances CVE détaillées
|
- Audit pentest complet / dépendances CVE détaillées
|
||||||
- Refonte UI/UX visuelle
|
- Refonte UI/UX visuelle
|
||||||
- Implémentation Coinflip
|
- Réintroduction Coinflip / 1vX (non prévu)
|
||||||
- Contenu (balancing drops / prix items)
|
- 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”.*
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ orderId }),
|
body: JSON.stringify({ orderId }),
|
||||||
}),
|
}),
|
||||||
|
shopOsuOrder: (orderId) =>
|
||||||
|
request(`/api/shop/osu/orders/${encodeURIComponent(orderId)}`),
|
||||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||||
feed: () => request('/api/feed'),
|
feed: () => request('/api/feed'),
|
||||||
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
|
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
|
||||||
|
|||||||
@@ -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 {
|
.inv-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ export default function CasePage() {
|
|||||||
const finishingRef = useRef(false);
|
const finishingRef = useRef(false);
|
||||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
|
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
|
||||||
const pendingAutoSellRef = useRef([]);
|
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([]);
|
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);
|
const pendingRespinFinalRef = useRef(null);
|
||||||
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
|
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
|
||||||
const pendingPreRespinAnnounceRef = useRef(null);
|
const pendingPreRespinAnnounceRef = useRef(null);
|
||||||
@@ -417,8 +419,8 @@ export default function CasePage() {
|
|||||||
|
|
||||||
const heroHidden = opening || (spins.length > 0 && !allDone);
|
const heroHidden = opening || (spins.length > 0 && !allDone);
|
||||||
|
|
||||||
const beginReelSpins = (items) => {
|
const beginReelSpins = (items, extraAllDrops = []) => {
|
||||||
setAllDrops(items);
|
setAllDrops(extraAllDrops.length ? [...extraAllDrops, ...items] : items);
|
||||||
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
|
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
|
||||||
const next = showcase.map((item, idx) => ({
|
const next = showcase.map((item, idx) => ({
|
||||||
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
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;
|
pendingRespinFinalRef.current = null;
|
||||||
pendingPreRespinAnnounceRef.current = null;
|
pendingPreRespinAnnounceRef.current = null;
|
||||||
pendingPreRespinAutoSellRef.current = [];
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
|
pendingKeptPreRespinRef.current = [];
|
||||||
respinPhaseRef.current = null;
|
respinPhaseRef.current = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -508,6 +511,10 @@ export default function CasePage() {
|
|||||||
pendingPreRespinAutoSellRef.current = pre
|
pendingPreRespinAutoSellRef.current = pre
|
||||||
.filter((item) => item?.willAutoSell && item?.inventoryId)
|
.filter((item) => item?.willAutoSell && item?.inventoryId)
|
||||||
.map((item) => item.inventoryId);
|
.map((item) => item.inventoryId);
|
||||||
|
pendingKeptPreRespinRef.current =
|
||||||
|
result.keptPreRespinItems?.length
|
||||||
|
? result.keptPreRespinItems
|
||||||
|
: pre.filter((item) => item?.kept || (!item?.willAutoSell && item?.inventoryId));
|
||||||
pendingPreRespinAnnounceRef.current = {
|
pendingPreRespinAnnounceRef.current = {
|
||||||
items: pre,
|
items: pre,
|
||||||
caseName: resolvedCaseName || '',
|
caseName: resolvedCaseName || '',
|
||||||
@@ -517,6 +524,7 @@ export default function CasePage() {
|
|||||||
beginReelSpins(pre);
|
beginReelSpins(pre);
|
||||||
} else {
|
} else {
|
||||||
pendingPreRespinAutoSellRef.current = [];
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
|
pendingKeptPreRespinRef.current = [];
|
||||||
respinPhaseRef.current = null;
|
respinPhaseRef.current = null;
|
||||||
beginReelSpins(items);
|
beginReelSpins(items);
|
||||||
}
|
}
|
||||||
@@ -572,15 +580,21 @@ export default function CasePage() {
|
|||||||
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
pendingRespinFinalRef.current = null;
|
pendingRespinFinalRef.current = null;
|
||||||
|
const kept = pendingKeptPreRespinRef.current || [];
|
||||||
|
pendingKeptPreRespinRef.current = [];
|
||||||
respinPhaseRef.current = 'final';
|
respinPhaseRef.current = 'final';
|
||||||
|
const keptNote =
|
||||||
|
kept.length > 0
|
||||||
|
? ` · Kept ${kept.length} item${kept.length === 1 ? '' : 's'} from first spin`
|
||||||
|
: '';
|
||||||
setOpenBonusMessage((msg) =>
|
setOpenBonusMessage((msg) =>
|
||||||
msg?.includes('Respin')
|
msg?.includes('Respin')
|
||||||
? msg
|
? msg
|
||||||
: msg
|
: msg
|
||||||
? `${msg} · Respin! Re-rolling…`
|
? `${msg} · Respin! Re-rolling…${keptNote}`
|
||||||
: 'Respin! Re-rolling all drops…'
|
: `Respin! Re-rolling all drops…${keptNote}`
|
||||||
);
|
);
|
||||||
beginReelSpins(finalItems);
|
beginReelSpins(finalItems, kept);
|
||||||
setRespinRestarting(false);
|
setRespinRestarting(false);
|
||||||
}, 550);
|
}, 550);
|
||||||
|
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid">
|
<div className="grid inv-grid">
|
||||||
{pageItems.map((inv) => (
|
{pageItems.map((inv) => (
|
||||||
<ItemTile
|
<ItemTile
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export default function PrestigePage() {
|
|||||||
<li>Skill-tree bonuses</li>
|
<li>Skill-tree bonuses</li>
|
||||||
<li>Account & prestige count</li>
|
<li>Account & prestige count</li>
|
||||||
<li>Catalog progress</li>
|
<li>Catalog progress</li>
|
||||||
|
<li>Opens-by-case stats</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -73,6 +73,67 @@ export default function ShopPage() {
|
|||||||
};
|
};
|
||||||
}, [user?.id, user?.role, load]);
|
}, [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) => {
|
const buyCredits = async (packageId) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -111,6 +172,9 @@ export default function ShopPage() {
|
|||||||
: `+${data.osuGranted} osu added to your wallet`
|
: `+${data.osuGranted} osu added to your wallet`
|
||||||
);
|
);
|
||||||
await load();
|
await load();
|
||||||
|
} else if (checkout.mode === 'stripe' && checkout.url) {
|
||||||
|
window.location.assign(checkout.url);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
setError('Payment provider is not ready for live checkout yet');
|
setError('Payment provider is not ready for live checkout yet');
|
||||||
}
|
}
|
||||||
@@ -179,11 +243,13 @@ export default function ShopPage() {
|
|||||||
<span className="muted">Credits</span>
|
<span className="muted">Credits</span>
|
||||||
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
|
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
|
||||||
</div>
|
</div>
|
||||||
{payments?.mock && (
|
{payments?.mock ? (
|
||||||
<p className="shop-mock-note muted">
|
<p className="shop-mock-note muted">
|
||||||
Dev payments: mock checkout credits osu instantly (Stripe-ready backend).
|
Dev payments: mock checkout credits osu instantly.
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : payments?.provider === 'stripe' ? (
|
||||||
|
<p className="shop-mock-note muted">Secure checkout via Stripe.</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Generated
+20
-3
@@ -16,9 +16,9 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
},
|
"stripe": "^22.3.2"
|
||||||
"devDependencies": {}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
@@ -2154,6 +2154,23 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
|||||||
+2
-1
@@ -25,6 +25,7 @@
|
|||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"stripe": "^22.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { ensureAchievements, checkAchievements } from './achievements.js';
|
|||||||
import { ensureCategories } from './categories.js';
|
import { ensureCategories } from './categories.js';
|
||||||
import { ensureShopOsuPacks } from './shopOsu.js';
|
import { ensureShopOsuPacks } from './shopOsu.js';
|
||||||
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
@@ -92,6 +94,28 @@ const sessionMiddleware = session({
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(cors(corsOptions));
|
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(express.json());
|
||||||
app.use(sessionMiddleware);
|
app.use(sessionMiddleware);
|
||||||
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
|
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
|
||||||
|
|||||||
+181
-18
@@ -1,11 +1,23 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { formatFiat, paymentsProvider } from './shopCatalog.js';
|
import { formatFiat, paymentsProvider } from './shopCatalog.js';
|
||||||
import { getPurchasableOsuPack } from './shopOsu.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.
|
* Create a pending osu top-up order.
|
||||||
* Mock provider completes client-side via /confirm.
|
* Mock: client confirms via /confirm.
|
||||||
* Stripe provider (when configured) would return a clientSecret.
|
* Stripe: redirects to hosted Checkout; webhook credits osu.
|
||||||
*/
|
*/
|
||||||
export async function createOsuCheckout(userId, packId) {
|
export async function createOsuCheckout(userId, packId) {
|
||||||
const pack = await getPurchasableOsuPack(packId);
|
const pack = await getPurchasableOsuPack(packId);
|
||||||
@@ -33,28 +45,76 @@ export async function createOsuCheckout(userId, packId) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (provider === 'stripe') {
|
const packPayload = {
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
order: serializeOrder(order),
|
|
||||||
pack: {
|
|
||||||
id: pack.id,
|
id: pack.id,
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
osu: pack.osu,
|
osu: pack.osu,
|
||||||
priceCents: pack.priceCents,
|
priceCents: pack.priceCents,
|
||||||
currency: pack.currency,
|
currency: pack.currency,
|
||||||
displayPrice: formatFiat(pack.priceCents, pack.currency),
|
displayPrice: formatFiat(pack.priceCents, pack.currency),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (provider === 'stripe') {
|
||||||
|
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: packPayload,
|
||||||
mode: 'mock',
|
mode: 'mock',
|
||||||
/** Client calls POST /api/shop/osu/confirm with this id in mock mode */
|
|
||||||
confirmable: true,
|
confirmable: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,6 +130,12 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
|||||||
throw err;
|
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) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const order = await tx.paymentOrder.findUnique({ where: { id } });
|
const order = await tx.paymentOrder.findUnique({ where: { id } });
|
||||||
if (!order || order.userId !== Number(userId)) {
|
if (!order || order.userId !== Number(userId)) {
|
||||||
@@ -92,6 +158,17 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 now = new Date();
|
||||||
const updatedOrder = await tx.paymentOrder.update({
|
const updatedOrder = await tx.paymentOrder.update({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
@@ -99,13 +176,13 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const user = await tx.user.update({
|
const user = await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: order.userId },
|
||||||
data: { osuBalance: { increment: order.osuAmount } },
|
data: { osuBalance: { increment: order.osuAmount } },
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId: order.userId,
|
||||||
type: 'osu_purchase',
|
type: 'osu_purchase',
|
||||||
amount: String(order.osuAmount),
|
amount: String(order.osuAmount),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
@@ -114,12 +191,98 @@ export async function confirmMockOsuPayment(userId, orderId) {
|
|||||||
priceCents: order.priceCents,
|
priceCents: order.priceCents,
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
provider: order.provider,
|
provider: order.provider,
|
||||||
|
providerRef: order.providerRef || '',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { order: serializeOrder(updatedOrder), user, alreadyPaid: false };
|
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) {
|
export function serializeOrder(order) {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
delta: 1,
|
delta: 1,
|
||||||
maxLevel: 20,
|
maxLevel: 20,
|
||||||
title: 'Respin',
|
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: '%',
|
unit: '%',
|
||||||
branch: 'drops',
|
branch: 'drops',
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-11
@@ -277,33 +277,48 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
let { drops, createdTxIds } = await createDrops(count);
|
let { drops, createdTxIds } = await createDrops(count);
|
||||||
|
|
||||||
// Respin: re-roll all drops for free (replace first batch).
|
// Respin: free re-roll after showing the first result.
|
||||||
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
|
// - Auto-sell ON: junk (< threshold) stays for deferred sell; keepers (>= threshold)
|
||||||
// inventory (with inventoryId) so the client can credit them when the first
|
// stay in inventory; second roll is extra loot.
|
||||||
// reels finish; the rest are discarded immediately.
|
// - Auto-sell OFF: classic replace — first roll is discarded entirely.
|
||||||
let preRespinItems = null;
|
let preRespinItems = null;
|
||||||
|
let keptPreRespinItems = [];
|
||||||
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
||||||
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
||||||
if (didRespin) {
|
if (didRespin) {
|
||||||
const discardIds = drops
|
const autoSellOn = Boolean(user.autoSellEnabled);
|
||||||
.filter((d) => !d.willAutoSell)
|
|
||||||
.map((d) => d.inventoryId)
|
|
||||||
.filter(Boolean);
|
|
||||||
const pendingAutoSellIds = drops
|
const pendingAutoSellIds = drops
|
||||||
.filter((d) => d.willAutoSell)
|
.filter((d) => d.willAutoSell)
|
||||||
.map((d) => d.inventoryId)
|
.map((d) => d.inventoryId)
|
||||||
.filter(Boolean);
|
.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) => ({
|
preRespinItems = drops.map((d) => ({
|
||||||
...d,
|
...d,
|
||||||
inventoryId: d.willAutoSell ? d.inventoryId : null,
|
inventoryId: keepIdSet.has(d.inventoryId) ? d.inventoryId : null,
|
||||||
preRespin: true,
|
preRespin: true,
|
||||||
|
kept: Boolean(d.inventoryId && keepIdSet.has(d.inventoryId) && !d.willAutoSell),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (discardIds.length) {
|
if (discardIds.length) {
|
||||||
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
|
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);
|
const again = await createDrops(count);
|
||||||
drops = again.drops;
|
drops = again.drops;
|
||||||
createdTxIds = again.createdTxIds;
|
createdTxIds = again.createdTxIds;
|
||||||
@@ -317,6 +332,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
openCount: count,
|
openCount: count,
|
||||||
inventoryItemIds: drops.map((d) => d.inventoryId),
|
inventoryItemIds: drops.map((d) => d.inventoryId),
|
||||||
preRespinPendingAutoSellIds: pendingAutoSellIds,
|
preRespinPendingAutoSellIds: pendingAutoSellIds,
|
||||||
|
preRespinKeptIds: keptPreRespinItems.map((d) => d.inventoryId),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -330,7 +346,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
tx,
|
tx,
|
||||||
userId,
|
userId,
|
||||||
caseId,
|
caseId,
|
||||||
drops.map((d) => d.id)
|
[...keptPreRespinItems.map((d) => d.id), ...drops.map((d) => d.id)]
|
||||||
);
|
);
|
||||||
|
|
||||||
const applied = applyOpens(progressRow, count, 0);
|
const applied = applyOpens(progressRow, count, 0);
|
||||||
@@ -367,6 +383,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
items: drops,
|
items: drops,
|
||||||
preRespinItems,
|
preRespinItems,
|
||||||
|
keptPreRespinItems,
|
||||||
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||||
keysGained: applied.keysGained,
|
keysGained: applied.keysGained,
|
||||||
catalog,
|
catalog,
|
||||||
@@ -386,6 +403,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
count: result.items.length,
|
count: result.items.length,
|
||||||
items: result.items,
|
items: result.items,
|
||||||
preRespinItems: result.preRespinItems,
|
preRespinItems: result.preRespinItems,
|
||||||
|
keptPreRespinItems: result.keptPreRespinItems,
|
||||||
keyProgress: result.keyProgress,
|
keyProgress: result.keyProgress,
|
||||||
keysGained: result.keysGained,
|
keysGained: result.keysGained,
|
||||||
catalog: result.catalog,
|
catalog: result.catalog,
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||||
|
|
||||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
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({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+20
-12
@@ -11,7 +11,11 @@ import {
|
|||||||
scaleCreditPacks,
|
scaleCreditPacks,
|
||||||
} from '../shopCatalog.js';
|
} from '../shopCatalog.js';
|
||||||
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
|
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
|
||||||
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js';
|
import {
|
||||||
|
confirmMockOsuPayment,
|
||||||
|
createOsuCheckout,
|
||||||
|
getOrderForUser,
|
||||||
|
} from '../payments.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -42,15 +46,18 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const packages = scaleCreditPacks(user.balance);
|
const packages = scaleCreditPacks(user.balance);
|
||||||
const sectionEnabled = await isOsuTopupsSectionEnabled();
|
const sectionEnabled = await isOsuTopupsSectionEnabled();
|
||||||
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
|
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
|
||||||
|
const provider = paymentsProvider();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
packages,
|
packages,
|
||||||
osuTopups,
|
osuTopups,
|
||||||
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
|
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
|
||||||
payments: {
|
payments: {
|
||||||
provider: paymentsProvider(),
|
provider,
|
||||||
currency: 'eur',
|
currency: 'eur',
|
||||||
mock: paymentsProvider() === 'mock',
|
mock: provider === 'mock',
|
||||||
|
publishableKey:
|
||||||
|
provider === 'stripe' ? process.env.STRIPE_PUBLISHABLE_KEY || '' : '',
|
||||||
},
|
},
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
ad: adPayload(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) => {
|
router.post('/osu/checkout', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const packId = String(req.body.packId || '');
|
const packId = String(req.body.packId || '');
|
||||||
@@ -155,15 +162,16 @@ router.post('/osu/confirm', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** Poll order status after Stripe redirect (webhook may lag a second). */
|
||||||
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
|
router.get('/osu/orders/:id', requireAuth, async (req, res) => {
|
||||||
* Keep raw body parsing in mind when wiring Stripe.
|
try {
|
||||||
*/
|
const order = await getOrderForUser(req.session.userId, req.params.id);
|
||||||
router.post('/osu/webhook', async (req, res) => {
|
if (!order) return res.status(404).json({ error: 'Order not found' });
|
||||||
if (paymentsProvider() !== 'stripe') {
|
res.json({ order });
|
||||||
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
|
} 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) => {
|
router.post('/ad', requireAuth, async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user