docs(bruno): collection Billing + endpoints check-in in-app
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 17:25:55 +02:00
parent 0f1a309be3
commit 3bad1451a9
9 changed files with 453 additions and 0 deletions

View File

@ -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.
}

View File

@ -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.
}

View File

@ -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).
}

View File

@ -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.
}

View File

@ -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"
}

View File

@ -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`.
}

View File

@ -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`
}

View File

@ -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"`
}

View File

@ -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)