From 3bad1451a96b39fcd965963065c7c32b9dc5276e Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 17:25:55 +0200 Subject: [PATCH] docs(bruno): collection Billing + endpoints check-in in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau dossier `09-Billing/` avec : - folder.bru (overview : plans + flows upgrade/cancel/reactivate) - 01 Get subscription : state du plan, caps, grace period, cancel flag - 02 Start checkout : crée une Checkout Session Stripe (Pro/Business × monthly/yearly) - 03 Open portal : Customer Portal pour gérer CB/annulation - 04 Reactivate : annule l'annulation programmée (sans paiement immédiat) — gère le conflit Stripe cancel_at vs cancel_at_period_end Aussi documenté les endpoints in-app check-in qui manquaient dans Bruno : - 03 In-app pending : liste des factures awaiting_user_confirmation - 04 In-app respond paid : équivalent du lien email "C'est payé" - 05 In-app respond pending : équivalent "Toujours en attente" README mis à jour avec le parcours étendu (signup → … → billing). Co-Authored-By: Claude Opus 4.7 --- bruno/08-Checkin/03 In-app pending.bru | 60 +++++++++++++++++ bruno/08-Checkin/04 In-app respond paid.bru | 44 +++++++++++++ .../08-Checkin/05 In-app respond pending.bru | 44 +++++++++++++ bruno/09-Billing/01 Get subscription.bru | 65 ++++++++++++++++++ bruno/09-Billing/02 Start checkout.bru | 63 ++++++++++++++++++ bruno/09-Billing/03 Open portal.bru | 50 ++++++++++++++ bruno/09-Billing/04 Reactivate.bru | 55 ++++++++++++++++ bruno/09-Billing/folder.bru | 66 +++++++++++++++++++ bruno/README.md | 6 ++ 9 files changed, 453 insertions(+) create mode 100644 bruno/08-Checkin/03 In-app pending.bru create mode 100644 bruno/08-Checkin/04 In-app respond paid.bru create mode 100644 bruno/08-Checkin/05 In-app respond pending.bru create mode 100644 bruno/09-Billing/01 Get subscription.bru create mode 100644 bruno/09-Billing/02 Start checkout.bru create mode 100644 bruno/09-Billing/03 Open portal.bru create mode 100644 bruno/09-Billing/04 Reactivate.bru create mode 100644 bruno/09-Billing/folder.bru diff --git a/bruno/08-Checkin/03 In-app pending.bru b/bruno/08-Checkin/03 In-app pending.bru new file mode 100644 index 0000000..31ace7a --- /dev/null +++ b/bruno/08-Checkin/03 In-app pending.bru @@ -0,0 +1,60 @@ +meta { + name: 03 In-app pending + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/checkin/inapp/pending + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("data est un array", function () { + expect(res.getBody().data).to.be.an("array"); + }); +} + +docs { + GET /api/v1/checkin/inapp/pending — auth requise + + Liste les factures de l'org en `awaiting_user_confirmation` (= ayant + reçu l'email check-in mais pas encore eu de réponse). C'est la queue + que la modale du SPA affiche au login pour rappeler à l'user qu'il a + des décisions à prendre. + + Tri : `due_date` ASC (les plus anciennes échéances d'abord). + + Réponse : + ```json + { + "data": [ + { + "id": "uuid", + "numero": "F2026-0007", + "amountTtcCents": 12345, + "issueDate": "2026-04-07T...", + "dueDate": "2026-05-07T...", + "status": "awaiting_user_confirmation", + "clientName": "Boulangerie Martin", + "planName": "Standard B2B" + } + ] + } + ``` + + ## Note V1 + + En prod actuelle, le statut DB de l'invoice reste `pending` jusqu'à ce + que l'user réponde au check-in (le `send_checkin_job` ne change pas + le statut). C'est le seed démo qui force `awaiting_user_confirmation` + pour pré-peupler des cas. À aligner V1.5. +} diff --git a/bruno/08-Checkin/04 In-app respond paid.bru b/bruno/08-Checkin/04 In-app respond paid.bru new file mode 100644 index 0000000..152408c --- /dev/null +++ b/bruno/08-Checkin/04 In-app respond paid.bru @@ -0,0 +1,44 @@ +meta { + name: 04 In-app respond paid + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/checkin/inapp/{{invoiceId}}/paid + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("status = paid", function () { + expect(res.getBody().data.status).to.equal("paid"); + }); +} + +docs { + POST /api/v1/checkin/inapp/:invoiceId/paid — auth requise + + Réponse "Oui — la facture est payée" depuis l'app (modale check-in OU + bouton "Marquer encaissée" sur la fiche). Effets identiques au lien + email mais auth-based (pas de token URL). + + Effets : + - CheckinTask (si elle existe) : status='answered', answer='paid' + - Invoice : status='paid', paid_at=now, rubis_earned+1 + - Organization.rubis_count+1 + - ActivityEvent kind=invoice_paid (label "via confirmation") + - Toutes les RelanceTask scheduled de la facture → cancelled + + Réponse : la facture sérialisée (status='paid' désormais). + + Précondition : `invoiceId` doit appartenir à l'org de l'user (404 sinon). + Idempotent : si déjà paid → renvoie l'état courant sans bumper. +} diff --git a/bruno/08-Checkin/05 In-app respond pending.bru b/bruno/08-Checkin/05 In-app respond pending.bru new file mode 100644 index 0000000..ae272e4 --- /dev/null +++ b/bruno/08-Checkin/05 In-app respond pending.bru @@ -0,0 +1,44 @@ +meta { + name: 05 In-app respond pending + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/api/v1/checkin/inapp/{{invoiceId}}/pending + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("status = in_relance", function () { + expect(res.getBody().data.status).to.equal("in_relance"); + }); +} + +docs { + POST /api/v1/checkin/inapp/:invoiceId/pending — auth requise + + Réponse "Non — toujours en attente, lance les relances" depuis l'app + (modale check-in OU bouton "Relancer maintenant" sur la fiche). + + Effets : + - CheckinTask (si elle existe) : status='answered', answer='still_pending' + - `scheduleRelancesForInvoice()` : crée les RelanceTask scheduled selon + le plan, enqueue dans BullMQ. Si certaines étapes sont déjà passées + (catch-up), la 1re part à `now+1min`. + - Invoice.status passe immédiatement en `in_relance` (l'user voit la + facture sortir de l'attente sans attendre le 1er envoi mail) + - ActivityEvent kind=relance_sent (label "Relances activées pour …") + + Réponse : la facture sérialisée (status='in_relance' désormais). + + Précondition : `invoiceId` doit appartenir à l'org (404 sinon). +} diff --git a/bruno/09-Billing/01 Get subscription.bru b/bruno/09-Billing/01 Get subscription.bru new file mode 100644 index 0000000..9592be4 --- /dev/null +++ b/bruno/09-Billing/01 Get subscription.bru @@ -0,0 +1,65 @@ +meta { + name: 01 Get subscription + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/billing/subscription + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("plan dans l'enum", function () { + expect(res.getBody().data.plan).to.be.oneOf(["free", "pro", "business"]); + }); + test("caps présents", function () { + expect(res.getBody().data.caps).to.have.property("activeInvoicesLimit"); + expect(res.getBody().data.caps).to.have.property("seatsLimit"); + }); + test("flags grace + cancel exposés", function () { + expect(res.getBody().data).to.have.property("inGracePeriod"); + expect(res.getBody().data).to.have.property("cancelAtPeriodEnd"); + }); +} + +docs { + GET /api/v1/billing/subscription — auth requise + + Retourne l'état de la souscription pour l'org de l'user courant. + + Réponse : + ```json + { + "data": { + "plan": "free" | "pro" | "business", + "caps": { + "activeInvoicesLimit": 5 | null, + "seatsLimit": 1 | 5 | null, + "multiUsers": false | true, + "replyFromUserEmail": false | true, + "smsEnabled": false + }, + "activeInvoicesCount": 12, + "inGracePeriod": true | false, + "gracePeriodEndsAt": "2026-08-06T..." | null, + "subscriptionStatus": "active" | "past_due" | "canceled" | null, + "billingCycle": "monthly" | "yearly" | null, + "currentPeriodEnd": "2026-06-07T..." | null, + "hasStripeCustomer": true | false, + "cancelAtPeriodEnd": false | true + } + } + ``` + + Utilisé par `/parametres/abonnement` côté SPA pour afficher le plan + courant + les caps + l'état d'annulation. +} diff --git a/bruno/09-Billing/02 Start checkout.bru b/bruno/09-Billing/02 Start checkout.bru new file mode 100644 index 0000000..2c3cdec --- /dev/null +++ b/bruno/09-Billing/02 Start checkout.bru @@ -0,0 +1,63 @@ +meta { + name: 02 Start checkout + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/v1/billing/checkout + body: json + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "plan": "pro", + "cycle": "monthly" + } +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("URL Stripe Checkout retournée", function () { + expect(res.getBody().data.url).to.match(/^https:\/\/checkout\.stripe\.com\//); + }); +} + +docs { + POST /api/v1/billing/checkout — auth requise + + Crée une Stripe Checkout Session pour upgrader vers Pro ou Business. + Body : + ```json + { "plan": "pro" | "business", "cycle": "monthly" | "yearly" } + ``` + + Effets : + - Crée le Stripe Customer si l'org n'en a pas (idempotent via `stripeCustomerId`) + - Crée une Checkout Session en mode `subscription` avec le bon Price (lookup_key) + - `subscription_data.metadata.organization_id` posé pour le webhook + - `allow_promotion_codes: true`, `locale: "fr"`, billing address auto + + Réponse : + ```json + { "data": { "url": "https://checkout.stripe.com/c/pay/cs_test_..." } } + ``` + + Le SPA fait `window.location.href = url`. L'user paye sur Stripe (UI hostée), + puis Stripe le redirect vers `${WEB_URL}/parametres/abonnement?checkout=success`. + + ## Pour tester + + 1. Récupère un `token` via Auth → 01 Signup + 2. Lance cette requête → tu reçois une URL Checkout + 3. Ouvre l'URL dans un navigateur, paye avec CB test : `4242 4242 4242 4242` + 4. Vérifie via **01 Get subscription** que `plan: "pro"` est posé + 5. Mailpit (http://localhost:8025) : tu reçois un email "thank you for subscribing" +} diff --git a/bruno/09-Billing/03 Open portal.bru b/bruno/09-Billing/03 Open portal.bru new file mode 100644 index 0000000..fede392 --- /dev/null +++ b/bruno/09-Billing/03 Open portal.bru @@ -0,0 +1,50 @@ +meta { + name: 03 Open portal + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/v1/billing/portal + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("URL Stripe Billing Portal retournée", function () { + expect(res.getBody().data.url).to.match(/^https:\/\/billing\.stripe\.com\//); + }); +} + +docs { + POST /api/v1/billing/portal — auth requise + + Crée une Stripe Billing Portal Session pour que l'user gère lui-même + sa souscription (changement de CB, factures Stripe, annulation). + + Précondition : l'org doit avoir un `stripeCustomerId` (= avoir déjà + passé un checkout au moins une fois). Sinon → 400 `no_stripe_customer`. + + Réponse : + ```json + { "data": { "url": "https://billing.stripe.com/p/session/..." } } + ``` + + Le SPA fait `window.location.href = url`. L'user fait ce qu'il veut + côté Stripe Portal, puis le redirect retourne vers + `${WEB_URL}/parametres/abonnement`. + + ## Annulation via portail + + Quand l'user clique "Cancel plan" + confirme, Stripe pose `cancel_at` + (timestamp = period_end) et fire `customer.subscription.updated`. Notre + webhook détecte les 2 mécaniques (`cancel_at_period_end` ET `cancel_at`) + et les unifie en `org.cancel_at_period_end = true`. +} diff --git a/bruno/09-Billing/04 Reactivate.bru b/bruno/09-Billing/04 Reactivate.bru new file mode 100644 index 0000000..fda99d3 --- /dev/null +++ b/bruno/09-Billing/04 Reactivate.bru @@ -0,0 +1,55 @@ +meta { + name: 04 Reactivate + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/billing/reactivate + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("ok=true", function () { + expect(res.getBody().data.ok).to.equal(true); + }); +} + +docs { + POST /api/v1/billing/reactivate — auth requise + + Annule une annulation programmée — la subscription continue son cycle + normal, sans paiement immédiat. + + Effets : + - Retrieve le sub Stripe pour savoir laquelle des 2 mécaniques est posée + (Stripe REFUSE qu'on passe `cancel_at_period_end` ET `cancel_at` dans + le même update : "Please pass in only one") + - Si `cancel_at` est posé → on envoie `cancel_at: null` + - Sinon → on envoie `cancel_at_period_end: false` + - Persiste sur l'org : `cancel_at_period_end = false` + + Idempotent : + - Si l'org n'a pas de sub Stripe → 400 `no_active_subscription` + - Si l'annulation n'est PAS programmée → 200 `{ok: true}` sans toucher Stripe + + Réponse : + ```json + { "data": { "ok": true } } + ``` + + ## Pour tester + + 1. Annule la sub via le Customer Portal (03 Open portal → click Cancel) + 2. Vérifie via **01 Get subscription** : `cancelAtPeriodEnd: true` + 3. Lance cette requête + 4. Re-vérifie : `cancelAtPeriodEnd: false` +} diff --git a/bruno/09-Billing/folder.bru b/bruno/09-Billing/folder.bru new file mode 100644 index 0000000..e8d4aef --- /dev/null +++ b/bruno/09-Billing/folder.bru @@ -0,0 +1,66 @@ +meta { + name: Billing + seq: 10 +} + +docs { + ## Billing — Stripe Checkout, Customer Portal, Subscriptions + + Endpoints qui pilotent la souscription d'une org : passage Free → Pro/Business + via Stripe Checkout, gestion CB & annulation via Customer Portal, et webhook + Stripe pour tenir l'org synchro avec l'état Stripe. + + ## Plans + + | Plan | Mensuel | Annuel | Limite factures actives | V2 | + |---|---|---|---|---| + | Free | 0 € | — | 5 (post-grace 3 mois) | — | + | Pro | 19 € | 190 € | illimité | — | + | Business | 49 € | 490 € | illimité | 5 sièges, reply-from-user | + + ## Flow d'upgrade + + 1. SPA → **02 Start checkout** (auth) avec `{plan, cycle}` → reçoit l'URL Stripe + 2. SPA redirect vers l'URL → user paye sur Stripe Checkout (UI hostée) + 3. Stripe redirect vers `?checkout=success` côté SPA + 4. **En parallèle** : Stripe envoie `checkout.session.completed` au webhook + → `applySubscriptionToOrg` set `org.plan='pro'`, `subscription_status='active'` + 5. SPA repoll **01 Get subscription** → affiche le nouveau plan + + ## Flow d'annulation + + 1. SPA → **03 Open portal** (auth) → reçoit l'URL portal + 2. SPA redirect → user click "Cancel plan" + confirme + 3. Stripe pose `cancel_at` (timestamp = period_end) ou `cancel_at_period_end=true` + 4. Stripe envoie `customer.subscription.updated` au webhook + → `org.cancel_at_period_end = true` + 5. UI affiche bandeau "ANNULÉ — accès jusqu'au DD/MM" + bouton "Réactiver" + 6. À `current_period_end`, Stripe envoie `customer.subscription.deleted` + → `org.plan = 'free'` + + ## Flow de réactivation + + Si l'user clique "Réactiver" avant la fin de période : + 1. SPA → **04 Reactivate** (auth) + 2. Backend retrieve la sub Stripe pour savoir si `cancel_at` ou + `cancel_at_period_end` est posé (ils sont mutuellement exclusifs côté + Stripe API), puis clear le bon + 3. Pas de paiement immédiat — la sub continue son cycle normal + + ## Webhook idempotence + + Stripe peut re-livrer un event plusieurs fois. Notre handler est read-then-write + (pas d'assumption "1-shot"). Les 4 events pris en charge : + - checkout.session.completed + - customer.subscription.created/updated → applySubscriptionToOrg + - customer.subscription.deleted → bascule en Free + - invoice.payment_failed → status past_due + + ## Pour tester en local + + 1. `node ace stripe:setup` une fois pour créer Products + Prices test mode + 2. `stripe listen --forward-to localhost:3333/api/v1/billing/webhook` + → copie le `whsec_...` dans `.env` (STRIPE_WEBHOOK_SECRET) + 3. Lance Bruno **02 Start checkout** → ouvre l'URL → CB test 4242 4242 4242 4242 + 4. Reviens sur **01 Get subscription** → tu devrais voir `plan: "pro"` +} diff --git a/bruno/README.md b/bruno/README.md index 8b8e2ef..b5dfeea 100644 --- a/bruno/README.md +++ b/bruno/README.md @@ -48,6 +48,12 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e 11. **Imports → 01 Upload (mock)** (capture `batchId` + `draftId`) 12. **Imports → 02 Get batch** (review des drafts pending) 13. **Imports → 03 Validate draft** (transforme le draft en facture) +14. **Checkin → 03 In-app pending** (liste les factures `awaiting_user_confirmation`) +15. **Checkin → 04 In-app respond paid** ou **05 In-app respond pending** +16. **Billing → 01 Get subscription** (state du plan + caps) +17. **Billing → 02 Start checkout** (upgrade Pro/Business via Stripe Checkout) +18. **Billing → 03 Open portal** (gérer CB / annuler via Stripe Portal) +19. **Billing → 04 Reactivate** (annule l'annulation programmée) ### Flow refresh (silent re-login)