# Recipe: Billing without a coupled provider > A Next.js-style web framework for Go: a folder under app/ is a route, HTML is written in Go, and nothing outside the standard library is imported. > /trilha/llms/recipes/billing.txt - [Billing without a coupled provider](/trilha/cookbook/billing): trilha add billing — plans, subscriptions on a closed state machine, a signed idempotent webhook, dunning by e-mail, and the screens behind two roles. The provider charges the card; the application decides what each event means. · 1888 tokens est. - [Database](/trilha/cookbook/database): One pool for the process, queries that carry the request's context, a transaction that rolls back on its own, and migrations applied in order. · 2289 tokens est. - [Work that outlives the request](/trilha/cookbook/tasks): trilha/task — the four stages of processing something, with a screen that shows the progress, a panic that does not take the process down, and a store the state survives in. · 2988 tokens est. - [webhook](/trilha/reference/webhook): Hooks, Options, Emit, Subscribe, Verify, Store and the panel — the API of the webhook package, with the defaults and what each field changes. · 2820 tokens est. 4 page(s) · 9985 tokens est. in all --- Every product that charges ends up writing the same five things: a table of plans, a subscription that moves between states, the endpoint the payment provider calls, the e-mail that goes out when a card fails, and a screen for whoever looks after the money. None of it is the provider's to decide — the provider charges the card and says what happened. What *past due* means for your application, when a subscription is over, who may export the invoices: that is the application's, and it is what `trilha add billing` writes. ## The command ```bash trilha add login connections billing ``` `billing` stands on two recipes and refuses without them, by name, before writing anything: `login` for the session and the roles, `connections` for the sealed secret the provider signs with. ## What it installs | Where | What | |---|---| | `internal/cobranca/` | plans, subscriptions, invoices, the state machine, the webhook, the reminders | | `migrations/0100_billing.sql` | the tables `billing_plans`, `billing_subscriptions`, `billing_invoices`, `billing_events`, in the convention of [`trilha add store`](/trilha/cookbook/database) | | `migrations/0101_billing_indexes.sql` | the indexes the screens order and filter by; a file of its own, since an applied migration is checked by its sum | | `app/webhooks/billing/` | `POST /webhooks/billing`, the address the provider calls — fixed, because it is typed into the provider's dashboard | | `app/billing/` | subscriptions filtered by state, `planos/` (create and delete), `faturas/` and `faturas/csv/` | | `billing_test.go` | the tests below, in your project, run by `trilha check` | Two roles, declared once as an `auth.Policy`: `billing:reader` reads the screens, `billing:admin` edits plans and exports invoices. `admin` gets the second level too. ```go // Politica is who may do what with money. "admin" is here because the person // who administers the application should not need a second role to look at // what it charges. var Politica = auth.Policy{ Modules: []string{"billing"}, Levels: auth.Levels{"ler", "administrar"}, Roles: map[string]auth.Grants{ PapelLeitor: {"billing": "ler"}, PapelAdmin: {"billing": "administrar"}, "admin": {"billing": "administrar"}, }, } ``` ## Price `trilha ctx --pack billing` costs **~366 tokens (est.)** — what an agent reads to know what the recipe brought, measured on a minimal project by `TestRecipeCtxPackCost`. `trilha add --list` shows the same number, and the badge on this page prices the documentation it links to. ## The machine Nothing changes a subscription's state without passing through `Mover`, and `Mover` only allows what this table says. A canceled subscription does not come back to life because an old event arrived late. ```go // Transicoes is the whole machine. Nothing changes a subscription's state // without passing through Mover, and Mover only allows what is written here — // a canceled subscription does not come back to life because an old event // arrived late. var Transicoes = map[Estado][]Estado{ Trial: {Ativa, Atrasada, Cancelada}, Ativa: {Atrasada, Cancelada}, Atrasada: {Ativa, Cancelada}, Cancelada: nil, } ``` A failed charge moves the subscription to `past_due` and counts one attempt; a paid invoice puts it back to `active` and forgets the failures; the third failure in a row cancels it (`MaxTentativas`). Each failure queues a reminder as a [task](/trilha/cookbook/tasks) — the provider is waiting for the answer, and an e-mail server is not a reason to make it wait — and the third reminder says the subscription is over. ## The endpoint The provider signs `timestamp.body` with the secret of the `billing-webhook` connection, in `X-Webhook-Timestamp` and `X-Webhook-Signature` — the scheme of [`webhook.Verify`](/trilha/reference/webhook), which checks it in constant time and refuses a timestamp more than five minutes away from now. Every way of failing is the same `401` with the same code, [`E_BILLING_WEBHOOK_UNSIGNED`](/trilha/docs/errors/E_BILLING_WEBHOOK_UNSIGNED): saying which part failed tells a stranger how close they got. ```go // Receber is the whole endpoint: signature over timestamp.body, the window, // the event seen once, the change, the trail, the answer. // // webhook.Verify checks the signature in constant time and refuses a // timestamp more than webhook.Tolerance (five minutes) away from now, in // either direction — a captured request stops working before anybody could // replay it. Every way of failing is the same 401 with the same code: saying // which part failed tells a stranger how close they got. func (b *Billing) Receber(c *trilha.Ctx) error { segredo, err := Segredo(c) if err != nil { c.Log().Warn("cobranca: webhook refused", "code", CodigoNaoAssinado, "why", "no connection") return naoAssinado() } corpo, err := webhook.Verify(c.Request(), segredo) if err != nil { c.Log().Warn("cobranca: webhook refused", "code", CodigoNaoAssinado) return naoAssinado() } var ev Evento if err := json.Unmarshal(corpo, &ev); err != nil || ev.ID == "" { return trilha.Errorf(http.StatusBadRequest, "invalid event") } // Signed and not seen before, and only then: an unsigned request must not // be able to burn an id a real event will carry later. agora := time.Now().UTC() visto, err := b.Store.Visto(c.Context(), ev.ID, agora) if err != nil { return err // 500: the provider tries again } if visto { return c.JSON(http.StatusOK, map[string]string{"status": "duplicate"}) } if err := b.aplicar(c, ev, agora); err != nil { var falha falhaDoStore if errors.As(err, &falha) { // The store failed and wrote nothing: the id is forgotten and the // answer is 500, so the provider's retry is processed. if ferr := b.Store.Esquecer(c.Context(), ev.ID); ferr != nil { c.Log().Error("cobranca: event id kept after a failure", "event", ev.ID, "err", ferr) } return falha.err } // The event was understood and refused by the machine — an old event // arriving after a cancel, say. It is logged with the reason, and the // answer is still 200: retrying it would not make it valid. c.Log().Warn("cobranca: event not applied", "event", ev.ID, "type", ev.Tipo, "err", err) } return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } ``` The event id is recorded only after the signature checks out — an unsigned request must not be able to burn an id a real event will carry later — and a second delivery of the same id answers `200` without applying anything. Every change that touches money goes to the audit trail with the event id and the transition. ## Extending it - **Another provider**: the struct `Evento` and the four event names in `internal/cobranca/webhook.go` are the only provider-shaped code. A provider that signs another way is `webhook.VerifyHMAC` in place of `webhook.Verify` in `Receber`. - **A database**: with [`trilha add store`](/trilha/cookbook/database) in the project — before or after — the `trilha:link billing-store` line of `app/setup.go` points `cobranca.Banco` at the pool, and the rows go to `billing_*` (`internal/cobranca/sql.go`, SQLite or PostgreSQL). No screen changes; `cobrancatest.Contrato` holds memory and database to the same behaviour, and a database failure in the webhook answers 500 and forgets the id, so the provider's retry counts. - **Production**: the secret lives in `connections`, and `trilha audit` warns while those are kept in memory — a restart would forget the secret and every event would be refused.