Skip to content
Trilha
Chapters

Cookbook

Billing without a coupled provider

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.

ctx --pack · est. 9985 tokens est. · the recipe's llms.txt

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#

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#

WhereWhat
internal/cobranca/plans, subscriptions, invoices, the state machine, the webhook, the reminders
migrations/0100_billing.sqlthe tables billing_plans, billing_subscriptions, billing_invoices, billing_events, in the convention of trilha add store
migrations/0101_billing_indexes.sqlthe 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.gothe 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.

// 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.

// 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 — 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, 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: saying which part failed tells a stranger how close they got.

// 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 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.