Skip to content

Blog ·

Fifteen things a billing migration cannot carry

A billing migration is not a data copy. Fifteen measured omissions, money as integer cents, and why a fingerprint is the only usable idempotency key.

By Provibr

Moving a hosting business from one billing panel to another looks like a data copy and is not one. Two panels that both know what a customer, a service and an invoice are will still disagree about what those objects contain, and the disagreement is never symmetric: the source usually knows more than the target's API is willing to accept. A migration that pretends otherwise writes documents that do not match the ones the customer already received.

So the first thing this tool produces is not data. It is a list of what will not arrive.

Money is integer cents, and the conversion reads text

Both panels hand out amounts as a JSON number (9.95) or as decimal text ("9.95"). The obvious conversion is to multiply by a hundred.

The obvious conversion is wrong. As a 64 bit float, 9.95 is 9.949999999999999289, and 9.95 * 100.0 is 994.9999999999999. Truncating that gives 994 cents: one cent less than the invoice says. On a game server that is a rounding annoyance. On an administration it is an incorrect document, and it surfaces when somebody closes the year.

The canonical model therefore carries an amount as an integer number of cents plus a currency code, and the conversion counts digits in the textual form instead of going through a float at all. Fractions beyond two decimals round half away from zero rather than truncating, because half a cent truncated to nothing makes an invoice line disappear. Scientific notation is refused outright: supporting 1e2 halfway would turn a hundred euro invoice into one cent, and refusing is cheaper than being clever. There is an upper bound of 2^53 cents, because the other side of the wire counts in JavaScript numbers, and a value we accept that they can no longer represent exactly is a silent divergence rather than an error.

Fifteen omissions, each one measured

Eight of them apply to any billing migration, and seven are specific to the first route, FOSSBilling to Paymenter. All fifteen were measured against running containers, not read from documentation.

The general eight are the ones you can predict once you say them out loud. Customer passwords are hashes that were computed with the source application's key and would not work anywhere else, and the target's user endpoint demands a fresh password of at least eight characters. Stored cards and mandates are tokens belonging to the source panel's account at the payment provider; carrying them would mean a direct debit leaving from an account the customer never signed for. Gateway configuration carries keys and webhook URLs that belong to the old panel, so it is deliberately never read. Email templates are written in the template language of their own panel, and a carried template produces mail with unsubstituted placeholders. Ticket attachments exist as files on the source panel's disk with no endpoint that returns their content. Affiliate bookkeeping has no counterpart with the same meaning, and carrying it would recreate commissions that were already paid. Promo codes without their redemption history are actively wrong: a code limited to ten uses that has been used four times arrives as a code with ten uses left. Two factor secrets are stored encrypted per panel and are never handed back.

The route specific seven are the interesting half, because they are all limits of the target's admin API rather than limits of the target.

What stays behindWhy, measured
Company, address, VAT number, phone, languageThe create user request validates first name, last name, email, password, verification date and role, and the controller creates from exactly the validated set. Everything else falls away.
Invoice numbersinvoices.number is unique in the database but appears in neither validation rule and in no resource; it cannot be written and cannot be read back.
VAT on an invoice lineAn invoice line accepts description, quantity, price and a reference. There is no tax field.
The product catalogueThe product controller has only index and show, and the permission file grants only view on products and categories.
Payment recordsThe API publishes no payment or transaction endpoint at all.
A service already past dueThe expiry date is validated as on or after today.
Staff identitiesA ticket message requires a user_id that exists as a user, and staff of the source panel are not users of the target.

Two of those rows carry a decision worth naming.

Invoice numbers go to stderr. Since the target assigns its own sequence and refuses to accept or return the old one, the migrator writes one line per invoice on standard error with the source number on it, where the administrator's own agent log picks it up. That is not elegant. It is honest: the number exists on a document the customer is holding, and the only place we can still put it is a log the operator controls.

