Billing

Billing is a module: it has the same registration as a domain, but it owns a feature rather than a part of the core model, and it keeps its own DbContext and PostgreSQL schema. The product never asks "which plan is this organization on?". It asks "what is this organization entitled to?", and Billing answers that question with an integration event.

What it does

For the person using the app

  • An anonymous /pricing page lists the three tiers, Free, Pro and Business, priced per seat, monthly or annually.
  • An administrator of an organization opens Settings → Billing, checks out to a paid plan, switches plan, cancels, reads the invoices, and opens the provider's customer portal (Manage billing) to change the card or pay an unpaid invoice.
  • What the plan grants applies to the organization: the number of workspaces, the AI credits, whether it may use its own model provider key, and whether single sign-on is available (Business only).

For the developer, the product domains reference nothing from Billing: they read a local projection, OrganizationEntitlements. The plan catalog is configuration (Billing:Catalog), not sample data. Stripe is one class behind the IPaymentGateway port. With no key, billing is disabled but the app runs.

How it works

Projects

ProjectHolds
Blouppy.Billing.AbstractionsThe public surface: IBillingCatalogService, IBillingSubscriptionService, IBillingAvailability, IPaymentNotificationEndpoint, PlanType, SubscriptionStatus and the request and response records
Blouppy.BillingEverything else, internal: Domain/ (Plan, Subscription and their domain events), Application/ (use cases and event handlers), Catalog/, Infrastructure/ (BillingDbContext, the repositories, the consumers, Payments/), SampleData/
Blouppy.Billing.UnitTestsIncluding Architecture/BillingModuleBoundaryTests.cs, which holds the module's references down

The module's tables, plans and subscriptions, are in the billing schema with their own migrations history. The product's organization_entitlements table, in blouppy, is a projection, not a source of truth.

Entitlements, not plans

Billing publishes OrganizationEntitlementsChangedIntegrationEvent (Blouppy.Infrastructure.Messaging.Abstractions/Billing/). It deliberately carries no plan name, price or currency:

public sealed record OrganizationEntitlementsChangedIntegrationEvent(
    OrganizationId OrganizationId,
    int? MaxWorkspaces,
    DateTimeOffset EffectiveAtUtc,
    bool AllowsOwnAiKey = false,
    int? MonthlyAiCredits = null,
    int? MonthlyAiCreditsPerMember = null,
    int? MonthlyAutomationAiCredits = 0,
    bool AllowsSso = false) : IIntegrationEvent;

A null limit means unlimited. The fields added after the first version are last and defaulted, so a message already in the outbox still deserializes. On the product side, UpdateEntitlementsWhenOrganizationEntitlementsChangedIntegrationEventHandler (Blouppy.Organizations/Entitlements/) upserts the row, and OrganizationEntitlements.Update ignores an event older than the one it holds.

The event is published from two places only:

  • GrantEntitlementsWhenOrganizationCreatedIntegrationEventHandler: a new organization gets the Free plan's figures, on OrganizationCreatedIntegrationEvent.
  • PublishIntegrationEventWhenSubscriptionEntitlementsChangedDomainEventHandler: a subscription raised SubscriptionEntitlementsChangedDomainEvent.
StripeBillingRabbitMQ (outbox)OrganizationsStripeBillingRabbitMQ (outbox)OrganizationsAn organization is createdAn administrator upgradesOrganizationCreatedGrantEntitlementsWhenOrganizationCreated…OrganizationEntitlementsChanged (Free)upsert organization_entitlementsPOST …/checkout (Web controller)checkout session (status Incomplete)POST /billing/notifications (signed)ApplyProviderState → ActiveOrganizationEntitlementsChanged (Pro)upsert organization_entitlements
StripeBillingRabbitMQ (outbox)OrganizationsStripeBillingRabbitMQ (outbox)OrganizationsAn organization is createdAn administrator upgradesOrganizationCreatedGrantEntitlementsWhenOrganizationCreated…OrganizationEntitlementsChanged (Free)upsert organization_entitlementsPOST …/checkout (Web controller)checkout session (status Incomplete)POST /billing/notifications (signed)ApplyProviderState → ActiveOrganizationEntitlementsChanged (Pro)upsert organization_entitlements

Only an active subscription grants anything.Subscription.GetEntitledPlan() answers Free for any status other than Active, so a checkout grants nothing until the provider confirms the payment, and a PastDue subscription falls back to Free limits. Changing the plan of a live subscription (ChangeActivePlan) raises the new entitlements at once, without waiting for the provider's notification.

Before the first event arrives, the product reads FallbackEntitlements (Blouppy.Organizations/Entitlements/FallbackEntitlements.cs): the Free figures, duplicated on purpose in this one place so that creating an organization never waits on Billing.

Enforcement never calls Billing. WorkspaceCreationValidator reads IOrganizationsPublicDataService.GetEntitlementsAsync and refuses a workspace past MaxWorkspaces. Only creation is checked: an organization that moves to a smaller plan keeps what it has. The AI module reads the credits the same way (see AI), and the identity host reads AllowsSso (see SSO and SCIM).

Seats and the provider

Plans are priced per seat. The controller counts the organization's members and passes the number to StartCheckoutAsync and ChangePlanAsync, so Billing never reads the membership tables. After that, the provider's notifications keep the count up to date. Free counts as one seat.

IPaymentGateway (Infrastructure/Payments/) is the port, and StripePaymentGateway is the only type that knows Stripe. A provider notification goes through PaymentNotificationEndpoint, which authenticates and translates it before ISubscriptionReconciliationService sees it. Before it charges, a checkout or a change of plan reads the provider's price back and throws PriceMismatchException when it differs from the catalog's amount.

API

