Blog ·
Quindici cose che una migrazione di fatturazione non può portarsi dietro
Una migrazione di fatturazione non è una copia di dati. Quindici omissioni misurate, il denaro in centesimi interi e l’impronta come unica chiave utile.
Di Provibr
Spostare un’attività di hosting da un pannello di fatturazione a un altro sembra una copia di dati e non lo è. Due pannelli che sanno entrambi cosa siano un cliente, un servizio e una fattura restano comunque in disaccordo su cosa contengano quegli oggetti, e il disaccordo non è mai simmetrico: la sorgente di solito sa più di quanto l’API della destinazione sia disposta ad accettare. Una migrazione che finge il contrario scrive documenti che non corrispondono a quelli che il cliente ha già ricevuto.
La prima cosa che questo strumento produce, quindi, non sono dati. È un elenco di ciò che non arriverà.
Il denaro è in centesimi interi, e la conversione legge testo
Entrambi i pannelli restituiscono un importo come numero JSON (9.95) o come testo decimale ("9.95"). La conversione ovvia è moltiplicare per cento.
La conversione ovvia è sbagliata. Come numero in virgola mobile a 64 bit, 9,95 vale 9,949999999999999289, e 9.95 * 100.0 dà 994,9999999999999. Troncato fa 994 centesimi: un centesimo in meno di quanto dica la fattura. Su un server di gioco è una noia di arrotondamento. Su una contabilità è un documento inesatto, e salta fuori quando qualcuno chiude l’esercizio.
Il modello canonico porta perciò un importo come numero intero di centesimi più un codice valuta, e la conversione conta le cifre nella forma testuale invece di passare per un numero in virgola mobile. Oltre due decimali si arrotonda allontanandosi da zero anziché troncare, perché mezzo centesimo troncato fa sparire una riga di fattura. La notazione scientifica viene rifiutata subito: gestire 1e2 a metà trasformerebbe una fattura da cento euro in un centesimo, e rifiutare costa meno che fare i furbi. Esiste un limite superiore di 2^53 centesimi, perché l’altro capo del filo conta in numeri JavaScript, e un valore che noi accettiamo e che lì non è più rappresentabile con esattezza è uno scostamento silenzioso anziché un errore.
Quindici omissioni, ciascuna misurata
Otto valgono per qualunque migrazione di fatturazione, e sette sono proprie della prima rotta, da FOSSBilling a Paymenter. Tutte e quindici sono state misurate contro container in esecuzione e non riprese dalla documentazione.
Le otto generali sono quelle che si prevedono appena le si pronuncia. Le password dei clienti sono impronte calcolate con la chiave applicativa della sorgente e non funzionerebbero altrove, e l’endpoint utenti della destinazione pretende una password nuova di almeno otto caratteri. Carte e mandati salvati sono token che appartengono al conto del pannello di origine presso il fornitore di pagamento; portarli significherebbe far partire un addebito da un conto che il cliente non ha mai firmato. La configurazione dei gateway porta chiavi e indirizzi webhook del vecchio pannello, e per questo non viene mai letta. I modelli di posta sono scritti nel linguaggio di template del proprio pannello, e un modello trasferito produce messaggi con segnaposto non sostituiti. Gli allegati dei ticket esistono come file sul disco del pannello di origine, senza alcun endpoint che ne restituisca il contenuto. La contabilità degli affiliati non ha una controparte con lo stesso significato, e portarla farebbe rinascere provvigioni già liquidate. I codici sconto privati della loro storia di utilizzo sono decisamente errati: un codice limitato a dieci usi e già usato quattro volte arriva come un codice con dieci usi residui. I segreti a due fattori sono cifrati per pannello e non vengono mai restituiti.
Le sette proprie della rotta sono la metà interessante, perché sono tutte limiti dell’API di amministrazione della destinazione e non della destinazione stessa.
| Cosa resta indietro | Perché, misurato |
|---|---|
| Azienda, indirizzo, partita IVA, telefono, lingua | La richiesta di creazione valida nome, cognome, email, password, data di verifica e ruolo, e il controller crea esattamente l’insieme validato. Tutto il resto cade. |
| Numeri di fattura | invoices.number è unico nel database ma non compare in nessuna delle due regole di validazione né in alcuna risorsa; non è scrivibile né rileggibile. |
| L’imposta su una riga | Una riga di fattura accetta descrizione, quantità, prezzo e un riferimento. Non esiste un campo imposta. |
| Il catalogo prodotti | Il controller dei prodotti ha solo index e show, e il file dei permessi concede unicamente view su prodotti e categorie. |
| Le registrazioni di pagamento | L’API non pubblica alcun endpoint per pagamenti o transazioni. |
| Un servizio già scaduto | La data di scadenza è validata come oggi o successiva. |
| Le identità dello staff | Un messaggio di ticket pretende uno user_id esistente come utente, e lo staff della sorgente non è utente della destinazione. |
Due di quelle righe portano una decisione che vale la pena nominare.
I numeri di fattura vanno su stderr. Poiché la destinazione assegna una propria serie e non accetta né restituisce il numero vecchio, il migratore scrive una riga per fattura sull’uscita di errore con il numero di origine, dove il registro dell’agente la raccoglie. Non è elegante. È onesto: il numero è su un documento che il cliente ha in mano, e l’unico posto in cui possiamo ancora metterlo è un registro che l’amministratore controlla.
Una fattura con imposta viene bloccata anziché scritta. La destinazione calcola l’imposta dalle proprie impostazioni, quindi la stessa fattura otterrebbe lì un totale diverso dal documento già inviato. Scriverla comunque produce un secondo record contraddittorio di una sola operazione. Il piano colloca quelle fatture sotto tax_not_carried e prosegue.
Questa distinzione attraversa tutto lo strumento. Un prodotto assente blocca il servizio invece di archiviarlo altrove. Bloccare è un risultato; indovinare no.
La sorgente risponde HTTP 200 quando fallisce
L’API di amministrazione di FOSSBilling risponde a una chiamata fallita con stato 200 e un corpo di {"result":null,"error":{"message":"...","code":9999}}. Chi guarda solo il codice di stato legge un errore come risposta vuota, e una migrazione che non distingue vuoto da fallito un giorno sincronizzerà una contabilità fino a svuotarla. Esiste esattamente una funzione che apre quella busta, e guarda error prima di result.
Il lettore è inoltre di sola lettura per costruzione, e questa è una prova anziché una promessa. Ogni metodo che può chiamare è una costante con nome che finisce in get o get_list, sette in tutto, e un test legge il file sorgente e confronta ogni chiamata client.get( con quell’elenco. Un lettore che acquisisse una chiamata _create sarebbe un test rosso e non una scoperta successiva.
Un’altra stranezza misurata: un prodotto FOSSBilling torna con tutti e sette i periodi di fatturazione contrassegnati come attivi e a prezzo zero, qualunque cosa venda il pannello. Il prezzo è l’unico segnale utilizzabile su quale ciclo sia reale.
Idempotenza su un’impronta, perché non c’è chiave esterna
Il modo consueto di rendere ripartibile una migrazione è scrivere un identificatore proprio sulla destinazione e cercarlo la volta dopo. Paymenter non offre dove metterlo: non esiste un campo di riferimento esterno che sopravviva a una scrittura e torni in lettura.
L’identità è quindi un’impronta calcolata dal contenuto su entrambi i lati.
- Un cliente è il suo indirizzo email, in minuscolo.
- Un servizio è email, nome prodotto, prezzo in centesimi, valuta e quantità.
- Una fattura è email, valuta, data di scadenza e le sue righe, con il totale sommato da quelle righe.
- Un ticket è email più oggetto.
Due di esse portano un avviso nel codice. L’impronta del servizio esclude deliberatamente la prossima data di scadenza, perché la destinazione rifiuta una data passata e ne memorizza una vuota, e allora lo stesso servizio avrebbe alla ripresa un’impronta diversa da quella che aveva alla scrittura e verrebbe creato una seconda volta: un campo che la destinazione può modificare non può far parte della chiave. Il totale della fattura si somma dalle righe invece di leggerlo dal campo totale della sorgente, perché sulla destinazione quel valore è un calcolo e non una colonna, e due lati che sommano in modo diverso producono una chiave che non coincide mai. Le righe vengono ordinate prima della composizione, perché l’ordine in cui tornano è quello dei loro identificatori, che non deve per forza essere l’ordine di scrittura se una di esse è caduta.
La chiave del ticket è quella debole, e sta scritto così: due ticket dello stesso cliente con esattamente lo stesso oggetto si fondono in uno. Includere il primo messaggio lo risolverebbe e costa una seconda chiamata per ticket sulla destinazione.
Tre modi di trattare una collisione
Quando un oggetto esiste già sulla destinazione, l’esecuzione fa una di tre cose, e la scelta è preventiva: skip lascia in pace la riga esistente, overwrite_empty riempie solo i campi che lì sono vuoti, e stop ferma l’intera esecuzione prima di scrivere qualunque cosa.
Misurato contro un vero FOSSBilling 0.8 e un vero Paymenter: una simulazione ha pianificato quattro clienti, quattro servizi, quattro fatture, cinque prodotti e due ticket; l’esecuzione ha creato quattro clienti, quattro servizi, quattro fatture e due ticket, con riconteggio successivo. Uccidere il processo a metà con kill -9 e riavviarlo ha creato zero oggetti e ha lasciato la destinazione identica byte per byte. Con stop è seguito un arresto senza una sola scrittura. Con overwrite_empty sono stati riempiti solo i campi nome vuoti e quelli già popolati sono rimasti intatti. Una fattura in una valuta non supportata è tornata come un 422 che nominava il campo, è stata annotata come currency_unknown, e l’esecuzione è proseguita.
Perché la rotta non si chiama live
Lo strumento è finito e misurato, e la rotta non è ancora avviabile dal pannello. Il motivo è un confine che si è rivelato importante: le credenziali di una migrazione si leggono dalla tabella delle integrazioni, e la colonna di tipo di quella tabella è il registro dell’infrastruttura che gestiamo noi. Una voce live lì porta una pagina di documentazione, una procedura di configurazione, uno schema di credenziali, una prova di connessione e nove traduzioni, e compare sul sito come qualcosa con cui si governano server. Un pannello di fatturazione non è quello. Lo leggiamo una volta e non lo tocchiamo più.
Chiamare live la rotta ci metterebbe sopra un distintivo che dice qualcosa di diverso dallo schermo sottostante, perciò esiste un terzo stato, adapter_ready, che dice ciò che è vero: l’adattatore esiste ed è stato misurato contro pannelli reali, e il posto in cui inserire i due pannelli non esiste ancora. Quel posto è un giro a sé, perché salvare un pannello di fatturazione come collegamento è una decisione di schema e non un sottoprodotto di uno strumento di migrazione.
Dal lavoro di migrazione di Provibr. Perché un’API che risponde 200 in caso di errore è una classe di bug e non un incidente sta in perché un 200 di Proxmox non è un esito, e perché una migrazione che ha saltato in silenzio è peggio di una fallita sta in la migrazione che non è mai partita.