Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
661 changes: 296 additions & 365 deletions content/backlog.yaml

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions src/content/docs/admin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ sidebar:
order: 0
---

import Glossary from "@components/Glossary.astro";

import { LinkCard, CardGrid } from "@astrojs/starlight/components";
import Persona from "@components/Persona.astro";

<Persona for="Operator">
This guide is for operators running Polaris Express. It covers the
admin web console (manage.example.com) and the iOS scanner /
kiosk app.
<Glossary term="Kiosk mode">kiosk</Glossary> app.
</Persona>

The admin guide is organized by domain. Common pairs (Devices &
Expand All @@ -23,9 +25,9 @@ in the sidebar.

<CardGrid>
<LinkCard title="Register a charger" href="/admin/devices/register-charger/" />
<LinkCard title="Link an EV card to a customer" href="/admin/cards/link-card-to-customer/" />
<LinkCard title="Link an <Glossary term="EV card">EV card</Glossary> to a customer" href="/admin/cards/link-card-to-customer/" />
<LinkCard title="Finalize an invoice" href="/admin/billing/finalize-invoice/" />
<LinkCard title="Replay a failed Lago webhook" href="/admin/webhooks/replay-webhook/" />
<LinkCard title="Replay a failed <Glossary term="Lago">Lago</Glossary> webhook" href="/admin/webhooks/replay-webhook/" />
<LinkCard title="Trigger a manual sync" href="/admin/sync/trigger-manual-sync/" />
<LinkCard title="Impersonate a customer" href="/admin/users/impersonate-customer/" />
<LinkCard title="Activate iOS kiosk mode" href="/admin/ios/kiosk/activate-kiosk-mode/" />
Expand All @@ -38,13 +40,13 @@ in the sidebar.
health, charging profiles.
- **EV cards** — linking, unlinking, bulk imports, diagnosing
"card not charging".
- **Transactions** — browsing, inspecting meter values, force-closing
- **Transactions** — browsing, inspecting <Glossary term="Meter value">meter values</Glossary>, force-closing
stuck sessions, exports.
- **Reservations** — managing, overriding.
- **Billing (Lago)** — finalize, void, credit notes, subscription
management, reconciliation.
- **Users** — admin accounts, impersonation, session revocation,
user mappings.
<Glossary term="User mapping">user mappings</Glossary>.
- **Sync runs** — manual triggers, run inspection, adaptive cadence.
- **Webhooks** — event log, replay, circuit breaker.
- **iOS app** — scanner install, NFC scanning, diagnostics, kiosk
Expand Down
148 changes: 148 additions & 0 deletions src/content/docs/admin/billing/finalize-invoice.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
title: Finalize a draft invoice
description: Promote a draft Lago invoice to finalized status so it can be paid, exported, and counted against revenue.
sidebar:
order: 10
persona: Operator
surface: web
tier: P0
code_refs:
- web/routes/api/admin/invoice/[id]/finalize.ts
last_authored: 2025-02-14
prompt_template: admin-runbook
---

import Glossary from "@components/Glossary.astro";

import { Aside, Steps } from "@astrojs/starlight/components";
import Tutorial from "../../../../components/Tutorial.astro";
import TutorialStep from "../../../../components/TutorialStep.astro";
import Screenshot from "../../../../components/Screenshot.astro";
import Endpoint from "../../../../components/Endpoint.astro";

Finalize a draft invoice when you need to lock its line items and make
it payable. Use this runbook at month-end close, before sending an
invoice to a customer, or when a draft has been reviewed and is ready
to count against revenue. Finalization is performed against [<Glossary term="Lago">Lago</Glossary>](/glossary#lago)
and is effectively one-way — once finalized, you cannot edit the line
items.

## Prerequisites

- You are signed in as an operator with admin access to the billing
console.
- You are on the **Billing → Invoices** screen and have located the
draft invoice.
- You have reviewed the draft's line items, fees, and taxes with the
customer record open in a second tab if needed.
- The corresponding [<Glossary term="Lago customer">Lago customer</Glossary>](/glossary#lago-customer) and
[<Glossary term="Lago subscription">Lago subscription</Glossary>](/glossary#lago-subscription) have already
synced — confirm via the most recent [sync run](/glossary#sync-run).

<Aside type="danger">
Finalization is **not reversible** from the admin UI. A finalized
invoice cannot be returned to draft. If you finalize the wrong
invoice you will need to issue a credit note or void it in Lago
directly. Confirm the invoice ID and customer before you click.
</Aside>

## Procedure

<Tutorial>
<TutorialStep title="Open the draft invoice">
From **Billing → Invoices**, filter by status **Draft** and click
the row for the invoice you want to finalize. The detail panel
opens with the invoice's line items, fees, and taxes.

<Screenshot alt="Invoice list filtered to draft status with one row selected" />
</TutorialStep>

<TutorialStep title="Verify the customer and totals">
Confirm:

- The customer name and Lago customer ID match the account you
intend to bill.
- The line items reflect the billing period you expect.
- The fees and taxes totals match your expectation. The UI reads
`fees_amount_cents` and `taxes_amount_cents` straight from Lago.

If anything looks wrong, **stop**. Resolve the underlying data in
Lago (or wait for the next sync) before finalizing.
</TutorialStep>

<TutorialStep title="Click Finalize">
In the invoice detail panel, click **Finalize invoice**. The
admin app issues:

<Endpoint method="POST" path="/api/admin/invoice/{id}/finalize" />

which calls Lago's `PUT /invoices/:id/finalize` and returns the
refreshed invoice. The UI status updates from `draft` to whatever
`deriveInvoiceUiStatus` resolves to based on the new Lago
`status`, `payment_status`, and `payment_overdue` fields —
typically `finalized` or `pending`.

<Screenshot alt="Invoice detail panel showing the Finalize invoice button" />
</TutorialStep>
</Tutorial>

## Verify

The finalize call is synchronous. When it returns:

- The invoice's **UI status** should no longer be `draft`. Expect
`finalized`, `pending`, or `overdue` depending on payment state.
- The **Fees** and **Taxes** rows in the detail panel reflect the
values Lago returned.
- Refreshing the invoice list and filtering by **Draft** should no
longer show this invoice. Filtering by **Finalized** should.
- The action appears in the [audit log](/glossary#audit-log) attributed
to your operator account. <!-- TODO: confirm finalize emits an audit log entry -->

If the request failed, the panel shows an error toast with the upstream
Lago message and the API returns `502` with `{ error: "Failed to
finalize invoice: <message>" }`.

## If something goes wrong

<Aside type="caution">
A `502` response means Lago rejected the finalize call. The draft
is unchanged — it is safe to retry once the underlying issue is
resolved.
</Aside>

Common failure modes:

- **Lago returns an error** — the draft remains a draft. Read the
error message in the toast. Typical causes: the invoice was already
finalized in Lago by another operator, the customer is missing
required tax info, or the subscription state has changed. Fix the
underlying data in Lago and retry.
- **401 Unauthorized** — your session expired. Sign back in and retry.
- **400 Missing invoice id** — refresh the page; the route param
didn't propagate. Reopen the invoice from the list.
- **You finalized the wrong invoice** — you cannot undo this from the
admin UI. See *Audit and reversibility* below.

## Audit and reversibility

Finalization is a one-way transition in Lago. To unwind a mistaken
finalization:

1. Open the invoice in the Lago admin console directly.
2. Issue a **credit note** for the full amount, or **void** the
invoice if your Lago plan and the invoice's payment state allow
it.
3. If a replacement is needed, generate a new draft from the
subscription and finalize that one instead.

The original finalize action is recorded in the [audit log](/glossary#audit-log)
with the operator who performed it, the invoice ID, and the
timestamp. <!-- TODO: confirm audit log shape for finalize -->

## Related

- [Issue a credit note](/admin/billing/credit-note/)
- [Reconcile a Lago sync run](/admin/billing/reconcile-sync-run/)
- [Resolve a failed payment](/admin/billing/failed-payment/)
- [Glossary: Lago](/glossary#lago)
168 changes: 168 additions & 0 deletions src/content/docs/admin/billing/lago-overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
---
title: How Lago billing fits together
description: A conceptual map of how Polaris Express talks to Lago — customers, subscriptions, events, invoices, and the surfaces that wrap them.
sidebar:
order: 10
persona: Operator
surface: n/a
tier: P0
code_refs:
- web/src/lib/lago-client.ts
last_authored: 2025-02-14
prompt_template: concept
---

import Glossary from "@components/Glossary.astro";

Polaris Express does not run its own billing engine. Every customer,
subscription, plan, coupon, wallet, and invoice you see in the admin UI
is a thin projection of state held in [<Glossary term="Lago">Lago</Glossary>](/glossary#lago) — an
open-source billing platform we deploy alongside the rest of the stack.
Understanding that boundary is the difference between fixing a billing
issue in five minutes and chasing ghosts in our database.

## The model

There are four core entities to keep in your head. Everything else in the
billing surface is built on top of them.

```mermaid
erDiagram
CUSTOMER ||--o{ SUBSCRIPTION : "has"
CUSTOMER ||--o{ WALLET : "has"
CUSTOMER ||--o{ INVOICE : "is billed via"
CUSTOMER ||--o{ APPLIED_COUPON : "receives"
SUBSCRIPTION }o--|| PLAN : "instantiates"
SUBSCRIPTION ||--o{ EVENT : "accrues usage from"
PLAN ||--o{ BILLABLE_METRIC : "charges on"
EVENT }o--|| BILLABLE_METRIC : "increments"
INVOICE ||--o{ FEE : "itemizes"
```

### Customers

A [<Glossary term="Lago customer">Lago customer</Glossary>](/glossary#lago-customer) is the billing identity for
one Polaris account. We address customers by their `external_id`, which
is always the Polaris user's [<Glossary term="Public ID">Public ID</Glossary>](/glossary#public-id) — never
Lago's internal `lago_id`. This means we can recreate the Lago side
deterministically if the billing database is ever rebuilt: the same
Polaris user always maps to the same Lago customer.

Customer create and update both POST to `/customers` — Lago treats it
as upsert-by-`external_id`. The two methods in `lago-client.ts` exist
only to signal intent at the call site.

### Plans and subscriptions

A **plan** is the price sheet: a base fee, a currency, and a list of
*charges*, each pointing at a [<Glossary term="Lago metric">Lago metric</Glossary>](/glossary#lago-metric) with
a pricing model (flat, package, tiered, volume). Plans are managed in
the Lago admin UI, not in Polaris — we read them but never write them.

A [<Glossary term="Lago subscription">Lago subscription</Glossary>](/glossary#lago-subscription) attaches one customer
to one plan. Subscriptions, like customers, are keyed by an external ID
we control. A single customer can hold several subscriptions
simultaneously (e.g. a base plan plus a top-up).

Subscriptions also carry a `metadata` bag, which Polaris uses to mirror
the active [charging profile](/glossary#charging-profile) onto the
subscription. This is why `getSubscription` returns a
metadata-tolerant schema while `getSubscriptions` (the list endpoint)
does not — only callers that need the mirror pay the schema cost.

### Events

Usage is reported as **events**. Every event has:

- a `transaction_id` (idempotency key — Lago dedupes on it),
- an `external_subscription_id` (which subscription to bill),
- a `code` (the metric to increment),
- a `properties` bag (typically `{ value: <kWh> }` for sum-aggregated
metrics).

Events are append-only. Once Lago has accepted an event with a given
`transaction_id`, sending it again is a no-op. This is the property
that lets us retry safely after a network blip.

```mermaid
sequenceDiagram
participant SteVe
participant Polaris
participant Lago
SteVe->>Polaris: MeterValues / StopTransaction
Polaris->>Polaris: Build LagoEvent (txn_id = session_id)
Polaris->>Lago: POST /events (or /events/batch)
Lago-->>Polaris: 200 OK
Note over Lago: Event applied to subscription usage
Lago->>Lago: At billing period end → invoice
```

### Invoices

Invoices are produced by Lago at the end of each billing period, or
on-demand for one-off line items. They flow through these states:
`draft` → `finalized` → `paid` / `voided`. Polaris exposes the verbs
that move an invoice between states (`finalize`, `void`, `refresh`,
`retry_payment`, `download`), but the state machine itself lives in
Lago.

PDF generation is asynchronous: `downloadInvoicePdf` enqueues the job
and returns immediately. The `file_url` populates later, so the UI
polls `getInvoice` until it appears.

## Why it works this way

**Lago owns the truth.** We considered caching subscriptions and
invoices in Polaris's database, but every cache we sketched had the
same failure mode: it would drift, and the drift would silently
misbill someone. Treating Lago as the source of truth — and re-fetching
on every render — keeps the blast radius of a Polaris bug small.
Polaris bugs cause UI glitches; they don't cause invoices to be wrong.

**External IDs are ours.** Lago's internal `lago_id`s are opaque UUIDs
that change if the Lago instance is ever rebuilt. By keying every
relationship by IDs we generate ([Public ID](/glossary#public-id) for
customers, session ID for events), we make the integration
reproducible and debuggable. If a customer asks "why was I charged
3.2 kWh for session X?", we can search Lago for `transaction_id = X`
and get a one-row answer.

**Idempotency on the wire.** The `transaction_id` field is not
optional — even our batch event creator forwards it untouched. This
means retry logic at every layer (the `retry` wrapper in
`lago-client.ts`, the sync queue, the operator clicking "resync") is
safe by construction.

**Schemas at the boundary.** Every Lago response is parsed through a
Zod schema before returning to callers. If Lago changes a field type
in a minor version, the failure surfaces immediately at the boundary —
not five layers up in a React component.

## What this means for you

As an operator working with billing:

- **Don't try to "fix" billing data in Polaris's database.** Polaris
has no billing tables to fix. Open the Lago admin UI (or use the
Lago API) for any correction that needs to persist.
- **The Polaris admin UI is a window, not a workspace.** Buttons like
"Void invoice" or "Apply coupon" round-trip to Lago. If Lago is down,
these buttons fail loudly — they do not queue.
- **Event replay is safe.** If you suspect events were lost (e.g. Lago
was unreachable for a period), you can re-run the relevant
[sync run](/glossary#sync-run). Events that already landed are deduped
by `transaction_id`.
- **Plan changes happen in Lago.** Polaris never edits plan pricing.
If you need a new tier, create it in the Lago admin UI and Polaris
will pick it up on its next plan list refresh.
- **Customer self-service is iframed.** When a customer hits the
billing portal, we fetch a signed `portal_url` from Lago and render
it in an iframe. We never proxy that traffic.

## Related

- [Reconciling Polaris users with Lago customers](/admin/billing/user-mapping/)
- [Replaying lost usage events](/runbooks/lago-event-replay/)
- [Issuing a one-off invoice](/admin/billing/one-off-invoice/)
- [Webhook signature verification](/concepts/lago-webhooks/)
- [<Glossary term="Charging profile">Charging profile</Glossary> metadata mirror](/concepts/charging-profile-mirror/)
Loading
Loading