EndpointAccess
GET /api/v1/billing/plansAnonymous
GET /api/v1/billing/status (paymentsEnabled)Anonymous
GET /api/v1/billing/subscriptions/{organizationId}, …/invoicesPolicies.Read
POST …/{organizationId}/checkout, …/plan, …/portal, …/cancelPolicies.Manage
POST /api/v1/billing/subscriptions/notificationsAnonymous; the signature is the credential
GET /api/v1/organizations/{organizationId}/entitlementsThe product's projection

The gateway exposes the notification endpoint as POST /billing/notifications (Blouppy.Gateway/Billing/PaymentNotificationsRoute.cs). It is a plain YARP route, with the cookie stripped and the body forwarded unchanged, because the provider has no session. Checkout and portal return URLs must point into the application, or the API answers 400.

Plan changes are published for the audit log (SubscriptionPlanChangeRequested…, SubscriptionPlanChanged…), and the module forgets a person on AccountErasedIntegrationEvent (see Privacy).

Configuration

The catalog

BillingCatalogOptions binds Billing:Catalog. Its defaults are the template's catalog:

PlanMonthlyPrice / AnnualPriceMaxWorkspacesAI credits per seat / per member / automationOwn AI keySSO
Free0 / 03100 / 30 / 0nono
Pro9 / 90unlimited300 / 300 / 500nono
Business19 / 190unlimited1000 / 1000 / 2000yesyes

The currency defaults to USD. Prices are per seat, and AnnualPrice is the real yearly charge. Override only what differs:

Billing__Catalog__Currency=EUR
Billing__Catalog__Plans__Pro__MonthlyPrice=12
Billing__Catalog__Plans__Free__MaxWorkspaces=5

Each plan also takes AllowsOwnAiKey, AllowsSso, MonthlyAiCreditsPerSeat, MonthlyAiCreditsPerMember and MonthlyAutomationAiCredits. On every start, BillingCatalogSynchronizer adds a missing tier and revises a changed one in place, so the subscriptions that point to it follow. A catalog it cannot write stops the API at startup with the key at fault: a missing tier, a negative price or limit, a priced Free plan, or an invalid currency. A subscribed organization keeps its entitlements until its subscription next changes.

Stripe

AddStripePaymentGateway binds the Stripe section of Blouppy.Web:

KeyValue
Stripe:SecretKeysk_… or a restricted rk_… key
Stripe:WebhookSecretThe whsec_… of the endpoint {GatewayUrl}/billing/notifications
Stripe:Prices:{Pro|Business}:{Monthly|Annual}The four price_… ids, per seat, at the catalog's amounts and currency

In development, set them as user secrets of Blouppy.Web and forward the events with stripe listen --forward-to https://localhost:5071/billing/notifications. Use the whsec_ it prints as the webhook secret. In production, register the gateway's URL in Stripe with checkout.session.completed, customer.subscription.updated and customer.subscription.deleted, and turn on the customer portal. See Deployment.

In Production, once a key is set, StripeOptionsValidator refuses to start without a whsec_ secret and the four price ids. Outside Development, StripeConfigurationReport logs a warning when billing is disabled, and logs another when a test key is used in Production.

Where seeding is enabled, SeedBillingSampleDataAsync puts Blouppy Technologies on Business and Acme Corporation on Pro, and leaves TechStart on Free. Their provider ids are placeholders that a real Stripe account will not recognize.

Extending it

Add an entitlement

A new capability is a field that flows from the catalog to the projection:

  1. Add the property to PlanDefinition and its default per tier in BillingCatalogOptions (Blouppy.Billing/Catalog/).
  2. Carry it on Plan: the constructor, HasSameTermsAs and Revise, then BillingCatalog.CreatePlan, PlanConfiguration, and a BillingDbContext migration.
  3. Append it, last and defaulted, to OrganizationEntitlementsChangedIntegrationEvent, and pass it in both publishing handlers.
  4. On the product side, add it to OrganizationEntitlements (constructor and Update), FallbackEntitlements, OrganizationEntitlementsConfiguration, the upsert handler and OrganizationEntitlementsPublicDataDto, with a BlouppyDbContext migration.
  5. Enforce it where it applies, in a validator that reads GetEntitlementsAsync, and display it through BillingPlan and BillingPlanDto if the pricing page should show it.

The module's migrations go to its own project and context:

dotnet ef migrations add <Name> -s back/Blouppy.Web -p back/Blouppy.Billing \
  -c BillingDbContext -o Infrastructure/Database/Migrations

Other changes

  • Prices or limits need no code: set Billing__Catalog__* and the matching Stripe__Prices__*. The pricing page's marketing copy is in features/pricing/pricing.data.ts, as translation keys.
  • A fourth tier is a code change: PlanType, its catalog default, its Stripe prices (the validator iterates every paid PlanType) and the front end's PlanType.
  • Another provider is one class implementing IPaymentGateway, registered in place of StripePaymentGateway.

Remove billing

Delete the Blouppy.Billing* projects and their wiring in Blouppy.Web: AddBillingModule and AddStripePaymentGateway (DomainServiceRegistration), AddAspireBillingServices (Program), ConfigureBillingMessaging (InfrastructureServiceRegistration), SynchronizeBillingCatalogAsync (Startup) and SeedBillingSampleDataAsync (SampleData/SampleDataSeeder). Then set FallbackEntitlements to the limits you want, and delete the front end's pricing, checkout and organization-billing features.

Limits

  • Seats are fixed at checkout. Members joining or leaving do not change what the provider charges until the next checkout or change of plan.
  • The entitlements are eventually consistent. They arrive moments after the payment, through the bus.
  • A change of plan that the provider refuses on the spot leaves no audit entry.ChangePlanAsync asks the provider before it commits.
  • Free is not a subscription. It is the absence of one, so subscribing to it throws, and moving to Free means cancelling.