@mostajs/wagon-engine 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.6.1] — 2026-10-28
7
+
8
+ ### Corrigé
9
+ - **/compte d'une période prépayée dit ce qui a été payé** : le montant et la monnaie de la commande réglée
10
+ (`Payment`, @mostajs/payment), et sa durée (« Classe — 8 000 DZD — 1 mois »), au lieu du prix Stripe en euros par
11
+ mois. Relevé par le fondateur après un paiement Chargily sur Speaking. Sans commande retrouvée, le prix prépayé du
12
+ palier dans la monnaie de la banque. Les paiements prépayés réglés figurent parmi « Vos factures ».
13
+ - Le dinar s'affiche sans décimales (« 8 000 DZD »), comme dans wagon-ui, aussi sur l'écran du choix du paiement.
14
+ - Essais : T-ENG-15 ; T-WAG-13 (suite de conformité) vérifie le montant prépayé dans /compte.
15
+
16
+ ### Dépendances
17
+ - `@mostajs/wagon-ui` ^0.11.1.
18
+
19
+ ## [0.6.0] — 2026-10-28
20
+
21
+ ### Ajouté
22
+ - **Le vendeur de chaque devise** (`@mostajs/payment` ≥ 2.7) : `PAIEMENT_VENDEUR_<DEVISE>` dans le `.env`. Dès qu'une
23
+ telle variable est déclarée, une devise sans vendeur nommé n'est pas vendue : Stripe et les banques prépayées de
24
+ cette devise sont écartés, même avec leurs clés (un avertissement le dit au démarrage). `{{VENDEUR}}` des textes
25
+ légaux nomme le vendeur de chaque devise vendue, une ligne par devise ; `config.legal.VENDEUR` l'emporte s'il est
26
+ posé. `PAIEMENT_STRIPE_DEVISES` dit la devise de Stripe (défaut EUR), reprise par l'affichage des prix. Sans aucune
27
+ `PAIEMENT_VENDEUR_*`, rien ne change. Essai T-ENG-14.
28
+ - `creerWagon(…).moyens` : les moyens réellement proposés ; `demarrer` le rend tel quel.
29
+ - `llms.txt` et le README s'ouvrent sur la configuration du `.env` par `@mostajs/config` (chargement, cascade
30
+ `MOSTA_ENV`, valeur vide, priorité de l'environnement).
31
+
32
+ ### Dépendances
33
+ - `@mostajs/payment` ^2.7.0.
34
+
6
35
  ## [0.5.0] — 2026-10-28
7
36
 
8
37
  ### Ajouté
package/README.md CHANGED
@@ -41,6 +41,12 @@ await demarrer({ config, charge });
41
41
  | `CHARGILY_SECRET_KEY` · `SATIM_MERCHANT_ID`, `SATIM_PASSWORD`, `SATIM_TERMINAL_ID` · `PAYPAL_…` | les banques prépayées proposées par `config.paiement.prepaye` |
42
42
  | `PAIEMENT_STRIPE=off`, `PAIEMENT_CHARGILY=off`, `PAIEMENT_SATIM=off`, `PAIEMENT_PAYPAL=off` | **coupe un moyen** sans toucher à ses clés |
43
43
  | `WAGON_ADMINS` | les courriels administrateurs (confirmés par un code reçu) |