An invoice with VAT is blocked rather than written. The target computes tax from its own settings, so the same invoice would end up with a different total than the document that was already sent. Writing it would produce a second, conflicting record of one transaction. The plan lists those invoices under tax_not_carried and moves on.

That distinction runs through the whole tool. A missing product blocks the service instead of filing it elsewhere. Blocking is a result; guessing is not.

The source answers HTTP 200 when it fails

FOSSBilling's admin API replies to a failed call with status 200 and a body of {"result":null,"error":{"message":"...","code":9999}}. Anyone who looks only at the status code reads a failure as an empty answer, and a migration that cannot tell "empty" from "failed" will one day synchronise an administration into nothing. There is exactly one function that opens that envelope, and it looks at error before it looks at result.

The reader is also structurally read only, and that is a test rather than a promise. Every method it may call is a named constant ending in get or get_list, seven of them, and a test reads the source file and matches every single client.get( call against that list. A reader that grew a _create call would be a red test, not a discovery afterwards.

One more measured oddity: a FOSSBilling product comes back with all seven billing periods marked enabled and priced at zero, whatever the panel sells. The price is the only usable signal about which cycle is real.

Idempotency on a fingerprint, because there is no external key

The normal way to make a migration resumable is to write your own identifier on the target and look it up next time. Paymenter offers nowhere to put one: there is no external reference field that survives a write and comes back on a read.

So identity is a fingerprint computed from the content on both sides.

  • A customer is their email address, lowercased.
  • A service is email, product name, price in cents, currency and quantity.
  • An invoice is email, currency, due date, and its lines with the total added up from those lines.
  • A ticket is email plus subject.

Two of those carry a warning in the code. The service fingerprint deliberately excludes the next due date, because the target refuses a past date and stores an empty one, which would give the same service a different fingerprint on resume and create it a second time: a field the target is allowed to change cannot be part of the key. The invoice total is summed from the lines rather than read from the source's total field, because on the target that value is a computation rather than a column, and two sides that add up differently produce a key that never matches. The invoice lines are sorted before assembly, because the order they come back in is the order of their ids, which need not be the order they were written in if one of them fell out.

The ticket key is the weak one, and it is written down as such: two tickets from the same customer with exactly the same subject collapse into one. Including the first message would fix that and costs a second call per ticket.

Three ways to handle a collision

When an object already exists on the target, the run does one of three things, and the operator chooses in advance: skip leaves the existing row alone, overwrite_empty fills in only fields that are empty there, and stop halts the whole run before writing anything at all.

Measured against a real FOSSBilling 0.8 and a real Paymenter: a dry run planned four customers, four services, four invoices, five products and two tickets; the apply created four customers, four services, four invoices and two tickets with a recount afterwards. Killing the process with kill -9 halfway and starting it again created zero objects and left the target byte identical. Setting stop produced a halt without one write. Setting overwrite_empty filled only the blank name fields and left the populated ones alone. An invoice in an unsupported currency came back as a 422 naming the field, was recorded as currency_unknown, and the run carried on.

Why the route is not called live

The tooling is finished and measured, and the route still cannot be started from the panel. The reason is a boundary that turned out to matter: credentials for a migration are read from the integrations table, and that table's type column is the register of infrastructure we operate. A live entry there carries a documentation page, a setup walkthrough, a credential schema, a connection test and nine translations, and it appears on the marketing site as something you drive servers with. A billing panel is not that. We read it once and never touch it again.

Calling the route live would put a badge on it that says something different from the screen underneath, so there is a third status, adapter_ready, that says what is true: the adapter exists and has been measured against real panels, and the place to enter the two panels does not exist yet. That place is its own round, because storing a billing panel as a connection is a schema decision and not a side effect of a migration tool.

From the migration work on Provibr. Why an API that answers 200 on failure is a category of bug rather than an accident is in why a Proxmox 200 is not a result, and why a migration that silently skipped is worse than one that failed is in the migration that never ran.

Start with one host and one agent

Create an account, install the agent with a single command and connect your first Proxmox host. The trial runs for 14 days and asks for no payment details.