S
supero.docs
Documentation/Reference apps/In the Ledgerline billing dashboard, customers cannot edit the bill

In the Ledgerline billing dashboard, customers cannot edit the bill

Ledgerline is an open-source subscription billing dashboard with invoices, dunning and expense approval. Source open on GitHub, with a live demo running.

  • apps
  • fintech
  • billing
  • workflows
  • access-control

Overview

Four record types, three workflows and the shortest front end of the 19 reference apps add up to an open-source subscription billing dashboard.
A customer signed in to Ledgerline can read their own account record and update it. They cannot change the plan it names, the seat count, or the state that says they are past due. That split is one rule in setup.py:
python
PolicyRule(entity="customer", can_read=True, can_update=True,
           filter_field="owner_username", filter_match="$user.name",
           readonly_fields=["mrr", "plan_name", "tier", "seats",
                            "usage_units", "account_state"]),
The filter_field line is policy-based row scoping. A tenant_user gets back only the customer row whose owner_username matches their login. The readonly_fields line then declares six fields on that row as not writable by that role. The finance team signs in as tenant_admin and carries neither restriction. I searched all 19 setup.py files for this shape: two apps put readonly_fields on a rule, and Ledgerline is the only one where the same rule also carries a row filter, so it is the place to look if your product has an account page the customer may edit and a balance they may not.
Six field names. That is the whole guard.

Four objects and no joins

Everything quoted here is in the ledgerline folder on GitHub. schemas.py is 99 lines. It defines Plan and Customer for the subscription side, with Invoice for what is owed and Expense for what the team spends. Plan is the only public one, so the pricing page loads without a login.
None of the four references another. An invoice carries customer_name and customer_email as plain strings, and every screen in the app is fed by a single list call per object. Each lifecycle sits in its own enum, named for its object: account_state on the customer, invoice_state on the invoice. The invoice also carries the one validation in the file:
python
"validations": [
    {"id": "amount-nonneg", "assert": {">=": [{"var": "amount"}, 0]},
     "message": "Invoice amount cannot be negative.", "severity": "error"},
],
The seed is short enough to read in a minute. I counted 4 plans and 8 customers, with 6 invoices and 6 expenses behind them.

Dunning that names its own undo

Workflows are plain data in setup.py, next to the policies. invoice_dunning, the chase on an unpaid invoice, has two steps. The first updates the invoice to overdue. The second sends a reminder through the email service. The workflow is declared with "on_error": "compensate", and the first step carries a compensate block: an update that puts invoice_state back to sent. Its description in the file reads "reverts the step if the email send fails". You write the undo beside the action it reverses, in the same dict, and there is no separate rollback handler to keep in step with it.
expense_approval runs three steps: approve the expense, email the submitter, mark it reimbursed. The email step is set to continue on error. The approve step compensates back to submitted.
The third workflow has no button at all. subscription_welcome is bound to the event @create:ledgerline:customer, so creating a customer row is what sends the welcome mail.
On the browser side, ui/app.js on main routes every workflow call through one function, runSaga, and that function treats a failed step or any status other than completed as a failure, so when a dunning run does not finish the code shows the finance user "The dunning step was not advanced" and no success message. The hosted demo can run a build behind main, so read the repo for this part.

Where the billing dashboard gets its MRR number

There is no analytics table.
The console home screen fetches the customer rows and sums mrr, monthly recurring revenue, for every row whose account_state is active or past_due. ARR is that figure times 12. Logo churn is churned rows over all rows. On the seed data that gives 6 counted customers, $3,644 of MRR and $43,728 of ARR, with 1 of 8 accounts churned. The live demo may have drifted from those figures, since anyone using the finance login can add and edit customers.
A customer never sees that screen. Their portal makes the same getObjects('customer') call the console makes and, under the rule above, receives one row. The scoping happens on the server, and the portal code has no filter of its own.
The Pay button on an open invoice calls the stripe_checkout service and redirects to the hosted checkout URL it returns. If the call fails, the code on main leaves the invoice unpaid and tells the customer so. Ledgerline demonstrates a billing workflow. It is a reference app and carries no certification.
The whole front end is 387 lines of React with no build step. It is the shortest ui/app.js in the repo.

Two logins, then your own domain

The login page at ledgerline.supero.live lists two demo accounts. One is the finance team (tenant_admin). The other is a customer (tenant_user) signed in as Northwind Labs, and in the seed that customer owns invoices LL-2041 and LL-2078.
To run your own copy from the source folder:
bash
git clone https://github.com/supero-platform/supero-apps
cd supero-apps/apps/fintech/ledgerline
cp .env.example .env    # set SUPERO_DOMAIN and SUPERO_PASSWORD
./run.sh
No account is needed. run.sh registers the domain you name in .env, and the app is running in about two minutes on http://localhost:5664.
The app code is MIT. The platform behind it is a hosted service. If your product bills by usage, edit the four entries in PLANS and re-run. A billing app built around your own price list is a new project at supero.dev.