44
+ | `PAIEMENT_VENDEUR_DZD`, `PAIEMENT_VENDEUR_EUR`… | 0.6 — **le vendeur de chaque devise** (dénomination, forme, adresse, immatriculations) ; dès qu'une est déclarée, une devise sans vendeur n'est pas vendue, et `{{VENDEUR}}` des CGV les nomme |
45
+ | `PAIEMENT_STRIPE_DEVISES` | 0.6 — la devise de Stripe (défaut `EUR`) |
46
+
47
+ **Le `.env` d'abord.** Toutes les clés passent par `getEnv` de `@mostajs/config` : cascade `MOSTA_ENV` (`PROD_CLÉ` l'emporte sur `CLÉ`), valeur vide = absente, et `loadEnv` n'écrase jamais une variable déjà posée par l'orchestrateur. Si `wagon.config.mjs` lit des variables, il appelle `loadEnv` lui-même, en tête.
48
+
49
+ **Un vendeur par devise (0.6).** Un SaaS qui vend en dinars par un auto-entrepreneur et en euros par une société pose `PAIEMENT_VENDEUR_DZD=…` et `PAIEMENT_VENDEUR_EUR=…`. Une devise déclarée vide (`PAIEMENT_VENDEUR_EUR=`, la société n'est pas encore immatriculée) n'est pas vendue : Stripe est écarté même avec sa clé. Sans aucune `PAIEMENT_VENDEUR_*`, rien ne change.
44
50
 
45
51
  **Le paiement est au choix de l'utilisateur**, parmi les moyens actifs. Un moyen est actif quand ses clés sont dans le `.env` et que son commutateur n'est pas sur `off`. Une banque prépayée doit en plus être proposée par la configuration.
46
52
 
package/llms.txt CHANGED
@@ -4,13 +4,28 @@
4
4
  > PayPal), espace client, administration, pages légales, robots/sitemap, sauvegarde, fr/en/ar. Une GREFFE n'apporte que
5
5
  > wagon.config.mjs, charge/, legal/ et son .env. N2 (pack). Extrait du gabarit mosta-wagon 0.11.0 le 26/10/2026.
6
6
 
7
- - Version: 0.2.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
7
+ - Version: 0.6.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
8
8
  - Chemin: mostajs/mosta-platform-stack/mosta-wagon-engine · gabarit d'une greffe : Entreprise/mosta-wagon
9
9
 
10
+ ## ⚙️ D'EMBLÉE : LE .env ET LA CASCADE @mostajs/config
11
+ Une greffe se configure par SON `.env` (dossier courant de `demarrer`, ou `racine`). Avant tout :
12
+ 1. **Qui le charge** : `demarrer` appelle `loadEnv({ path: <racine>/.env })` de `@mostajs/config`. Si `wagon.config.mjs`
13
+ lit lui-même des variables (getEnv au chargement du module), il doit appeler `loadEnv` EN TÊTE, avant de les lire :
14
+ `import { loadEnv, getEnv } from '@mostajs/config'; loadEnv({ path: join(process.cwd(), '.env') });`
15
+ Le second appel (celui de demarrer) n'écrase rien.
16
+ 2. **loadEnv ne SURCHARGE pas** une variable déjà dans l'environnement : la valeur de l'orchestrateur (pm2, systemd,
17
+ conteneur) prime sur le fichier.
18
+ 3. **Cascade** (première valeur NON VIDE) : `${MOSTA_ENV}_${CLÉ}` → `${CLÉ}` → défaut. Un seul .env, des profils
19
+ `TEST_`/`DEV_`/`PROD_`. Toutes les clés ci-dessous, et celles de `@mostajs/payment`, passent par `getEnv`.
20
+ 4. **Vide = absent** : `WAGON_STRIPE_SECRET_KEY=` ne configure rien. Exception voulue : `PAIEMENT_VENDEUR_<DEVISE>=`
21
+ vide DÉCLARE la devise sans la nommer (elle n'est pas vendue).
22
+ 5. Les secrets et les informations de l'entreprise sont posés par l'exploitant sur le serveur, jamais commités ;
23
+ `.env.example` de la greffe liste chaque clé, sans valeur.
24
+
10
25
  ## EXPORTS
11
- - demarrer({ config, charge, racine = cwd, journal }) → { wagon, serveur, moyens: string[], fermer() }
26
+ - demarrer({ config, charge, racine = cwd, journal }) → { wagon, serveur, moyens: string[], fermer() } (moyens = wagon.moyens, 0.6)
12
27
  - creerWagon({ config, charge, sgbd: string|{dialect,uri}, baseUrl, stripe?, webhookSecret?, transport?, journal?,
13
- paiement?: { banques: { chargily:'DZD', … }, port? }, racine? }) → { handler, catalogue(), catalogueStripe(), fermer(), … }
28
+ paiement?: { banques: { chargily:'DZD', … }, port?, deviseStripe? }, racine? }) → { moyens, handler, catalogue(), catalogueStripe(), fermer(), … }
14
29
  - schemasWagon() (sauvegarde) · sauvegarder({ cibles, driver, seau, recipients, garder }) · suivrePalette(…)
