docs(bruno): collection Billing + endpoints check-in in-app
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s
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:
parent
0f1a309be3
commit
3bad1451a9
60
bruno/08-Checkin/03 In-app pending.bru
Normal file
60
bruno/08-Checkin/03 In-app pending.bru
Normal 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.
|
||||
}
|
||||
44
bruno/08-Checkin/04 In-app respond paid.bru
Normal file
44
bruno/08-Checkin/04 In-app respond paid.bru
Normal 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.
|
||||
}
|
||||
44
bruno/08-Checkin/05 In-app respond pending.bru
Normal file
44
bruno/08-Checkin/05 In-app respond pending.bru
Normal 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).
|
||||
}
|
||||
65
bruno/09-Billing/01 Get subscription.bru
Normal file
65
bruno/09-Billing/01 Get subscription.bru
Normal 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.
|
||||
}
|
||||
63
bruno/09-Billing/02 Start checkout.bru
Normal file
63
bruno/09-Billing/02 Start checkout.bru
Normal 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"
|
||||
}
|
||||
50
bruno/09-Billing/03 Open portal.bru
Normal file
50
bruno/09-Billing/03 Open portal.bru
Normal 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`.
|
||||
}
|
||||
55
bruno/09-Billing/04 Reactivate.bru
Normal file
55
bruno/09-Billing/04 Reactivate.bru
Normal 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`
|
||||
}
|
||||
66
bruno/09-Billing/folder.bru
Normal file
66
bruno/09-Billing/folder.bru
Normal 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"`
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user