Registration, Trial & Subscription Lifecycle
Status: Living reference. Written 2026-08-14, following the TRA-359/403/404/417 investigation arc — the most thorough real-world testing this flow has ever received. This is the actual, current behavior, verified against real Stripe events and a real database, not the originally-intended design.
Why this document exists: this is the flow that turns a signup into revenue. It's also genuinely non-obvious in one specific way — purchasing a subscription is not one synchronous action, it's two separate things that happen at different times, on different systems, connected only by a webhook. Understanding that split is the single most important thing in this document.
Part 1 — Registration
Key points:
- Registration returns success immediately after enqueueing the background job — it does not wait for Entra provisioning to complete. See TRA-383 for what happens if that background job later fails (the frontend polls a public status endpoint rather than trusting the initial response).
ProvisioningEntraUserIdgets saved as a checkpoint before role assignment is attempted — this is what makes a retried job safe rather than re-inviting the same email (TRA-375). It also cross-checks againstTenantUsersunder other tenants and against Entra's own role-assignment history to catch identity collisions (TRA-375/377) — see that ticket's history for the real incident that made this necessary.- A tenant is "on trial" the moment
Activate()runs — there is no separateTrialstatus. On-trial means:Status = Active,TrialEndsAtis a future date,SubscriptionStatusis stillnull.
Part 2 — The trial period
Two Hangfire recurring jobs govern this, both running daily:
TrialLifecycleSweepJob— finds tenants approachingTrialEndsAt, sends a reminder email (once, tracked viaTrialReminderSentAtso it never double-sends), and flipsStatus = TrialExpiredviaTenant.ExpireTrial()once the date passes.SubscriptionEnforcementBehavior— the pipeline behavior that actually gates writes based on subscription state. Checked on every command, not just at trial-expiry time.
A trial-expired tenant can still log in and view data; write actions are blocked until a real subscription exists.
Part 3 — Purchasing a subscription (the part that's genuinely two-phase)
This is the most important thing to understand in this whole document. CreateCheckoutSessionCommand does not activate anything. It builds a Stripe Checkout session and hands the user a URL. The actual activation happens later, asynchronously, when Stripe calls back — on its own schedule, from its own infrastructure, completely decoupled from the original request.
Why the metadata matters: the webhook event has no way to know which RoundTrip tenant it belongs to except through the tenantId embedded in Metadata when the session was created. This is set in two separate places — on the Checkout Session itself, and again on SubscriptionData.Metadata for the resulting Subscription object — because different webhook events carry different Stripe objects as their payload.
The three real webhook events this endpoint handles, and what each one does to Tenant:
| Event | Domain method | Effect |
|---|---|---|
checkout.session.completed | ActivateSubscription(stripeSubscriptionId, plan) | SubscriptionStatus = Active, Status = Active, Plan set, StripeSubscriptionId set |
customer.subscription.deleted | CancelSubscription() | SubscriptionStatus = Cancelled, Status = Suspended, StripeSubscriptionId cleared |
invoice.payment_failed | MarkPaymentFailed() | SubscriptionStatus = PaymentFailed |
Part 4 — Hard-won operational lessons (read this before touching this code)
Real things this week's investigation found, each with real consequences:
The webhook endpoint is a black box unless you make it not one
StripeWebhookEndpoint is public, signature-verified, and asynchronous — there's no user watching a loading spinner who'll notice if something silently goes wrong. Every failure mode needs its own explicit log line, or it's invisible. TRA-359's original incident (a real checkout that silently failed to activate) took a full multi-day investigation to root-cause specifically because one failure path had zero logging.
A caught exception can mean two completely different things
EventUtility.ConstructEvent throws the same StripeException type for a genuine bad signature and for an API-version mismatch — the SDK exposes no structured way to tell them apart, only the message text. TRA-417 found this the hard way: a real, ongoing configuration bug (the installed SDK 15 months behind the account's API version) was being logged as "signature verification failed" for months, pointing anyone investigating toward completely the wrong subsystem.
Never write to Tenant/Technician/TenantUser outside the aggregate
TRA-359/403/404 found three separate live endpoints bypassing domain guards via raw SQL — silently skipping validation, never raising domain events. If you're adding a new way to mutate one of these three, route it through the real aggregate method. TRA-409 added an architecture test enforcing this for these specific tables — if it fires, that's it working as intended, not a false positive to work around.
Test payloads must derive real values dynamically, not hardcode them
The original StripeWebhookEndpointTests hardcoded an API version string in their test payloads — which is exactly what let TRA-417's bug ship unnoticed; the tests kept passing against a value that had quietly gone stale in production. They now pull the real, installed SDK's actual pinned version (Stripe.StripeConfiguration.ApiVersion) instead — so they'll keep proving something true on the next SDK upgrade too, not just this one.
There is currently no way to manually manage a subscription
No live path exists (as of this writing) to manually suspend, reinstate, or change a tenant's plan outside Stripe's own automatic checkout flow. Real gap for support situations — tracked in TRA-359's remaining scope.
Verification checklist, if you're changing anything in this flow
Given how much of this week went into discovering that clean builds and passing tests aren't sufficient proof for this specific code:
- Real unit tests, using dynamically-derived values (see above), not hardcoded ones
- A real, live checkout through dev's actual UI — not just
stripe triggerfixtures, which carry no real tenant metadata - Confirm via the Stripe dashboard's Event deliveries tab (not just the general Events feed) that the webhook was actually delivered and returned the status you expect
- Confirm the database actually changed — the state you expect, not just "no error was thrown"