@mostajs/wagon-engine 0.6.1 → 0.7.0

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,18 @@
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
  Format : Keep a Changelog ; versions : SemVer.
5
5
 
6
+ ## [0.7.0] — 2026-10-29
7
+
8
+ ### Ajouté
9
+ - **Le paiement ponctuel de la charge** : `ctx.paiement.ouvrir({ moyen, montant, description, reference, metadata,
10
+ merci, annule })` ouvre une session chez une banque prépayée active (celle qui a un vendeur, 0.6) pour un achat à
11
+ l'unité de la charge (une place de cours de Speaking) ; `ctx.paiement.moyens` dit les moyens et leur devise ;
12
+ `ctx.paiement` est `null` sans banque. La banque confirme à `/api/paiement/charge/<moyen>` (webhook authentifié par
13
+ sa signature, hors CSRF et hors session) ou renvoie l'acheteur à `/api/paiement/charge-retour/<moyen>` (banque
14
+ réinterrogée, comme SATIM) ; le wagon remet le constat à `charge.paiementRegle({ reference, statut, commande, montant,
15
+ devise, moyen, metadata })`. Les chemins de retour sont locaux. La ligne `Payment` reste dans la base du wagon.
16
+ Essai T-ENG-16.
17
+
6
18
  ## [0.6.1] — 2026-10-28
7
19
 
8
20
  ### Corrigé
package/README.md CHANGED
@@ -50,6 +50,8 @@ await demarrer({ config, charge });
50
50
 
51
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.
52
52
 
53
+ **Un achat à l'unité de la charge (0.7).** `ctx.paiement.ouvrir({ moyen, montant, description, reference, metadata, merci, annule })` ouvre un paiement ponctuel (montant en unités mineures) chez une banque prépayée active ; `ctx.paiement.moyens` les liste, `ctx.paiement` est `null` sans banque. La banque confirme à `/api/paiement/charge/<moyen>` (signature) ou y renvoie l'acheteur (`/api/paiement/charge-retour/<moyen>`) ; le wagon appelle `charge.paiementRegle({ reference, statut, commande, montant, devise, moyen, metadata })`, que la charge écrit idempotent.
54
+
53
55
  ## Juger une greffe
54
56
 
55
57
  La suite de conformité est publiée avec le moteur. Elle juge la greffe du dossier courant, ou celle que désigne `WAGON_GREFFE` :
package/llms.txt CHANGED
@@ -45,10 +45,11 @@ encore immatriculée → `PAIEMENT_VENDEUR_DZD=…`, `PAIEMENT_VENDEUR_EUR=` (vi
45
45
  ## CONTRAT DE LA CHARGE (charge/index.mjs)
46
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 ·
47
47
  routes(req, res, ctx) (passent en premier ; true si servie) · conformite (essai de quota T-WAG-3) · amorcer() (base au
48
- démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauvegarde() · permissions (0.11 :
48
+ démarrage) · exporterCompte(id), effacerCompte(id) (RGPD) · sitemap() · sauvegarde() · paiementRegle(p) (0.7) · permissions (0.11 :
49
49
  { '/page': { microphone: 'self' }, '/dossier/*': … }, garde-http ≥ 0.2).
50
50
  ctx : chemin, methode, lang, t, ui, page({ titre, contenu }), envoyer, json, rediriger, formulaire, corpsJson({ strict }),
51
51
  compte, consommer(cle), refuser(v), palier({ compte? }), plafond(cle, n, { compte? }), courriel({ to, subject, text, html? }) → bool, admin.
52
+ 0.7 : ctx.paiement (null sans banque prépayée active) = { moyens: [{ moyen, devise }], ouvrir({ moyen, montant (unités MINEURES), description, reference, metadata, merci: '/chemin', annule: '/chemin' }) → { ok, url, commande } | { ok: false, raison } } — paiement PONCTUEL de la charge (une place de cours…). La banque confirme à /api/paiement/charge/<moyen> (webhook, signature) ou renvoie l'acheteur à /api/paiement/charge-retour/<moyen> (SATIM) ; le wagon appelle alors charge.paiementRegle({ reference, statut: paid|failed|refunded|…, commande, montant (mineures), devise, moyen, metadata }), à écrire IDEMPOTENT (une banque rejoue). merci/annule : chemins locaux seulement.
52
53
  0.4 : /inscription?suite=/chemin et /connexion?suite=/chemin — retour à une page LOCALE après inscription (si ni clé ni paiement à montrer ; sinon bouton « Continuer ») ou connexion par mot de passe ; //hôte et adresses absolues ignorés.
53
54
  0.3 : ctx.page({ disposition: 'vitrine'|'application'|'console'|'nu'|<fournie>, application: { retour, titre, avatar, onglets, langues }, console: { menu, contexte, utilisateur } }) (wagon-ui 0.10) ; config.jetons { rayon, police, policeTitres, ombre, fond } ; config.marque.logo / logoClair (SVG en ligne ou chemin /…).
54
55
  0.2 : { compte } = identifiant d'un compte DÉSIGNÉ (requête sans session bornée par le palier d'un compte rattaché par la charge).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/wagon-engine",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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",
package/src/wagon.mjs CHANGED
@@ -25,7 +25,8 @@ 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
+ import * as paymentServer from '@mostajs/payment/server';
29
+ const { venteAutorisee, vendeursFromEnv } = paymentServer;
29
30
  import { formatNumber, resolveFormatLocale } from '@mostajs/i18n';
30
31
  import { createPasswordAuth, createEmailCodeAuth, SessionSchema, ipClient, memoryLimiter } from '@mostajs/auth-lite';
31
32
  import { emailCodeEmailHtml, emailCodeEmailText, emailCodeForm, emailLinkConfirmPage, AUTH_CSS } from '@mostajs/auth-ui';
@@ -259,6 +260,53 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
259
260
  return faits;
260
261
  }
