MovixOpenSource/API/Mainapi/utils/vipDonations.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
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>
2026-07-05 18:22:13 +02:00

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
};