15
30
  - '@mostajs/wagon-engine/conformite' = src/conformite/conformite.test.mjs (suite mjs-unit, juge WAGON_GREFFE ou cwd)
16
31
 
@@ -19,7 +34,13 @@ PORT, HOST · WAGON_BASE_URL · WAGON_DB_DIALECT (sqlite|postgres…), WAGON_SGB
19
34
  WAGON_EMAIL_FROM (ou WAGON_COURRIEL_FICHIER pour les essais) · WAGON_STRIPE_SECRET_KEY|RESTRICTED_KEY, WAGON_STRIPE_WEBHOOK_SECRET ·
20
35
  CHARGILY_SECRET_KEY|API_KEY · SATIM_MERCHANT_ID, SATIM_PASSWORD, SATIM_TERMINAL_ID · PAYPAL_CLIENT_ID, PAYPAL_SECRET,
21
36
  PAYPAL_WEBHOOK_ID · PAIEMENT_<STRIPE|CHARGILY|SATIM|PAYPAL>=off (coupe le moyen) · WAGON_ADMINS · WAGON_INDEXER.
22
- Moyen ACTIF = clés présentes ET commutateur ≠ off (ET, pour une banque prépayée, proposée par config.paiement.prepaye).
37
+ 0.6 (@mostajs/payment ≥ 2.7) : PAIEMENT_VENDEUR_<DEVISE> (le vendeur de cette devise : dénomination, forme, adresse,
38
+ immatriculations) · PAIEMENT_STRIPE_DEVISES (la devise de Stripe, défaut EUR).
39
+ Moyen ACTIF = clés présentes ET commutateur ≠ off (ET, pour une banque prépayée, proposée par config.paiement.prepaye)
40
+ ET, 0.6, dès qu'une PAIEMENT_VENDEUR_* est déclarée, un vendeur NOMMÉ pour sa devise. `{{VENDEUR}}` des textes légaux
41
+ nomme alors le vendeur de chaque devise vendue (une ligne par devise, nom de la devise dans la langue de la page) ;
42
+ `config.legal.VENDEUR` l'emporte s'il est posé. Exemple : dinars par un auto-entrepreneur, euros par une société pas
43
+ encore immatriculée → `PAIEMENT_VENDEUR_DZD=…`, `PAIEMENT_VENDEUR_EUR=` (vide) : seule la vente en dinars s'ouvre.
23
44
 
24
45
  ## CONTRAT DE LA CHARGE (charge/index.mjs)
25
46
  liens · ressources (quota { cle, limite, periode } | plafond { cle, limite, plafond: true }) · accueil (fiche { fr: { titre, lede, actions, preuves } }, ou 0.3 : async (ctx) => ({ titre, contenu, disposition, application, console })) · dispositions (0.3 : { nom: (p) => corps }) · textes ·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-engine",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Le wagon d'un SaaS, une fois pour toutes : comptes, connexion, paliers, quotas, paiement (Stripe, Chargily, SATIM), espace client, administration, pages légales, fr/en/ar. Une greffe n'apporte que wagon.config.mjs, charge/, legal/ et son .env ; la suite de conformité publiée juge la greffe.",
5
5
  "keywords": [
6
6
  "mostajs",
@@ -50,9 +50,9 @@
50
50
  "@mostajs/mailer": "^0.2.0",
51
51
  "@mostajs/markdown-html": "^0.3.0",
52
52
  "@mostajs/orm": "^2.12.1",
53
- "@mostajs/payment": "^2.6.0",
53
+ "@mostajs/payment": "^2.7.0",
54
54
  "@mostajs/subscriptions-plan": "^0.9.0",
55
- "@mostajs/wagon-ui": "^0.11.0",
55
+ "@mostajs/wagon-ui": "^0.11.1",
56
56
  "better-sqlite3": "^12.11.1",
57
57
  "stripe": "^17.7.0"
58
58
  },
@@ -590,6 +590,11 @@ const PRIX_DZD = PAYANT && (PAYANT.prepaid ?? {}).DZD?.month;
590
590
  const compte = (await req(base, '/compte', { cookie: c })).texte;
