docs: design pour le handle d'abonnement personnalisé et l'historique des abonnements
Spec pour remplacer generate_handle par un handle abo-{user_id}-N unique,
ce qui nécessite de scinder subscriptions en historique multi-lignes et
d'introduire subscriber_state pour l'état cumulatif (votes, essai).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# Handle d'abonnement personnalisé + historique des abonnements — design
|
||||
|
||||
- **Contexte** : suite de [2026-06-25-frisbii-abonnements-design.md](2026-06-25-frisbii-abonnements-design.md)
|
||||
- **Date** : 2026-07-01
|
||||
|
||||
## Objectif
|
||||
|
||||
Remplacer le paramètre `generate_handle: true` (Frisbii génère le handle de
|
||||
l'abonnement) par un handle choisi par colibre, préfixé `abo`, unique. Générer
|
||||
ce handle nécessite de pouvoir consulter l'historique des abonnements d'un
|
||||
utilisateur — ce qui n'est pas possible avec le schéma actuel de
|
||||
`subscriptions`, qui n'a qu'une ligne par utilisateur (`user_id PRIMARY KEY`),
|
||||
écrasée à chaque nouvelle souscription. Ce design scinde donc `subscriptions`
|
||||
en un historique multi-lignes et introduit une table séparée pour l'état
|
||||
cumulatif de l'utilisateur (votes, essai), en plus du changement de handle.
|
||||
|
||||
## Décisions clés
|
||||
|
||||
1. **Format du handle : `abo-{user_id}-{N}`**, `N` incrémental par
|
||||
utilisateur (ex. `abo-42-1`, puis `abo-42-2` en cas de résiliation puis
|
||||
réabonnement). Calculé en scannant les handles déjà utilisés par cet
|
||||
utilisateur dans `subscriptions` (`LIKE 'abo-{user_id}-%'`) et en prenant
|
||||
le suffixe max + 1.
|
||||
2. **`subscriptions` devient un historique multi-lignes** (une ligne par
|
||||
tentative d'abonnement Frisbii), `id` auto-incrémenté comme clé primaire,
|
||||
`user_id` non-unique.
|
||||
3. **Nouvelle table `subscriber_state`** (1 ligne par utilisateur) pour l'état
|
||||
cumulatif indépendant du cycle de vie d'un abonnement particulier
|
||||
(`trial_used`, `votes_balance`, `votes_last_credited_at`) — ces colonnes
|
||||
n'ont pas de sens sur une ligne d'historique précise. Alternative écartée :
|
||||
les ajouter à `users` (mélangerait identité et logique d'abonnement/billing
|
||||
dans `auth/db.py`, qui doit rester focalisé sur l'identité).
|
||||
4. **Le handle est écrit par colibre à la création, pas par le webhook.**
|
||||
Contrairement au comportement actuel (Frisbii génère le handle, on
|
||||
l'apprend via le webhook), colibre choisit `abo-{user_id}-{N}` et
|
||||
l'enregistre dans `subscriptions.frisbii_subscription_handle` **avant**
|
||||
d'appeler l'API Frisbii. Le webhook ne fait plus que lire/matcher sur ce
|
||||
handle (`get_by_handle`), jamais l'écrire.
|
||||
- Ordre retenu : écrire chez nous d'abord, appeler Frisbii ensuite. En cas
|
||||
d'échec de l'appel Frisbii, on se retrouve avec une ligne locale
|
||||
`pending`→`failed` dont le handle n'a jamais existé côté Frisbii — c'est
|
||||
inoffensif (voir décision 5). L'ordre inverse (Frisbii d'abord) est plus
|
||||
risqué : un succès Frisbii suivi d'un échec d'écriture locale laisserait
|
||||
un abonnement réel et potentiellement payant chez Frisbii sans aucune
|
||||
trace côté colibre, avec un risque de collision de handle au prochain
|
||||
essai.
|
||||
5. **Statut `failed`** pour distinguer un échec propre côté Frisbii (juste
|
||||
après `create_pending`) d'un abonnement réellement `pending` chez Frisbii
|
||||
(session créée, paiement pas encore confirmé). Sans ce statut, l'écran
|
||||
`/compte/abonnement` affiche `_active_view` dès qu'une ligne existe pour
|
||||
l'utilisateur (peu importe le statut), ce qui bloquerait indéfiniment le
|
||||
bouton « S'abonner » derrière un écran « Ajouter une méthode de paiement »
|
||||
pour un abonnement fantôme, et « Me désabonner » échouerait aussi (handle
|
||||
inconnu de Frisbii). La ligne `failed` reste en base (pas de suppression)
|
||||
pour que `abo-{user_id}-{N}` ne réutilise jamais ce suffixe — protection
|
||||
utile en cas d'échec réseau ambigu (timeout ne garantit pas que la requête
|
||||
n'a pas été traitée côté Frisbii).
|
||||
6. **Chaque tentative de souscription crée une nouvelle ligne** (pas de
|
||||
réutilisation d'une ligne `pending` existante). Chaque clic sur
|
||||
« S'abonner » qui atteint `create_subscription_session` crée un véritable
|
||||
nouvel objet abonnement chez Frisbii ; réutiliser une ligne locale
|
||||
reviendrait à réutiliser un handle déjà proposé à Frisbii pour un objet
|
||||
différent.
|
||||
|
||||
## Schéma
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
frisbii_customer_handle TEXT,
|
||||
frisbii_subscription_handle TEXT,
|
||||
plan TEXT,
|
||||
prix_ht REAL,
|
||||
status TEXT, -- 'pending' | 'trial' | 'active' | 'cancelled' | 'expired' | 'failed'
|
||||
current_period_end TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE UNIQUE INDEX idx_subscriptions_handle ON subscriptions(frisbii_subscription_handle);
|
||||
|
||||
CREATE TABLE subscriber_state (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
trial_used INTEGER NOT NULL DEFAULT 0,
|
||||
votes_balance INTEGER NOT NULL DEFAULT 0,
|
||||
votes_last_credited_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
`ALTER TABLE` ne permet ni de retirer `user_id` comme clé primaire ni de
|
||||
répartir des colonnes vers une nouvelle table. Migration par reconstruction de
|
||||
table, sur le modèle de `_rebuild_users_password_nullable` dans
|
||||
`src/auth/db.py:86` : fonction Python dédiée (pas une entrée de la liste
|
||||
générique `_MIGRATIONS`, qui n'exécute qu'une instruction SQL simple), guard
|
||||
d'idempotence sur `PRAGMA table_info(subscriptions)` (absence de la colonne
|
||||
`id`), transaction unique :
|
||||
|
||||
1. `CREATE TABLE subscriptions_new` (nouveau schéma).
|
||||
2. `INSERT INTO subscriptions_new (...) SELECT user_id, frisbii_customer_handle, ... FROM subscriptions` (les anciennes lignes deviennent l'historique, un `id` frais leur est attribué).
|
||||
3. `INSERT INTO subscriber_state (user_id, trial_used, votes_balance, votes_last_credited_at, updated_at) SELECT ... FROM subscriptions` (depuis l'ancienne table, avant le `DROP`).
|
||||
4. `DROP TABLE subscriptions`, `ALTER TABLE subscriptions_new RENAME TO subscriptions`, recréation des index.
|
||||
|
||||
Appelée depuis `subscriptions/db.py:init_schema()`, `PRAGMA foreign_keys = OFF`
|
||||
pendant le `DROP`/`RENAME` (cascade), comme dans le précédent auth.
|
||||
|
||||
## API `subscriptions/db.py`
|
||||
|
||||
- `get_by_user` → **`get_current(user_id) -> Row | None`** : dernière ligne
|
||||
(`ORDER BY id DESC LIMIT 1`). Remplace tous les usages actuels de
|
||||
« l'abonnement de l'utilisateur ».
|
||||
- `get_by_customer` → **`customer_known(customer_handle) -> bool`** : test
|
||||
d'existence seul (un `frisbii_customer_handle` peut apparaître sur
|
||||
plusieurs lignes d'historique).
|
||||
- **`get_by_handle(subscription_handle) -> Row | None`** (nouveau) : résout la
|
||||
ligne exacte à mettre à jour depuis un webhook.
|
||||
- **`_next_handle(user_id) -> str`** (nouveau, privé) :
|
||||
```python
|
||||
def _next_handle(user_id: int) -> str:
|
||||
prefix = f"abo-{user_id}-"
|
||||
rows = get_conn().execute(
|
||||
"SELECT frisbii_subscription_handle FROM subscriptions "
|
||||
"WHERE user_id = ? AND frisbii_subscription_handle LIKE ?",
|
||||
(user_id, f"{prefix}%"),
|
||||
).fetchall()
|
||||
n = max(
|
||||
(int(r[0][len(prefix):]) for r in rows if r[0][len(prefix):].isdigit()),
|
||||
default=0,
|
||||
)
|
||||
return f"{prefix}{n + 1}"
|
||||
```
|
||||
- **`create_pending(user_id, customer_handle, plan, prix_ht=None) -> tuple[str, int]`** :
|
||||
génère le handle via `_next_handle`, **INSERT** une nouvelle ligne
|
||||
(`status='pending'`, `frisbii_subscription_handle` déjà renseigné), renvoie
|
||||
`(handle, subscription_id)`.
|
||||
- **`mark_failed(subscription_id) -> None`** (nouveau) :
|
||||
`UPDATE subscriptions SET status='failed', updated_at=? WHERE id=?`. Pas de
|
||||
logique de crédit de votes (rien n'a jamais été actif), contrairement à
|
||||
`set_cancelled`.
|
||||
- **`update_from_webhook(subscription_handle, status, current_period_end)`** :
|
||||
signature simplifiée (retrait de `customer_handle`), cible la ligne via
|
||||
`get_by_handle`. `trial_used` déplacé vers `subscriber_state`.
|
||||
- **`set_cancelled(subscription_id, current_period_end)`** : opère par `id`
|
||||
de ligne (plus par `user_id`) — `routes.cancel()` récupère déjà la ligne via
|
||||
`get_current`, lui passe `row["id"]`.
|
||||
- `has_active_subscription`, `has_used_trial`, `credit_pending`, `spend_vote`,
|
||||
`next_recharge_at`, `freeze_votes_cursor` : signatures inchangées, mais
|
||||
relus/écrits sur `subscriber_state` pour le solde/l'essai. `credit_pending`
|
||||
reste la seule fonction à toucher aux deux tables (lit le statut courant sur
|
||||
`subscriptions` via `get_current`, écrit le solde sur `subscriber_state`).
|
||||
`has_used_trial`/les fonctions de vote tolèrent l'absence de ligne
|
||||
`subscriber_state` (première interaction de l'utilisateur : valeurs par
|
||||
défaut, pas d'erreur).
|
||||
|
||||
## `client.py`
|
||||
|
||||
`create_subscription_session` reçoit un paramètre `handle: str` obligatoire ;
|
||||
remplace `"generate_handle": True` par `"handle": handle` dans le corps de la
|
||||
requête `POST /v1/subscription`.
|
||||
|
||||
## `routes.py`
|
||||
|
||||
- `subscribe()` : appelle `db.create_pending(...)` → `(handle, subscription_id)`,
|
||||
passe `handle` à `client.create_subscription_session(..., handle=handle)`.
|
||||
Dans le `except client.FrisbiiError`, appelle `db.mark_failed(subscription_id)`
|
||||
avant de rediriger vers `?error=frisbii`.
|
||||
- `cancel()` : récupère la ligne via `get_current`, passe `row["id"]` à
|
||||
`set_cancelled`.
|
||||
- `webhook()` : garde existant basé sur `customer_known(customer)` ; appelle
|
||||
`update_from_webhook(sub_handle, status, current_period_end)` (sans
|
||||
`customer_handle`).
|
||||
|
||||
## `compte_abonnement.py`
|
||||
|
||||
`layout()` : `if row is not None:` → `if row is not None and row["status"] != "failed":`
|
||||
pour qu'une ligne `failed` ne bloque plus l'affichage de `_plan_cards` (retour
|
||||
au formulaire de souscription).
|
||||
|
||||
## Sites d'appel à migrer
|
||||
|
||||
`get_by_user`/`get_by_customer` sont utilisés dans :
|
||||
`src/pages/compte_roadmap.py`, `src/pages/_compte_shell.py`,
|
||||
`src/pages/compte_abonnement.py`, `src/auth/routes.py` — renommage mécanique
|
||||
vers `get_current`. `tests/subscriptions/test_db.py` et `test_routes.py`
|
||||
nécessitent une réécriture significative (nouvelles signatures, statut
|
||||
`failed`, table `subscriber_state`).
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- **Nettoyage automatique des lignes `pending`/`failed` orphelines** (essais
|
||||
de souscription abandonnés ou échoués). Inoffensif : ne bloque pas la
|
||||
réinscription, ne crée pas de collision de handle. Pas de cron de
|
||||
réconciliation pour l'instant — cohérent avec la décision « pas de
|
||||
réconciliation périodique automatique » du design initial.
|
||||
- **Scoping des colonnes des `SELECT *`** dans `subscriptions/db.py` /
|
||||
`auth/db.py` (évoqué en discussion, explicitement écarté de cette tâche).
|
||||
Reference in New Issue
Block a user