From 27cfa9ac13c05fb8af2ab8650adfab89641ccfda Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 14:40:41 +0200 Subject: [PATCH] =?UTF-8?q?docs(bruno):=20collection=20compl=C3=A8te=20des?= =?UTF-8?q?=20routes=20API=20+=20environnement=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collection Bruno (.bru text files, comme Postman mais file-based versionable) qui couvre l'API V1 actuelle. Open Collection → bruno/ → sélectionner l'environnement "local". Domaines couverts (22 requêtes) : - 00-Auth : Signup, Login, Logout - 01-Account : Get/Update profile - 02-Organizations : Get/Update my org - 03-Clients : List, List+stats, Search, Create, Create duplicate (409), Create without email (422), Get detail, Update - 04-Plans : List, Get by slug, Update (steps remplacés) - 05-Invoices : List, List+filters, Counts, Create, Get detail, Mark paid Environnement local (bruno/environments/local.bru) : - baseUrl, email/password/fullName en dur - token, userId, organizationId, clientId, invoiceId remplis automatiquement par les script:post-response Chaque requête a : - assertions Chai (statut, shape de la réponse) - bloc docs avec sémantique métier + erreurs typiques - inheritance auth Bearer via folder.bru pour ne pas répéter le header Mise à jour de docs/tech/dev-setup.md pour pointer vers la collection. Le parcours recommandé Signup → Update org → Create client → Create invoice → Mark paid couvre le happy path et permet de checker rubisCount qui s'incrémente. --- bruno/00-Auth/01 Signup.bru | 62 +++++++++++++++++++ bruno/00-Auth/02 Login.bru | 45 ++++++++++++++ bruno/00-Auth/03 Logout.bru | 36 +++++++++++ bruno/00-Auth/folder.bru | 16 +++++ bruno/01-Account/01 Get profile.bru | 29 +++++++++ bruno/01-Account/02 Update profile.bru | 35 +++++++++++ bruno/01-Account/folder.bru | 16 +++++ bruno/02-Organizations/01 Get my org.bru | 31 ++++++++++ bruno/02-Organizations/02 Update my org.bru | 41 ++++++++++++ bruno/02-Organizations/folder.bru | 19 ++++++ bruno/03-Clients/01 List.bru | 27 ++++++++ bruno/03-Clients/02 List with stats.bru | 42 +++++++++++++ bruno/03-Clients/03 Search.bru | 27 ++++++++ bruno/03-Clients/04 Create.bru | 47 ++++++++++++++ .../03-Clients/05 Create duplicate (409).bru | 49 +++++++++++++++ .../06 Create without email (422).bru | 33 ++++++++++ bruno/03-Clients/07 Get detail.bru | 29 +++++++++ bruno/03-Clients/08 Update.bru | 36 +++++++++++ bruno/03-Clients/folder.bru | 19 ++++++ bruno/04-Plans/01 List.bru | 33 ++++++++++ bruno/04-Plans/02 Get by slug.bru | 30 +++++++++ bruno/04-Plans/03 Update.bru | 60 ++++++++++++++++++ bruno/04-Plans/folder.bru | 20 ++++++ bruno/05-Invoices/01 List.bru | 30 +++++++++ bruno/05-Invoices/02 List filters.bru | 32 ++++++++++ bruno/05-Invoices/03 Counts.bru | 33 ++++++++++ bruno/05-Invoices/04 Create.bru | 55 ++++++++++++++++ bruno/05-Invoices/05 Get detail.bru | 35 +++++++++++ bruno/05-Invoices/06 Mark paid.bru | 42 +++++++++++++ bruno/05-Invoices/folder.bru | 22 +++++++ bruno/README.md | 58 +++++++++++++++++ bruno/bruno.json | 6 ++ bruno/collection.bru | 30 +++++++++ bruno/environments/local.bru | 12 ++++ docs/tech/dev-setup.md | 13 +++- 35 files changed, 1149 insertions(+), 1 deletion(-) create mode 100644 bruno/00-Auth/01 Signup.bru create mode 100644 bruno/00-Auth/02 Login.bru create mode 100644 bruno/00-Auth/03 Logout.bru create mode 100644 bruno/00-Auth/folder.bru create mode 100644 bruno/01-Account/01 Get profile.bru create mode 100644 bruno/01-Account/02 Update profile.bru create mode 100644 bruno/01-Account/folder.bru create mode 100644 bruno/02-Organizations/01 Get my org.bru create mode 100644 bruno/02-Organizations/02 Update my org.bru create mode 100644 bruno/02-Organizations/folder.bru create mode 100644 bruno/03-Clients/01 List.bru create mode 100644 bruno/03-Clients/02 List with stats.bru create mode 100644 bruno/03-Clients/03 Search.bru create mode 100644 bruno/03-Clients/04 Create.bru create mode 100644 bruno/03-Clients/05 Create duplicate (409).bru create mode 100644 bruno/03-Clients/06 Create without email (422).bru create mode 100644 bruno/03-Clients/07 Get detail.bru create mode 100644 bruno/03-Clients/08 Update.bru create mode 100644 bruno/03-Clients/folder.bru create mode 100644 bruno/04-Plans/01 List.bru create mode 100644 bruno/04-Plans/02 Get by slug.bru create mode 100644 bruno/04-Plans/03 Update.bru create mode 100644 bruno/04-Plans/folder.bru create mode 100644 bruno/05-Invoices/01 List.bru create mode 100644 bruno/05-Invoices/02 List filters.bru create mode 100644 bruno/05-Invoices/03 Counts.bru create mode 100644 bruno/05-Invoices/04 Create.bru create mode 100644 bruno/05-Invoices/05 Get detail.bru create mode 100644 bruno/05-Invoices/06 Mark paid.bru create mode 100644 bruno/05-Invoices/folder.bru create mode 100644 bruno/README.md create mode 100644 bruno/bruno.json create mode 100644 bruno/collection.bru create mode 100644 bruno/environments/local.bru diff --git a/bruno/00-Auth/01 Signup.bru b/bruno/00-Auth/01 Signup.bru new file mode 100644 index 0000000..d4ff6dd --- /dev/null +++ b/bruno/00-Auth/01 Signup.bru @@ -0,0 +1,62 @@ +meta { + name: 01 Signup + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/v1/auth/signup + body: json + auth: none +} + +body:json { + { + "email": "{{email}}", + "password": "{{password}}", + "fullName": "{{fullName}}" + } + +} + +script:post-response { + if (res.getStatus() === 201) { + const session = res.getBody().data; + bru.setEnvVar("token", session.accessToken); + bru.setEnvVar("userId", session.user.id); + bru.setEnvVar("organizationId", session.user.organizationId); + } +} + +tests { + test("201 Created", function () { + expect(res.getStatus()).to.equal(201); + }); + test("AuthSession shape", function () { + const data = res.getBody().data; + expect(data).to.have.property("accessToken"); + expect(data).to.have.property("expiresAt"); + expect(data.user).to.have.property("id"); + expect(data.user).to.have.property("organizationId"); + }); +} + +docs { + POST /api/v1/auth/signup + + Crée une organisation vide + un user + duplique les 4 plans pré-fournis + (Standard B2B / Rapide / Patient / Ferme) dans la même transaction. Émet + ensuite un access token (TTL 30 min). + + Le nom de l'organisation reste `""` jusqu'à ce que l'utilisateur passe + l'onboarding via PATCH /organizations/me. + + Validation côté API : + - email format + unique + - password ≥ 8 chars + - fullName 2-120 chars + + Erreurs typiques : + - 422 validation_failed (email/password/fullName invalides) + - 422 + `rule: database.unique` si email déjà pris +} diff --git a/bruno/00-Auth/02 Login.bru b/bruno/00-Auth/02 Login.bru new file mode 100644 index 0000000..f43aae0 --- /dev/null +++ b/bruno/00-Auth/02 Login.bru @@ -0,0 +1,45 @@ +meta { + name: 02 Login + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/v1/auth/login + body: json + auth: none +} + +body:json { + { + "email": "{{email}}", + "password": "{{password}}" + } + +} + +script:post-response { + if (res.getStatus() === 200) { + const session = res.getBody().data; + bru.setEnvVar("token", session.accessToken); + bru.setEnvVar("userId", session.user.id); + bru.setEnvVar("organizationId", session.user.organizationId); + } +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); +} + +docs { + POST /api/v1/auth/login + + Émet un nouveau access token. Pratique pour récupérer un token sans + re-signup (l'email/password de fixture restent les mêmes entre runs). + + Erreurs : + - 422 validation_failed (email/password manquants) + - 401 invalid_credentials (mauvais mot de passe ou email inconnu) +} diff --git a/bruno/00-Auth/03 Logout.bru b/bruno/00-Auth/03 Logout.bru new file mode 100644 index 0000000..14a9e15 --- /dev/null +++ b/bruno/00-Auth/03 Logout.bru @@ -0,0 +1,36 @@ +meta { + name: 03 Logout + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/v1/account/logout + body: none + auth: bearer +} + +auth:bearer { + token: {{token}} +} + +script:post-response { + if (res.getStatus() === 204) { + bru.setEnvVar("token", ""); + } +} + +tests { + test("204 No Content", function () { + expect(res.getStatus()).to.equal(204); + }); +} + +docs { + POST /api/v1/account/logout + + Révoque le access token courant. Réponse 204 sans body. + + Le script post-réponse vide la variable `token` pour que les requêtes + suivantes échouent en 401 (test du flow déconnexion). +} diff --git a/bruno/00-Auth/folder.bru b/bruno/00-Auth/folder.bru new file mode 100644 index 0000000..fbd6692 --- /dev/null +++ b/bruno/00-Auth/folder.bru @@ -0,0 +1,16 @@ +meta { + name: Auth + seq: 1 +} + +docs { + ## Auth — public (pas d'auth Bearer requise) + + - **Signup** crée organisation + user + provisionne les 4 plans pré-fournis dans une transaction, puis émet un access token. + - **Login** vérifie email/password et émet un nouveau token. + - **Logout** révoque le token courant (auth requise — c'est volontairement dans le dossier Auth pour rester groupé sémantiquement). + + La réponse `AuthSession` est : `{ data: { accessToken, expiresAt, user } }`. + + Le script post-réponse de Signup/Login capture `token`, `userId`, `organizationId` dans l'environnement actif. +} diff --git a/bruno/01-Account/01 Get profile.bru b/bruno/01-Account/01 Get profile.bru new file mode 100644 index 0000000..89b7ff3 --- /dev/null +++ b/bruno/01-Account/01 Get profile.bru @@ -0,0 +1,29 @@ +meta { + name: 01 Get profile + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/account/profile + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("user shape", function () { + const u = res.getBody().data; + expect(u).to.have.property("id"); + expect(u).to.have.property("email"); + expect(u).to.have.property("organizationId"); + }); +} + +docs { + GET /api/v1/account/profile + + Retourne l'utilisateur courant. Réponse `{ data: User }`. +} diff --git a/bruno/01-Account/02 Update profile.bru b/bruno/01-Account/02 Update profile.bru new file mode 100644 index 0000000..5d6a9ac --- /dev/null +++ b/bruno/01-Account/02 Update profile.bru @@ -0,0 +1,35 @@ +meta { + name: 02 Update profile + type: http + seq: 2 +} + +patch { + url: {{baseUrl}}/api/v1/account/profile + body: json + auth: inherit +} + +body:json { + { + "fullName": "Alice Bruno (mise à jour)", + "signature": "—\nAlice\nBoulangerie Bruno" + } + +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("signature persisted", function () { + expect(res.getBody().data.signature).to.contain("Boulangerie Bruno"); + }); +} + +docs { + PATCH /api/v1/account/profile + + Met à jour fullName, email ou signature (tous optionnels). La signature + est utilisée comme footer des emails de relance. +} diff --git a/bruno/01-Account/folder.bru b/bruno/01-Account/folder.bru new file mode 100644 index 0000000..53c8dea --- /dev/null +++ b/bruno/01-Account/folder.bru @@ -0,0 +1,16 @@ +meta { + name: Account + seq: 2 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +docs { + ## Compte courant — auth Bearer requise +} diff --git a/bruno/02-Organizations/01 Get my org.bru b/bruno/02-Organizations/01 Get my org.bru new file mode 100644 index 0000000..7f0f428 --- /dev/null +++ b/bruno/02-Organizations/01 Get my org.bru @@ -0,0 +1,31 @@ +meta { + name: 01 Get my org + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/organizations/me + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("rubisCount is integer", function () { + expect(res.getBody().data.rubisCount).to.be.a("number"); + }); +} + +docs { + GET /api/v1/organizations/me + + Retourne l'organisation rattachée à l'utilisateur courant. + + Champs : + - `id`, `name` (vide tant que onboarding pas fait), `siret`, `monthlyVolumeBucket` + - `rubisCount` : compteur cumulé (1 rubis = 10 min libérées) + - `onboardingCompletedAt` : null tant que le nom est vide +} diff --git a/bruno/02-Organizations/02 Update my org.bru b/bruno/02-Organizations/02 Update my org.bru new file mode 100644 index 0000000..cf1230d --- /dev/null +++ b/bruno/02-Organizations/02 Update my org.bru @@ -0,0 +1,41 @@ +meta { + name: 02 Update my org + type: http + seq: 2 +} + +patch { + url: {{baseUrl}}/api/v1/organizations/me + body: json + auth: inherit +} + +body:json { + { + "name": "Boulangerie Bruno", + "siret": "12345678901234", + "monthlyVolumeBucket": "10-50" + } + +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("onboardingCompletedAt is set", function () { + expect(res.getBody().data.onboardingCompletedAt).to.not.be.null; + }); +} + +docs { + PATCH /api/v1/organizations/me + + Étape onboarding 2. Met à jour nom / SIRET (14 chiffres si fourni) / + bucket de volume mensuel. + + Pose `onboardingCompletedAt` automatiquement la première fois qu'un + nom non-vide est posé. + + Buckets valides : `moins-10`, `10-50`, `50-100`, `100-200`, `plus-200`. +} diff --git a/bruno/02-Organizations/folder.bru b/bruno/02-Organizations/folder.bru new file mode 100644 index 0000000..5a3f32b --- /dev/null +++ b/bruno/02-Organizations/folder.bru @@ -0,0 +1,19 @@ +meta { + name: Organizations + seq: 3 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +docs { + ## Organisation rattachée au user courant + + V1 mono-tenant : un user → une org. PATCH déclenche la pose de + `onboardingCompletedAt` à la première mise en place du nom. +} diff --git a/bruno/03-Clients/01 List.bru b/bruno/03-Clients/01 List.bru new file mode 100644 index 0000000..c7497ea --- /dev/null +++ b/bruno/03-Clients/01 List.bru @@ -0,0 +1,27 @@ +meta { + name: 01 List + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/clients + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("data is array", function () { + expect(res.getBody().data).to.be.an("array"); + }); +} + +docs { + GET /api/v1/clients + + Liste à plat (sans stats), triée alphabétique par nom. Utilisée par le + combobox client à la saisie de facture. +} diff --git a/bruno/03-Clients/02 List with stats.bru b/bruno/03-Clients/02 List with stats.bru new file mode 100644 index 0000000..af679b2 --- /dev/null +++ b/bruno/03-Clients/02 List with stats.bru @@ -0,0 +1,42 @@ +meta { + name: 02 List with stats + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/clients?withStats=1 + body: none + auth: inherit +} + +params:query { + withStats: 1 +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("clients are enriched", function () { + const list = res.getBody().data; + if (list.length > 0) { + expect(list[0]).to.have.property("invoiceCount"); + expect(list[0]).to.have.property("lateInvoiceCount"); + expect(list[0]).to.have.property("paidLifetimeCents"); + } + }); +} + +docs { + GET /api/v1/clients?withStats=1 + + Liste enrichie pour la page /clients. Chaque client expose : + - `invoiceCount` (total) + - `activeInvoiceCount` / `lateInvoiceCount` + - `paidInvoiceCount` / `paidLifetimeCents` + - `pendingLifetimeCents` + - `lastActivityAt` + + Tri : retards d'abord (actionnable), puis activité récente. +} diff --git a/bruno/03-Clients/03 Search.bru b/bruno/03-Clients/03 Search.bru new file mode 100644 index 0000000..3e3c4b2 --- /dev/null +++ b/bruno/03-Clients/03 Search.bru @@ -0,0 +1,27 @@ +meta { + name: 03 Search + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/clients?q=boulang + body: none + auth: inherit +} + +params:query { + q: boulang +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); +} + +docs { + GET /api/v1/clients?q= + + Recherche case-insensitive sur `name` et `email`. +} diff --git a/bruno/03-Clients/04 Create.bru b/bruno/03-Clients/04 Create.bru new file mode 100644 index 0000000..7a9df54 --- /dev/null +++ b/bruno/03-Clients/04 Create.bru @@ -0,0 +1,47 @@ +meta { + name: 04 Create + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/clients + body: json + auth: inherit +} + +body:json { + { + "name": "Boulangerie Martin SARL", + "email": "compta@martin.fr", + "phone": "06 12 34 56 78", + "siret": "12345678901234", + "notes": "Bon payeur historique" + } + +} + +script:post-response { + if (res.getStatus() === 201) { + bru.setEnvVar("clientId", res.getBody().data.id); + } +} + +tests { + test("201 Created", function () { + expect(res.getStatus()).to.equal(201); + }); + test("clientId saved to env", function () { + expect(bru.getEnvVar("clientId")).to.not.be.empty; + }); +} + +docs { + POST /api/v1/clients + + Création manuelle. Email **requis** (pivot produit). SIRET = 14 chiffres + exactement si fourni. + + Capture `clientId` dans l'env pour les requêtes suivantes (détail, + update, création de facture). +} diff --git a/bruno/03-Clients/05 Create duplicate (409).bru b/bruno/03-Clients/05 Create duplicate (409).bru new file mode 100644 index 0000000..cd772b0 --- /dev/null +++ b/bruno/03-Clients/05 Create duplicate (409).bru @@ -0,0 +1,49 @@ +meta { + name: 05 Create duplicate (409) + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/api/v1/clients + body: json + auth: inherit +} + +body:json { + { + "name": "BOULANGERIE MARTIN SARL", + "email": "autre@martin.fr" + } + +} + +tests { + test("409 Conflict", function () { + expect(res.getStatus()).to.equal(409); + }); + test("error code = duplicate_client", function () { + const err = res.getBody().errors[0]; + expect(err.code).to.equal("duplicate_client"); + expect(err.field).to.equal("name"); + }); + test("existing payload returned", function () { + expect(res.getBody().existing).to.have.property("id"); + }); +} + +docs { + Démontre la détection de doublon par nom (case-insensitive). + + Réponse 409 avec : + ```json + { + "errors": [ + { "code": "duplicate_client", "message": "...", "field": "name" } + ], + "existing": { "id": "...", "name": "Boulangerie Martin SARL", ... } + } + ``` + + Le SPA peut proposer "Voir le client existant" plutôt que créer. +} diff --git a/bruno/03-Clients/06 Create without email (422).bru b/bruno/03-Clients/06 Create without email (422).bru new file mode 100644 index 0000000..a4debb4 --- /dev/null +++ b/bruno/03-Clients/06 Create without email (422).bru @@ -0,0 +1,33 @@ +meta { + name: 06 Create without email (422) + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/v1/clients + body: json + auth: inherit +} + +body:json { + { + "name": "Sans email" + } + +} + +tests { + test("422 Unprocessable", function () { + expect(res.getStatus()).to.equal(422); + }); + test("field error on email", function () { + const err = res.getBody().errors[0]; + expect(err.field).to.equal("email"); + }); +} + +docs { + Email REQUIS — sans email pas de relance possible. C'est le pivot du + produit (cf. CLAUDE.md → Principes). +} diff --git a/bruno/03-Clients/07 Get detail.bru b/bruno/03-Clients/07 Get detail.bru new file mode 100644 index 0000000..32e32b9 --- /dev/null +++ b/bruno/03-Clients/07 Get detail.bru @@ -0,0 +1,29 @@ +meta { + name: 07 Get detail + type: http + seq: 7 +} + +get { + url: {{baseUrl}}/api/v1/clients/{{clientId}} + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("invoices array present", function () { + expect(res.getBody().data.invoices).to.be.an("array"); + }); +} + +docs { + GET /api/v1/clients/:id + + Détail enrichi : champs Client + stats agrégées + `invoices: []` + (factures récentes, à brancher quand le domaine Invoice est rempli). + + Nécessite `clientId` capturé via "04 Create". +} diff --git a/bruno/03-Clients/08 Update.bru b/bruno/03-Clients/08 Update.bru new file mode 100644 index 0000000..20abe89 --- /dev/null +++ b/bruno/03-Clients/08 Update.bru @@ -0,0 +1,36 @@ +meta { + name: 08 Update + type: http + seq: 8 +} + +patch { + url: {{baseUrl}}/api/v1/clients/{{clientId}} + body: json + auth: inherit +} + +body:json { + { + "phone": "06 99 88 77 66", + "address": "12 rue du Pain, 75011 Paris", + "notes": "Mise à jour adresse + tel" + } + +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("phone updated", function () { + expect(res.getBody().data.phone).to.contain("06 99"); + }); +} + +docs { + PATCH /api/v1/clients/:id + + Édition partielle : tous les champs sont optionnels. SIRET passé en `null` + réinitialise le champ. +} diff --git a/bruno/03-Clients/folder.bru b/bruno/03-Clients/folder.bru new file mode 100644 index 0000000..0c43474 --- /dev/null +++ b/bruno/03-Clients/folder.bru @@ -0,0 +1,19 @@ +meta { + name: Clients + seq: 4 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +docs { + ## Clients — auth requise, scope par organization du user + + Email **requis** : sans email, Rubis ne peut pas relancer. + Détection de doublons par nom case-insensitive (409 + payload `existing`). +} diff --git a/bruno/04-Plans/01 List.bru b/bruno/04-Plans/01 List.bru new file mode 100644 index 0000000..965d4f7 --- /dev/null +++ b/bruno/04-Plans/01 List.bru @@ -0,0 +1,33 @@ +meta { + name: 01 List + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/plans + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("4 default plans provisioned", function () { + expect(res.getBody().data.length).to.be.at.least(4); + }); + test("each plan has steps + usageCount", function () { + const p = res.getBody().data[0]; + expect(p).to.have.property("steps"); + expect(p).to.have.property("usageCount"); + }); +} + +docs { + GET /api/v1/plans + + Retourne les 4 plans pré-fournis (Standard B2B, Rapide, Patient, Ferme) + avec leurs étapes (subject, body, ton, offsetDays, requiresManualValidation) + et un `usageCount` (nombre de factures actives utilisant ce plan). +} diff --git a/bruno/04-Plans/02 Get by slug.bru b/bruno/04-Plans/02 Get by slug.bru new file mode 100644 index 0000000..2c31bbf --- /dev/null +++ b/bruno/04-Plans/02 Get by slug.bru @@ -0,0 +1,30 @@ +meta { + name: 02 Get by slug + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/plans/{{planSlug}} + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("plan has steps array", function () { + expect(res.getBody().data.steps).to.be.an("array"); + }); +} + +docs { + GET /api/v1/plans/:slug + + Détail d'un plan par slug. Le SPA utilise des URLs lisibles : + `/parametres/plans/standard-30j`. + + Slugs disponibles par défaut : `standard-30j`, `rapide-15j`, + `patient-60j`, `ferme-7j`. +} diff --git a/bruno/04-Plans/03 Update.bru b/bruno/04-Plans/03 Update.bru new file mode 100644 index 0000000..28f8ea2 --- /dev/null +++ b/bruno/04-Plans/03 Update.bru @@ -0,0 +1,60 @@ +meta { + name: 03 Update + type: http + seq: 3 +} + +patch { + url: {{baseUrl}}/api/v1/plans/{{planSlug}} + body: json + auth: inherit +} + +body:json { + { + "name": "Standard B2B (édité)", + "description": "Cadence affinée après retour utilisateur.", + "steps": [ + { + "order": 0, + "offsetDays": 5, + "tone": "amical", + "subject": "Petit rappel — facture 1", + "body": "Bonjour name,\n\nUn rappel pour la facture 1.\n\nSignatire", + "requiresManualValidation": false + }, + { + "order": 1, + "offsetDays": 12, + "tone": "courtois", + "subject": "Relance facture 1", + "body": "Bonjour, la facture 1 reste impayée.\n\nSignature", + "requiresManualValidation": false + } + ] + + } +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("steps replaced (2 steps now)", function () { + expect(res.getBody().data.steps).to.have.lengthOf(2); + }); +} + +docs { + PATCH /api/v1/plans/:slug + + Édite name, description et/ou steps. Les steps sont **remplacés en bloc** + dans une transaction (pas de diff fin id-par-id) — plus simple et + prévisible côté UX. + + Validation des steps : + - 1 à 10 étapes + - `tone` ∈ `amical | courtois | ferme | mise_en_demeure` + - `offsetDays` ∈ [-30, 180] + - `subject` 1-200, `body` 1-5000 +} diff --git a/bruno/04-Plans/folder.bru b/bruno/04-Plans/folder.bru new file mode 100644 index 0000000..1ad4aad --- /dev/null +++ b/bruno/04-Plans/folder.bru @@ -0,0 +1,20 @@ +meta { + name: Plans + seq: 5 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +docs { + ## Plans de relance + + Les 4 plans pré-fournis (`standard-30j`, `rapide-15j`, `patient-60j`, + `ferme-7j`) sont provisionnés à la création de l'organisation. Lookup + par slug pour des URLs stables côté SPA. +} diff --git a/bruno/05-Invoices/01 List.bru b/bruno/05-Invoices/01 List.bru new file mode 100644 index 0000000..0814112 --- /dev/null +++ b/bruno/05-Invoices/01 List.bru @@ -0,0 +1,30 @@ +meta { + name: 01 List + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/invoices + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("meta has total + page", function () { + const meta = res.getBody().meta; + expect(meta).to.have.property("total"); + expect(meta).to.have.property("page"); + }); +} + +docs { + GET /api/v1/invoices + + Liste paginée (50/page). Tri actionnable : + `awaiting_user_confirmation` → `in_relance` → `pending` → `litigation` + → `paid` → `cancelled`, puis `dueDate` croissant. +} diff --git a/bruno/05-Invoices/02 List filters.bru b/bruno/05-Invoices/02 List filters.bru new file mode 100644 index 0000000..75d8b11 --- /dev/null +++ b/bruno/05-Invoices/02 List filters.bru @@ -0,0 +1,32 @@ +meta { + name: 02 List filters + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/invoices?status=pending&q=F-2026&page=1 + body: none + auth: inherit +} + +params:query { + status: pending + q: F-2026 + page: 1 +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); +} + +docs { + GET /api/v1/invoices avec filtres : + + - `status` ∈ statuses + `all` (recommandé pour les chips dashboard) + - `q` : recherche sur `numero` ET nom du client (jointure ILIKE) + - `clientId` : UUID, filtre une fiche client + - `page` : pagination 1-indexed (50/page) +} diff --git a/bruno/05-Invoices/03 Counts.bru b/bruno/05-Invoices/03 Counts.bru new file mode 100644 index 0000000..ed736c5 --- /dev/null +++ b/bruno/05-Invoices/03 Counts.bru @@ -0,0 +1,33 @@ +meta { + name: 03 Counts + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/invoices/counts + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("counts shape", function () { + const c = res.getBody().data; + for (const key of ["all", "pending", "in_relance", "awaiting_user_confirmation", "paid", "litigation", "cancelled"]) { + expect(c).to.have.property(key); + } + }); +} + +docs { + GET /api/v1/invoices/counts + + Compteurs par statut, agrégés en une requête PG `GROUP BY status`. + Utilisé pour les chips de filtre sur le dashboard / la liste factures. + + ⚠️ Ordre des routes : `/counts` est déclaré AVANT `/:id` côté API, + sinon `:id` matcherait la string "counts". +} diff --git a/bruno/05-Invoices/04 Create.bru b/bruno/05-Invoices/04 Create.bru new file mode 100644 index 0000000..7d8fd87 --- /dev/null +++ b/bruno/05-Invoices/04 Create.bru @@ -0,0 +1,55 @@ +meta { + name: 04 Create + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/invoices + body: json + auth: inherit +} + +body:json { + { + "clientId": "{{clientId}}", + "clientName": "Boulangerie Martin SARL", + "numero": "F-2026-0042", + "amountTtcCents": 124000, + "issueDate": "2026-04-20T09:00:00.000Z", + "dueDate": "2026-05-20T09:00:00.000Z" + } + +} + +script:post-response { + if (res.getStatus() === 201) { + bru.setEnvVar("invoiceId", res.getBody().data.id); + } +} + +tests { + test("201 Created", function () { + expect(res.getStatus()).to.equal(201); + }); + test("invoiceId saved", function () { + expect(bru.getEnvVar("invoiceId")).to.not.be.empty; + }); + test("rubisEarned = 1 (bonus saisie)", function () { + expect(res.getBody().data.rubisEarned).to.equal(1); + }); +} + +docs { + POST /api/v1/invoices + + Saisie manuelle. Résolution client en 3 étapes : + 1. `clientId` fourni → utilise tel quel + 2. sinon match par nom (case-insensitive) sur les clients existants + 3. sinon création à la volée — `clientEmail` REQUIS sinon 422 + `client_email_required` + + Bonus +1 rubis à la création (gamification). + + Capture `invoiceId` dans l'env pour les requêtes suivantes. +} diff --git a/bruno/05-Invoices/05 Get detail.bru b/bruno/05-Invoices/05 Get detail.bru new file mode 100644 index 0000000..97fdf05 --- /dev/null +++ b/bruno/05-Invoices/05 Get detail.bru @@ -0,0 +1,35 @@ +meta { + name: 05 Get detail + type: http + seq: 5 +} + +get { + url: {{baseUrl}}/api/v1/invoices/{{invoiceId}} + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("client preload present", function () { + expect(res.getBody().data.client).to.have.property("name"); + }); + test("timeline is array with `issued` event", function () { + const tl = res.getBody().data.timeline; + expect(tl).to.be.an("array"); + expect(tl[0].state).to.equal("past"); + }); +} + +docs { + GET /api/v1/invoices/:id + + Détail enrichi : champs Invoice + `client` (Client préchargé) + `plan` + (Plan + steps préchargés) + `timeline` composée par le serveur. + + La timeline calque les `plan.steps` sur `dueDate + offsetDays` et marque + chaque étape `past` / `current` / `future` selon l'horodatage courant. +} diff --git a/bruno/05-Invoices/06 Mark paid.bru b/bruno/05-Invoices/06 Mark paid.bru new file mode 100644 index 0000000..ba4c4f7 --- /dev/null +++ b/bruno/05-Invoices/06 Mark paid.bru @@ -0,0 +1,42 @@ +meta { + name: 06 Mark paid + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/v1/invoices/{{invoiceId}}/mark-paid + body: none + auth: inherit +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("status = paid", function () { + expect(res.getBody().data.status).to.equal("paid"); + }); + test("paidAt non null", function () { + expect(res.getBody().data.paidAt).to.not.be.null; + }); + test("rubisEarned bumpé à 2 (1 saisie + 1 encaissement)", function () { + expect(res.getBody().data.rubisEarned).to.be.at.least(2); + }); +} + +docs { + POST /api/v1/invoices/:id/mark-paid + + Marque la facture encaissée : + - `status` → `paid` + - `paidAt` → maintenant + - `rubisEarned` → +1 (bonus encaissement) + - `organizations.rubis_count` → +1 (compteur agrégé pour le dashboard) + + **Idempotent** : si la facture est déjà payée, retourne l'état courant + sans bumper. + + Vérifier ensuite via "Organizations → 01 Get my org" que `rubisCount` + a augmenté. +} diff --git a/bruno/05-Invoices/folder.bru b/bruno/05-Invoices/folder.bru new file mode 100644 index 0000000..33d0718 --- /dev/null +++ b/bruno/05-Invoices/folder.bru @@ -0,0 +1,22 @@ +meta { + name: Invoices + seq: 6 +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} + +docs { + ## Factures + + Statuts possibles : + `pending` · `awaiting_user_confirmation` · `in_relance` · `paid` · + `litigation` · `cancelled` + + Toutes les routes scopées par `organization_id` du user courant. +} diff --git a/bruno/README.md b/bruno/README.md new file mode 100644 index 0000000..2ba164c --- /dev/null +++ b/bruno/README.md @@ -0,0 +1,58 @@ +# Rubis API — Collection Bruno + +Collection [Bruno](https://www.usebruno.com/) qui répertorie toutes les routes de l'API et permet de les tester en suivant un parcours réaliste (signup → onboarding → création client → facture → encaissement). + +## Installation + +1. Installe Bruno : https://www.usebruno.com/downloads +2. Dans Bruno, **Open Collection** → sélectionne le dossier `bruno/` à la racine du repo. +3. Sélectionne l'environnement **local** dans le sélecteur en haut à droite. + +## Démarrer l'API + +```bash +pnpm dev:up # Postgres, Redis, MinIO, Mailpit +pnpm dev:api # API sur http://localhost:3333 +``` + +Si la DB est neuve : `pnpm -F api exec node ace migration:run` + +## Variables d'environnement + +Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, etc.) sont remplies automatiquement par les `script:post-response` : + +| Variable | Source | Utilisée par | +|---|---|---| +| `baseUrl` | en dur (`http://localhost:3333`) | toutes | +| `email` / `password` / `fullName` | en dur (login fixture) | Signup, Login | +| `token` | rempli après Signup/Login | toutes les routes auth | +| `userId` | rempli après Signup/Login | (info) | +| `organizationId` | rempli après Signup/Login | (info, debug) | +| `clientId` | rempli après Create client | détail/update client, création facture | +| `planSlug` | en dur (`standard-30j`) | détail/update plan | +| `invoiceId` | rempli après Create invoice | détail/mark-paid | + +## Parcours recommandé (premier run) + +1. **Auth → 01 Signup** (récupère un token + crée l'org + provisionne les 4 plans) +2. **Account → 01 Get profile** (vérifie l'auth) +3. **Organizations → 02 Update my org** (onboarding step 2) +4. **Clients → 04 Create** (crée un client, capture `clientId`) +5. **Plans → 01 List** (vérifie les 4 plans pré-fournis) +6. **Invoices → 04 Create** (crée une facture liée au client) +7. **Invoices → 05 Get detail** (vérifie la timeline) +8. **Invoices → 06 Mark paid** (encaisse + bonus rubis) +9. **Organizations → 01 Get my org** (vérifie `rubisCount` incrémenté) +10. **Clients → 02 List with stats** (vérifie les compteurs) + +## Reset entre runs + +L'email `alice@bruno.test` est unique en DB → 2e signup retourne 422 `email_taken`. +Pour repartir propre : + +```bash +docker exec rubis-postgres psql -U rubis -d rubis_dev -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" +pnpm -F api exec node ace migration:run +``` + +Ou change `email` dans l'environnement local pour forcer un nouveau signup à chaque run. diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..a15b663 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "Rubis API", + "type": "collection", + "ignore": ["node_modules", ".git"] +} diff --git a/bruno/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..217f87d --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,30 @@ +docs { + # Rubis API + + Collection Bruno qui couvre l'API REST de Rubis (`/api/v1/*`). + + ## Domaines + + - **Auth** — signup / login / logout + - **Account** — profil utilisateur courant + - **Organizations** — organisation rattachée (V1 mono-tenant) + - **Clients** — fiches clients (CRUD + stats) + - **Plans** — plans de relance (4 pré-fournis + édition) + - **Invoices** — factures + counts + détail enrichi + mark-paid + + ## Conventions transverses + + - **Auth** : Bearer token dans `Authorization`. Le token est capturé + automatiquement après Signup ou Login (`bru.setEnvVar('token', ...)`). + - **Wrapper** : toutes les réponses sont `{ data: ... }` (pour les + listes : `{ data, meta: { total, page } }`). + - **Erreurs** : `{ errors: [{ code, message, field? }] }` avec codes + stables (`validation_failed`, `duplicate_client`, `client_email_required`, + `not_found`, `unauthenticated`…). + - **IDs** : UUID v4 partout, jamais d'increments. + - **Montants** : centimes (int), jamais de float. + + ## Lancer l'API + + Voir `bruno/README.md` à la racine du dossier. +} diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru new file mode 100644 index 0000000..ad3ecb4 --- /dev/null +++ b/bruno/environments/local.bru @@ -0,0 +1,12 @@ +vars { + baseUrl: http://localhost:3333 + email: alice@bruno.test + password: password123 + fullName: Alice Bruno + token: + userId: + organizationId: + clientId: + planSlug: standard-30j + invoiceId: +} diff --git a/docs/tech/dev-setup.md b/docs/tech/dev-setup.md index 6691088..bf683ac 100644 --- a/docs/tech/dev-setup.md +++ b/docs/tech/dev-setup.md @@ -90,7 +90,18 @@ Voir `apps/api/.env.example` — c'est la source de vérité. Récap : - **OCR_PROVIDER** : `mock` en dev, `mistral` quand l'API key est en place - **ACCESS_TOKEN_TTL_MINUTES** / **REFRESH_TOKEN_TTL_DAYS** : durées d'auth -## 5. Données de seed +## 5. Tester les routes — collection Bruno + +Une collection [Bruno](https://www.usebruno.com/) prête à l'emploi est dans `/bruno/`. Elle couvre toutes les routes documentées et capture le token + les IDs créés au fil des requêtes. + +```bash +# Open Collection → sélectionne le dossier bruno/ +# Puis sélectionne l'environnement "local" en haut à droite +``` + +Parcours recommandé : Signup → Get profile → Update org → Create client → List plans → Create invoice → Get detail → Mark paid → Get org (vérifier rubisCount). Détails dans `/bruno/README.md`. + +## 6. Données de seed `pnpm -F api exec node ace db:seed` lance :