591
591
  expect(compte.includes(PAYANT.name)).toBe(true);
592
592
  expect(/payé jusqu’au/.test(compte)).toBe(true);
593
+ // 0.6.1 — le palier dit ce qui a été payé, dans la monnaie de la banque ; pas le prix Stripe en euros (défaut du 28/10/2026)
594
+ const [, devise, prixPrepaye] = OFFRE;
595
+ const sansEspace = (x) => x.replace(/[\s\u00a0\u202f]/g, '');
596
+ expect(sansEspace(compte).includes(sansEspace(String(Number(prixPrepaye.month) / 100)))).toBe(true);
597
+ expect([compte.includes(devise) || compte.includes('DA') || compte.includes('€'), Number(PAYANT.price) > 0 && devise !== 'EUR' ? compte.includes('€') : false]).toEqual([true, false]);
593
598
  expect((await webhook('ok')).status).toBe(200); // rejeu sans effet
594
599
  expect((await req(base, '/api/paiement/webhook/inconnue', { method: 'POST', json: '{}', headers: { 'content-type': 'application/json' } })).status).toBe(404);
595
600
  await C.fermer();
package/src/demarrer.mjs CHANGED
@@ -11,6 +11,7 @@ import { createServer } from 'node:http';
11
11
  import { appendFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { loadEnv, getEnv } from '@mostajs/config';
14
+ import { devisesDuMoyen } from '@mostajs/payment/server';
14
15
  import { creerWagon } from './wagon.mjs';
15
16
 
16
17
  const coupe = (moyen) => getEnv(`PAIEMENT_${moyen.toUpperCase()}`, '') === 'off';
@@ -53,10 +54,11 @@ export async function demarrer({ config, charge, racine = process.cwd(), journal
53
54
  stripe: cle ? new (await import('stripe')).default(cle) : null,
54
55
  webhookSecret: getEnv('WAGON_STRIPE_WEBHOOK_SECRET', ''),
55
56
  transport: await transport(),
56
- paiement: { banques },
57
+ // 0.6 — la devise de Stripe (PAIEMENT_STRIPE_DEVISES, défaut EUR) : creerWagon écarte une devise sans vendeur.
58
+ paiement: { banques, deviseStripe: devisesDuMoyen('stripe', ['EUR'])[0] },
57
59
  });
58
60
  await wagon.catalogue();
59
- const moyens = [...(cle ? ['stripe'] : []), ...Object.keys(banques)];
61
+ const moyens = wagon.moyens;
60
62
  const serveur = createServer(wagon.handler);
61
63
  const port = Number(getEnv('PORT', '4800'));
62
64
  await new Promise((ok) => serveur.listen(port, getEnv('HOST', '127.0.0.1'), ok));
package/src/wagon.mjs CHANGED
@@ -25,6 +25,7 @@ import { creerWagonUi, esc } from '@mostajs/wagon-ui';
25
25
  import { creerComptes, schemasComptes } from '@mostajs/comptes';
26
26
  import { createBilling, createStripeSale, createPrepaidSale, billingSchemas, checkLimits, assertMonotonicPlans } from '@mostajs/subscriptions-plan/server';
27
27
  import { PaymentSchema } from '@mostajs/payment';
28
+ import { venteAutorisee, vendeursFromEnv } from '@mostajs/payment/server';
28
29
  import { formatNumber, resolveFormatLocale } from '@mostajs/i18n';
29
30
  import { createPasswordAuth, createEmailCodeAuth, SessionSchema, ipClient, memoryLimiter } from '@mostajs/auth-lite';
30
31
  import { emailCodeEmailHtml, emailCodeEmailText, emailCodeForm, emailLinkConfirmPage, AUTH_CSS } from '@mostajs/auth-ui';
@@ -117,7 +118,9 @@ const DOMAINES_BANQUES = {
117
118
  paypal: 'https://www.paypal.com https://www.sandbox.paypal.com',
118
119
  satim: 'https://cib.satim.dz https://test.satim.dz',
119
120
  };
120
- const montant = (mineures, devise, lang) => formatNumber(resolveFormatLocale(lang), Number(mineures) / 100, { style: 'currency', currency: devise });
121
+ // 0.6.1 — le dinar sans décimales, comme on l'écrit en Algérie (et comme wagon-ui ≥ 0.10.1) : « 8 000 DZD ».
122
+ const montant = (mineures, devise, lang) => formatNumber(resolveFormatLocale(lang), Number(mineures) / 100,
123
+ { style: 'currency', currency: devise, ...(devise === 'DZD' ? { maximumFractionDigits: 0, minimumFractionDigits: 0 } : {}) });
121
124
 
122
125
  /** Le message d'un PLAFOND atteint (wagon-ui n'a que celui des clés d'API). */
123
126
  const PLAFOND_ATTEINT = { fr: 'Plafond de votre palier atteint : {n} ({ressource}).', en: 'Your tier limit is reached: {n} ({ressource}).', ar: 'بلغتَ حدّ فئتك: {n} ({ressource}).' };
@@ -165,12 +168,18 @@ const cookieDe = (req, nom) => (req.headers.cookie || '').split(';').map((s) =>
165
168
  * @param {{ envoyer: Function } | null} [o.transport] les courriels (code de connexion)
166
169
  * @param {{ banques?: Record<string, string>, port?: object }} [o.paiement] 0.3 — vente prépayée : les banques ACTIVES
167
170
  * et leur devise (`{ chargily: 'DZD', paypal: 'EUR' }`, server.mjs ne garde que celles dont le .env porte les
168
- * clés) ; `port` remplace @mostajs/payment (essais)
171
+ * clés) ; `port` remplace @mostajs/payment (essais) ; 0.6 — `deviseStripe` (défaut 'EUR') : la devise de Stripe
172
+ * 0.6 — une devise sans VENDEUR (`PAIEMENT_VENDEUR_<DEVISE>`, @mostajs/payment ≥ 2.7, règle en vigueur dès qu'une telle
173
+ * variable est déclarée) n'est pas vendue : Stripe et les banques de cette devise sont écartés ici, et `{{VENDEUR}}`
174
+ * des textes légaux nomme le vendeur de chaque devise vendue.
169
175
  */
170
176
  /** 0.5 — le commit en service (posé par le déployeur dans le dossier de l'instance) et l'heure de démarrage. */
171
177
  const lireRevision = (racine) => { try { return readFileSync(join(racine, 'REVISION'), 'utf8').trim() || null; } catch { return null; } };
172
178
  export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null, webhookSecret = '', transport = null, journal = console, paiement = null, racine = process.cwd() }) {
173
179
  const origine = String(baseUrl || '').replace(/\/$/, '');
180
+ // 0.6 — pas de vente dans une devise sans vendeur (même si les clés de sa banque sont posées).
181
+ const deviseStripe = String(paiement?.deviseStripe ?? 'EUR').toUpperCase();
182
+ if (stripe && !venteAutorisee(deviseStripe)) { journal.warn(`wagon : Stripe écarté — aucun vendeur pour ${deviseStripe} (PAIEMENT_VENDEUR_${deviseStripe})`); stripe = null; }
174
183
  const REVISION = lireRevision(racine); const DEMARRE_LE = new Date().toISOString();
175
184
  const https = /^https:/.test(origine);
176
185
  const S = Object.fromEntries(schemasWagon().map((s) => [s.name, s]));
@@ -226,7 +235,11 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
226
235
  accountOf: async (c) => (await d.findOne(S.Account, { stripeCustomerId: c }))?.id ?? null,
227
236
  },
