Files
colibre/docs/superpowers/specs/2026-07-01-subscriptions-handle-history-design.md
T
Colin Maudry 17c379f0b3 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>
2026-07-01 14:12:11 +02:00

11 KiB

Handle d'abonnement personnalisé + historique des abonnements — design

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 pendingfailed 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

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_userget_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_customercustomer_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é) :
    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).