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
/pricingpage lists the three tiers,Free,ProandBusiness, 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
| Project | Holds |
|---|---|
Blouppy.Billing.Abstractions | The public surface: IBillingCatalogService, IBillingSubscriptionService, IBillingAvailability, IPaymentNotificationEndpoint, PlanType, SubscriptionStatus and the request and response records |
Blouppy.Billing | Everything 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.UnitTests | Including 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, onOrganizationCreatedIntegrationEvent.PublishIntegrationEventWhenSubscriptionEntitlementsChangedDomainEventHandler: a subscription raisedSubscriptionEntitlementsChangedDomainEvent.
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
| Endpoint | Access |
|---|---|
GET /api/v1/billing/plans | Anonymous |
GET /api/v1/billing/status (paymentsEnabled) | Anonymous |
GET /api/v1/billing/subscriptions/{organizationId}, …/invoices | Policies.Read |
POST …/{organizationId}/checkout, …/plan, …/portal, …/cancel | Policies.Manage |
POST /api/v1/billing/subscriptions/notifications | Anonymous; the signature is the credential |
GET /api/v1/organizations/{organizationId}/entitlements | The 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:
| Plan | MonthlyPrice / AnnualPrice | MaxWorkspaces | AI credits per seat / per member / automation | Own AI key | SSO |
|---|---|---|---|---|---|
Free | 0 / 0 | 3 | 100 / 30 / 0 | no | no |
Pro | 9 / 90 | unlimited | 300 / 300 / 500 | no | no |
Business | 19 / 190 | unlimited | 1000 / 1000 / 2000 | yes | yes |
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:
| Key | Value |
|---|---|
Stripe:SecretKey | sk_… or a restricted rk_… key |
Stripe:WebhookSecret | The 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:
- Add the property to
PlanDefinitionand its default per tier inBillingCatalogOptions(Blouppy.Billing/Catalog/). - Carry it on
Plan: the constructor,HasSameTermsAsandRevise, thenBillingCatalog.CreatePlan,PlanConfiguration, and aBillingDbContextmigration. - Append it, last and defaulted, to
OrganizationEntitlementsChangedIntegrationEvent, and pass it in both publishing handlers. - On the product side, add it to
OrganizationEntitlements(constructor andUpdate),FallbackEntitlements,OrganizationEntitlementsConfiguration, the upsert handler andOrganizationEntitlementsPublicDataDto, with aBlouppyDbContextmigration. - Enforce it where it applies, in a validator that reads
GetEntitlementsAsync, and display it throughBillingPlanandBillingPlanDtoif 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 matchingStripe__Prices__*. The pricing page's marketing copy is infeatures/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 paidPlanType) and the front end'sPlanType. - Another provider is one class implementing
IPaymentGateway, registered in place ofStripePaymentGateway.
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.
ChangePlanAsyncasks 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.