gigafibre-fsm/docs/features/billing-payments.md
louispaulb 2b04e6bd86 feat(portal): passwordless magic-link login — retire ERPNext /login
Customers no longer authenticate with passwords. A POST to the hub's
/portal/request-link mints a 24h customer-scoped JWT and sends it via
email + SMS; the /#/login Vue page sits on top of this and a navigation
guard hydrates the Pinia store from the token on arrival.

Why now: legacy customer passwords are unsalted MD5 from the old PHP
system. Migrating hashes to PBKDF2 would still require a forced reset
for every customer, so it's simpler to drop passwords entirely. The
earlier Authentik forwardAuth attempt was already disabled on
client.gigafibre.ca; this removes the last vestige of ERPNext's
password form from the customer-facing path.

Hub changes:
  - services/targo-hub/lib/portal-auth.js (new) — POST /portal/request-link
    • 3-requests / 15-min per identifier rate limit (in-memory Map + timer)
    • Lookup by email (email_id + email_billing), customer id (legacy +
      direct name), or phone (cell + tel_home)
    • Anti-enumeration: always 200 OK with redacted contact hint
    • Email template with CTA button + raw URL fallback; SMS short form
  - services/targo-hub/server.js — mount the new /portal/* router

Client changes:
  - apps/client/src/pages/LoginPage.vue (new) — standalone full-page,
    single identifier input, success chips, rate-limit banner
  - apps/client/src/api/auth-portal.js (new) — thin fetch wrapper
  - apps/client/src/stores/customer.js — hydrateFromToken() sync decoder,
    stripTokenFromUrl (history.replaceState), init() silent Authentik
    fallback preserved for staff impersonation
  - apps/client/src/router/index.js — PUBLIC_ROUTES allowlist + guard
    that hydrates from URL token before redirecting
  - apps/client/src/api/auth.js — logout() clears store + bounces to
    /#/login (no more Authentik redirect); 401 in authFetch is warn-only
  - apps/client/src/composables/useMagicToken.js — thin read-through to
    the store (no more independent decoding)
  - PaymentSuccess/Cancel/CardAdded pages — goToLogin() uses router,
    not window.location to id.gigafibre.ca

Infra:
  - apps/portal/traefik-client-portal.yml — block /login and
    /update-password on client.gigafibre.ca, redirect to /#/login.
    Any stale bookmark or external link lands on the Vue page, not
    ERPNext's password form.

Docs:
  - docs/roadmap.md — Phase 4 checkbox flipped; MD5 migration item retired
  - docs/features/billing-payments.md — replace MD5 reset note with
    magic-link explainer

Online appointment booking (Plan B from the same discussion) is queued
for a follow-up session; this commit is Plan A only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:25:28 -04:00

438 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Facturation & Paiements — Handoff dev
> Référence unique pour toutes les fonctionnalités facture / paiement construites sur
> la stack `erp.gigafibre.ca` + `client.gigafibre.ca` + `ops`. Lisez
> [../architecture/overview.md](../architecture/overview.md) d'abord pour le contexte
> réseau/services.
Dernière MàJ : 2026-04-17
---
## Table des matières
1. [Importation du système legacy vers ERPNext](#1-importation-du-système-legacy-vers-erpnext)
2. [App Frappe custom — `gigafibre_utils`](#2-app-frappe-custom--gigafibre_utils)
3. [Print Format « Facture TARGO »](#3-print-format--facture-targo-)
4. [Flux de paiement public (sans Authentik)](#4-flux-de-paiement-public-sans-authentik)
5. [Infrastructure Docker](#5-infrastructure-docker)
6. [UI Ops — aperçu client](#6-ui-ops--aperçu-client)
7. [Configuration & secrets](#7-configuration--secrets)
8. [Points connus / TODO](#8-points-connus--todo)
---
## 1. Importation du système legacy vers ERPNext
### Particularités
- **Source** : MariaDB legacy `gestionclient` (conteneur `legacy-db`, 10.100.80.100).
- **Cible** : PostgreSQL ERPNext v16 (patches GROUP BY/HAVING/quotes appliqués — cf.
`feedback_erpnext_postgres.md`).
- **Correspondance des clés** :
- `customer.id` legacy → `Customer.legacy_id` (custom field).
- `service.delivery_id` legacy → `Service Location.legacy_delivery_id` (custom field).
- `invoice_item.service_id` legacy → chaîné via `service.delivery_id` pour rattacher
un item à son `Service Location` ERPNext.
- **Prix** : conservés tels quels (pas de re-calcul des taxes); TPS/TVQ sont imports
inclus dans `taxes[]` de chaque `Sales Invoice`.
- **Volumes finaux** : 6 667 customers · 21 K subscriptions · 115 K invoices ·
1,6 M invoice items · 242 K tickets.
### Fonctionnalités — scripts `scripts/migration/*`
| Script | Rôle |
|---|---|
| `import_invoices.py` | Import complet des factures legacy + JOIN sur `service` pour récupérer `delivery_id` et renseigner `service_location` sur chaque item. |
| `backfill_service_location.py` | Script one-shot pour remplir `service_location` sur les 1,6 M items déjà importés (batches `execute_values` de 20 K lignes, 227 s). |
| `import_payments.py` / `reimport_payments.py` / `import_expro_payments.py` | Import des paiements legacy en `Payment Entry` réconciliés. |
| `import_payment_methods.py` | Carte + ACH + chèque — créés en tant que `Mode of Payment`. |
| `fix_invoice_outstanding.py` | Réconciliation après import — recalcule `outstanding_amount` via Frappe. |
| `fix_invoice_customer_names.py` | Renomme customers pour refléter le nom légal courant (`Subscription.bill_to_name`). |
| `import_payment_arrangements.py` | Ententes de paiement (échelonnement) → `Payment Request` + scheduler. |
| `geocode_locations.py` | Géocode toutes les `Service Location` avec Mapbox. |
### Restauration Item ↔ Service Location (particularité majeure)
Sans ce lien le PDF ne peut pas grouper les lignes par adresse.
```
legacy.invoice_item.service_id
└─→ legacy.service.delivery_id
└─→ ERPNext Service Location.legacy_delivery_id (custom field)
```
1. Custom field `Sales Invoice Item.service_location` (Link → Service Location) créé
via `add_missing_custom_fields.py`.
2. `import_invoices.py` mis à jour : JOIN sur `service` + lookup
`Service Location.legacy_delivery_id` → écrit `service_location` lors de l'INSERT.
3. Backfill ponctuel : `backfill_service_location.py`
(`UPDATE ... FROM (VALUES %s)` via `psycopg2.extras.execute_values`).
### Points d'attention
- **Auth portail** = passwordless magic-link (depuis 2026-04-22) —
`POST /portal/request-link` envoie un JWT 24h par email + SMS.
Les mots de passe legacy MD5 ne sont **plus** utilisés ; le formulaire
ERPNext `/login` est bloqué au niveau Traefik sur `client.gigafibre.ca`
(redirect vers `/#/login`). Aucune migration de hash requise.
- **Devices** rattachés aux **addresses** (Service Location), pas aux customers
(`feedback_device_hierarchy.md`).
- **Serials TPLG** ERPNext ≠ serials réels — matching via MAC
(`feedback_device_serial_mapping.md`).
---
## 2. App Frappe custom — `gigafibre_utils`
### Particularités
- **Chemin source** (serveur ERP) : `/opt/erpnext/custom/gigafibre_utils/`
- **Cuite dans l'image Docker** — `/opt/erpnext/custom/Dockerfile` copie l'app dans
`apps/gigafibre_utils` et la pip-installe en editable.
- **Raison d'exister** : contourner le sandbox Frappe `safe_exec` (qui bloque
`__import__` et certaines opérations) en exposant des `@frappe.whitelist()` Python
natifs appelables depuis Jinja et côté client.
### Arborescence
```
gigafibre_utils/
├── Dockerfile # FROM frappe/erpnext:v16.10.1 + chromium + cairosvg
└── gigafibre_utils/
├── __init__.py
├── hooks.py
├── modules.txt
├── patches.txt
├── api.py # 823 lignes, toutes les méthodes whitelisted
└── www/
└── pay-public.html # Page publique /pay-public (sans Authentik)
```
### Fonctionnalités — `api.py`
#### 2.1 Rendu visuel (QR, logo, descriptions)
| Méthode | `allow_guest` | Rôle |
|---|:-:|---|
| `invoice_qr(invoice)` | ✅ | PNG binaire du QR code (payload = URL `/pay-public` signée, TTL 60 j). |
| `invoice_qr_base64(invoice)` | ✅ | Base64 brut (pour embed direct dans Jinja). |
| `invoice_qr_datauri(invoice)` | ✅ | `data:image/png;base64,…` prêt à injecter en `<img src>`. |
| `logo_base64(height)` | ✅ | Logo TARGO rasterisé via cairosvg (SVG → PNG 96 px). |
| `short_item_name(name, max_len)` | ✅ | Nettoie/tronque les noms d'item (retire caractères parasites, limite longueur). |
**Particularité QR** : la fonction `_sign_pay_token` génère un token HMAC-SHA256
(clé `gigafibre_pay_secret`) → `{invoice, expires_at}` encodé base64-url. Le QR
pointe vers `https://client.gigafibre.ca/pay-public?inv=…&t=…`.
**Particularité logo** : wkhtmltopdf (QtWebKit 2013) ignore `<defs><style>` SVG.
Solution retenue : rendu serveur-side via **cairosvg → PNG base64** injecté dans
le template. Aucune dépendance côté client.
#### 2.2 Génération PDF (`invoice_pdf`)
- Contourne **complètement** la pipeline PDF Frappe (qui ajoute 15 mm de marge top
forcée si pas de `#header-html`, et post-process avec pypdf).
- Lance `chromium --headless=new --no-pdf-header-footer --print-to-pdf` directement
dans un `tempfile.TemporaryDirectory`.
- Réponse HTTP : `Content-Type: application/pdf` + `Content-Disposition: inline`
(⚠️ **ne pas** utiliser `"binary"` — empêche l'affichage iframe et Acrobat échoue
à ouvrir le fichier téléchargé).
- Producer : **Skia/PDF m147** (identique à Antigravity/Gemini → fidélité pixel-parfait).
Usage :
```
GET /api/method/gigafibre_utils.api.invoice_pdf?name=SINV-2026-700010
GET /api/method/gigafibre_utils.api.invoice_pdf?name=SINV-xxx&print_format=Facture+TARGO
```
#### 2.3 Code de parrainage (`referral_code`)
- Déterministe par Customer — **HMAC-SHA256** (clé `gigafibre_pay_secret`) sur
`customer.name` → 30 bits → **6 caractères Crockford base32** (pas de `0/O/1/I/L/U`
ambigus).
- Stable dans le temps (pas stocké en DB).
- ~1,07 milliard de codes possibles → collisions négligeables pour <10 M customers.
---
## 3. Print Format « Facture TARGO »
### Particularités
- **Install** : `scripts/migration/setup_invoice_print_format.py` (idempotent).
- **Doctype** : `Sales Invoice`.
- **Générateur** : **`pdf_generator="chrome"`** Chromium `--print-to-pdf`
headless. C'est **la meilleure méthode** dans ERPNext v16 ; wkhtmltopdf
(QtWebKit 2013) est obsolète et donne un rendu dégradé (SVG `<defs><style>`
ignorés, flexbox cassé, polices Unicode QC incomplètes). Avantages :
- **CSS moderne** : flexbox/grid, `@page` avec `counter(page)` / `counter(pages)`
pour la pagination (p. ex. « Page 1 de 2 » en `@top-right`), `break-inside: avoid`.
- **Polices système** : les accents/caractères spéciaux (é, à, œ, ℃) passent
sans mapping manuel.
- **Rendu pixel-perfect** : même moteur que le navigateur de preview le
preview HTML et le PDF sont identiques.
- **Performances** : ~1 s pour une facture typique (vs ~3 s wkhtmltopdf).
- **Prérequis** : Chromium installé dans le container (voir Dockerfile
`FROM frappe/erpnext:v16.10.1 + apt-get install chromium`), clé
`chromium_path: /usr/bin/chromium` dans `common_site_config.json`.
- **Marges** : forcées par le Print Format `margin_top=5, bottom=5, left=15,
right=15` (mm) Chrome PDF ignore `@page { margin: … }` dans le CSS
(`preferCSSPageSize=false`), il faut passer par les options ERPNext.
- **Template Jinja** : ~25 Ko, inliné dans le script `setup_invoice_print_format.py`
(constante `html_template = r"""…"""`) source de vérité unique, versionnée.
### Fonctionnalités clés du template
1. **Prélude de résolution de données** récupère `customer`, `service_locations`,
`prev_invoice`, `recent_payments`, `remaining_balance`, `account_number`, etc.
depuis le doc `Sales Invoice`.
2. **Position de la fenêtre d'enveloppe** bloc adresse client à **50 mm du haut**
(2″) pour s'aligner sur enveloppe #10 à fenêtre (ouverture 2-3½″).
- `margin-top` du `.cl-block` varie selon la présence d'un `prev_invoice`
(10 mm si bloc SOMMAIRE DU COMPTE présent en amont, 33 mm sinon).
3. **Regroupement par Service Location** la section « FRAIS COURANTS » itère
`current_charges_locations[]`, chaque location a ses items (pas de sous-total
par location pour réduire la hauteur).
4. **Période de service inline** calculée par item :
- Utilise `service_start_date` / `service_end_date` si présents (ERPNext deferred).
- Sinon déduit la période courante sauf si l'item est **one-time** (mots-clés :
`install`, `activation`, `rabais`, `frais unique`, `remise`, ou montant négatif).
- Format : `(1er avril au 30 avril)` plein mois, `(16 avril au 30 avril)` prorata.
5. **Colonne de droite (meta + QR + parrainage + conditions)** :
- Meta-band : N° compte · Date · N° facture
- **Montant ** (boîte verte)
- QR code (base64 inline, 60 j TTL)
- Boîte parrainage (fond gris, accent vert) code 6 car. via `gigafibre_utils.api.referral_code`.
- Conditions légales sera **assujettie** à des frais de retard »)
- Contactez-nous : **1867 chemin de la Rivière, Ste-Clotilde, J0L 1W0 · 855 888-2746**
6. **Mini-footer CPRST** en bas de page.
### Particularités Jinja
- Variables `svc_start` / `svc_end` **doivent être définies avant la boucle items**
(sinon `UndefinedError`).
- Les appels `frappe.call(...)` dans le sandbox Jinja requièrent un `frappe.local.request`
vivant le rendu hors contexte web (ex. `bench execute`) échoue : **toujours
tester via HTTP** (l'endpoint `invoice_pdf` fait ça correctement).
### Fichiers connexes
- `scripts/migration/invoice_preview.jinja` copie de travail lisible (pas utilisée
en prod seul le Print Format installé compte).
- `scripts/migration/test_jinja_render.py` rend le template localement via Chrome
macOS pour comparaison pixel (référence).
---
## 4. Flux de paiement public (sans Authentik)
### Vue d'ensemble
```
QR (facture) / SMS / Email Authentik-less
↓ ↑
https://client.gigafibre.ca/pay-public?inv=X&t=HMAC
validate_pay_token → affiche résumé facture
[Payer avec Stripe]
create_checkout_session → redirect Stripe
Stripe Checkout (hors ERP)
stripe_webhook → Payment Entry reconciled
```
### Trois TTL de tokens (signés HMAC-SHA256)
| Token | TTL | Usage |
|---|---|---|
| **QR code** | 60 jours | Lien permanent sur facture imprimée. |
| **Magic link** | 15 minutes | SMS/email en cas de token expiré. |
| **pay_redirect** | 1 heure | Bridge depuis portail authentifié (customer connecté). |
### Fonctionnalités — `api.py`
| Méthode | `allow_guest` | Rôle |
|---|:-:|---|
| `pay_token(invoice, ttl_days)` | (admin) | Génère une URL signée (debug/test). |
| `validate_pay_token(invoice, token)` | | Vérifie signature+TTL, retourne résumé facture. |
| `request_magic_link(invoice, channel, last4)` | | Envoie SMS/email (gated par `last4` du téléphone). |
| `create_checkout_session(invoice, token)` | | Crée Stripe Checkout Session. |
| `stripe_webhook()` | | Vérifie signature Stripe, crée Payment Entry. |
| `pay_redirect(invoice)` | sous Authentik | Bridge portal pay-public (bypass staff @targo.ca/@gigafibre.ca). |
### Landing page `/pay-public`
- Servie par Frappe depuis `gigafibre_utils/www/pay-public.html`.
- Vanilla JS pas de build step, pas de framework.
- Lit `inv` + `t` depuis `frappe.form_dict`.
- Appelle `validate_pay_token` affiche facture OU formulaire magic-link.
- Bouton « Payer avec Stripe » `create_checkout_session` redirect.
### Carve-out Traefik (contournement Authentik)
Fichier : `/opt/traefik/dynamic/pay-public.yml`.
Routeurs avec **priority ≥ 250** (> priorité 150 des règles `/api` Authentik) :
- `client-pay-public``PathPrefix(/pay-public)` (landing)
- `client-api-validate-pay-token`
- `client-api-magic-link`
- `client-api-create-checkout-session`
- `client-api-stripe-webhook`
Tous servis sur `client-portal-svc`, **aucun middleware** (pas de ForwardAuth).
### Stripe
- **Checkout Session API** : créée côté serveur (`_stripe_post`), redirect 303 vers
`session.url`.
- **Webhook** : vérifie `Stripe-Signature` (`_stripe_verify_signature`) avec
`gigafibre_stripe_webhook_secret` → crée `Payment Entry` rattaché à la facture.
- **Clé réutilisée** depuis targo-hub (`sk_live_51QCMOI…`).
### Twilio (magic link SMS)
- Auth basic (`twilio_account_sid:twilio_auth_token`).
- From `twilio_from_number=+14382313838`.
- Anti-abuse : `request_magic_link` exige `last4` matchant les 4 derniers chiffres
du téléphone customer avant d'envoyer.
---
## 5. Infrastructure Docker
### Image custom ERPNext
`/opt/erpnext/custom/Dockerfile` :
```dockerfile
FROM frappe/erpnext:v16.10.1
USER root
RUN apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 shared-mime-info \
chromium fonts-liberation fonts-noto-color-emoji
USER frappe
COPY --chown=frappe:frappe gigafibre_utils apps/gigafibre_utils
RUN env/bin/pip install -e apps/gigafibre_utils cairosvg
RUN grep -qx gigafibre_utils apps/apps.txt || echo gigafibre_utils >> apps/apps.txt
```
### Particularités
- Chromium installé à `/usr/bin/chromium` (config key `chromium_path`).
- cairosvg (Python) pour rasterisation SVG → PNG.
- App `gigafibre_utils` **copiée dans l'image** (pas un volume) → toute modif
requiert `docker compose build erpnext-backend && docker compose up -d`.
- ⚠️ Piège : `sites/apps.txt` peut se retrouver avec une ligne parasite
`apps.txt` qui casse `bench` (`ModuleNotFoundError: No module named 'apps'`) —
nettoyer si ça arrive.
### Build & déploiement
```bash
cd /opt/erpnext/custom
docker compose build erpnext-backend
docker compose up -d --force-recreate erpnext-backend
```
---
## 6. UI Ops — aperçu client
### Fonctionnalité
Onglet « Aperçu client » dans le volet de détail facture
(`apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue`).
- `q-tabs` : **Détails** (champs grid) ↔ **Aperçu client** (iframe PDF).
- Iframe pointe sur `gigafibre_utils.api.invoice_pdf?name=…` → rendu Chrome
pixel-parfait identique au PDF client.
### Particularité Vue scoped
Les styles `.modal-field-grid / .mf / .mf-label` définis dans `DetailModal.vue`
avec `scoped` **ne cascadent pas** vers les composants enfants montés via
`<component :is>`. Il faut les **dupliquer** dans le `<style scoped>` du composant
enfant (contre-intuitif — documenter pour les nouveaux devs).
---
## 7. Configuration & secrets
Toutes les clés vivent dans `/opt/erpnext/custom/erpnext/sites/common_site_config.json`
(montage bind du volume `sites` sur le conteneur `erpnext-backend`).
### Clés requises
| Clé | Valeur / format | Utilisation |
|---|---|---|
| `gigafibre_pay_secret` | 64 hex | HMAC signing tokens + referral codes |
| `gigafibre_pay_host` | `https://client.gigafibre.ca/pay-public` | Base URL QR/magic-link |
| `gigafibre_stripe_secret_key` | `sk_live_…` | Stripe Checkout API |
| `gigafibre_stripe_webhook_secret` | `whsec_…` | **TODO** — webhook signature verify |
| `twilio_account_sid` | `AC…` | SMS magic-link |
| `twilio_auth_token` | (hex) | SMS auth |
| `twilio_from_number` | `+14382313838` | SMS from |
| `chromium_path` | `/usr/bin/chromium` | PDF generator |
### Commande pour poser une clé
```bash
docker exec erpnext-backend-1 bench set-config -g <key> "<value>"
```
---
## 8. Points connus / TODO
### ⚠️ TODO bloquants
- [ ] **Webhook Stripe** : enregistrer un second endpoint pointant vers
`https://client.gigafibre.ca/api/method/gigafibre_utils.api.stripe_webhook`
dans le dashboard Stripe, récupérer le `whsec_…`, poser via
`bench set-config -g gigafibre_stripe_webhook_secret`.
### Améliorations suggérées
- [ ] **Inverse lookup parrainage** : `customer_from_referral_code(code)` pour
appliquer le crédit 50 $ automatiquement à la souscription du parrain.
- [ ] **Rate-limit** sur `request_magic_link` (actuellement protégé par last4
seulement — OK pour l'abus téléphone, pas pour énumération d'invoices).
- [ ] **Refresh token** côté Traccar : le token généré expire le 2031-04-17 ;
prévoir rotation automatique (script cron + API `/api/session/token`).
- [ ] **Archivage local des PDFs** : stocker dans `File Doctype` pour historique
et éviter de re-rendre via Chromium à chaque affichage.
### Pièges connus
- `frappe.response.type = "binary"` ⇒ Acrobat échoue à ouvrir le fichier
téléchargé → **toujours utiliser `"pdf"`** pour les PDFs.
- Postgres v16 + ERPNext : bugs `GROUP BY`/`HAVING`/double-quotes —
patches dans `feedback_erpnext_postgres.md`.
- Script `bench execute` échoue sur `gigafibre_utils.api.invoice_pdf` hors
contexte web (Jinja appelle `frappe.call` qui requiert `frappe.local.request`).
Tester via HTTP.
- Traccar token ≠ FCM token — les `APA91b…` sont pour Firebase push, pas pour
l'API Traccar (cf. conversation 2026-04-17).
---
## Fichiers à connaître (index rapide)
| Fichier | Rôle |
|---|---|
| `/opt/erpnext/custom/Dockerfile` | Image ERPNext custom |
| `/opt/erpnext/custom/gigafibre_utils/gigafibre_utils/api.py` | **823 lignes** — toutes les méthodes whitelisted |
| `/opt/erpnext/custom/gigafibre_utils/gigafibre_utils/www/pay-public.html` | Landing publique |
| `/opt/traefik/dynamic/pay-public.yml` | Carve-out Authentik |
| `scripts/migration/setup_invoice_print_format.py` | Install/update du Print Format |
| `scripts/migration/import_invoices.py` | Import legacy → ERPNext |
| `scripts/migration/backfill_service_location.py` | Backfill item↔location 1,6 M lignes |
| `scripts/migration/test_jinja_render.py` | Rendu local pour comparaison pixel |
| `apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue` | Onglet Aperçu client |
| `services/targo-hub/lib/traccar.js` | Proxy Traccar (Bearer token) |