mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2961 lines
93 KiB
JavaScript
2961 lines
93 KiB
JavaScript
const crypto = require('crypto');
|
|
const axios = require('axios');
|
|
const bitcoin = require('bitcoinjs-lib');
|
|
const ecc = require('tiny-secp256k1');
|
|
const { BIP32Factory } = require('bip32');
|
|
|
|
const { invalidateVipCache } = require('../checkVip');
|
|
const { fetchAddressTxs, fetchTipHeight } = require('./chainExplorer');
|
|
|
|
bitcoin.initEccLib(ecc);
|
|
|
|
const bip32 = BIP32Factory(ecc);
|
|
|
|
const VIP_PACKS = Object.freeze({
|
|
5: { amountEur: 5, vipYears: 1, durationLabel: '1 an' },
|
|
7: { amountEur: 7, vipYears: 1.5, durationLabel: '18 mois' },
|
|
10: { amountEur: 10, vipYears: 2, durationLabel: '2 ans' },
|
|
15: { amountEur: 15, vipYears: 3, durationLabel: '3 ans' },
|
|
20: { amountEur: 20, vipYears: 4, durationLabel: '4 ans' }
|
|
});
|
|
|
|
const VIP_PAYMENT_METHODS = Object.freeze({
|
|
btc: { type: 'crypto', coin: 'btc' },
|
|
ltc: { type: 'crypto', coin: 'ltc' },
|
|
paygate_hosted: { type: 'paygate', coin: null },
|
|
payblis: { type: 'payblis', coin: null }
|
|
});
|
|
const VIP_PAYMENT_METHOD_ENUM_SQL = "ENUM('btc', 'ltc', 'paygate_hosted', 'autobuy', 'payblis')";
|
|
|
|
const DEFAULT_SUPPORT_TELEGRAM_URL = 'https://t.me/movix_site';
|
|
const FINAL_STATUSES = new Set(['delivered', 'cancelled']);
|
|
const DEFAULT_EXPIRATION_MINUTES = 210;
|
|
const PAYGATE_DEFAULT_DOMAIN = 'checkout.paygate.to';
|
|
const DEFAULT_PAYGATE_MIN_PAID_RATIO = 0.60;
|
|
const DEFAULT_PAYGATE_MIN_AMOUNT_EUR = 6.25;
|
|
const DEFAULT_PAYGATE_MIN_PACK_EUR = 7;
|
|
const SATOSHI_FACTOR = 1e8;
|
|
const PAYGATE_USDC_POLYGON_CONTRACT = '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359';
|
|
const STATUS_REASONS = {
|
|
awaiting_payment: 'Aucun paiement détecté pour le moment.',
|
|
partial_payment: 'Paiement détecté mais montant insuffisant.',
|
|
confirming: 'Paiement détecté, confirmations en cours.',
|
|
paid: 'Paiement confirmé sur la blockchain.',
|
|
delivered: 'Clé VIP livrée.',
|
|
expired: 'Invoice expirée.',
|
|
cancelled: 'Invoice annulée.'
|
|
};
|
|
|
|
const BTC_NETWORK = {
|
|
messagePrefix: '\x18Bitcoin Signed Message:\n',
|
|
bech32: 'bc',
|
|
bip32: {
|
|
public: 0x0488b21e,
|
|
private: 0x0488ade4
|
|
},
|
|
pubKeyHash: 0x00,
|
|
scriptHash: 0x05,
|
|
wif: 0x80
|
|
};
|
|
|
|
const LTC_NETWORK = {
|
|
messagePrefix: '\x19Litecoin Signed Message:\n',
|
|
bech32: 'ltc',
|
|
bip32: {
|
|
public: 0x019da462,
|
|
private: 0x019d9cfe
|
|
},
|
|
pubKeyHash: 0x30,
|
|
scriptHash: 0x32,
|
|
wif: 0xb0
|
|
};
|
|
|
|
const EXTENDED_KEY_FORMATS = {
|
|
btc: {
|
|
xpub: { bip32Public: 0x0488b21e, defaultAddressType: 'legacy' },
|
|
ypub: { bip32Public: 0x049d7cb2, defaultAddressType: 'p2sh-segwit' },
|
|
zpub: { bip32Public: 0x04b24746, defaultAddressType: 'segwit' }
|
|
},
|
|
ltc: {
|
|
xpub: { bip32Public: 0x0488b21e, defaultAddressType: 'legacy' },
|
|
Ltub: { bip32Public: 0x019da462, defaultAddressType: 'legacy' }
|
|
}
|
|
};
|
|
|
|
const PAYGATE_PRICE_CONVERT_COINS = new Set(['polygon_pol', 'eth', 'bep20_bnb']);
|
|
|
|
function parseNumber(value, fallback = 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function roundFiat(value) {
|
|
return Number(parseNumber(value).toFixed(2));
|
|
}
|
|
|
|
function roundCrypto(value) {
|
|
return Number(parseNumber(value).toFixed(8));
|
|
}
|
|
|
|
function toSqlDateTime(dateInput) {
|
|
const date = dateInput instanceof Date ? dateInput : new Date(dateInput);
|
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
function fromSqlDateTime(dateInput) {
|
|
if (!dateInput) return null;
|
|
return new Date(dateInput);
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
return String(value || '').trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
function getSupportTelegramUrl() {
|
|
return process.env.VIP_SUPPORT_TELEGRAM_URL || DEFAULT_SUPPORT_TELEGRAM_URL;
|
|
}
|
|
|
|
function buildFrontendUrl(pathname) {
|
|
const baseUrl = normalizeBaseUrl(process.env.FRONTEND_BASE_URL);
|
|
if (!baseUrl) return pathname;
|
|
return `${baseUrl}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
|
}
|
|
|
|
function getInvoicePath(publicId) {
|
|
return `/vip/invoice/${encodeURIComponent(publicId)}`;
|
|
}
|
|
|
|
function getGiftPath(giftToken) {
|
|
return `/vip/cadeau/${encodeURIComponent(giftToken)}`;
|
|
}
|
|
|
|
function hashIp(ipAddress) {
|
|
if (!ipAddress) return null;
|
|
const secret = process.env.JWT_SECRET || 'movix-vip-ip';
|
|
return crypto.createHmac('sha256', secret).update(ipAddress).digest('hex');
|
|
}
|
|
|
|
function getClientIp(req) {
|
|
return req.headers['cf-connecting-ip']
|
|
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|
|
|| req.ip
|
|
|| null;
|
|
}
|
|
|
|
function resolvePack(packEur) {
|
|
const normalizedAmount = parseNumber(packEur);
|
|
return VIP_PACKS[normalizedAmount] || null;
|
|
}
|
|
|
|
function normalizePaymentMethod(method, fallbackCoin = null) {
|
|
const normalized = String(method || '').trim().toLowerCase();
|
|
if (VIP_PAYMENT_METHODS[normalized]) {
|
|
return normalized;
|
|
}
|
|
|
|
return normalizeCoin(fallbackCoin);
|
|
}
|
|
|
|
function normalizeCoin(coin) {
|
|
const normalized = String(coin || '').trim().toLowerCase();
|
|
return normalized === 'btc' || normalized === 'ltc' ? normalized : null;
|
|
}
|
|
|
|
function isCryptoPaymentMethod(paymentMethod) {
|
|
return paymentMethod === 'btc' || paymentMethod === 'ltc';
|
|
}
|
|
|
|
function isPaygatePaymentMethod(paymentMethod) {
|
|
return paymentMethod === 'paygate_hosted';
|
|
}
|
|
|
|
function isPayblisPaymentMethod(paymentMethod) {
|
|
return paymentMethod === 'payblis';
|
|
}
|
|
|
|
function isAutoBuyPaymentMethod(paymentMethod) {
|
|
const normalized = String(paymentMethod || '').trim().toLowerCase();
|
|
return normalized === 'autobuy' || normalized === 'autobuy_paypal';
|
|
}
|
|
|
|
function getInvoicePaymentMethod(invoice) {
|
|
if (!invoice) return null;
|
|
|
|
const paymentMethod = normalizePaymentMethod(invoice.payment_method, invoice.coin);
|
|
if (paymentMethod) {
|
|
return paymentMethod;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeRecipientMode(mode) {
|
|
const normalized = String(mode || '').trim().toLowerCase();
|
|
return normalized === 'self' || normalized === 'gift' ? normalized : null;
|
|
}
|
|
|
|
function normalizeEmailAddress(email) {
|
|
const normalized = String(email || '').trim().toLowerCase();
|
|
if (!normalized) return null;
|
|
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized);
|
|
return isValid ? normalized : null;
|
|
}
|
|
|
|
function getCoinEnvPrefix(coin) {
|
|
return coin === 'btc' ? 'BTC' : 'LTC';
|
|
}
|
|
|
|
function normalizeAddressType(addressType, fallback) {
|
|
const normalized = String(addressType || '').trim().toLowerCase();
|
|
if (normalized === 'legacy' || normalized === 'segwit' || normalized === 'p2sh-segwit') {
|
|
return normalized;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function getPaygateCheckoutDomain() {
|
|
return String(process.env.VIP_PAYGATE_DOMAIN || PAYGATE_DEFAULT_DOMAIN)
|
|
.trim()
|
|
.replace(/^https?:\/\//i, '')
|
|
.replace(/\/+$/, '');
|
|
}
|
|
|
|
function getPaygatePayoutWallet() {
|
|
const wallet = String(process.env.VIP_PAYGATE_SETTLEMENT_WALLET || '').trim();
|
|
if (!wallet) {
|
|
return '';
|
|
}
|
|
|
|
if (!/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
|
|
throw new Error('VIP_PAYGATE_SETTLEMENT_WALLET invalide: adresse EVM/Polygon attendue');
|
|
}
|
|
|
|
if (wallet.toLowerCase() === PAYGATE_USDC_POLYGON_CONTRACT) {
|
|
throw new Error('VIP_PAYGATE_SETTLEMENT_WALLET invalide: utilise ton wallet, pas le contrat USDC Polygon');
|
|
}
|
|
|
|
return wallet;
|
|
}
|
|
|
|
function getPaygateMinPaidRatio() {
|
|
const ratio = parseNumber(process.env.VIP_PAYGATE_MIN_PAID_RATIO, DEFAULT_PAYGATE_MIN_PAID_RATIO);
|
|
return Math.min(1, Math.max(0.5, ratio));
|
|
}
|
|
|
|
function getPaygateMinimumAmountEur() {
|
|
return roundFiat(Math.max(
|
|
0,
|
|
parseNumber(process.env.VIP_PAYGATE_MIN_AMOUNT_EUR, DEFAULT_PAYGATE_MIN_AMOUNT_EUR)
|
|
));
|
|
}
|
|
|
|
function getPaygateCheckoutAmountEur(amountEur) {
|
|
return roundFiat(Math.max(parseNumber(amountEur), getPaygateMinimumAmountEur()));
|
|
}
|
|
|
|
function isPaygatePackAllowed(pack) {
|
|
if (!pack) {
|
|
return false;
|
|
}
|
|
|
|
return parseNumber(pack.amountEur, 0) >= parseNumber(
|
|
process.env.VIP_PAYGATE_MIN_PACK_EUR,
|
|
DEFAULT_PAYGATE_MIN_PACK_EUR
|
|
);
|
|
}
|
|
|
|
function getPaygateBranding() {
|
|
return {
|
|
logo: String(process.env.VIP_PAYGATE_LOGO_URL || '').trim(),
|
|
background: String(process.env.VIP_PAYGATE_BACKGROUND || '').trim(),
|
|
theme: String(process.env.VIP_PAYGATE_THEME || '').trim(),
|
|
button: String(process.env.VIP_PAYGATE_BUTTON || '').trim()
|
|
};
|
|
}
|
|
|
|
function getPaygateCallbackBaseUrl(context = {}) {
|
|
const envBaseUrl = normalizeBaseUrl(process.env.VIP_PAYGATE_CALLBACK_BASE_URL);
|
|
if (envBaseUrl) {
|
|
return envBaseUrl;
|
|
}
|
|
|
|
return normalizeBaseUrl(context.callbackBaseUrl);
|
|
}
|
|
|
|
function buildPaygateCallbackUrl(publicId, callbackNonce, context = {}) {
|
|
const callbackBaseUrl = getPaygateCallbackBaseUrl(context);
|
|
if (!callbackBaseUrl) {
|
|
throw new Error('VIP PayGate callback URL introuvable');
|
|
}
|
|
|
|
const query = new URLSearchParams({
|
|
publicId,
|
|
nonce: callbackNonce
|
|
});
|
|
|
|
return `${callbackBaseUrl}/api/vip/paygate/callback?${query.toString()}`;
|
|
}
|
|
|
|
function buildPaygateCheckoutUrl({ trackingAddress, amountEur, payerEmail, branding = {} }) {
|
|
const domain = getPaygateCheckoutDomain();
|
|
if (!domain) {
|
|
throw new Error('VIP PayGate domain manquant');
|
|
}
|
|
|
|
const queryParts = [
|
|
`address=${String(trackingAddress || '').trim()}`,
|
|
`amount=${roundFiat(amountEur).toFixed(2)}`,
|
|
`email=${encodeURIComponent(String(payerEmail || '').trim())}`,
|
|
'currency=EUR'
|
|
];
|
|
|
|
if (branding.logo) queryParts.push(`logo=${encodeURIComponent(branding.logo)}`);
|
|
if (branding.background) queryParts.push(`background=${encodeURIComponent(branding.background)}`);
|
|
if (branding.theme) queryParts.push(`theme=${encodeURIComponent(branding.theme)}`);
|
|
if (branding.button) queryParts.push(`button=${encodeURIComponent(branding.button)}`);
|
|
|
|
return `https://${domain}/pay.php?${queryParts.join('&')}`;
|
|
}
|
|
|
|
function resolvePaygateCheckoutUrl(invoice) {
|
|
if (!invoice) {
|
|
return null;
|
|
}
|
|
|
|
const trackingAddress = String(invoice.paygate_tracking_address || invoice.payment_address || '').trim();
|
|
const payerEmail = normalizeEmailAddress(invoice.paygate_payer_email);
|
|
|
|
if (!trackingAddress || !payerEmail) {
|
|
return invoice.paygate_checkout_url || null;
|
|
}
|
|
|
|
try {
|
|
return buildPaygateCheckoutUrl({
|
|
trackingAddress,
|
|
amountEur: getPaygateCheckoutAmountEur(invoice.amount_eur),
|
|
payerEmail,
|
|
branding: getPaygateBranding()
|
|
});
|
|
} catch (error) {
|
|
return invoice.paygate_checkout_url || null;
|
|
}
|
|
}
|
|
|
|
function getStartDerivationIndex(coin) {
|
|
const envPrefix = getCoinEnvPrefix(coin);
|
|
return Math.max(0, parseNumber(process.env[`VIP_${envPrefix}_START_INDEX`], 0));
|
|
}
|
|
|
|
function getExtendedKeyFormat(coin, extendedKey) {
|
|
const formats = EXTENDED_KEY_FORMATS[coin] || {};
|
|
return Object.entries(formats).find(([prefix]) => extendedKey.startsWith(prefix)) || null;
|
|
}
|
|
|
|
function derivePaymentAddress(coin, derivationIndex) {
|
|
const envPrefix = getCoinEnvPrefix(coin);
|
|
const xpub = String(process.env[`VIP_${envPrefix}_XPUB`] || '').trim();
|
|
if (!xpub) {
|
|
throw new Error(`VIP_${envPrefix}_XPUB manquant`);
|
|
}
|
|
|
|
const formatEntry = getExtendedKeyFormat(coin, xpub);
|
|
if (!formatEntry) {
|
|
throw new Error(`Format de xpub non supporté pour ${coin.toUpperCase()}`);
|
|
}
|
|
|
|
const [, format] = formatEntry;
|
|
const baseNetwork = coin === 'btc' ? BTC_NETWORK : LTC_NETWORK;
|
|
const addressType = normalizeAddressType(
|
|
process.env[`VIP_${envPrefix}_ADDRESS_TYPE`],
|
|
format.defaultAddressType
|
|
);
|
|
|
|
const parsingNetwork = {
|
|
...baseNetwork,
|
|
bip32: {
|
|
public: format.bip32Public,
|
|
private: baseNetwork.bip32.private
|
|
}
|
|
};
|
|
|
|
const accountNode = bip32.fromBase58(xpub, parsingNetwork);
|
|
const chainIndex = parseNumber(process.env[`VIP_${envPrefix}_CHAIN`], 0);
|
|
const childNode = accountNode.derive(chainIndex).derive(derivationIndex);
|
|
const publicKey = Buffer.from(childNode.publicKey);
|
|
|
|
let payment;
|
|
if (addressType === 'segwit') {
|
|
payment = bitcoin.payments.p2wpkh({ pubkey: publicKey, network: baseNetwork });
|
|
} else if (addressType === 'p2sh-segwit') {
|
|
payment = bitcoin.payments.p2sh({
|
|
redeem: bitcoin.payments.p2wpkh({ pubkey: publicKey, network: baseNetwork }),
|
|
network: baseNetwork
|
|
});
|
|
} else {
|
|
payment = bitcoin.payments.p2pkh({ pubkey: publicKey, network: baseNetwork });
|
|
}
|
|
|
|
if (!payment.address) {
|
|
throw new Error(`Impossible de dériver une adresse ${coin.toUpperCase()}`);
|
|
}
|
|
|
|
return {
|
|
address: payment.address,
|
|
addressType,
|
|
derivationIndex
|
|
};
|
|
}
|
|
|
|
async function fetchCoinbaseSpotPrices(coin) {
|
|
const symbol = coin.toUpperCase();
|
|
const [eurResponse, usdResponse] = await Promise.all([
|
|
axios.get(`https://api.coinbase.com/v2/prices/${symbol}-EUR/spot`, { timeout: 15000 }),
|
|
axios.get(`https://api.coinbase.com/v2/prices/${symbol}-USD/spot`, { timeout: 15000 })
|
|
]);
|
|
|
|
return {
|
|
eurPricePerCoin: parseNumber(eurResponse.data?.data?.amount),
|
|
usdPricePerCoin: parseNumber(usdResponse.data?.data?.amount)
|
|
};
|
|
}
|
|
|
|
async function fetchCoinGeckoSpotPrices(coin) {
|
|
const coinId = coin === 'btc' ? 'bitcoin' : 'litecoin';
|
|
const response = await axios.get(
|
|
'https://api.coingecko.com/api/v3/simple/price',
|
|
{
|
|
params: {
|
|
ids: coinId,
|
|
vs_currencies: 'eur,usd'
|
|
},
|
|
timeout: 15000
|
|
}
|
|
);
|
|
|
|
return {
|
|
eurPricePerCoin: parseNumber(response.data?.[coinId]?.eur),
|
|
usdPricePerCoin: parseNumber(response.data?.[coinId]?.usd)
|
|
};
|
|
}
|
|
|
|
async function buildPricingSnapshot(coin, amountEur) {
|
|
let prices;
|
|
try {
|
|
prices = await fetchCoinbaseSpotPrices(coin);
|
|
} catch (error) {
|
|
prices = await fetchCoinGeckoSpotPrices(coin);
|
|
}
|
|
|
|
if (!prices.eurPricePerCoin || !prices.usdPricePerCoin) {
|
|
throw new Error(`Impossible de récupérer le prix ${coin.toUpperCase()}`);
|
|
}
|
|
|
|
return {
|
|
amountEur: roundFiat(amountEur),
|
|
amountUsd: roundFiat((amountEur / prices.eurPricePerCoin) * prices.usdPricePerCoin),
|
|
amountCryptoExpected: roundCrypto(amountEur / prices.eurPricePerCoin),
|
|
eurPricePerCoin: roundFiat(prices.eurPricePerCoin),
|
|
usdPricePerCoin: roundFiat(prices.usdPricePerCoin)
|
|
};
|
|
}
|
|
|
|
async function fetchPaygateConvertedUsd(amount, fromCurrency = 'eur') {
|
|
const response = await axios.get(
|
|
'https://api.paygate.to/control/convert.php',
|
|
{
|
|
params: {
|
|
value: roundFiat(amount).toFixed(2),
|
|
from: String(fromCurrency || 'eur').trim().toLowerCase()
|
|
},
|
|
timeout: 15000
|
|
}
|
|
);
|
|
|
|
const usdAmount = parseNumber(response.data?.value_coin);
|
|
if (!usdAmount) {
|
|
throw new Error('Impossible de convertir le montant via PayGate');
|
|
}
|
|
|
|
return roundFiat(usdAmount);
|
|
}
|
|
|
|
async function fetchPaygateTemporaryWallet(callbackUrl) {
|
|
const payoutWallet = getPaygatePayoutWallet();
|
|
if (!payoutWallet) {
|
|
throw new Error('VIP_PAYGATE_SETTLEMENT_WALLET manquant');
|
|
}
|
|
|
|
const response = await axios.get(
|
|
'https://api.paygate.to/control/wallet.php',
|
|
{
|
|
params: {
|
|
address: payoutWallet,
|
|
callback: callbackUrl
|
|
},
|
|
timeout: 15000
|
|
}
|
|
);
|
|
|
|
const trackingAddress = String(response.data?.address_in || '').trim();
|
|
const temporaryWalletAddress = String(response.data?.polygon_address_in || '').trim();
|
|
const callbackUrlFromGateway = String(response.data?.callback_url || '').trim();
|
|
const ipnToken = String(response.data?.ipn_token || '').trim();
|
|
|
|
if (!trackingAddress) {
|
|
throw new Error('PayGate n\'a pas retourné d\'adresse de suivi');
|
|
}
|
|
|
|
return {
|
|
trackingAddress,
|
|
temporaryWalletAddress: temporaryWalletAddress || null,
|
|
callbackUrl: callbackUrlFromGateway || null,
|
|
ipnToken: ipnToken || null
|
|
};
|
|
}
|
|
|
|
// Poll officiel PayGate (payment-status.php) via l'ipn_token retourné par
|
|
// wallet.php. Filet de sécurité quand le callback GET de PayGate n'atteint
|
|
// jamais notre serveur (WAF, timeout, outage) — sinon l'invoice reste bloquée
|
|
// en awaiting_payment alors que le paiement est passé.
|
|
async function fetchPaygatePaymentStatus(ipnToken) {
|
|
const response = await axios.get(
|
|
'https://api.paygate.to/control/payment-status.php',
|
|
{
|
|
params: { ipn_token: ipnToken },
|
|
timeout: 15000
|
|
}
|
|
);
|
|
|
|
return response.data || {};
|
|
}
|
|
|
|
async function normalizePaygatePaidUsdValue(receivedCoin, paidValue) {
|
|
const rawValue = parseNumber(paidValue);
|
|
if (!rawValue) {
|
|
return 0;
|
|
}
|
|
|
|
const normalizedCoin = String(receivedCoin || '').trim().toLowerCase();
|
|
if (!PAYGATE_PRICE_CONVERT_COINS.has(normalizedCoin)) {
|
|
return roundFiat(rawValue);
|
|
}
|
|
|
|
try {
|
|
const coinPath = normalizedCoin.replace('_', '/');
|
|
const response = await axios.get(
|
|
`https://api.paygate.to/crypto/${coinPath}/info.php`,
|
|
{ timeout: 15000 }
|
|
);
|
|
const usdPrice = parseNumber(response.data?.prices?.USD);
|
|
if (!usdPrice) {
|
|
return roundFiat(rawValue);
|
|
}
|
|
|
|
return roundFiat(rawValue * usdPrice);
|
|
} catch (error) {
|
|
return roundFiat(rawValue);
|
|
}
|
|
}
|
|
|
|
function buildQrPayload(coin, address, amountCryptoExpected) {
|
|
const scheme = coin === 'btc' ? 'bitcoin' : 'litecoin';
|
|
return `${scheme}:${address}?amount=${roundCrypto(amountCryptoExpected).toFixed(8)}`;
|
|
}
|
|
|
|
async function reserveDerivationIndex(pool, coin) {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT next_index FROM vip_derivation_counters WHERE coin = ? FOR UPDATE',
|
|
[coin]
|
|
);
|
|
|
|
const startIndex = getStartDerivationIndex(coin);
|
|
let derivationIndex = startIndex;
|
|
if (rows.length === 0) {
|
|
await connection.execute(
|
|
'INSERT INTO vip_derivation_counters (coin, next_index, created_at, updated_at) VALUES (?, ?, NOW(), NOW())',
|
|
[coin, derivationIndex + 1]
|
|
);
|
|
} else {
|
|
derivationIndex = Math.max(parseNumber(rows[0].next_index, 0), startIndex);
|
|
await connection.execute(
|
|
'UPDATE vip_derivation_counters SET next_index = ?, updated_at = NOW() WHERE coin = ?',
|
|
[derivationIndex + 1, coin]
|
|
);
|
|
}
|
|
|
|
await connection.commit();
|
|
return derivationIndex;
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async function logVipInvoiceEvent(db, invoiceId, eventType, message, payload, actorType = 'system', actorId = null) {
|
|
await db.execute(
|
|
`INSERT INTO vip_invoice_events
|
|
(invoice_id, event_type, actor_type, actor_id, message, payload_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
|
|
[
|
|
invoiceId,
|
|
eventType,
|
|
actorType,
|
|
actorId,
|
|
message || null,
|
|
payload ? JSON.stringify(payload) : null
|
|
]
|
|
);
|
|
}
|
|
|
|
async function expireAwaitingInvoiceIfOverdue(db, invoice) {
|
|
if (!invoice || invoice.status !== 'awaiting_payment') {
|
|
return invoice;
|
|
}
|
|
|
|
const expiresAt = fromSqlDateTime(invoice.expires_at);
|
|
if (!expiresAt || expiresAt.getTime() >= Date.now()) {
|
|
return invoice;
|
|
}
|
|
|
|
const [updateResult] = await db.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'expired',
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?
|
|
AND status = 'awaiting_payment'
|
|
AND expires_at IS NOT NULL
|
|
AND expires_at < NOW()`,
|
|
[invoice.id]
|
|
);
|
|
|
|
if (parseNumber(updateResult?.affectedRows, 0) > 0) {
|
|
await logVipInvoiceEvent(
|
|
db,
|
|
invoice.id,
|
|
'invoice_status_updated',
|
|
STATUS_REASONS.expired,
|
|
{
|
|
from: 'awaiting_payment',
|
|
to: 'expired'
|
|
},
|
|
'system',
|
|
invoice.public_id || null
|
|
);
|
|
}
|
|
|
|
const [rows] = await db.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? LIMIT 1',
|
|
[invoice.id]
|
|
);
|
|
|
|
return rows[0] || {
|
|
...invoice,
|
|
status: 'expired'
|
|
};
|
|
}
|
|
|
|
async function fetchInvoiceByPublicId(pool, publicId) {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM vip_invoices WHERE public_id = ? LIMIT 1',
|
|
[publicId]
|
|
);
|
|
return expireAwaitingInvoiceIfOverdue(pool, rows[0] || null);
|
|
}
|
|
|
|
async function fetchInvoiceById(pool, invoiceId) {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? LIMIT 1',
|
|
[invoiceId]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function fetchInvoiceByGiftToken(pool, giftToken) {
|
|
const [rows] = await pool.execute(
|
|
'SELECT * FROM vip_invoices WHERE gift_token = ? LIMIT 1',
|
|
[giftToken]
|
|
);
|
|
return expireAwaitingInvoiceIfOverdue(pool, rows[0] || null);
|
|
}
|
|
|
|
async function generateUniqueVipKey(db) {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const candidate = `VIP-${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
|
|
const [rows] = await db.execute(
|
|
'SELECT key_value FROM access_keys WHERE key_value = ? LIMIT 1',
|
|
[candidate]
|
|
);
|
|
if (rows.length === 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new Error('Impossible de générer une clé VIP unique');
|
|
}
|
|
|
|
function buildDurationLabel(vipYears) {
|
|
const normalizedYears = parseNumber(vipYears, 1);
|
|
const totalMonths = Math.max(1, Math.round(normalizedYears * 12));
|
|
|
|
if (totalMonths % 12 === 0) {
|
|
const totalYears = totalMonths / 12;
|
|
return `${totalYears} ${totalYears > 1 ? 'ans' : 'an'}`;
|
|
}
|
|
|
|
return `${totalMonths} mois`;
|
|
}
|
|
|
|
async function deliverInvoiceIfReady(pool, invoiceId, actorType = 'system', actorId = null, reason = 'auto') {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE',
|
|
[invoiceId]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
throw new Error('Invoice introuvable');
|
|
}
|
|
|
|
const invoice = rows[0];
|
|
if (invoice.status === 'delivered' && invoice.vip_key_value) {
|
|
await connection.commit();
|
|
return invoice;
|
|
}
|
|
|
|
if (invoice.status !== 'paid') {
|
|
await connection.commit();
|
|
return invoice;
|
|
}
|
|
|
|
const vipYears = parseNumber(invoice.vip_years, 0);
|
|
const totalMonths = Math.max(1, Math.round(vipYears * 12));
|
|
const durationLabel = buildDurationLabel(vipYears);
|
|
const keyValue = await generateUniqueVipKey(connection);
|
|
const expiresAt = new Date();
|
|
expiresAt.setMonth(expiresAt.getMonth() + totalMonths);
|
|
|
|
await connection.execute(
|
|
`INSERT INTO access_keys
|
|
(key_value, active, used, duree_validite, expires_at, created_at, updated_at)
|
|
VALUES (?, 1, 0, ?, ?, NOW(), NOW())`,
|
|
[keyValue, durationLabel, expiresAt.getTime()]
|
|
);
|
|
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'delivered',
|
|
vip_key_value = ?,
|
|
delivered_at = COALESCE(delivered_at, NOW()),
|
|
paid_at = COALESCE(paid_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[keyValue, invoiceId]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoiceId,
|
|
'invoice_delivered',
|
|
'Clé VIP livrée automatiquement.',
|
|
{
|
|
reason,
|
|
keyValue,
|
|
vipYears,
|
|
durationMonths: totalMonths,
|
|
durationLabel
|
|
},
|
|
actorType,
|
|
actorId
|
|
);
|
|
|
|
await connection.commit();
|
|
invalidateVipCache(keyValue);
|
|
|
|
return fetchInvoiceById(pool, invoiceId);
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
function getRequiredConfirmations(coin) {
|
|
const envPrefix = getCoinEnvPrefix(coin);
|
|
return Math.max(1, parseNumber(process.env[`VIP_${envPrefix}_CONFIRMATIONS`], 1));
|
|
}
|
|
|
|
async function fetchAddressActivity(coin, address, options = {}) {
|
|
if (coin !== 'btc' && coin !== 'ltc') {
|
|
throw new Error(`Coin non supporté pour la surveillance: ${coin}`);
|
|
}
|
|
|
|
const [txs, tipHeight] = await Promise.all([
|
|
fetchAddressTxs(coin, address),
|
|
fetchTipHeight(coin)
|
|
]);
|
|
|
|
let totalReceived = 0;
|
|
let confirmedReceived = 0;
|
|
let maxConfirmations = 0;
|
|
const txHashes = [];
|
|
const requiredConfirmations = getRequiredConfirmations(coin);
|
|
const ignoreBeforeMs = options.ignoreBefore ? new Date(options.ignoreBefore).getTime() : 0;
|
|
const ignoreBeforeToleranceMs = 1500;
|
|
|
|
for (const tx of txs) {
|
|
const status = tx?.status || {};
|
|
const isConfirmed = Boolean(status.confirmed);
|
|
const blockTimeMs = Number.isFinite(status.block_time) ? status.block_time * 1000 : null;
|
|
|
|
// Skip confirmed txs mined strictly before the invoice was created.
|
|
// Mempool txs (not yet confirmed) are always considered.
|
|
if (
|
|
ignoreBeforeMs
|
|
&& isConfirmed
|
|
&& blockTimeMs !== null
|
|
&& blockTimeMs < (ignoreBeforeMs - ignoreBeforeToleranceMs)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
let amountForAddress = 0;
|
|
for (const output of Array.isArray(tx.vout) ? tx.vout : []) {
|
|
if (output && output.scriptpubkey_address === address) {
|
|
amountForAddress += parseNumber(output.value, 0) / SATOSHI_FACTOR;
|
|
}
|
|
}
|
|
|
|
if (amountForAddress <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const confirmations = isConfirmed
|
|
&& Number.isFinite(status.block_height)
|
|
&& tipHeight >= status.block_height
|
|
? (tipHeight - status.block_height + 1)
|
|
: 0;
|
|
|
|
totalReceived += amountForAddress;
|
|
if (confirmations >= requiredConfirmations) {
|
|
confirmedReceived += amountForAddress;
|
|
}
|
|
if (confirmations > maxConfirmations) {
|
|
maxConfirmations = confirmations;
|
|
}
|
|
if (tx.txid) {
|
|
txHashes.push(tx.txid);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalReceived: roundCrypto(totalReceived),
|
|
confirmedReceived: roundCrypto(confirmedReceived),
|
|
confirmations: maxConfirmations,
|
|
txHashes
|
|
};
|
|
}
|
|
|
|
function determineInvoiceStatus(invoice, addressActivity) {
|
|
const expected = roundCrypto(parseNumber(invoice.amount_crypto_expected));
|
|
const received = roundCrypto(addressActivity.totalReceived);
|
|
const confirmed = roundCrypto(addressActivity.confirmedReceived);
|
|
const isExpired = fromSqlDateTime(invoice.expires_at)?.getTime() < Date.now();
|
|
|
|
if (confirmed >= expected && expected > 0) {
|
|
return 'paid';
|
|
}
|
|
|
|
if (received >= expected && expected > 0) {
|
|
return 'confirming';
|
|
}
|
|
|
|
if (received > 0) {
|
|
return 'partial_payment';
|
|
}
|
|
|
|
if (isExpired) {
|
|
return 'expired';
|
|
}
|
|
|
|
return 'awaiting_payment';
|
|
}
|
|
|
|
function safeJsonParse(value) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function serializePublicInvoice(invoice) {
|
|
if (!invoice) return null;
|
|
|
|
const paymentMethod = getInvoicePaymentMethod(invoice);
|
|
const isCryptoInvoice = isCryptoPaymentMethod(paymentMethod);
|
|
const invoicePath = getInvoicePath(invoice.public_id);
|
|
const giftPath = invoice.gift_token ? getGiftPath(invoice.gift_token) : null;
|
|
const isGiftDelivered = invoice.recipient_mode === 'gift' && invoice.status === 'delivered' && invoice.gift_token;
|
|
const isSelfDelivered = invoice.recipient_mode === 'self' && invoice.status === 'delivered' && invoice.vip_key_value;
|
|
|
|
return {
|
|
publicId: invoice.public_id,
|
|
status: invoice.status,
|
|
paymentMethod,
|
|
coin: isCryptoInvoice ? normalizeCoin(invoice.coin) : null,
|
|
packEur: roundFiat(invoice.pack_eur),
|
|
amountEur: roundFiat(invoice.amount_eur),
|
|
amountUsd: roundFiat(invoice.amount_usd),
|
|
amountCryptoExpected: isCryptoInvoice ? roundCrypto(invoice.amount_crypto_expected) : null,
|
|
amountCryptoReceived: isCryptoInvoice ? roundCrypto(invoice.amount_crypto_received) : null,
|
|
vipYears: parseNumber(invoice.vip_years),
|
|
durationLabel: buildDurationLabel(parseNumber(invoice.vip_years)),
|
|
recipientMode: invoice.recipient_mode,
|
|
paymentAddress: isCryptoInvoice ? invoice.payment_address : null,
|
|
trackingAddress: isPaygatePaymentMethod(paymentMethod)
|
|
? (invoice.paygate_tracking_address || invoice.payment_address || null)
|
|
: null,
|
|
checkoutUrl: isPaygatePaymentMethod(paymentMethod)
|
|
? resolvePaygateCheckoutUrl(invoice)
|
|
: isPayblisPaymentMethod(paymentMethod)
|
|
? (invoice.payblis_checkout_url || null)
|
|
: null,
|
|
addressType: isCryptoInvoice ? (invoice.address_type || null) : null,
|
|
confirmations: isCryptoInvoice ? parseNumber(invoice.confirmations) : null,
|
|
requiredConfirmations: isCryptoInvoice ? parseNumber(invoice.required_confirmations) : null,
|
|
expiresAt: fromSqlDateTime(invoice.expires_at)?.toISOString() || null,
|
|
paidAt: fromSqlDateTime(invoice.paid_at)?.toISOString() || null,
|
|
deliveredAt: fromSqlDateTime(invoice.delivered_at)?.toISOString() || null,
|
|
createdAt: fromSqlDateTime(invoice.created_at)?.toISOString() || null,
|
|
qrPayload: isCryptoInvoice ? invoice.qr_payload : null,
|
|
invoicePath,
|
|
invoiceUrl: buildFrontendUrl(invoicePath),
|
|
giftPath: isGiftDelivered ? giftPath : null,
|
|
giftUrl: isGiftDelivered ? buildFrontendUrl(giftPath) : null,
|
|
vipKey: isSelfDelivered ? invoice.vip_key_value : null,
|
|
externalOrderId: null,
|
|
externalGateway: null,
|
|
externalAmount: null,
|
|
externalCurrency: null,
|
|
supportTelegramUrl: getSupportTelegramUrl()
|
|
};
|
|
}
|
|
|
|
function serializeAdminInvoice(invoice) {
|
|
if (!invoice) return null;
|
|
|
|
return {
|
|
...serializePublicInvoice(invoice),
|
|
id: parseNumber(invoice.id),
|
|
derivationIndex: parseNumber(invoice.derivation_index, -1) >= 0
|
|
? parseNumber(invoice.derivation_index)
|
|
: null,
|
|
txHash: invoice.tx_hash || null,
|
|
giftToken: invoice.gift_token || null,
|
|
giftSealed: Boolean(invoice.gift_sealed),
|
|
giftUnsealedAt: fromSqlDateTime(invoice.gift_unsealed_at)?.toISOString() || null,
|
|
giftUnsealCount: parseNumber(invoice.gift_unseal_count),
|
|
createdByUserId: invoice.created_by_user_id || null,
|
|
createdByUserType: invoice.created_by_user_type || null,
|
|
createdBySessionId: invoice.created_by_session_id || null,
|
|
createdIpHash: invoice.created_ip_hash || null,
|
|
payerEmail: invoice.paygate_payer_email || invoice.payblis_payer_email || null,
|
|
temporaryWalletAddress: invoice.paygate_temporary_wallet_address || null,
|
|
callbackUrl: invoice.paygate_callback_url || null,
|
|
paidCoin: invoice.paygate_paid_coin || null,
|
|
paidValue: invoice.paygate_paid_value !== null && invoice.paygate_paid_value !== undefined
|
|
? roundFiat(invoice.paygate_paid_value)
|
|
: null,
|
|
paidTxid: invoice.paygate_paid_txid || invoice.payblis_transaction_id || null,
|
|
externalOrderId: invoice.payblis_ref_order || null,
|
|
externalProductId: null,
|
|
externalGateway: invoice.payblis_ref_order ? 'payblis' : null,
|
|
externalCurrency: invoice.payblis_paid_currency || null,
|
|
externalAmount: invoice.payblis_paid_amount !== null && invoice.payblis_paid_amount !== undefined
|
|
? roundFiat(invoice.payblis_paid_amount)
|
|
: null,
|
|
payblisMethod: invoice.payblis_method || null,
|
|
payblisCustomerName: invoice.payblis_customer_name || null,
|
|
payblisIpnReceivedAt: fromSqlDateTime(invoice.payblis_ipn_received_at)?.toISOString() || null,
|
|
reason: STATUS_REASONS[invoice.status] || null
|
|
};
|
|
}
|
|
|
|
function serializeGift(invoice) {
|
|
if (!invoice || invoice.recipient_mode !== 'gift') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
giftToken: invoice.gift_token,
|
|
status: invoice.gift_sealed ? 'sealed' : 'unsealed',
|
|
invoiceStatus: invoice.status,
|
|
vipYears: parseNumber(invoice.vip_years),
|
|
durationLabel: buildDurationLabel(parseNumber(invoice.vip_years)),
|
|
createdAt: fromSqlDateTime(invoice.created_at)?.toISOString() || null,
|
|
unsealedAt: fromSqlDateTime(invoice.gift_unsealed_at)?.toISOString() || null,
|
|
vipKey: !invoice.gift_sealed && invoice.status === 'delivered' ? invoice.vip_key_value : null,
|
|
supportTelegramUrl: getSupportTelegramUrl()
|
|
};
|
|
}
|
|
|
|
async function listUserVipInvoices(pool, auth, options = {}) {
|
|
if (!auth?.userId || !auth?.userType) {
|
|
return [];
|
|
}
|
|
|
|
const limit = Math.min(100, Math.max(1, parseNumber(options.limit, 30)));
|
|
const [rows] = await pool.execute(
|
|
`SELECT * FROM vip_invoices
|
|
WHERE created_by_user_id = ?
|
|
AND created_by_user_type = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`,
|
|
[auth.userId, auth.userType, limit]
|
|
);
|
|
|
|
const normalizedRows = await Promise.all(
|
|
rows.map((invoice) => expireAwaitingInvoiceIfOverdue(pool, invoice))
|
|
);
|
|
|
|
return normalizedRows.map(serializePublicInvoice);
|
|
}
|
|
|
|
async function refreshInvoiceStatus(pool, invoiceInput, options = {}) {
|
|
const invoice = invoiceInput?.id ? invoiceInput : await fetchInvoiceById(pool, invoiceInput);
|
|
if (!invoice) {
|
|
return null;
|
|
}
|
|
|
|
if (FINAL_STATUSES.has(invoice.status)) {
|
|
return invoice;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const nextCheckAt = invoice.next_check_at ? new Date(invoice.next_check_at).getTime() : 0;
|
|
const force = options.force === true;
|
|
|
|
if (!force && nextCheckAt > now) {
|
|
return invoice;
|
|
}
|
|
|
|
const paymentMethod = getInvoicePaymentMethod(invoice);
|
|
const rawPaymentMethod = String(invoice.payment_method || '').trim().toLowerCase();
|
|
|
|
if (isPayblisPaymentMethod(paymentMethod)) {
|
|
try {
|
|
const status = await require('./vipPayblis').fetchPayblisTransactionStatus(
|
|
invoice.payblis_ref_order,
|
|
{}
|
|
);
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
invoice.id,
|
|
'invoice_payblis_status_refreshed',
|
|
'Statut Payblis rafraîchi via REST API.',
|
|
{ refOrder: invoice.payblis_ref_order, status },
|
|
options.actorType || 'system',
|
|
options.actorId || null
|
|
);
|
|
const mapped = require('./vipPayblis').normalizeIpnStatus(
|
|
status?.data?.status || status?.status || status?.event
|
|
);
|
|
if (mapped === 'success' && invoice.status === 'awaiting_payment') {
|
|
await markPayblisInvoicePaidFromRest(pool, invoice, status);
|
|
}
|
|
} catch (error) {
|
|
// Non-fatal — backup poll, don't escalate.
|
|
}
|
|
return fetchInvoiceById(pool, invoice.id);
|
|
}
|
|
|
|
if (isPaygatePaymentMethod(paymentMethod)) {
|
|
// Fallback officiel PayGate: si le callback n'est jamais arrivé, on
|
|
// interroge payment-status.php avec l'ipn_token stocké à la création.
|
|
// Couvre aussi les invoices expirées côté Movix mais payées côté PayGate.
|
|
if (invoice.status !== 'paid' && invoice.paygate_ipn_token) {
|
|
try {
|
|
const statusPayload = await fetchPaygatePaymentStatus(invoice.paygate_ipn_token);
|
|
if (String(statusPayload?.status || '').trim().toLowerCase() === 'paid') {
|
|
return markPaygateInvoicePaidFromStatusPoll(pool, invoice, statusPayload, {
|
|
actorType: options.actorType || 'system',
|
|
actorId: options.actorId || null
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Non-fatal — poll de secours, le callback reste la voie principale.
|
|
}
|
|
}
|
|
|
|
const isExpired = fromSqlDateTime(invoice.expires_at)?.getTime() < Date.now();
|
|
const shouldExpire = invoice.status === 'awaiting_payment' && isExpired;
|
|
|
|
if (shouldExpire) {
|
|
await pool.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'expired',
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[invoice.id]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
invoice.id,
|
|
'invoice_status_updated',
|
|
STATUS_REASONS.expired,
|
|
{
|
|
from: invoice.status,
|
|
to: 'expired'
|
|
},
|
|
options.actorType || 'system',
|
|
options.actorId || null
|
|
);
|
|
} else {
|
|
// 60s (pas 25s): payment-status.php est doc'd "casual basis only",
|
|
// on espace les polls PayGate pour éviter le rate limit.
|
|
await pool.execute(
|
|
'UPDATE vip_invoices SET next_check_at = DATE_ADD(NOW(), INTERVAL 60 SECOND), updated_at = NOW() WHERE id = ?',
|
|
[invoice.id]
|
|
);
|
|
}
|
|
|
|
const refreshedInvoice = await fetchInvoiceById(pool, invoice.id);
|
|
if (refreshedInvoice?.status === 'paid') {
|
|
return deliverInvoiceIfReady(
|
|
pool,
|
|
refreshedInvoice.id,
|
|
options.actorType || 'system',
|
|
options.actorId || null,
|
|
'paygate_callback'
|
|
);
|
|
}
|
|
|
|
return refreshedInvoice;
|
|
}
|
|
|
|
if (isAutoBuyPaymentMethod(rawPaymentMethod)) {
|
|
const isExpired = fromSqlDateTime(invoice.expires_at)?.getTime() < Date.now();
|
|
|
|
if (invoice.status === 'paid') {
|
|
return deliverInvoiceIfReady(
|
|
pool,
|
|
invoice.id,
|
|
options.actorType || 'system',
|
|
options.actorId || null,
|
|
'legacy_autobuy_paid_invoice'
|
|
);
|
|
}
|
|
|
|
if (invoice.status === 'awaiting_payment' && isExpired) {
|
|
await pool.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'expired',
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[invoice.id]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
invoice.id,
|
|
'invoice_status_updated',
|
|
STATUS_REASONS.expired,
|
|
{
|
|
from: invoice.status,
|
|
to: 'expired'
|
|
},
|
|
options.actorType || 'system',
|
|
options.actorId || null
|
|
);
|
|
} else {
|
|
await pool.execute(
|
|
'UPDATE vip_invoices SET next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND), updated_at = NOW() WHERE id = ?',
|
|
[invoice.id]
|
|
);
|
|
}
|
|
|
|
const refreshedInvoice = await fetchInvoiceById(pool, invoice.id);
|
|
return refreshedInvoice;
|
|
}
|
|
|
|
const addressActivity = await fetchAddressActivity(invoice.coin, invoice.payment_address, {
|
|
ignoreBefore: invoice.created_at
|
|
});
|
|
const newStatus = determineInvoiceStatus(invoice, addressActivity);
|
|
const amountReceived = roundCrypto(addressActivity.totalReceived);
|
|
const txHash = addressActivity.txHashes[0] || invoice.tx_hash || null;
|
|
const shouldMarkPaidAt = newStatus === 'paid' && !invoice.paid_at;
|
|
const hasChanged = newStatus !== invoice.status
|
|
|| roundCrypto(parseNumber(invoice.amount_crypto_received)) !== amountReceived
|
|
|| parseNumber(invoice.confirmations) !== addressActivity.confirmations
|
|
|| txHash !== (invoice.tx_hash || null);
|
|
|
|
if (hasChanged) {
|
|
await pool.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = ?,
|
|
amount_crypto_received = ?,
|
|
confirmations = ?,
|
|
tx_hash = ?,
|
|
paid_at = CASE WHEN ? THEN COALESCE(paid_at, NOW()) ELSE paid_at END,
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
newStatus,
|
|
amountReceived,
|
|
addressActivity.confirmations,
|
|
txHash,
|
|
shouldMarkPaidAt ? 1 : 0,
|
|
invoice.id
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
invoice.id,
|
|
'invoice_status_updated',
|
|
STATUS_REASONS[newStatus] || 'Statut d\'invoice mis à jour.',
|
|
{
|
|
from: invoice.status,
|
|
to: newStatus,
|
|
amountCryptoReceived: amountReceived,
|
|
confirmations: addressActivity.confirmations,
|
|
txHash
|
|
},
|
|
options.actorType || 'system',
|
|
options.actorId || null
|
|
);
|
|
} else {
|
|
await pool.execute(
|
|
'UPDATE vip_invoices SET next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND), updated_at = NOW() WHERE id = ?',
|
|
[invoice.id]
|
|
);
|
|
}
|
|
|
|
const refreshedInvoice = await fetchInvoiceById(pool, invoice.id);
|
|
if (refreshedInvoice?.status === 'paid') {
|
|
return deliverInvoiceIfReady(
|
|
pool,
|
|
refreshedInvoice.id,
|
|
options.actorType || 'system',
|
|
options.actorId || null,
|
|
'blockchain_confirmed'
|
|
);
|
|
}
|
|
|
|
return refreshedInvoice;
|
|
}
|
|
|
|
async function listVipInvoices(pool, options = {}) {
|
|
const page = Math.max(1, parseNumber(options.page, 1));
|
|
const limit = Math.min(100, Math.max(1, parseNumber(options.limit, 30)));
|
|
const offset = (page - 1) * limit;
|
|
const conditions = [];
|
|
const params = [];
|
|
|
|
if (options.status) {
|
|
conditions.push('status = ?');
|
|
params.push(options.status);
|
|
}
|
|
|
|
if (options.search) {
|
|
conditions.push(`(
|
|
public_id LIKE ?
|
|
OR payment_address LIKE ?
|
|
OR COALESCE(tx_hash, '') LIKE ?
|
|
OR COALESCE(vip_key_value, '') LIKE ?
|
|
OR COALESCE(gift_token, '') LIKE ?
|
|
OR COALESCE(paygate_tracking_address, '') LIKE ?
|
|
OR COALESCE(paygate_paid_txid, '') LIKE ?
|
|
OR COALESCE(paygate_payer_email, '') LIKE ?
|
|
OR COALESCE(autobuy_order_id, '') LIKE ?
|
|
OR COALESCE(autobuy_product_id, '') LIKE ?
|
|
OR COALESCE(autobuy_email, '') LIKE ?
|
|
)`);
|
|
const searchTerm = `%${options.search}%`;
|
|
params.push(
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm,
|
|
searchTerm
|
|
);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const [rows] = await pool.execute(
|
|
`SELECT * FROM vip_invoices ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
[...params, limit, offset]
|
|
);
|
|
const [countRows] = await pool.execute(
|
|
`SELECT COUNT(*) AS total FROM vip_invoices ${whereClause}`,
|
|
params
|
|
);
|
|
|
|
const total = parseNumber(countRows[0]?.total, 0);
|
|
|
|
return {
|
|
items: rows.map(serializeAdminInvoice),
|
|
total,
|
|
page,
|
|
limit,
|
|
hasMore: offset + rows.length < total
|
|
};
|
|
}
|
|
|
|
async function getVipInvoiceDetails(pool, invoiceId) {
|
|
const invoice = await fetchInvoiceById(pool, invoiceId);
|
|
if (!invoice) {
|
|
return null;
|
|
}
|
|
|
|
const [events] = await pool.execute(
|
|
'SELECT * FROM vip_invoice_events WHERE invoice_id = ? ORDER BY created_at DESC, id DESC',
|
|
[invoiceId]
|
|
);
|
|
|
|
return {
|
|
invoice: serializeAdminInvoice(invoice),
|
|
events: events.map((event) => ({
|
|
id: event.id,
|
|
eventType: event.event_type,
|
|
actorType: event.actor_type,
|
|
actorId: event.actor_id,
|
|
message: event.message,
|
|
payload: event.payload_json ? safeJsonParse(event.payload_json) : null,
|
|
createdAt: fromSqlDateTime(event.created_at)?.toISOString() || null
|
|
}))
|
|
};
|
|
}
|
|
|
|
function createVipInvoiceError(message, statusCode = 400) {
|
|
const error = new Error(message);
|
|
error.statusCode = statusCode;
|
|
return error;
|
|
}
|
|
|
|
async function createCryptoVipInvoice(pool, payload, context = {}) {
|
|
const pack = payload.pack;
|
|
const coin = payload.coin;
|
|
const recipientMode = payload.recipientMode;
|
|
|
|
if (!pack || !coin || !recipientMode) {
|
|
throw createVipInvoiceError('Paramètres d\'invoice invalides');
|
|
}
|
|
|
|
const pricing = await buildPricingSnapshot(coin, pack.amountEur);
|
|
const derivationIndex = await reserveDerivationIndex(pool, coin);
|
|
const derivedAddress = derivePaymentAddress(coin, derivationIndex);
|
|
const publicId = `inv_${crypto.randomBytes(12).toString('hex')}`;
|
|
const giftToken = recipientMode === 'gift' ? `gift_${crypto.randomBytes(16).toString('hex')}` : null;
|
|
const expiresAt = new Date(Date.now() + (Math.max(1, parseNumber(process.env.VIP_INVOICE_EXPIRATION_MINUTES, DEFAULT_EXPIRATION_MINUTES)) * 60 * 1000));
|
|
const qrPayload = buildQrPayload(coin, derivedAddress.address, pricing.amountCryptoExpected);
|
|
const createdIpHash = hashIp(context.ipAddress);
|
|
const auth = context.auth || null;
|
|
|
|
const [insertResult] = await pool.execute(
|
|
`INSERT INTO vip_invoices
|
|
(public_id, payment_method, status, coin, pack_eur, amount_eur, amount_usd, amount_crypto_expected,
|
|
amount_crypto_received, vip_years, recipient_mode, payment_address, address_type,
|
|
derivation_index, confirmations, required_confirmations, tx_hash, qr_payload, gift_token,
|
|
gift_sealed, gift_unsealed_at, gift_unseal_count, gift_unsealed_by_ip_hash,
|
|
vip_key_value, created_by_user_id, created_by_user_type, created_by_session_id,
|
|
created_ip_hash, expires_at, next_check_at, created_at, updated_at)
|
|
VALUES (?, ?, 'awaiting_payment', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, 1, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
|
|
[
|
|
publicId,
|
|
coin,
|
|
coin,
|
|
pack.amountEur,
|
|
pricing.amountEur,
|
|
pricing.amountUsd,
|
|
pricing.amountCryptoExpected,
|
|
pack.vipYears,
|
|
recipientMode,
|
|
derivedAddress.address,
|
|
derivedAddress.addressType,
|
|
derivationIndex,
|
|
getRequiredConfirmations(coin),
|
|
qrPayload,
|
|
giftToken,
|
|
auth?.userId || null,
|
|
auth?.userType || null,
|
|
auth?.sessionId || null,
|
|
createdIpHash,
|
|
toSqlDateTime(expiresAt)
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
insertResult.insertId,
|
|
'invoice_created',
|
|
'Invoice VIP créée.',
|
|
{
|
|
paymentMethod: coin,
|
|
coin,
|
|
packEur: pack.amountEur,
|
|
amountCryptoExpected: pricing.amountCryptoExpected,
|
|
vipYears: pack.vipYears,
|
|
recipientMode,
|
|
derivationIndex,
|
|
paymentAddress: derivedAddress.address
|
|
},
|
|
auth ? 'user' : 'guest',
|
|
auth?.userId || null
|
|
);
|
|
|
|
return fetchInvoiceById(pool, insertResult.insertId);
|
|
}
|
|
|
|
async function createPaygateVipInvoice(pool, payload, context = {}) {
|
|
const pack = payload.pack;
|
|
const recipientMode = payload.recipientMode;
|
|
const normalizedEmail = normalizeEmailAddress(payload.payerEmail);
|
|
|
|
if (!pack || !recipientMode || !normalizedEmail) {
|
|
throw createVipInvoiceError('Adresse email invalide pour PayGate');
|
|
}
|
|
|
|
const publicId = `inv_${crypto.randomBytes(12).toString('hex')}`;
|
|
const callbackNonce = crypto.randomBytes(24).toString('hex');
|
|
const callbackUrl = buildPaygateCallbackUrl(publicId, callbackNonce, context);
|
|
const checkoutAmountEur = getPaygateCheckoutAmountEur(pack.amountEur);
|
|
const amountUsd = await fetchPaygateConvertedUsd(checkoutAmountEur, 'eur');
|
|
const paygateWallet = await fetchPaygateTemporaryWallet(callbackUrl);
|
|
const expiresAt = new Date(Date.now() + (Math.max(1, parseNumber(process.env.VIP_INVOICE_EXPIRATION_MINUTES, DEFAULT_EXPIRATION_MINUTES)) * 60 * 1000));
|
|
const giftToken = recipientMode === 'gift' ? `gift_${crypto.randomBytes(16).toString('hex')}` : null;
|
|
const createdIpHash = hashIp(context.ipAddress);
|
|
const auth = context.auth || null;
|
|
const checkoutUrl = buildPaygateCheckoutUrl({
|
|
trackingAddress: paygateWallet.trackingAddress,
|
|
amountEur: checkoutAmountEur,
|
|
payerEmail: normalizedEmail,
|
|
branding: getPaygateBranding()
|
|
});
|
|
|
|
const [insertResult] = await pool.execute(
|
|
`INSERT INTO vip_invoices
|
|
(public_id, payment_method, status, coin, pack_eur, amount_eur, amount_usd, amount_crypto_expected,
|
|
amount_crypto_received, vip_years, recipient_mode, payment_address, address_type,
|
|
derivation_index, confirmations, required_confirmations, tx_hash, qr_payload, gift_token,
|
|
gift_sealed, gift_unsealed_at, gift_unseal_count, gift_unsealed_by_ip_hash,
|
|
vip_key_value, created_by_user_id, created_by_user_type, created_by_session_id,
|
|
created_ip_hash, expires_at, next_check_at, paygate_tracking_address, paygate_temporary_wallet_address,
|
|
paygate_callback_url, paygate_callback_nonce, paygate_checkout_url, paygate_payer_email,
|
|
paygate_ipn_token, paygate_paid_coin, paygate_paid_value, paygate_paid_txid, created_at, updated_at)
|
|
VALUES (?, 'paygate_hosted', 'awaiting_payment', NULL, ?, ?, ?, 0, 0, ?, ?, ?, NULL,
|
|
-1, 0, 0, NULL, '', ?, 1, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, NOW(),
|
|
?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NOW(), NOW())`,
|
|
[
|
|
publicId,
|
|
pack.amountEur,
|
|
checkoutAmountEur,
|
|
amountUsd,
|
|
pack.vipYears,
|
|
recipientMode,
|
|
paygateWallet.trackingAddress,
|
|
giftToken,
|
|
auth?.userId || null,
|
|
auth?.userType || null,
|
|
auth?.sessionId || null,
|
|
createdIpHash,
|
|
toSqlDateTime(expiresAt),
|
|
paygateWallet.trackingAddress,
|
|
paygateWallet.temporaryWalletAddress,
|
|
paygateWallet.callbackUrl || callbackUrl,
|
|
callbackNonce,
|
|
checkoutUrl,
|
|
normalizedEmail,
|
|
paygateWallet.ipnToken
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
insertResult.insertId,
|
|
'invoice_created',
|
|
'Invoice VIP PayGate créée.',
|
|
{
|
|
paymentMethod: 'paygate_hosted',
|
|
packEur: pack.amountEur,
|
|
checkoutAmountEur,
|
|
amountUsd,
|
|
vipYears: pack.vipYears,
|
|
recipientMode,
|
|
payerEmail: normalizedEmail,
|
|
trackingAddress: paygateWallet.trackingAddress
|
|
},
|
|
auth ? 'user' : 'guest',
|
|
auth?.userId || null
|
|
);
|
|
|
|
return fetchInvoiceById(pool, insertResult.insertId);
|
|
}
|
|
|
|
async function createPayblisVipInvoice(pool, { pack, recipientMode, payerEmail, auth }, context) {
|
|
const payblisMod = require('./vipPayblis');
|
|
if (!payblisMod.isPayblisEnabled()) {
|
|
throw createVipInvoiceError('Paiement Payblis indisponible pour le moment');
|
|
}
|
|
|
|
const merchantKey = payblisMod.getPayblisMerchantKey();
|
|
const secretKey = payblisMod.getPayblisSecretKey();
|
|
if (!merchantKey || !secretKey) {
|
|
throw createVipInvoiceError('Configuration Payblis manquante');
|
|
}
|
|
|
|
const normalizedEmail = normalizeEmailAddress(payerEmail) || normalizeEmailAddress(auth?.email);
|
|
if (!normalizedEmail) {
|
|
throw createVipInvoiceError('Email requis pour Payblis');
|
|
}
|
|
|
|
const ipnBaseUrl = payblisMod.getPayblisIpnBaseUrl(context);
|
|
if (!ipnBaseUrl) {
|
|
throw createVipInvoiceError('VIP Payblis callback URL introuvable');
|
|
}
|
|
const frontendBaseUrl = payblisMod.getPayblisFrontendBaseUrl();
|
|
if (!frontendBaseUrl) {
|
|
throw createVipInvoiceError('FRONTEND_BASE_URL manquant');
|
|
}
|
|
|
|
const publicId = `inv_${crypto.randomBytes(12).toString('hex')}`;
|
|
const refOrder = `mvx_${publicId}`;
|
|
const customerName = payblisMod.derivePseudo({ auth, payerEmail: normalizedEmail });
|
|
const createdIpHash = hashIp(context.ipAddress);
|
|
const expiresAt = new Date(Date.now() + DEFAULT_EXPIRATION_MINUTES * 60 * 1000);
|
|
const amountUsd = await fetchPaygateConvertedUsd(pack.amountEur, 'eur').catch(() => roundFiat(pack.amountEur * 1.08));
|
|
const giftToken = recipientMode === 'gift' ? crypto.randomBytes(16).toString('hex') : null;
|
|
|
|
const payload = payblisMod.buildPayblisPayload({
|
|
merchantKey,
|
|
secretKey,
|
|
sandbox: payblisMod.isPayblisSandbox(),
|
|
amountEur: pack.amountEur,
|
|
productName: `Movix VIP ${buildDurationLabel(pack.vipYears)}`,
|
|
refOrder,
|
|
payerEmail: normalizedEmail,
|
|
customerName,
|
|
countryCode: 'FR',
|
|
userIp: context.ipAddress || '',
|
|
lang: context.locale === 'en' ? 'en' : 'fr',
|
|
storeName: payblisMod.getPayblisStoreName(),
|
|
urlOK: `${frontendBaseUrl}/vip/invoice/${encodeURIComponent(publicId)}?payblis=ok`,
|
|
urlKO: `${frontendBaseUrl}/vip/invoice/${encodeURIComponent(publicId)}?payblis=ko`,
|
|
ipnURL: `${ipnBaseUrl}/api/vip/payblis/ipn?publicId=${encodeURIComponent(publicId)}`
|
|
});
|
|
const checkoutUrl = payblisMod.buildPayblisCheckoutUrl(payload);
|
|
|
|
const [insertResult] = await pool.execute(
|
|
`INSERT INTO vip_invoices
|
|
(public_id, payment_method, status, coin, pack_eur, amount_eur, amount_usd, amount_crypto_expected,
|
|
amount_crypto_received, vip_years, recipient_mode, payment_address, address_type, derivation_index,
|
|
confirmations, required_confirmations, tx_hash, qr_payload, gift_token, gift_sealed, gift_unsealed_at,
|
|
gift_unseal_count, gift_unsealed_by_ip_hash, vip_key_value, created_by_user_id, created_by_user_type,
|
|
created_by_session_id, created_ip_hash, expires_at, next_check_at,
|
|
payblis_ref_order, payblis_checkout_url, payblis_payer_email, payblis_customer_name,
|
|
created_at, updated_at)
|
|
VALUES (?, 'payblis', 'awaiting_payment', NULL, ?, ?, ?, NULL, 0, ?, ?, NULL, NULL, NULL,
|
|
0, 0, NULL, NULL, ?, 1, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, NOW(),
|
|
?, ?, ?, ?, NOW(), NOW())`,
|
|
[
|
|
publicId,
|
|
pack.amountEur,
|
|
pack.amountEur,
|
|
amountUsd,
|
|
pack.vipYears,
|
|
recipientMode,
|
|
giftToken,
|
|
auth?.userId || null,
|
|
auth?.userType || null,
|
|
auth?.sessionId || null,
|
|
createdIpHash,
|
|
toSqlDateTime(expiresAt),
|
|
refOrder,
|
|
checkoutUrl,
|
|
normalizedEmail,
|
|
customerName
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
insertResult.insertId,
|
|
'invoice_payblis_created',
|
|
'Invoice VIP Payblis créée.',
|
|
{
|
|
paymentMethod: 'payblis',
|
|
packEur: pack.amountEur,
|
|
amountUsd,
|
|
vipYears: pack.vipYears,
|
|
recipientMode,
|
|
refOrder,
|
|
payerEmail: normalizedEmail,
|
|
customerName,
|
|
sandbox: payblisMod.isPayblisSandbox()
|
|
},
|
|
auth ? 'user' : 'guest',
|
|
auth?.userId || null
|
|
);
|
|
|
|
return fetchInvoiceById(pool, insertResult.insertId);
|
|
}
|
|
|
|
async function handlePaygateCallback(pool, payload = {}) {
|
|
const publicId = String(payload.publicId || payload.public_id || '').trim();
|
|
const callbackNonce = String(payload.nonce || '').trim();
|
|
const paidCoin = String(payload.coin || '').trim().toLowerCase() || null;
|
|
const paidTxid = String(payload.txid_out || payload.txid || '').trim() || null;
|
|
const rawPaidValue = payload.value_coin;
|
|
|
|
if (!publicId) {
|
|
throw createVipInvoiceError('publicId PayGate manquant');
|
|
}
|
|
if (!callbackNonce) {
|
|
throw createVipInvoiceError('nonce PayGate manquant', 403);
|
|
}
|
|
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE public_id = ? FOR UPDATE',
|
|
[publicId]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
throw createVipInvoiceError('Invoice PayGate introuvable', 404);
|
|
}
|
|
|
|
const invoice = rows[0];
|
|
if (!isPaygatePaymentMethod(getInvoicePaymentMethod(invoice))) {
|
|
throw createVipInvoiceError('Cette invoice n\'utilise pas PayGate', 409);
|
|
}
|
|
if (!invoice.paygate_callback_nonce || invoice.paygate_callback_nonce !== callbackNonce) {
|
|
throw createVipInvoiceError('Nonce PayGate invalide', 403);
|
|
}
|
|
|
|
if (invoice.status === 'delivered' || invoice.status === 'cancelled') {
|
|
await connection.commit();
|
|
return invoice;
|
|
}
|
|
|
|
const paidValue = await normalizePaygatePaidUsdValue(paidCoin, rawPaidValue);
|
|
const minimumPaidAmount = roundFiat(parseNumber(invoice.amount_usd) * getPaygateMinPaidRatio());
|
|
const nextStatus = paidValue >= minimumPaidAmount && paidValue > 0 ? 'paid' : 'partial_payment';
|
|
const shouldMarkPaidAt = nextStatus === 'paid' && !invoice.paid_at;
|
|
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = ?,
|
|
tx_hash = COALESCE(?, tx_hash),
|
|
paid_at = CASE WHEN ? THEN COALESCE(paid_at, NOW()) ELSE paid_at END,
|
|
paygate_paid_coin = ?,
|
|
paygate_paid_value = ?,
|
|
paygate_paid_txid = ?,
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
nextStatus,
|
|
paidTxid,
|
|
shouldMarkPaidAt ? 1 : 0,
|
|
paidCoin,
|
|
paidValue,
|
|
paidTxid,
|
|
invoice.id
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_paygate_callback',
|
|
nextStatus === 'paid'
|
|
? 'Callback PayGate validé, invoice marquée comme payée.'
|
|
: 'Callback PayGate reçu avec montant insuffisant.',
|
|
{
|
|
previousStatus: invoice.status,
|
|
nextStatus,
|
|
paidCoin,
|
|
paidValue,
|
|
paidTxid,
|
|
minimumPaidAmount
|
|
},
|
|
'gateway',
|
|
'paygate'
|
|
);
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
const refreshedInvoice = await fetchInvoiceByPublicId(pool, publicId);
|
|
if (refreshedInvoice?.status === 'paid') {
|
|
return deliverInvoiceIfReady(pool, refreshedInvoice.id, 'gateway', 'paygate', 'paygate_callback');
|
|
}
|
|
|
|
return refreshedInvoice;
|
|
}
|
|
|
|
// Marque une invoice PayGate payée depuis un poll payment-status.php confirmé.
|
|
// Même seuil (min ratio USD) et mêmes colonnes que handlePaygateCallback, mais
|
|
// sans nonce : l'ipn_token stocké en DB authentifie déjà la requête sortante.
|
|
async function markPaygateInvoicePaidFromStatusPoll(pool, invoiceInput, statusPayload, options = {}) {
|
|
const baseInvoice = invoiceInput?.id ? invoiceInput : await fetchInvoiceById(pool, invoiceInput);
|
|
if (!baseInvoice) return null;
|
|
if (FINAL_STATUSES.has(baseInvoice.status)) return baseInvoice;
|
|
if (!isPaygatePaymentMethod(getInvoicePaymentMethod(baseInvoice))) return baseInvoice;
|
|
|
|
const paidCoin = String(statusPayload?.coin || '').trim().toLowerCase() || null;
|
|
const paidTxid = String(statusPayload?.txid_out || '').trim() || null;
|
|
const paidValue = await normalizePaygatePaidUsdValue(paidCoin, statusPayload?.value_coin);
|
|
const minimumPaidAmount = roundFiat(parseNumber(baseInvoice.amount_usd) * getPaygateMinPaidRatio());
|
|
const nextStatus = paidValue >= minimumPaidAmount && paidValue > 0 ? 'paid' : 'partial_payment';
|
|
|
|
// Idempotence: poll répété sans changement → pas d'UPDATE ni d'event spam.
|
|
if (
|
|
baseInvoice.status === nextStatus
|
|
&& roundFiat(parseNumber(baseInvoice.paygate_paid_value)) === paidValue
|
|
&& (paidTxid || null) === (baseInvoice.paygate_paid_txid || null)
|
|
) {
|
|
return baseInvoice;
|
|
}
|
|
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE',
|
|
[baseInvoice.id]
|
|
);
|
|
const invoice = rows[0];
|
|
if (!invoice || FINAL_STATUSES.has(invoice.status)) {
|
|
await connection.commit();
|
|
return invoice || null;
|
|
}
|
|
|
|
const shouldMarkPaidAt = nextStatus === 'paid' && !invoice.paid_at;
|
|
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = ?,
|
|
tx_hash = COALESCE(?, tx_hash),
|
|
paid_at = CASE WHEN ? THEN COALESCE(paid_at, NOW()) ELSE paid_at END,
|
|
paygate_paid_coin = COALESCE(?, paygate_paid_coin),
|
|
paygate_paid_value = ?,
|
|
paygate_paid_txid = COALESCE(?, paygate_paid_txid),
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
nextStatus,
|
|
paidTxid,
|
|
shouldMarkPaidAt ? 1 : 0,
|
|
paidCoin,
|
|
paidValue,
|
|
paidTxid,
|
|
invoice.id
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_paygate_status_poll',
|
|
nextStatus === 'paid'
|
|
? 'Paiement PayGate confirmé via payment-status.php (callback manqué).'
|
|
: 'Poll payment-status.php PayGate: montant insuffisant.',
|
|
{
|
|
previousStatus: invoice.status,
|
|
nextStatus,
|
|
paidCoin,
|
|
paidValue,
|
|
paidTxid,
|
|
minimumPaidAmount
|
|
},
|
|
options.actorType || 'system',
|
|
options.actorId || null
|
|
);
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
const refreshed = await fetchInvoiceById(pool, baseInvoice.id);
|
|
if (refreshed?.status === 'paid') {
|
|
return deliverInvoiceIfReady(
|
|
pool,
|
|
refreshed.id,
|
|
options.actorType || 'system',
|
|
options.actorId || null,
|
|
'paygate_status_poll'
|
|
);
|
|
}
|
|
|
|
return refreshed;
|
|
}
|
|
|
|
async function handlePayblisIpn(pool, { publicId, body, sourceIp, headerSignature }) {
|
|
const payblisMod = require('./vipPayblis');
|
|
const secretKey = payblisMod.getPayblisSecretKey();
|
|
|
|
if (!secretKey) {
|
|
const error = createVipInvoiceError('Secret Key Payblis non configurée', 500);
|
|
error.silent = true;
|
|
throw error;
|
|
}
|
|
|
|
const safePublicId = String(publicId || '').trim();
|
|
if (!safePublicId) {
|
|
throw createVipInvoiceError('publicId Payblis manquant', 400);
|
|
}
|
|
|
|
const { normalized, raw } = payblisMod.normalizeIpnPayload(body);
|
|
const headerSig = typeof headerSignature === 'string' ? headerSignature.trim() : '';
|
|
const providedSignature = headerSig || normalized.signature || null;
|
|
const signatureSource = headerSig
|
|
? 'header'
|
|
: (normalized.signature ? 'body' : 'none');
|
|
const signatureValid = payblisMod.verifyPayblisIpnSignature(raw, providedSignature, secretKey);
|
|
|
|
if (!signatureValid) {
|
|
const [invoiceRows] = await pool.execute(
|
|
"SELECT id FROM vip_invoices WHERE public_id = ? AND payment_method = 'payblis' LIMIT 1",
|
|
[safePublicId]
|
|
);
|
|
const invoiceId = invoiceRows[0]?.id || null;
|
|
if (invoiceId) {
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
invoiceId,
|
|
'invoice_payblis_ipn_invalid_signature',
|
|
'Signature Payblis invalide rejetée.',
|
|
{
|
|
sourceIp,
|
|
providedSignature: providedSignature || null,
|
|
signatureSource,
|
|
keys: Object.keys(normalized).sort()
|
|
},
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
}
|
|
throw createVipInvoiceError('Signature Payblis invalide', 401);
|
|
}
|
|
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
"SELECT * FROM vip_invoices WHERE public_id = ? FOR UPDATE",
|
|
[safePublicId]
|
|
);
|
|
if (rows.length === 0) {
|
|
throw createVipInvoiceError('Invoice Payblis introuvable', 404);
|
|
}
|
|
const invoice = rows[0];
|
|
if (!isPayblisPaymentMethod(getInvoicePaymentMethod(invoice))) {
|
|
throw createVipInvoiceError('Cette invoice n\'utilise pas Payblis', 409);
|
|
}
|
|
|
|
const expectedRefOrder = invoice.payblis_ref_order || `mvx_${safePublicId}`;
|
|
if (String(normalized.merchant_reference || normalized.RefOrder || '') !== expectedRefOrder) {
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_payblis_ipn_invalid_signature',
|
|
'Ref Order Payblis ne correspond pas.',
|
|
{
|
|
expected: expectedRefOrder,
|
|
received: normalized.merchant_reference || normalized.RefOrder || null
|
|
},
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
throw createVipInvoiceError('RefOrder Payblis incohérent', 409);
|
|
}
|
|
|
|
const reportedAmount = parseNumber(normalized.amount);
|
|
const invoiceAmount = parseNumber(invoice.amount_eur);
|
|
const reportedStatus = payblisMod.normalizeIpnStatus(normalized.status || normalized.event);
|
|
const priorTransactionId = invoice.payblis_transaction_id;
|
|
const receivedTransactionId = String(normalized.transaction_id || '').trim() || null;
|
|
|
|
if (priorTransactionId && receivedTransactionId && priorTransactionId !== receivedTransactionId) {
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_payblis_tx_mismatch',
|
|
'Transaction_id Payblis diffère d\'un IPN précédent.',
|
|
{ priorTransactionId, receivedTransactionId },
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
throw createVipInvoiceError('Transaction Payblis incohérente', 409);
|
|
}
|
|
|
|
// Duplicate: invoice already delivered, acknowledge idempotently.
|
|
if (invoice.status === 'delivered') {
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_payblis_ipn_duplicate',
|
|
'IPN Payblis reçu pour une invoice déjà livrée.',
|
|
{ reportedStatus, receivedTransactionId },
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
await connection.commit();
|
|
return fetchInvoiceById(pool, invoice.id);
|
|
}
|
|
|
|
// Cancelled invoices: log but don't mutate.
|
|
if (invoice.status === 'cancelled') {
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_payblis_ipn',
|
|
'IPN Payblis reçu pour une invoice annulée (ignoré).',
|
|
{ reportedStatus, reportedAmount, receivedTransactionId, raw },
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
await connection.commit();
|
|
return fetchInvoiceById(pool, invoice.id);
|
|
}
|
|
|
|
let nextStatus = invoice.status;
|
|
if (reportedStatus === 'success') {
|
|
nextStatus = reportedAmount > 0 && reportedAmount + 1e-6 >= invoiceAmount
|
|
? 'paid'
|
|
: 'partial_payment';
|
|
} else if (reportedStatus === 'failed') {
|
|
nextStatus = invoice.status; // stay in place, let user retry
|
|
}
|
|
|
|
const rawJson = JSON.stringify(raw).slice(0, 65000);
|
|
const shouldMarkPaidAt = nextStatus === 'paid' && !invoice.paid_at;
|
|
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = ?,
|
|
paid_at = CASE WHEN ? THEN COALESCE(paid_at, NOW()) ELSE paid_at END,
|
|
payblis_method = COALESCE(?, payblis_method),
|
|
payblis_transaction_id = COALESCE(?, payblis_transaction_id),
|
|
payblis_paid_amount = ?,
|
|
payblis_paid_currency = COALESCE(NULLIF(?, ''), payblis_paid_currency),
|
|
payblis_ipn_received_at = COALESCE(payblis_ipn_received_at, NOW()),
|
|
payblis_ipn_raw_payload = ?,
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
nextStatus,
|
|
shouldMarkPaidAt ? 1 : 0,
|
|
normalized.method || null,
|
|
receivedTransactionId,
|
|
reportedAmount || null,
|
|
String(normalized.currency || ''),
|
|
rawJson,
|
|
invoice.id
|
|
]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
reportedStatus === 'failed' ? 'invoice_payblis_ipn_failed' : 'invoice_payblis_ipn',
|
|
reportedStatus === 'success'
|
|
? 'IPN Payblis validé, invoice marquée comme payée.'
|
|
: `IPN Payblis reçu (${reportedStatus}).`,
|
|
{
|
|
previousStatus: invoice.status,
|
|
nextStatus,
|
|
reportedStatus,
|
|
reportedAmount,
|
|
invoiceAmount,
|
|
receivedTransactionId,
|
|
method: normalized.method || null
|
|
},
|
|
'gateway',
|
|
'payblis'
|
|
);
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
const refreshed = await fetchInvoiceByPublicId(pool, safePublicId);
|
|
if (refreshed?.status === 'paid') {
|
|
return deliverInvoiceIfReady(pool, refreshed.id, 'gateway', 'payblis', 'payblis_ipn');
|
|
}
|
|
return refreshed;
|
|
}
|
|
|
|
// Marks a Payblis invoice as paid from a trusted REST `transaction-status` poll.
|
|
// The REST endpoint is authenticated via X-Merchant-Key + HTTPS — no IPN
|
|
// signature is involved (Payblis does not sign REST responses), so this path
|
|
// MUST NOT funnel through handlePayblisIpn (which would always reject).
|
|
async function markPayblisInvoicePaidFromRest(pool, invoiceInput, restPayload) {
|
|
const baseInvoice = invoiceInput?.id ? invoiceInput : await fetchInvoiceById(pool, invoiceInput);
|
|
if (!baseInvoice) return null;
|
|
if (FINAL_STATUSES.has(baseInvoice.status)) return baseInvoice;
|
|
if (!isPayblisPaymentMethod(getInvoicePaymentMethod(baseInvoice))) return baseInvoice;
|
|
|
|
const data = restPayload?.data || {};
|
|
const reportedRefOrder = String(data.reference || '').trim();
|
|
const expectedRefOrder = baseInvoice.payblis_ref_order || `mvx_${baseInvoice.public_id}`;
|
|
if (reportedRefOrder && reportedRefOrder !== expectedRefOrder) {
|
|
await logVipInvoiceEvent(
|
|
pool,
|
|
baseInvoice.id,
|
|
'invoice_payblis_rest_poll_mismatch',
|
|
'Poll REST Payblis: RefOrder ne correspond pas.',
|
|
{ expected: expectedRefOrder, received: reportedRefOrder },
|
|
'system',
|
|
'payblis-rest-poll'
|
|
);
|
|
return baseInvoice;
|
|
}
|
|
|
|
const restStatus = String(data.status || '').trim().toLowerCase();
|
|
const isPaid = restStatus === 'paid' || restStatus === 'success' || restStatus === 'completed';
|
|
if (!isPaid) return baseInvoice;
|
|
|
|
const reportedAmount = parseNumber(data.amount);
|
|
const invoiceAmount = parseNumber(baseInvoice.amount_eur);
|
|
const nextStatus = reportedAmount > 0 && reportedAmount + 1e-6 >= invoiceAmount
|
|
? 'paid'
|
|
: 'partial_payment';
|
|
|
|
if (baseInvoice.status === nextStatus) {
|
|
if (nextStatus === 'paid') {
|
|
return deliverInvoiceIfReady(pool, baseInvoice.id, 'system', 'payblis-rest-poll', 'payblis_rest_poll');
|
|
}
|
|
return baseInvoice;
|
|
}
|
|
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE',
|
|
[baseInvoice.id]
|
|
);
|
|
const invoice = rows[0];
|
|
if (!invoice) {
|
|
await connection.rollback();
|
|
return null;
|
|
}
|
|
if (FINAL_STATUSES.has(invoice.status) || invoice.status === nextStatus) {
|
|
await connection.commit();
|
|
if (invoice.status === 'paid') {
|
|
return deliverInvoiceIfReady(pool, invoice.id, 'system', 'payblis-rest-poll', 'payblis_rest_poll');
|
|
}
|
|
return invoice;
|
|
}
|
|
|
|
const rawJson = JSON.stringify(restPayload || {}).slice(0, 65000);
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = ?,
|
|
paid_at = COALESCE(paid_at, NOW()),
|
|
payblis_paid_amount = ?,
|
|
payblis_paid_currency = COALESCE(NULLIF(payblis_paid_currency, ''), 'EUR'),
|
|
payblis_ipn_received_at = COALESCE(payblis_ipn_received_at, NOW()),
|
|
payblis_ipn_raw_payload = COALESCE(payblis_ipn_raw_payload, ?),
|
|
next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[nextStatus, reportedAmount || null, rawJson, invoice.id]
|
|
);
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'invoice_payblis_paid_via_rest_poll',
|
|
'Paiement Payblis confirmé via poll REST (X-Merchant-Key auth, sans IPN).',
|
|
{
|
|
previousStatus: invoice.status,
|
|
nextStatus,
|
|
reportedAmount,
|
|
invoiceAmount,
|
|
reference: reportedRefOrder || expectedRefOrder
|
|
},
|
|
'system',
|
|
'payblis-rest-poll'
|
|
);
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
const refreshed = await fetchInvoiceById(pool, baseInvoice.id);
|
|
if (refreshed?.status === 'paid') {
|
|
return deliverInvoiceIfReady(pool, refreshed.id, 'system', 'payblis-rest-poll', 'payblis_rest_poll');
|
|
}
|
|
return refreshed;
|
|
}
|
|
|
|
async function createVipInvoice(pool, payload, context = {}) {
|
|
const pack = resolvePack(payload.packEur);
|
|
const paymentMethod = normalizePaymentMethod(payload.paymentMethod, payload.coin);
|
|
const recipientMode = normalizeRecipientMode(payload.recipientMode);
|
|
|
|
if (!pack || !paymentMethod || !recipientMode) {
|
|
throw createVipInvoiceError('Paramètres d\'invoice invalides');
|
|
}
|
|
|
|
if (isPaygatePaymentMethod(paymentMethod)) {
|
|
if (!isPaygatePackAllowed(pack)) {
|
|
throw createVipInvoiceError('PayGate est indisponible sur le pack 5 EUR. Utilise BTC/LTC ou choisis le pack 7 EUR.');
|
|
}
|
|
|
|
const paygateEnabled = String(process.env.VIP_PAYGATE_ENABLED || 'true').trim().toLowerCase();
|
|
if (['0', 'false', 'no', 'off'].includes(paygateEnabled)) {
|
|
throw createVipInvoiceError('Paiement PayGate indisponible pour le moment');
|
|
}
|
|
|
|
return createPaygateVipInvoice(
|
|
pool,
|
|
{
|
|
pack,
|
|
recipientMode,
|
|
payerEmail: payload.payerEmail
|
|
},
|
|
context
|
|
);
|
|
}
|
|
|
|
if (isPayblisPaymentMethod(paymentMethod)) {
|
|
return createPayblisVipInvoice(
|
|
pool,
|
|
{
|
|
pack,
|
|
recipientMode,
|
|
payerEmail: payload.payerEmail,
|
|
auth: context.auth
|
|
},
|
|
context
|
|
);
|
|
}
|
|
|
|
return createCryptoVipInvoice(
|
|
pool,
|
|
{
|
|
pack,
|
|
coin: paymentMethod,
|
|
recipientMode
|
|
},
|
|
context
|
|
);
|
|
}
|
|
|
|
async function forceValidateInvoice(pool, invoiceId, admin) {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE',
|
|
[invoiceId]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
throw new Error('Invoice introuvable');
|
|
}
|
|
|
|
const invoice = rows[0];
|
|
const paymentMethod = getInvoicePaymentMethod(invoice);
|
|
if (invoice.status === 'cancelled') {
|
|
throw new Error('Cette invoice est annulée');
|
|
}
|
|
if (invoice.status !== 'delivered') {
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'paid',
|
|
confirmations = CASE
|
|
WHEN ? IN ('paygate_hosted', 'payblis') THEN confirmations
|
|
ELSE GREATEST(confirmations, COALESCE(required_confirmations, 1))
|
|
END,
|
|
amount_crypto_received = CASE
|
|
WHEN ? IN ('paygate_hosted', 'payblis') THEN amount_crypto_received
|
|
WHEN amount_crypto_received < amount_crypto_expected THEN amount_crypto_expected
|
|
ELSE amount_crypto_received
|
|
END,
|
|
paygate_paid_coin = CASE
|
|
WHEN ? = 'paygate_hosted' THEN COALESCE(paygate_paid_coin, 'manual')
|
|
ELSE paygate_paid_coin
|
|
END,
|
|
paygate_paid_value = CASE
|
|
WHEN ? = 'paygate_hosted' THEN COALESCE(paygate_paid_value, amount_usd)
|
|
ELSE paygate_paid_value
|
|
END,
|
|
payblis_paid_amount = CASE
|
|
WHEN ? = 'payblis' THEN COALESCE(payblis_paid_amount, amount_eur)
|
|
ELSE payblis_paid_amount
|
|
END,
|
|
payblis_paid_currency = CASE
|
|
WHEN ? = 'payblis' THEN COALESCE(payblis_paid_currency, 'EUR')
|
|
ELSE payblis_paid_currency
|
|
END,
|
|
paid_at = COALESCE(paid_at, NOW()),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[paymentMethod, paymentMethod, paymentMethod, paymentMethod, paymentMethod, paymentMethod, invoiceId]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoiceId,
|
|
'invoice_admin_validated',
|
|
'Invoice marquée comme payée par un administrateur.',
|
|
{
|
|
previousStatus: invoice.status,
|
|
adminId: admin?.userId || null
|
|
},
|
|
'admin',
|
|
admin?.userId || null
|
|
);
|
|
}
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
return deliverInvoiceIfReady(pool, invoiceId, 'admin', admin?.userId || null, 'admin_override');
|
|
}
|
|
|
|
async function cancelInvoice(pool, invoiceId, admin) {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE',
|
|
[invoiceId]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
throw new Error('Invoice introuvable');
|
|
}
|
|
|
|
const invoice = rows[0];
|
|
if (invoice.status === 'delivered') {
|
|
throw new Error('Impossible d\'annuler une invoice déjà livrée');
|
|
}
|
|
|
|
if (invoice.status !== 'cancelled') {
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET status = 'cancelled',
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[invoiceId]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoiceId,
|
|
'invoice_cancelled',
|
|
'Invoice annulée par un administrateur.',
|
|
{
|
|
previousStatus: invoice.status,
|
|
adminId: admin?.userId || null
|
|
},
|
|
'admin',
|
|
admin?.userId || null
|
|
);
|
|
}
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
return fetchInvoiceById(pool, invoiceId);
|
|
}
|
|
|
|
async function unsealGift(pool, giftToken, ipAddress) {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.beginTransaction();
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM vip_invoices WHERE gift_token = ? FOR UPDATE',
|
|
[giftToken]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
throw new Error('Cadeau introuvable');
|
|
}
|
|
|
|
const invoice = rows[0];
|
|
if (invoice.recipient_mode !== 'gift') {
|
|
throw new Error('Ce lien ne correspond pas à un cadeau');
|
|
}
|
|
if (invoice.status !== 'delivered' || !invoice.vip_key_value) {
|
|
throw new Error('La clé cadeau n\'est pas encore disponible');
|
|
}
|
|
|
|
if (invoice.gift_sealed) {
|
|
await connection.execute(
|
|
`UPDATE vip_invoices
|
|
SET gift_sealed = 0,
|
|
gift_unsealed_at = COALESCE(gift_unsealed_at, NOW()),
|
|
gift_unseal_count = gift_unseal_count + 1,
|
|
gift_unsealed_by_ip_hash = COALESCE(gift_unsealed_by_ip_hash, ?),
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[hashIp(ipAddress), invoice.id]
|
|
);
|
|
|
|
await logVipInvoiceEvent(
|
|
connection,
|
|
invoice.id,
|
|
'gift_unsealed',
|
|
'Cadeau descellé.',
|
|
{
|
|
giftToken,
|
|
ipHash: hashIp(ipAddress)
|
|
},
|
|
'gift',
|
|
giftToken
|
|
);
|
|
}
|
|
|
|
await connection.commit();
|
|
} catch (error) {
|
|
await connection.rollback();
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
return fetchInvoiceByGiftToken(pool, giftToken);
|
|
}
|
|
|
|
async function ensureColumnExists(pool, tableName, columnName, definitionSql) {
|
|
const [rows] = await pool.execute(
|
|
`SELECT COLUMN_NAME
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = ?
|
|
AND COLUMN_NAME = ?
|
|
LIMIT 1`,
|
|
[tableName, columnName]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
await pool.execute(`ALTER TABLE \`${tableName}\` ADD COLUMN ${definitionSql}`);
|
|
}
|
|
}
|
|
|
|
async function ensureIndexExists(pool, tableName, indexName, definitionSql) {
|
|
const [rows] = await pool.execute(
|
|
`SELECT INDEX_NAME
|
|
FROM INFORMATION_SCHEMA.STATISTICS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = ?
|
|
AND INDEX_NAME = ?
|
|
LIMIT 1`,
|
|
[tableName, indexName]
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
await pool.execute(`ALTER TABLE \`${tableName}\` ADD ${definitionSql}`);
|
|
}
|
|
}
|
|
|
|
async function normalizeVipInvoicePaymentMethods(pool) {
|
|
await pool.execute(`
|
|
ALTER TABLE vip_invoices
|
|
MODIFY COLUMN payment_method VARCHAR(64) NULL
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = NULLIF(LOWER(TRIM(payment_method)), '')
|
|
WHERE payment_method IS NOT NULL
|
|
`);
|
|
|
|
// payblis_ref_order is written only by createPayblisVipInvoice, so it is the
|
|
// authoritative marker of a Payblis invoice. Pin payment_method to 'payblis'
|
|
// here — both to recover rows a previous migration run mislabelled, and to
|
|
// stop the autobuy catch-all below (which predates the Payblis method) from
|
|
// rewriting them to 'autobuy' on every restart.
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'payblis'
|
|
WHERE payblis_ref_order IS NOT NULL
|
|
AND (payment_method IS NULL OR payment_method <> 'payblis')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = LOWER(TRIM(coin))
|
|
WHERE payment_method IS NULL
|
|
AND coin IS NOT NULL
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'btc'
|
|
WHERE payment_method IN ('bitcoin', 'btc_onchain', 'btc_mainnet')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'ltc'
|
|
WHERE payment_method IN ('litecoin', 'ltc_onchain', 'ltc_mainnet')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'paygate_hosted'
|
|
WHERE payment_method IN ('paygate', 'paygate_checkout', 'hosted', 'hosted_checkout', 'checkout')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET autobuy_gateway = COALESCE(
|
|
NULLIF(autobuy_gateway, ''),
|
|
CASE
|
|
WHEN payment_method = 'autobuy_paypal' THEN 'paypal'
|
|
ELSE payment_method
|
|
END
|
|
),
|
|
payment_method = 'autobuy'
|
|
WHERE payment_method IN ('autobuy_paypal', 'paypal', 'card', 'manual', 'other')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET autobuy_gateway = CASE
|
|
WHEN coin IN ('btc', 'ltc') THEN autobuy_gateway
|
|
ELSE COALESCE(NULLIF(autobuy_gateway, ''), 'crypto')
|
|
END,
|
|
payment_method = CASE
|
|
WHEN coin IN ('btc', 'ltc') THEN coin
|
|
ELSE 'autobuy'
|
|
END
|
|
WHERE payment_method = 'crypto'
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'paygate_hosted'
|
|
WHERE payment_method IS NULL
|
|
AND (
|
|
NULLIF(paygate_tracking_address, '') IS NOT NULL
|
|
OR NULLIF(paygate_temporary_wallet_address, '') IS NOT NULL
|
|
OR NULLIF(paygate_callback_url, '') IS NOT NULL
|
|
OR NULLIF(paygate_callback_nonce, '') IS NOT NULL
|
|
OR NULLIF(paygate_checkout_url, '') IS NOT NULL
|
|
OR NULLIF(paygate_payer_email, '') IS NOT NULL
|
|
OR NULLIF(paygate_paid_coin, '') IS NOT NULL
|
|
OR paygate_paid_value IS NOT NULL
|
|
OR NULLIF(paygate_paid_txid, '') IS NOT NULL
|
|
)
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = 'autobuy'
|
|
WHERE payment_method IS NULL
|
|
AND (
|
|
NULLIF(autobuy_order_id, '') IS NOT NULL
|
|
OR NULLIF(autobuy_product_id, '') IS NOT NULL
|
|
OR NULLIF(autobuy_email, '') IS NOT NULL
|
|
OR NULLIF(autobuy_checkout_url, '') IS NOT NULL
|
|
OR NULLIF(autobuy_gateway, '') IS NOT NULL
|
|
OR NULLIF(autobuy_currency, '') IS NOT NULL
|
|
OR autobuy_total IS NOT NULL
|
|
OR autobuy_order_created_at IS NOT NULL
|
|
)
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET autobuy_gateway = COALESCE(NULLIF(autobuy_gateway, ''), payment_method),
|
|
payment_method = 'autobuy'
|
|
WHERE payment_method IS NOT NULL
|
|
AND payment_method NOT IN ('btc', 'ltc', 'paygate_hosted', 'autobuy', 'payblis')
|
|
`);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = CASE
|
|
WHEN coin IN ('btc', 'ltc') THEN coin
|
|
WHEN (
|
|
NULLIF(paygate_tracking_address, '') IS NOT NULL
|
|
OR NULLIF(paygate_temporary_wallet_address, '') IS NOT NULL
|
|
OR NULLIF(paygate_callback_url, '') IS NOT NULL
|
|
OR NULLIF(paygate_callback_nonce, '') IS NOT NULL
|
|
OR NULLIF(paygate_checkout_url, '') IS NOT NULL
|
|
OR NULLIF(paygate_payer_email, '') IS NOT NULL
|
|
OR NULLIF(paygate_paid_coin, '') IS NOT NULL
|
|
OR paygate_paid_value IS NOT NULL
|
|
OR NULLIF(paygate_paid_txid, '') IS NOT NULL
|
|
) THEN 'paygate_hosted'
|
|
ELSE 'autobuy'
|
|
END
|
|
WHERE payment_method IS NULL
|
|
`);
|
|
}
|
|
|
|
async function ensureVipDonationsTables(pool) {
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS vip_derivation_counters (
|
|
coin ENUM('btc', 'ltc') PRIMARY KEY,
|
|
next_index INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS vip_invoices (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
public_id VARCHAR(64) NOT NULL UNIQUE,
|
|
payment_method ${VIP_PAYMENT_METHOD_ENUM_SQL} NOT NULL,
|
|
status ENUM('awaiting_payment', 'partial_payment', 'confirming', 'paid', 'delivered', 'expired', 'cancelled') NOT NULL DEFAULT 'awaiting_payment',
|
|
coin ENUM('btc', 'ltc') DEFAULT NULL,
|
|
pack_eur DECIMAL(10,2) NOT NULL,
|
|
amount_eur DECIMAL(10,2) NOT NULL,
|
|
amount_usd DECIMAL(10,2) NOT NULL,
|
|
amount_crypto_expected DECIMAL(20,8) NOT NULL,
|
|
amount_crypto_received DECIMAL(20,8) NOT NULL DEFAULT 0,
|
|
vip_years DECIMAL(5,2) NOT NULL,
|
|
recipient_mode ENUM('self', 'gift') NOT NULL,
|
|
payment_address VARCHAR(128) NOT NULL,
|
|
address_type VARCHAR(32) DEFAULT NULL,
|
|
derivation_index INT NOT NULL,
|
|
confirmations INT NOT NULL DEFAULT 0,
|
|
required_confirmations INT NOT NULL DEFAULT 1,
|
|
tx_hash VARCHAR(128) DEFAULT NULL,
|
|
qr_payload TEXT NOT NULL,
|
|
gift_token VARCHAR(64) DEFAULT NULL UNIQUE,
|
|
gift_sealed TINYINT(1) NOT NULL DEFAULT 1,
|
|
gift_unsealed_at DATETIME DEFAULT NULL,
|
|
gift_unseal_count INT NOT NULL DEFAULT 0,
|
|
gift_unsealed_by_ip_hash VARCHAR(128) DEFAULT NULL,
|
|
vip_key_value VARCHAR(255) DEFAULT NULL,
|
|
created_by_user_id VARCHAR(255) DEFAULT NULL,
|
|
created_by_user_type VARCHAR(32) DEFAULT NULL,
|
|
created_by_session_id VARCHAR(255) DEFAULT NULL,
|
|
created_ip_hash VARCHAR(128) DEFAULT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
paid_at DATETIME DEFAULT NULL,
|
|
delivered_at DATETIME DEFAULT NULL,
|
|
next_check_at DATETIME DEFAULT NULL,
|
|
paygate_tracking_address VARCHAR(255) DEFAULT NULL,
|
|
paygate_temporary_wallet_address VARCHAR(255) DEFAULT NULL,
|
|
paygate_callback_url TEXT DEFAULT NULL,
|
|
paygate_callback_nonce VARCHAR(128) DEFAULT NULL,
|
|
paygate_checkout_url TEXT DEFAULT NULL,
|
|
paygate_payer_email VARCHAR(255) DEFAULT NULL,
|
|
paygate_ipn_token VARCHAR(512) DEFAULT NULL,
|
|
paygate_paid_coin VARCHAR(64) DEFAULT NULL,
|
|
paygate_paid_value DECIMAL(18,8) DEFAULT NULL,
|
|
paygate_paid_txid VARCHAR(255) DEFAULT NULL,
|
|
autobuy_order_id VARCHAR(128) DEFAULT NULL,
|
|
autobuy_product_id VARCHAR(128) DEFAULT NULL,
|
|
autobuy_email VARCHAR(255) DEFAULT NULL,
|
|
autobuy_checkout_url TEXT DEFAULT NULL,
|
|
autobuy_gateway VARCHAR(64) DEFAULT NULL,
|
|
autobuy_currency VARCHAR(16) DEFAULT NULL,
|
|
autobuy_total DECIMAL(18,8) DEFAULT NULL,
|
|
autobuy_order_created_at DATETIME DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_vip_invoices_payment_method (payment_method),
|
|
INDEX idx_vip_invoices_status (status),
|
|
INDEX idx_vip_invoices_coin (coin),
|
|
INDEX idx_vip_invoices_created_at (created_at),
|
|
INDEX idx_vip_invoices_payment_address (payment_address),
|
|
INDEX idx_vip_invoices_vip_key (vip_key_value),
|
|
INDEX idx_vip_invoices_paygate_paid_txid (paygate_paid_txid),
|
|
UNIQUE KEY uniq_vip_invoices_autobuy_order_id (autobuy_order_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
|
|
await pool.execute(`
|
|
CREATE TABLE IF NOT EXISTS vip_invoice_events (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
invoice_id INT NOT NULL,
|
|
event_type VARCHAR(64) NOT NULL,
|
|
actor_type VARCHAR(32) DEFAULT NULL,
|
|
actor_id VARCHAR(255) DEFAULT NULL,
|
|
message VARCHAR(255) DEFAULT NULL,
|
|
payload_json LONGTEXT DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_vip_invoice_events_invoice (invoice_id),
|
|
INDEX idx_vip_invoice_events_type (event_type),
|
|
CONSTRAINT fk_vip_invoice_events_invoice
|
|
FOREIGN KEY (invoice_id) REFERENCES vip_invoices(id) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
`);
|
|
|
|
await pool.execute(
|
|
`INSERT IGNORE INTO vip_derivation_counters (coin, next_index, created_at, updated_at)
|
|
VALUES ('btc', 0, NOW(), NOW()), ('ltc', 0, NOW(), NOW())`
|
|
);
|
|
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payment_method',
|
|
`\`payment_method\` ${VIP_PAYMENT_METHOD_ENUM_SQL} NULL AFTER \`public_id\``
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_tracking_address',
|
|
"`paygate_tracking_address` VARCHAR(255) DEFAULT NULL AFTER `next_check_at`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_temporary_wallet_address',
|
|
"`paygate_temporary_wallet_address` VARCHAR(255) DEFAULT NULL AFTER `paygate_tracking_address`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_callback_url',
|
|
"`paygate_callback_url` TEXT DEFAULT NULL AFTER `paygate_temporary_wallet_address`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_callback_nonce',
|
|
"`paygate_callback_nonce` VARCHAR(128) DEFAULT NULL AFTER `paygate_callback_url`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_checkout_url',
|
|
"`paygate_checkout_url` TEXT DEFAULT NULL AFTER `paygate_callback_nonce`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_payer_email',
|
|
"`paygate_payer_email` VARCHAR(255) DEFAULT NULL AFTER `paygate_checkout_url`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_ipn_token',
|
|
"`paygate_ipn_token` VARCHAR(512) DEFAULT NULL AFTER `paygate_payer_email`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_paid_coin',
|
|
"`paygate_paid_coin` VARCHAR(64) DEFAULT NULL AFTER `paygate_payer_email`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_paid_value',
|
|
"`paygate_paid_value` DECIMAL(18,8) DEFAULT NULL AFTER `paygate_paid_coin`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'paygate_paid_txid',
|
|
"`paygate_paid_txid` VARCHAR(255) DEFAULT NULL AFTER `paygate_paid_value`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_order_id',
|
|
"`autobuy_order_id` VARCHAR(128) DEFAULT NULL AFTER `paygate_paid_txid`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_product_id',
|
|
"`autobuy_product_id` VARCHAR(128) DEFAULT NULL AFTER `autobuy_order_id`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_email',
|
|
"`autobuy_email` VARCHAR(255) DEFAULT NULL AFTER `autobuy_product_id`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_checkout_url',
|
|
"`autobuy_checkout_url` TEXT DEFAULT NULL AFTER `autobuy_email`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_gateway',
|
|
"`autobuy_gateway` VARCHAR(64) DEFAULT NULL AFTER `autobuy_checkout_url`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_currency',
|
|
"`autobuy_currency` VARCHAR(16) DEFAULT NULL AFTER `autobuy_gateway`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_total',
|
|
"`autobuy_total` DECIMAL(18,8) DEFAULT NULL AFTER `autobuy_currency`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'autobuy_order_created_at',
|
|
"`autobuy_order_created_at` DATETIME DEFAULT NULL AFTER `autobuy_total`"
|
|
);
|
|
|
|
// Payblis columns (added 2026-04-21).
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_ref_order',
|
|
"`payblis_ref_order` VARCHAR(128) DEFAULT NULL"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_checkout_url',
|
|
"`payblis_checkout_url` TEXT DEFAULT NULL AFTER `payblis_ref_order`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_payer_email',
|
|
"`payblis_payer_email` VARCHAR(255) DEFAULT NULL AFTER `payblis_checkout_url`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_customer_name',
|
|
"`payblis_customer_name` VARCHAR(255) DEFAULT NULL AFTER `payblis_payer_email`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_method',
|
|
"`payblis_method` VARCHAR(64) DEFAULT NULL AFTER `payblis_customer_name`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_transaction_id',
|
|
"`payblis_transaction_id` VARCHAR(255) DEFAULT NULL AFTER `payblis_method`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_paid_amount',
|
|
"`payblis_paid_amount` DECIMAL(18,2) DEFAULT NULL AFTER `payblis_transaction_id`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_paid_currency',
|
|
"`payblis_paid_currency` VARCHAR(16) DEFAULT NULL AFTER `payblis_paid_amount`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_ipn_received_at',
|
|
"`payblis_ipn_received_at` DATETIME DEFAULT NULL AFTER `payblis_paid_currency`"
|
|
);
|
|
await ensureColumnExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'payblis_ipn_raw_payload',
|
|
"`payblis_ipn_raw_payload` LONGTEXT DEFAULT NULL AFTER `payblis_ipn_received_at`"
|
|
);
|
|
|
|
// Unique index on RefOrder (dedup retries at the DB level).
|
|
try {
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices ADD UNIQUE KEY uniq_vip_invoices_payblis_ref_order (payblis_ref_order)"
|
|
);
|
|
} catch (error) {
|
|
if (!/duplicate|exists|1061/i.test(String(error?.message || ''))) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Non-unique index for IPN lookups by transaction_id.
|
|
try {
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices ADD INDEX idx_vip_invoices_payblis_tx (payblis_transaction_id)"
|
|
);
|
|
} catch (error) {
|
|
if (!/duplicate|exists|1061/i.test(String(error?.message || ''))) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Loosen NOT NULL on crypto-only columns so non-crypto methods (payblis, future)
|
|
// can insert NULL for fields that make no sense for them.
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices MODIFY payment_address VARCHAR(128) NULL DEFAULT NULL"
|
|
);
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices MODIFY amount_crypto_expected DECIMAL(20,8) NULL DEFAULT NULL"
|
|
);
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices MODIFY derivation_index INT NULL DEFAULT NULL"
|
|
);
|
|
await pool.execute(
|
|
"ALTER TABLE vip_invoices MODIFY qr_payload TEXT NULL DEFAULT NULL"
|
|
);
|
|
|
|
// Extend the payment_method ENUM to include 'payblis'. Idempotent because
|
|
// MySQL MODIFY COLUMN on an ENUM that already lists the value is a no-op.
|
|
await pool.execute(
|
|
`ALTER TABLE vip_invoices MODIFY payment_method ${VIP_PAYMENT_METHOD_ENUM_SQL} NOT NULL`
|
|
);
|
|
|
|
await pool.execute(`
|
|
UPDATE vip_invoices
|
|
SET payment_method = coin
|
|
WHERE payment_method IS NULL AND coin IS NOT NULL
|
|
`);
|
|
|
|
await pool.execute(`
|
|
ALTER TABLE vip_invoices
|
|
MODIFY COLUMN vip_years DECIMAL(5,2) NOT NULL
|
|
`);
|
|
await normalizeVipInvoicePaymentMethods(pool);
|
|
await pool.execute(`
|
|
ALTER TABLE vip_invoices
|
|
MODIFY COLUMN payment_method ${VIP_PAYMENT_METHOD_ENUM_SQL} NOT NULL
|
|
`);
|
|
await pool.execute(`
|
|
ALTER TABLE vip_invoices
|
|
MODIFY COLUMN coin ENUM('btc', 'ltc') NULL
|
|
`);
|
|
|
|
await ensureIndexExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'idx_vip_invoices_payment_method',
|
|
'INDEX `idx_vip_invoices_payment_method` (`payment_method`)'
|
|
);
|
|
await ensureIndexExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'idx_vip_invoices_paygate_paid_txid',
|
|
'INDEX `idx_vip_invoices_paygate_paid_txid` (`paygate_paid_txid`)'
|
|
);
|
|
await ensureIndexExists(
|
|
pool,
|
|
'vip_invoices',
|
|
'uniq_vip_invoices_autobuy_order_id',
|
|
'UNIQUE INDEX `uniq_vip_invoices_autobuy_order_id` (`autobuy_order_id`)'
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
VIP_PACKS,
|
|
STATUS_REASONS,
|
|
DEFAULT_EXPIRATION_MINUTES,
|
|
createVipInvoice,
|
|
handlePaygateCallback,
|
|
handlePayblisIpn,
|
|
markPayblisInvoicePaidFromRest,
|
|
fetchInvoiceByPublicId,
|
|
fetchInvoiceById,
|
|
fetchInvoiceByGiftToken,
|
|
refreshInvoiceStatus,
|
|
forceValidateInvoice,
|
|
cancelInvoice,
|
|
getVipInvoiceDetails,
|
|
listUserVipInvoices,
|
|
listVipInvoices,
|
|
serializePublicInvoice,
|
|
serializeAdminInvoice,
|
|
serializeGift,
|
|
unsealGift,
|
|
ensureVipDonationsTables,
|
|
getClientIp,
|
|
getSupportTelegramUrl,
|
|
parseNumber,
|
|
roundCrypto,
|
|
roundFiat,
|
|
resolvePack,
|
|
normalizePaymentMethod,
|
|
normalizeCoin,
|
|
normalizeRecipientMode,
|
|
buildPricingSnapshot,
|
|
reserveDerivationIndex,
|
|
derivePaymentAddress,
|
|
buildQrPayload,
|
|
logVipInvoiceEvent,
|
|
deliverInvoiceIfReady,
|
|
determineInvoiceStatus,
|
|
fetchAddressActivity,
|
|
getRequiredConfirmations,
|
|
hashIp,
|
|
safeJsonParse,
|
|
buildDurationLabel,
|
|
toSqlDateTime,
|
|
fromSqlDateTime,
|
|
buildFrontendUrl,
|
|
getInvoicePath,
|
|
getGiftPath
|
|
};
|
|
|