261
262
  if (prepaye && Object.keys(banques).some((b) => SANS_WEBHOOK.includes(b))) setInterval(reprendrePaiements, 10 * 60 * 1000).unref?.();
263
+
264
+ /**
265
+ * 0.7 — le PAIEMENT PONCTUEL de la charge (une place de cours, un achat à l'unité), par les banques prépayées
266
+ * actives (celles qui ont un vendeur) : `ctx.paiement.ouvrir(…)` ouvre la session chez la banque ; la banque confirme
267
+ * à /api/paiement/charge/<moyen> (webhook) ou y renvoie l'acheteur (/api/paiement/charge-retour/<moyen>, SATIM). Le
268
+ * paiement se constate par @mostajs/payment (signature vérifiée, ou banque réinterrogée : rien de l'URL n'est cru),
269
+ * puis le wagon appelle `charge.paiementRegle({ reference, statut, commande, montant, devise, moyen, metadata })`.
270
+ * Le wagon ne garde que la ligne Payment ; ce que le paiement règle (une réservation…) est l'affaire de la charge,
271
+ * qui doit être idempotente (une banque rejoue ses webhooks).
272
+ */
273
+ const portPaiement = async () => paiement?.port?.createPaymentCheckout ? paiement.port : paymentServer;
274
+ const CHEMIN_LOCAL = /^\/(?!\/)[^\s]*$/;
275
+ const paiementCharge = Object.keys(banques).length ? Object.freeze({
276
+ moyens: Object.entries(banques).map(([moyen, devise]) => ({ moyen, devise: String(devise).toUpperCase() })),
277
+ /** `montant` en unités MINEURES ; `merci` et `annule` : des chemins de la greffe. Rend { ok, url, commande } ou { ok: false, raison }. */
278
+ async ouvrir({ moyen, montant, description = '', reference, metadata = {}, merci = '/', annule = '/' }) {
279
+ const devise = banques[moyen];
280
+ if (!devise) return { ok: false, raison: `moyen non proposé : ${moyen}` };
281
+ const m = Math.round(Number(montant));
282
+ if (!(m > 0)) return { ok: false, raison: 'montant invalide' };
283
+ if (!reference || !CHEMIN_LOCAL.test(merci) || !CHEMIN_LOCAL.test(annule)) return { ok: false, raison: 'référence ou chemins de retour invalides' };
284
+ const commande = `ch_${String(reference).replace(/[^\w-]/g, '').slice(0, 40)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
285
+ try {
286
+ const r = await (await portPaiement()).createPaymentCheckout({
287
+ dialect: d, providerName: moyen, orderId: commande, amount: m / 100, currency: String(devise).toUpperCase(), description,
288
+ successUrl: `${origine}${merci}`, cancelUrl: `${origine}${annule}`,
289
+ webhookUrl: `${origine}/api/paiement/charge/${moyen}`, returnUrl: `${origine}/api/paiement/charge-retour/${moyen}`,
290
+ metadata: { ...metadata, wagon_charge: String(reference), wagon_merci: merci, wagon_annule: annule },
291
+ });
292
+ if (!r?.checkout?.url) return { ok: false, raison: 'la banque n’a pas rendu d’adresse de paiement' };
293
+ return { ok: true, url: r.checkout.url, commande };
294
+ } catch (e) { return { ok: false, raison: String(e?.message ?? e) }; }
295
+ },
296
+ }) : null;
297
+ /** Constate un paiement de la charge et le lui remet : { status, paye, meta }. */
298
+ async function reglerCharge(moyen, corpsBrut, headers) {
299
+ if (!banques[moyen]) return { status: 404, paye: false, meta: {} };
300
+ const r = await (await portPaiement()).settlePaymentFromWebhook({ dialect: d, providerName: moyen, body: corpsBrut, headers });
301
+ if (!r?.ok) return { status: 400, paye: false, meta: {} };
302
+ const pay = r.payment;
303
+ const meta = typeof pay?.metadata === 'string' ? JSON.parse(pay.metadata || '{}') : (pay?.metadata ?? {});
304
+ if (!pay || !r.status || !meta.wagon_charge) return { status: 200, paye: false, meta };
305
+ try {
306
+ await charge.paiementRegle?.({ reference: meta.wagon_charge, statut: r.status, commande: String(r.orderId ?? pay.orderId), montant: Math.round(Number(pay.amount) * 100), devise: pay.currency, moyen, metadata: meta });
307
+ } catch (e) { journal.warn(`wagon : paiementRegle de la charge : ${e?.message ?? e}`); return { status: 500, paye: false, meta }; }
308
+ return { status: 200, paye: r.status === 'paid', meta };
309
+ }
262
310
  const admins = new Set([...(config.admins ?? []), ...String(getEnv('WAGON_ADMINS', '') ?? '').split(',')].map((x) => x.trim().toLowerCase()).filter(Boolean));
263
311
  /** Un courriel est administrateur s'il est DÉCLARÉ et CONFIRMÉ. */
264
312
  const estAdministrateur = async (courriel) => {
@@ -556,6 +604,19 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
556
604
  return rediriger(r.paid ? '/paiement/merci' : '/tarifs');
557
605
  }
558
606
 
607
+ // 0.7 — les paiements ponctuels de la charge : webhook (signature) et retour de l'acheteur (banque réinterrogée).
608
+ if (chemin.startsWith('/api/paiement/charge/') && methode === 'POST') {
609
+ if (!paiementCharge) return json(503, { ok: false, error: 'paiement non configuré' });
610
+ const r = await reglerCharge(decodeURIComponent(chemin.slice('/api/paiement/charge/'.length)), (await corps()).toString('utf8'), req.headers);
611
+ return json(r.status, { ok: r.status === 200 });
612
+ }
613
+ if (chemin.startsWith('/api/paiement/charge-retour/') && (methode === 'GET' || methode === 'POST')) {
614
+ if (!paiementCharge) return rediriger('/');
615
+ const r = await reglerCharge(decodeURIComponent(chemin.slice('/api/paiement/charge-retour/'.length)), methode === 'GET' ? url.searchParams.toString() : (await corps()).toString('utf8'), req.headers);
616
+ const vers = r.paye ? r.meta.wagon_merci : r.meta.wagon_annule;
617
+ return rediriger(typeof vers === 'string' && CHEMIN_LOCAL.test(vers) ? vers : '/');
618
+ }
619
+
559
620
  // La porte de service : une clé d'API ouvre /api/v1/* ; sans clé valable, 401 — jamais une page.
560
621
  let compteCle = null;
561
622
  if (chemin.startsWith('/api/v1/')) {
@@ -631,7 +692,8 @@ export async function creerWagon({ config, charge, sgbd, baseUrl, stripe = null,
631
692
  if (!transport) return false;
632
693
  try { await transport.envoyer(m); return true; } catch (e) { journal.warn(`wagon : courriel de la charge non parti : ${e?.message ?? e}`); return false; }
633
694
  };
634
- const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle };
695
+ const ctx = { chemin, methode, lang, t, ui, page, envoyer, json, rediriger, formulaire, corpsJson, compte, consommer, refuser, palier, plafond, courriel, admin: estAdmin && !compteCle,
696
+ paiement: paiementCharge }; // 0.7 — null sans banque prépayée active
635
697
  if (charge.routes && (await charge.routes(req, res, ctx))) return true;
636
698
  if (chemin.startsWith('/api/')) return json(404, { ok: false, error: 'introuvable' });
637
699