228
237
  }) : null;
229
- const banques = { ...(paiement?.banques ?? {}) };
238
+ const banques = Object.fromEntries(Object.entries(paiement?.banques ?? {}).filter(([b, dv]) => {
239
+ if (venteAutorisee(String(dv))) return true;
240
+ journal.warn(`wagon : ${b} écarté — aucun vendeur pour ${String(dv).toUpperCase()} (PAIEMENT_VENDEUR_${String(dv).toUpperCase()})`);
241
+ return false;
242
+ }));
230
243
  const prepaye = Object.keys(banques).length ? createPrepaidSale({
231
244
  billing: facturation, providers: banques, payment: paiement?.port, metadataKeys: { account: META.account, plan: META.plan, period: 'wagon_periode' },
232
245
  // 0.10 — `return` : SATIM ne notifie pas le serveur, il renvoie l'acheteur (@mostajs/payment ≥ 2.6). Les banques à
@@ -329,12 +342,26 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
329
342
  */
330
343
  const NOMS_MOYENS = { stripe: 'Stripe (carte bancaire)', chargily: 'Chargily (CIB, Edahabia)', paypal: 'PayPal', satim: 'SATIM (CIB)' };
331
344
  const signales = new Set();
345
+ /**
346
+ * 0.6 — `{{VENDEUR}}` : le vendeur de chaque devise VENDUE (Stripe et les banques actives), d'après
347
+ * `PAIEMENT_VENDEUR_<DEVISE>` ; une ligne par devise. Vide si aucune n'a de vendeur : le texte n'est alors pas servi.
348
+ */
349
+ function vendeurLegal(lang) {
350
+ const vendeurs = vendeursFromEnv();
351
+ const parDevise = new Map(stripe ? [[deviseStripe, ['stripe']]] : []);
352
+ for (const [b, dv] of Object.entries(banques)) { const k = String(dv).toUpperCase(); parDevise.set(k, [...(parDevise.get(k) ?? []), b]); }
353
+ let noms = null; try { noms = new Intl.DisplayNames([lang, 'fr'], { type: 'currency' }); } catch { /* langue inconnue */ }
354
+ return [...parDevise].filter(([dv]) => vendeurs[dv]).map(([dv, ms]) => {
355
+ const n = noms?.of(dv) ?? dv;
356
+ return `- **${n.charAt(0).toUpperCase()}${n.slice(1)} (${dv})** — ${ms.map((m) => NOMS_MOYENS[m] ?? m).join(', ')} : ${vendeurs[dv]}`;
357
+ }).join('\n');
358
+ }
332
359
  function documentLegal(doc, lang) {
333
360
  const dossier = config.dossierLegal ?? join(racine, 'legal'); // la greffe, plus le moteur
334
361
  const f = [lang, 'fr'].map((l) => join(dossier, `${doc}.${l}.md`)).find((x) => existsSync(x));
335
362
  if (!f) return { html: '', complet: false };
336
363
  const moyens = [...(stripe ? ['stripe'] : []), ...Object.keys(paiement?.banques ?? {})].map((m) => NOMS_MOYENS[m] ?? m).join(', ');
337
- const valeurs = { SERVICE: config.marque.nom, DOMAINE: origine.replace(/^https?:\/\//, ''), CONTACT_EMAIL: config.marque.courriel ?? '', PAIEMENT: moyens, ...(config.legal ?? {}) };
364
+ const valeurs = { SERVICE: config.marque.nom, DOMAINE: origine.replace(/^https?:\/\//, ''), CONTACT_EMAIL: config.marque.courriel ?? '', PAIEMENT: moyens, VENDEUR: vendeurLegal(lang), ...(config.legal ?? {}) };
338
365
  const manquants = new Set();
339
366
  const texte = readFileSync(f, 'utf8').replace(/\{\{(\w+)\}\}/g, (m, k) => { if (valeurs[k]) return String(valeurs[k]); manquants.add(k); return ''; });
340
367
  const aTrancher = /⚖️|À FAIRE TRANCHER/.test(texte);
@@ -355,7 +382,36 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
355
382
  return servis.get(lang);
356
383
  };
357
384
 
358
- async function vueEspace(compte) {
385
+ /**
386
+ * 0.6.1 — ce qui a été PAYÉ pour une période prépayée (Chargily, SATIM…) : le montant et la monnaie de la commande, et sa
387
+ * durée. /compte montrait le prix Stripe en euros par mois à une école qui avait payé 8 000 DZD (28/10/2026).
388
+ */
389
+ async function tarifPrepaye(sub, plan, lang) {
390
+ if (!sub?.provider || sub.stripeSubId || sub.provider === 'stripe') return null;
391
+ const T = TEXTES_PAIEMENT[lang] ?? TEXTES_PAIEMENT.fr;
392
+ const dire = (centimes, devise, periode) => `${montant(centimes, String(devise).toUpperCase(), lang)}${T[periode] ? ` — ${T[periode]}` : ''}`;
393
+ // La commande payée (@mostajs/payment la garde) ; sinon, le prix prépayé du palier dans la monnaie de la banque.
394
+ const p = sub.prepaidOrderId ? await d.findOne(S.Payment, { orderId: String(sub.prepaidOrderId) }).catch(() => null) : null;
395
+ if (p && Number(p.amount) > 0) {
396
+ const meta = typeof p.metadata === 'string' ? JSON.parse(p.metadata || '{}') : (p.metadata ?? {});
397
+ return dire(Math.round(Number(p.amount) * 100), p.currency, meta.wagon_periode);
398
+ }
399
+ const devise = banques[sub.provider];
400
+ const pp = typeof plan?.prepaid === 'string' ? JSON.parse(plan.prepaid || 'null') : plan?.prepaid;
401
+ const jours = (new Date(sub.currentPeriodEnd).getTime() - new Date(sub.currentPeriodStart).getTime()) / 86400000;
402
+ const periode = jours >= 300 ? 'year' : 'month';
403
+ const centimes = Number(pp?.[devise]?.[periode]);
404
+ return devise && centimes > 0 ? dire(centimes, devise, periode) : null;
405
+ }
406
+ /** 0.6.1 — les paiements prépayés réglés du compte, parmi les factures (montant en unités mineures, comme Stripe). */
407
+ async function paiementsPrepayes(compte) {
408
+ return (await d.find(S.Payment, { status: 'paid' }).catch(() => [])).filter((p) => {
409
+ const meta = typeof p.metadata === 'string' ? JSON.parse(p.metadata || '{}') : (p.metadata ?? {});
410
+ return String(meta[META.account] ?? '') === String(compte.id);
411
+ }).map((p) => ({ createdAt: p.createdAt, amount: Math.round(Number(p.amount) * 100), currency: p.currency, status: 'paid', hostedUrl: null }));
412
+ }
413
+
414
+ async function vueEspace(compte, lang = 'fr') {
359
415
  const actif = await facturation.activePlan(String(compte.id));
360
416
  const quotas = {};
361
417
  for (const r of periodiques) {
@@ -363,8 +419,10 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
363
419
  quotas[r.cle] = { courant: v.current, limite: v.limit === -1 ? null : v.limit, restant: v.limit === -1 ? null : Math.max(0, v.limit - v.current) };
364
420
  }
365
421
  const cles = (await comptes.cles.lister(String(compte.id))).map((c) => ({ prefix: c.prefix, label: c.label, enabled: c.enabled, usageCount: c.usageCount }));
366
- const factures = (await d.find(S.Invoice, { account: String(compte.id) })).map((f) => ({ createdAt: f.createdAt, amount: f.amount, currency: f.currency, status: f.status, hostedUrl: f.hostedUrl }));
367
- return { plan: actif?.plan ?? null, quotas, cles, factures, portailPossible: !!(stripe && compte.stripeCustomerId), limites: actif?.limits ?? {} };
422
+ const factures = [...(await d.find(S.Invoice, { account: String(compte.id) })).map((f) => ({ createdAt: f.createdAt, amount: f.amount, currency: f.currency, status: f.status, hostedUrl: f.hostedUrl })),
423
+ ...(await paiementsPrepayes(compte))].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')));
424
+ const tarif = await tarifPrepaye(actif?.subscription, actif?.plan, lang);
425
+ return { plan: actif?.plan ?? null, ...(tarif ? { tarif } : {}), quotas, cles, factures, portailPossible: !!(stripe && compte.stripeCustomerId), limites: actif?.limits ?? {} };
368
426
  }
369
427
 
370
428
  /**
@@ -618,7 +676,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
618
676
  if (!p) return null;
619
677
  const pp = typeof p.prepaid === 'string' ? JSON.parse(p.prepaid || 'null') : p.prepaid;
620
678
  const l = [...new Set(Object.values(banques))].filter((dv) => Number(pp?.[dv]?.month) > 0).map((dv) => ({ centimes: Number(pp[dv].month), devise: dv }));
621
- if (stripe && Number(p.price) > 0) l.push({ centimes: Number(p.price), devise: 'EUR' });
679
+ if (stripe && Number(p.price) > 0) l.push({ centimes: Number(p.price), devise: deviseStripe });
622
680
  return l.length ? l : null;
623
681
  };
624
682
  const suite = suiteSure(url.searchParams.get('suite'));
@@ -729,7 +787,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
729
787
  const fin = sub?.currentPeriodEnd && !sub?.stripeSubId && Number(actif?.plan?.price) > 0
730
788
  ? `<p class="hint">${esc(T.jusquau.replace('{plan}', actif.plan.name).replace('{date}', new Date(sub.currentPeriodEnd).toLocaleDateString(resolveFormatLocale(lang), { day: 'numeric', month: 'long', year: 'numeric' })))}</p>` : '';
731
789
  const gestion = `<section class="gestion-compte"><h2>${esc(TC.gerer)}</h2><ul>${methodeConnexion === 'motdepasse' ? `<li><a href="/compte/mot-de-passe">${esc(TC.motDePasse)}</a></li>` : ''}<li><a href="/compte/export">${esc(TC.exporter)}</a></li><li><a href="/compte/supprimer">${esc(TC.supprimer)}</a></li></ul><form method="post" action="/deconnexion"><button class="bouton">${esc(TC.deconnexion)}</button></form></section>`;
732
- return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c) }) + fin + gestion }));
790
+ return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c, lang) }) + fin + gestion }));
733
791
  }
734
792
  // 0.5 — changer son mot de passe : l'actuel d'abord ; les AUTRES sessions sont fermées, celle-ci rouverte.
735
793
  if (chemin === '/compte/mot-de-passe' && methodeConnexion === 'motdepasse') {
@@ -748,7 +806,7 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
748
806
  // 0.5 — partir avec ses données (RGPD, portabilité) : un fichier JSON.
749
807
  if (chemin === '/compte/export' && methode === 'GET') {
750
808
  const u = await d.findOne(S.User, { email: String(qui.utilisateur.courriel) });
751
- const vue = await vueEspace(c);
809
+ const vue = await vueEspace(c, lang);
752
810
  const donnees = {
753
811
  exporteLe: new Date().toISOString(), service: config.marque.nom,
754
812
  utilisateur: { courriel: u?.email, prenom: u?.firstName, nom: u?.lastName, langue: u?.locale, creeLe: u?.createdAt },
@@ -788,8 +846,8 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
788
846
  const f = await formulaire();
789
847
  try {
790
848
  const k = await comptes.cles.creer(String(c.id), { label: String(f.libelle ?? '').slice(0, 60), portees: {} });
791
- return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c), cleNeuve: k.full }) }));
792
- } catch (e) { return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c), erreur: e.code === 'PLAFOND_CLES' ? t('espace.plafondAtteint', { n: (await vueEspace(c)).limites.maxCles }) : e.message }) })); }
849
+ return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c, lang), cleNeuve: k.full }) }));
850
+ } catch (e) { return envoyer(200, page({ actif: '/compte', titre: t('espace.compte'), contenu: ui.espace({ lang, vue: await vueEspace(c, lang), erreur: e.code === 'PLAFOND_CLES' ? t('espace.plafondAtteint', { n: (await vueEspace(c, lang)).limites.maxCles }) : e.message }) })); }
793
851
  }
794
852
  if (chemin === '/compte/cles/revoquer' && methode === 'POST') {
795
853
  await comptes.cles.revoquer(String(c.id), String((await formulaire()).prefix ?? ''));
@@ -894,6 +952,8 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
894
952
  }
895
953
 
896
954
  return {
955
+ // 0.6 — les moyens réellement proposés (après le contrôle du vendeur de chaque devise)
956
+ moyens: [...(stripe ? ['stripe'] : []), ...Object.keys(banques)],
897
957
  handler: (req, res) => handler(req, res).catch((e) => { journal.error(e); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Erreur interne'); } }),
898
958
  catalogue, catalogueStripe, dialect: d, schemas: S, comptes, facturation, confirmerAdmin, reprendrePaiements,
899
959
  /** Fixe le mot de passe d'un compte depuis le serveur (mot de passe perdu) ; lève si la règle le refuse. */