← Torna al blog
IngegneriaBuild in PublicPrezzi

Abbiamo venduto un piano che non funzionava

Abbiamo lanciato un piano da 4,90 €/mese. L'opzione annuale avrebbe addebitato 58,80 € ogni mese, il piano non concedeva alcun diritto e il worker ignorava il suo limite di durata. Tutti e tre trovati acquistandolo noi stessi.

4 ottobre 2026

Abbiamo venduto un piano che non funzionava

Creato il 4 ottobre 2026 · Aggiornato il 4 ottobre 2026

Un piano da 4,90 €/mese è andato online sulla nostra pagina prezzi. A chi avesse scelto l'opzione annuale sarebbero stati addebitati 58,80 € ogni mese.

Non è successo a nessuno.

Lo abbiamo intercettato prima che un solo cliente acquistasse il piano annuale, e non rileggendo il codice, ma acquistando il prodotto noi stessi con una carta vera.

Questo dettaglio conta. La build era verde. TypeScript era contento. L'API restituiva 200. Nulla di tutto ciò significava che il prodotto funzionasse.

Quella mattina abbiamo trovato tre difetti. Il nostro primo cliente Solo pagante si è abbonato ventisette minuti dopo l'ultimo di quei tre interventi. Mezz'ora nell'altro senso e sarebbe stato lui a pagare per un piano che non gli concedeva niente.

Difetto 1: il prezzo era giusto, l'intervallo no

Il nostro nuovo piano d'ingresso, Solo, costa 4,90 €/mese con fatturazione annuale, oppure 8,90 € al mese. 4,90 € × 12 = 58,80 € una volta all'anno.

Il prezzo Stripe dietro l'opzione annuale era stato creato con un intervallo di fatturazione mensile.

Stripe era quindi pronto ad addebitare 58,80 € ogni mese: dodici volte il prezzo previsto.

La pagina prezzi era corretta. Il codice usava l'identificatore di prezzo corretto. Il difetto viveva fuori dal repository, in un oggetto Stripe creato dalla dashboard.

È proprio questo che lo rendeva facile da mancare.

Stripe non consente di modificare l'intervallo di fatturazione di un prezzo esistente: si crea un nuovo prezzo e si sostituisce l'identificatore. Abbiamo quindi aggiunto un controllo prima della creazione della sessione di pagamento:

const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);

if (price.recurring?.interval !== expected) {
  throw new Error("PRICE_INTERVAL_MISMATCH");
}

Una chiamata API per ogni pagamento.

Se Stripe e la pagina prezzi si contraddicono, rifiutiamo di vendere.

L'oggetto Stripe difettoso può ancora esistere. Semplicemente non può più attraversare il pagamento senza che ce ne accorgiamo.

Difetto 2: il piano esisteva commercialmente, non funzionalmente

Il secondo difetto falliva nella direzione opposta: un cliente poteva pagarci e restare trattato come gratuito.

Aggiungere un piano sembra un cambiamento commerciale. In pratica, tocca ogni punto del prodotto che si chiede: «cosa è autorizzato a fare questo account?»

Nel nostro codice, quella domanda riceveva risposta in 29 file.

Quei controlli si erano accumulati nel tempo tra esportazioni, caricamenti, coda di elaborazione, endpoint API, fatturazione e dashboard. Erano in gran parte confronti di stringhe scritti contro i piani che esistevano quando quel codice era stato aggiunto.

Solo era nuovo.

Controlli del tipo è pro, creator o studio? cadevano quindi nel ramo predefinito. E il ramo predefinito era il gratuito.

Questo significava che un account Solo pagante poteva ricevere la quota gratuita, i limiti di caricamento del gratuito, la filigrana e le altre restrizioni del livello gratuito.

L'istinto dietro quel valore predefinito non era stupido. Nella sicurezza, fallire in chiusura è di solito ciò che si vuole.

Nella fatturazione, però, crea un modo di guasto diverso: il cliente ha già pagato, e il vostro ripiego più sicuro diventa proprio ciò che gli trattiene quello che ha comprato.

La correzione non è stata aggiungere "solo" a 29 condizioni. Abbiamo sostituito tutto questo con un unico posto in cui si descrive il comportamento di un piano:

export type Plan = "free" | "solo" | "pro" | "creator" | "studio";

export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null

I nomi qui sopra sono semplificati per l'articolo, ma la struttura è quella che abbiamo rilasciato.

La regola in testa a quel file dice ora: un piano non esiste finché non è qui.

