# 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 dû** (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) |
