17c379f0b3
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>
202 lines
11 KiB
Markdown
202 lines
11 KiB
Markdown
# 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).
|