Durante la correzione ci sono stati due quasi-incidenti utili.

Primo, abbiamo quasi usato quell'unico controllo isPaid() per tutte le porte, comprese quelle specifiche di una funzione. Avrebbe dato ai clienti Solo cose che non pagano, tra cui il doppiaggio IA e la programmazione dei post. «Pagante» e «ha diritto a questa funzione» non sono la stessa domanda.

Secondo, abbiamo creduto per un momento che la programmazione non avesse alcun controllo di piano, perché cercare "plan" nella route non restituiva nulla. La porta era dentro una funzione condivisa, a una chiamata di distanza.

Un grep che torna vuoto non è una risposta.

Difetto 3: il limite annunciato e mai applicato

La pagina prezzi dice che Solo accetta video sorgente fino a 60 minuti. Il worker che scarica ed elabora i video conosceva solo due limiti:

Solo è pagante. Quindi Solo ha ottenuto 90.

Questo difetto perdeva nella direzione opposta. Nessuno è stato sovraffatturato e nessuno ha perso accesso. Semplicemente regalavamo più calcolo di quanto il piano dovesse includere.

Per un prodotto video, questo conta. La durata della sorgente è uno dei fattori più direttamente legati al costo di trascrizione, rendering e banda.

Il worker porta ora lo stesso tetto dell'applicazione:

def _max_minutes(plan: str | None) -> int:
    """Specchio dei limiti di piano usati dall'applicazione.

    Cio che la pagina prezzi annuncia e cio che il worker applica
    devono essere lo stesso numero.

    Un limite annunciato senza essere applicato e margine che se ne va;
    applicato senza essere annunciato e una trappola.
    """

Un limite che esiste solo sulla pagina prezzi non è un limite. È una frase su una pagina.

Cosa ha trovato davvero questi difetti

Non il type checker. Non la suite di test. Non una revisione del codice.

Li ha trovati aprire il sito facendo finta di non averlo mai visto, cliccare sull'opzione più economica e inserire una carta vera.

Lo scarto di fatturazione è apparso nel riepilogo di pagamento di Stripe stesso. I diritti mancanti sono apparsi quando l'account pagante ha continuato a comportarsi come gratuito. Il problema di durata è apparso quando abbiamo chiesto al worker quale limite avrebbe applicato davvero.

Continuiamo a reimparare la stessa lezione: una build che passa non dimostra che un prodotto funzioni.

Ogni difetto viveva nello spazio tra due sistemi che, singolarmente, facevano esattamente quello che era stato detto loro di fare:

Il modo economico di trovare questa famiglia di difetti è sorprendentemente noioso: usare il prodotto come farà la persona che lo paga.

Un epilogo sull'attribuzione

Il nostro primo abbonato Solo è arrivato la stessa mattina.

Per circa un'ora ci siamo raccontati una bella storia: Solo era appena uscito, qualcuno l'aveva trovato da solo, aveva raggiunto il limite del gratuito e aveva convertito.

Il registro degli eventi raccontava qualcosa di meno ordinato.

Quella persona aveva trovato Katto tramite ChatGPT, si era registrata, aveva usato molto il piano gratuito, aveva raggiunto la quota, aveva aperto il pagamento diverse volte ed era andata via senza pagare.

La mattina dopo è partita un'email di recupero carrello abbandonato. Qualche ora più tardi, si è abbonata.

Ha scelto l'opzione da 8,90 € al mese invece di 4,90 € con fatturazione annuale, pagando l'82% in più al mese per evitare un impegno di dodici mesi. L'offerta founder che aveva cliccato e abbandonato il giorno prima era da 6 €.

Avevamo anche supposto che fosse il limite di 60 minuti sulla sorgente ad averla spinta verso Solo. Non era così. Ciò contro cui sbatteva continuamente era la quota di due video al mese.

Due ipotesi sono morte insieme. La conversione non era semplicemente organica: l'email di recupero ha probabilmente contato. E il limite che generava la pressione più forte ad abbonarsi non era quello che pensavamo.

Ha esattamente la stessa forma dei tre difetti sopra. Da fuori era facile raccontare una storia pulita. Poi abbiamo guardato cosa era davvero accaduto.


Katto trasforma i video lunghi in clip verticali. Pubblichiamo i numeri che misuriamo, compresi quelli che ci mettono in cattiva luce.

Articoli correlati

Pronto a trasformare i tuoi video in clip virali?

Katto taglia, sottotitola e riformatta automaticamente i tuoi video lunghi in contenuti brevi.

Prova Katto gratis →