Skip to content

Release Notes

Release Notes

What shipped in each Xpitro release, most recent first.

Updated Jul 20, 202624 min read
On this page

Dated entries, most recent first. Each entry records what shipped and, where applicable, what was found and fixed during verification -- not just what was intended.

2026-08-01 -- Multi-provider billing engine

Shipped

  • A real, configuration-driven payment-provider registry: billing_providers, billing_provider_connections, billing_provider_capabilities (migration V115__billing_provider_registry_and_profiles.sql), replacing the previous state where the only real "provider selection" was Stripe hardcoded directly into the public checkout controller.
  • BillingProfile -- the actual entity that owns provider/currency/tax/payment-method/invoice-settings/renewal-policy per Organisation -- and BillingProviderResolver, the single runtime owner of provider selection, with an explicit 3-level priority order (Billing Profile override, then Organisation default, then Platform default).
  • A runtime-queryable Provider Capability Registry: PaymentProvider.supports(BillingCapability) (SUBSCRIPTION_BILLING, ONE_OFF_PAYMENTS, USAGE_BILLING, HOSTED_CHECKOUT, REFUNDS, TAX, WEBHOOKS), with Stripe's real, Phase-0-confirmed values wired in -- no capability was assumed from what Stripe the platform could theoretically do.
  • Real Stripe refund handling: PaymentProvider.refund(), a real Refund.create call in StripeCheckoutService, and a new BillingService.refundPayment() with its own admin endpoint -- refunds did not exist anywhere in this codebase before this pass.
  • GoCardless registered as the confirmed second payment provider (PaymentProviderCode.GOCARDLESS already existed in code, ahead of spec 050's own prose which lists Paddle first) -- a real, registered PaymentProvider Spring bean, but deliberately left with no real capability implementation. Every capability GoCardless might support was unconfirmed during discovery, and this codebase's own rule is that no capability may be assumed from Stripe's shape -- implementing GoCardless for real needs its own dedicated discovery pass first.
  • Two real architectural violations found and fixed, not just documented:
    • The public checkout endpoint depended on StripeCheckoutService directly, bypassing the PaymentProvider abstraction entirely for the one real customer-facing checkout flow. Now resolves through BillingProviderResolver and calls the interface, like everything else is supposed to.
    • PendingSubscriptionService (a commercial-domain service, spec 051's PendingSubscription bridge) took a raw com.stripe.model.checkout.Session as a method parameter. Now takes a provider-neutral CheckoutCompletionDetails record; the Stripe-specific translation moved into the webhook translator, where Stripe types belong.
    • The most significant fix: StripeWebhookEventTranslator was calling subscriptionService.updateSubscription() directly for three event types (customer.subscription.updated, customer.subscription.deleted, customer.deleted) -- the exact "Stripe Webhook straight into SubscriptionService" pattern this architecture explicitly forbids. A new BillingEvent (mirrors the existing SubscriptionLifecycleEvent mechanism exactly) is now published instead, consumed by a new SubscriptionBillingEventListener that calls the same, already-tested Canonical Subscription Lifecycle API operations (resumeSubscription, suspendSubscription, cancelSubscription) -- no new subscription-mutation logic, only a new trigger path for existing, already-tested code.

Verified

  • 22 new unit tests across 4 new test classes (BillingProviderResolverTest, StripeWebhookEventTranslatorTest, SubscriptionBillingEventListenerTest, BillingServiceRefundTest), plus one existing test file updated for the PendingSubscriptionService signature change. Full backend suite: 1074 tests, 0 failures (5 pre-existing, unrelated Keycloak theme-coverage errors).
  • StripeWebhookEventTranslatorTest specifically proves the violation fix, not just that a new mechanism exists alongside the old one: every one of the three previously-offending handlers is asserted with verifyNoInteractions(subscriptionService) -- the direct call is gone, not merely supplemented.
  • Live, end-to-end verification against the real development database and a redeployed coreservice: migration V115 applied via Flyway on boot; seed data (3 providers, 1 platform connection, 7 Stripe capability rows) confirmed matching the real code, not a second hand-maintained list; GoCardless confirmed live in the real running application's provider list -- it was not there before this session's work; both violation fixes confirmed reachable through the real HTTP path, failing at the correct point given this environment has no Stripe secret configured (the same failure this code would have hit before the fix too -- no regression); the new refund endpoint confirmed live and correctly routed.
  • Event publication for the fixed violation was proven thoroughly at the unit level (11 of the 22 new tests target it directly) but not independently re-observed live in this pass -- doing so needs a genuine, signature-verified Stripe webhook delivery, which requires a configured Stripe secret this dev environment does not currently have. Recorded as an honest, explicit limitation with a concrete follow-up, not silently skipped.

Explicitly deferred / known limitations (not built this pass)

  • GoCardless's real capabilities, authentication method, and webhook handling -- deliberately deferred to its own discovery pass; the adapter shell proves the abstraction works, nothing more.
  • refundPayment() does not write a PaymentAllocationEntity reversal -- resolving which specific invoice allocation(s) a refund applies against needs more information than this pass had.
  • Real Stripe refunds are not yet end-to-end wired for webhook-originated payments -- today's payment-ingestion pipeline captures the Stripe event id, not a refundable PaymentIntent/Charge id.
  • Multiple-simultaneous-providers support was not built -- Product has not confirmed it's a real near-term need, and building it anyway would have been the same category of unconfirmed-scope mistake this whole investigation started by catching.

See docs/engineering/architecture/COM-VALIDATION-003-multi-provider-billing-engine-validation.md for full detail.

2026-07-31 -- Commercial plan activation and subscription lifecycle

Shipped

  • Plan and Plan Version activation, as a real, validated workflow for the first time: activatePlan()/activatePlanVersion() on CommercialSubscriptionService move a Plan/Plan Version from DRAFT to a subscribable ACTIVE state, mirroring the already-approved activatePriceVersion() pattern (require DRAFT, auto-retire the previous ACTIVE sibling), and additionally requiring the parent Plan be ACTIVE and the referenced entitlement set version not be DRAFT before a Plan Version can activate. Previously the only way to make a Plan Version ACTIVE was an unvalidated raw status field in the creation payload.
  • The Canonical Subscription Lifecycle API: activateSubscription, cancelSubscription, renewSubscription, schedulePlanChange, changePlan, startTrial, convertTrial, suspendSubscription, resumeSubscription, plus the system-triggered expireTrial. Every operation is idempotent (already-in-target-state is a no-op -- no duplicate history or audit records) and concurrency-safe (a new pessimistic-lock repository method, the same pattern already proven by UsagePeriodRepository's period lock, serializes conflicting concurrent operations on the same subscription deterministically).
  • Scheduled/future-dated plan changes: a new CommercialSubscriptionScheduledChangeEntity (migration V114__commercial_subscription_scheduled_changes.sql) records a scheduled change, applied later by re-running changePlan()'s full precondition set at the scheduled time -- never force-applied against a target that's since gone invalid.
  • A real scheduler integration, not a bespoke one: SubscriptionLifecycleEvaluationJobHandler registers BackgroundJobType.SUBSCRIPTION_LIFECYCLE_EVALUATION with the existing recurring-job framework (the same mechanism RetentionEvaluationJobHandler already uses), self-registered on every boot via BackgroundJobSchedulerService.seedPlatformRecurringJobs(), an ApplicationReadyEvent listener -- discovered live to be the real registration mechanism (an earlier draft wrongly assumed it required a manual admin action; no such admin flow exists for any of the 12 job types).
  • SubscriptionLifecycleEvent (a sealed interface, 10 record types) is the stable event contract for COM-DESIGN-004 (billing) to eventually consume via @TransactionalEventListener(phase = AFTER_COMMIT) -- published in-process on every operation; no listener is registered yet, by design.
  • 11 new endpoints on CommercialSubscriptionAdminInternalController for all of the above.

Verified

  • 31 unit tests in CommercialSubscriptionServiceTest (24 new), covering happy paths, idempotency no-ops, precondition failures, sibling plan-version retirement, and a dedicated regression test for the sweep-isolation bug below. Full backend suite: 1053 tests, 0 failures (5 pre-existing, unrelated Keycloak theme-coverage errors).
  • Live, end-to-end verification against the real development database and a redeployed coreservice: migration V114 applied via Flyway on boot; every new endpoint exercised through the real internal-admin HTTP path against real test data (activate plan/version, create subscription, upgrade, downgrade, suspend, resume, renew, schedule a future change, start/convert a trial, cancel); the real recurring-job scheduler observed actually executing due work -- a scheduled plan change and a trial expiry were both applied automatically by the live scheduler, not simulated.
  • A real bug was found live and fixed within the same pass: evaluateScheduledTransitions()'s trial-expiry loop had no exception handling, so one subscription failing its transition aborted the entire sweep -- including the separate scheduled-plan-change loop that runs afterward in the same method. Observed real impact: 3 consecutive scheduler runs failed or retried, and a legitimately-due scheduled plan change was blocked from applying until the underlying issue was found and fixed. Fix: each subscription's trial-expiry attempt is now isolated in its own try/catch, matching the isolation pattern the scheduled-change loop already had. Confirmed fixed against the live, running scheduler (three consecutive clean runs post-fix), not just in the unit test.
  • Event publication confirmed via a dedicated log line (subscription_lifecycle_event_published) added specifically so this could be verified live, since no listener exists yet to observe otherwise -- checked directly in the running container's logs after a fresh activate/cancel pair.

Explicitly deferred / known limitations (not built this pass)

  • Upgrade vs. downgrade remain a single, direction-agnostic changePlan() operation -- no tier ordering exists anywhere in the catalogue schema yet, so distinguishing them needs a Product decision first, not invented here.
  • Proration, billing-listener consumption of SubscriptionLifecycleEvent, and syncing renewalDate from real Stripe renewal events all remain COM-DESIGN-004 (billing engine) territory.
  • Test data created during live verification (4 organisations, 2 plans, 1 entitlement set, 4 subscriptions, clearly named COM-VALIDATION-002 */TEST-LIFECYCLE-*) was left in the dev database as part of the verification record, along with one deliberately-unresolved DEAD_LETTER background job row documenting the bug above.

See docs/engineering/architecture/COM-VALIDATION-002-commercial-plan-activation-validation.md for full detail.

2026-07-31 -- Reporting entitlement enforcement, commercial catalogue promotion, and ADR-007 programme close-out

Shipped

  • Reporting capability entitlements (DASHBOARD_VIEW, REPORT_VIEW, REPORT_EXPORT, REPORT_EXPORT_PDF, designed in COM-DESIGN-001) wired into real runtime enforcement: DashboardAnalyticsInternalController and PlatformReportInternalController (coreservice) now gate their endpoints via EntitlementLimitService.isFeatureEnabledForTenant(), matching the existing PlatformAiChatInternalController pattern. generate() (the only action that actually renders a PDF, via PdfBoxReportRenderer) is gated separately on REPORT_EXPORT_PDF, distinct from the REPORT_VIEW check on every other action, per COM-DESIGN-001's view/export capability split.
  • EntitlementLimitService selected and migrated to as the single canonical runtime entitlement service (P1), replacing FeatureEntitlementService for all production runtime-decision callers (MarketplaceService, CustomerPortalFacade, UsageMeteringService); the deprecated service's admin CRUD surface intentionally still functions, pending a separate open question about migrating real per-organisation entitlement data.
  • Per-capability CapabilityFailPolicy (FAIL_CLOSED/FAIL_OPEN/ADVISORY) implemented on EntitlementLimitService.applyFailPolicy(), consulted whenever a registered feature's grant can't be resolved, audited for all 3 outcomes (ENTITLEMENT_RESOLUTION_FAILED_OPEN/_ADVISORY_OVERRIDE/_FAILED_CLOSED). Unregistered features keep the pre-existing silent fail-open bootstrap behaviour, unchanged.
  • Commercial catalogue promoted end-to-end: V111 (usage metering buckets), V112 (fail-policy column, backfilling all 7 pre-existing capabilities), V113 (full catalogue skeleton -- products, plans, entitlement sets/versions, prices, and grants, including the 4 new Reporting identifiers scoped to Professional/Partner/Enterprise only). Applied to the real development database via coreservice's own Flyway integration on redeploy, not manual SQL -- confirmed via flyway_schema_history (success=true for all three) and a live schema validation with zero errors.
  • The 3 Advisory fail-policy changes (MAX_TENANT_USERS, MAX_MANAGED_ORGANISATIONS, STORAGE_BYTES moving off today's silent Fail-Open) were formally ratified as their own explicit decision, closing the sign-off COM-DESIGN-002-capability-availability-policy.md had named as required before that specific change could be considered approved -- distinct from, and not assumed by, the general implementation approval that had already carried the change into the real database via V112.
  • ADR-007 (COM-ADR-007-commercial-capability-entitlement-usage-metering-architecture.md) moved from Proposed to Accepted in decisions/ADR-INDEX.md -- all 5 Approval Gate (§13) conditions verified satisfied against cited evidence, not inferred from implementation completion. See COM-VALIDATION-001-adr-007-final-approval-gate-verification.md for the full gate-by-gate verification. The commercial entitlement architecture programme this ADR governed is now closed; further work (commercial plan activation, additional reporting capabilities, wider entitlement-framework adoption, billing integration) proceeds as separate initiatives against an accepted architecture.

Verified

  • New unit tests for both newly-gated controllers (DashboardAnalyticsInternalControllerTest, PlatformReportInternalControllerTest -- previously zero coverage for either), 12 tests, all passing.
  • Full backend suite: 1028 tests, 0 failures (5 pre-existing, unrelated Keycloak theme-coverage errors, present in every full-suite run this session).
  • Live, authenticated behavioral proof against the redeployed real development stack: a real Keycloak-issued access token for a real TENANT_ADMIN test user was used to call both newly-gated endpoints directly. Both returned 200 OK; the audit_event table showed matching ENTITLEMENT_RESOLUTION_FAILED_OPEN rows at the exact request timestamps, with correct featureCode/failPolicy/allowed metadata. Since this test tenant has no commercial subscription yet (catalogue plans remain DRAFT, not ACTIVE), this specifically proved the unresolved-tenant fail-open path end to end, not a resolved grant.
  • Catalogue data verified directly against the real database post-deploy: 14 commercial_features rows with correct fail_policy values (11 FAIL_OPEN, 3 ADVISORY); 52 entitlement_set_version_features grant rows, with the 3 Reporting-view/export identifiers confirmed present for Professional/Partner/Enterprise only and absent from Essentials.

Explicitly deferred / accepted limitations (not built this pass)

  • A real 403 FORBIDDEN: FEATURE_NOT_ENTITLED denial was not demonstrated live -- 0 of the 11 confirmed capabilities are currently FAIL_CLOSED, so nothing in the live catalogue can produce a policy-driven denial today. Demonstrating one needs either a FAIL_CLOSED capability (none recommended by COM-DESIGN-002) or an active subscription whose entitlement set excludes a feature (not seedable until plans go ACTIVE).
  • DASHBOARD_VIEW has no domain-level audit event of its own (DashboardAnalyticsService only reads audit events for a UI widget; PlatformReportService does emit real ones for the other three Reporting identifiers) -- recorded as an accepted limitation in COM-DESIGN-001's readiness review, not closed this pass.
  • BFF-layer entitlement checks were considered and explicitly not added: direct review of core/bff found zero entitlement logic anywhere for any of the 7 pre-existing capabilities -- it is a pure proxy by design, with coreservice's internal controllers as the sole, consistent enforcement point. Adding BFF checks for Reporting alone would have been a new, inconsistent mechanism.
  • FeatureEntitlementService's admin CRUD surface remains functional and un-removed, per P1's own exit criteria (a deliberate deferral, not a gap).

See docs/engineering/architecture/COM-VALIDATION-001-adr-007-final-approval-gate-verification.md and docs/engineering/architecture/commercial-entitlement-remaining-work-plan.md for full detail.

2026-07-20 -- Platform-wide entitlement enforcement

Shipped

  • EntitlementLimitService (previously a single-consumer primitive used only by UsageMeteringService) extended with isFeatureEnabled(organisationId, code), isFeatureEnabledForTenant(tenantId, code), and isWithinLimitForTenant(tenantId, code, currentUsage), and wired into the seven real, working capabilities that were previously completely ungated: AI document analysis, framework/assessment adoption, tenant user invites, PARTNER managed-organisation acceptance, document storage, the PARTNER-mode transition, and tenant-facing AI chat. V86__platform_entitlement_feature_catalogue.sql seeds the 7 new feature codes (AI_CHAT, AI_DOCUMENT_ANALYSIS, FRAMEWORK_ADOPTION, MAX_TENANT_USERS, MAX_MANAGED_ORGANISATIONS, STORAGE_BYTES, PARTNER_MODE) additively -- no per-organisation grants are backfilled, so every existing customer's behaviour is unchanged until an operator deliberately configures a restrictive grant via the existing Entitlements Console screen (the fail-open default this whole rollout leans on for non-regression).
  • New cross-service plumbing: TenantMemberInviteService lives in userservice, a separate Spring Boot application from coreservice (where EntitlementLimitService lives) with its own component scan -- it cannot inject the service directly. EntitlementBoundaryInternalController (coreservice) + CoreServiceBoundaryClient (userservice) close that gap over internal HTTP, mirroring the existing UserServiceBoundaryClient/ServiceBoundaryContractController contract in the reverse direction; it fails open if coreservice is unreachable.
  • AI chat's real plumbing gap closed alongside its gate: PlatformAiController was reached via the BFF's raw pass-through ProxyController with zero tenant context (unlike every other tenant-scoped feature's dedicated-controller pattern). Replaced with PlatformAiChatInternalController (coreservice, /internal/platform/tenants/{tenantId}/ai/chat, gated on AI_CHAT) and a dedicated BFF PlatformAiController (mirrors PlatformAuditController) plus a new UserServiceClient.chatPlatformAi method; /api/platform/ai/** removed from ProxyController's matcher and BackendRouter's routing table. ConsoleAiController's staff-only /api/console/ai/chat is unchanged and deliberately stays ungated.
  • Rejections use the codebase's existing convention: 402 PAYMENT_REQUIRED + FEATURE_USAGE_LIMIT_EXCEEDED for numeric-limit gates (tenant users, managed organisations, storage), 403 FORBIDDEN + FEATURE_NOT_ENTITLED for boolean gates (AI document analysis, framework adoption, PARTNER mode, AI chat).
  • Four capabilities from the original ask were investigated and confirmed not to exist as working features, so nothing was gated: branding (inert write-only field), SSO (zero code), audit export (disconnected UI mock), API access (no API-key concept). Documented as a tracked future-work item in COM-RULE-001-COMMERCIAL_BILLING_ARCHITECTURE_AND_OPERATIONS.md section 16 so the requirement isn't lost when those features are eventually built.

Verified

  • mvn compile/mvn test-compile clean for both services/backend and bff after each gate landed.
  • Focused unit tests per gate (fail-open-when-unconfigured case implicit in every pre-existing passing test via a lenient default stub; explicit blocked-when-restricted case per gate) -- PlatformAiAnalysisServiceTest, AssessmentEngineServiceTest, TenantMemberInviteServiceTest, PartnerProgrammeServiceTest, PlatformResourceServiceTest, OrganisationLifecycleServiceTest, PlatformAiChatInternalControllerTest all green.
  • Full backend test suite run after all seven gates landed.

Explicitly deferred (not built this pass)

  • Merging FeatureEntitlementService and EntitlementLimitService into a single entitlement authority -- recorded as a named future-architecture item (section 16), not attempted here; would meaningfully expand scope for no immediate behavioural gain.
  • Branding, SSO, audit export, API access -- not gated because they don't exist yet (see Shipped).

See docs/engineering/rules/COM-RULE-001-COMMERCIAL_BILLING_ARCHITECTURE_AND_OPERATIONS.md sections 15 and 16 for full detail.

2026-07-19 -- Commercial Operations Centre completion (Retention, Offboarding, Customer 360, Analytics, Dashboards)

Shipped

  • Split BillingSuspensions.tsx into three dedicated Console tabs: Suspensions (suspension cases + reinstatement + notes), Retention (BillingRetention.tsx), Offboarding (BillingOffboarding.tsx) -- purely a Console-side split, reusing the existing CustomerRiskCaseService endpoints with no backend changes.
  • New Customer 360 screen (BillingCustomer360.tsx, /billing/customer-360): a single consolidated view of one organisation -- identity, lifecycle stage/health/renewal/risk, active subscription, success owner, open suspension/offboarding/retention cases, onboarding journeys, recent commercial amendments, renewal windows, and organisation history. Assembled entirely from existing per-organisation endpoints; introduces no new backend aggregate table or service.
  • New cross-organisation CommercialAnalyticsService (services/backend/.../features/commercialanalytics/, a sibling package reading across the acquisition, onboarding, and customer-lifecycle domains without owning any tables of its own) exposing current-state counts and distributions: leads/opportunities/onboarding-journeys by status, lifecycle-stage/health/renewal/risk distribution, and open suspension/offboarding/active-retention counts. One new internal endpoint (GET /internal/console/analytics/commercial-overview), BFF proxy, and Console screen (BillingAnalytics.tsx, /billing/analytics) rendering the breakdowns as single-hue horizontal bar charts (magnitude comparison, per the dataviz skill's form heuristic -- not a categorical rainbow, since bar length already carries the signal).
  • BillingOverview.tsx ("Dashboards") gained four new KPI tiles (leads/opportunities/onboarding-journeys/open-risk-cases) alongside its existing catalogue/subscription/Stripe-sync counts, pulling from the same new analytics endpoint.

Verified

  • Full backend test suite green after the analytics service landed.
  • Clean mvn compile for both services/backend and bff.
  • Clean tsc --noEmit for the Console app after all new/changed screens landed.
  • Chart colors (existing Fluent-inspired status palette: green #107C10, blue #0078D4, yellow #CA5010, red #A4262C) checked against the dataviz skill's palette validator -- the red/yellow pair falls below the normal-vision separation floor as a categorical pair, which is why every bar always carries a direct text label (category name) rather than relying on color alone; this is the skill's documented mitigation for status-palette reuse, not an unaddressed finding.

Explicitly deferred (not built this pass)

  • Time-series funnel/cohort analytics (conversion rate over time) -- the current Analytics surface is current-state counts only; ConversionEvent (spec 051) has the timestamps to support trends later, but no bucketing/reporting layer reads it yet.
  • Dedicated unit tests for CommercialAnalyticsService.
  • PARTNER/partner portfolio-scoped lifecycle visibility, and customer-facing renewal/suspension/offboarding notice UI -- both already tracked as deferred in the prior Spec 052 entry, unchanged by this pass.

See docs/engineering/rules/COM-RULE-002-CUSTOMER_LIFECYCLE_MANAGEMENT_ARCHITECTURE_AND_OPERATIONS.md sections 5 and 15, and spec 052's Status section, for full detail.

2026-07-19 -- Customer Lifecycle Management (Spec 052)

Shipped

Built in two independent passes on the same organisation-scoped foundation -- flagged explicitly since parallel implementation of the same spec is unusual for this codebase's normal delivery style:

  • Pass 1 (lifecycle root): CustomerLifecycleProfile/CustomerLifecycleStage/CustomerMilestone, a validated primary-stage state machine (CommercialCustomerLifecycleService.transitionStage), bootstrap creation from existing Organisation/CommercialSubscription/CustomerTenant state, derived commercial/health/renewal/risk projection, and Customer Portal lifecycle projection.
  • Pass 2 (the remaining case-management surface, services/backend/.../features/customerlifecycle/): CustomerSuccessOwner/CustomerSuccessPlan, CustomerHealthAssessment/CustomerHealthSignal (explicit health records alongside the derived status), RenewalWindow/RenewalDecision (pinned to the organisation's real active subscription + latest commercial snapshot, never re-derived from Stripe), CommercialAmendment/ExpansionOpportunity/ContractionDecision, and the full suspension/retention/offboarding/reinstatement arc (SuspensionCase/RetentionIntervention/OffboardingCase/ReinstatementDecision/LifecycleNote).
  • Opening or resolving a case in the risk arc drives the primary-stage state machine directly rather than letting case state and lifecycle stage drift apart: resolving a SuspensionCase moves the profile to REINSTATING, never straight back to active; an OffboardingCase cannot reach ARCHIVED without passing through OFFBOARDED first; only an approved ReinstatementDecision re-activates a profile.
  • Full internal API (/internal/console/customers/lifecycle/**), BFF proxy (ConsoleCommercialController extensions + matching UserServiceClient methods), and five new Console screens: Billing -> Lifecycle/Health/Renewals/Amendments/Suspensions, each organisation-id-search-driven.
  • New rule doc: docs/engineering/rules/COM-RULE-002-CUSTOMER_LIFECYCLE_MANAGEMENT_ARCHITECTURE_AND_OPERATIONS.md.

Verified

  • Full backend test suite green after each implementation phase (both passes combined).
  • Clean mvn compile for both services/backend and bff.
  • Clean tsc --noEmit for the Console app after all five new screens landed.
  • Not yet verified: a live, browser-driven Console walkthrough, or a real organisation's full lifecycle journey exercised end-to-end (activation through suspension through reinstatement).

Explicitly deferred (not built this pass)

  • A dedicated lifecycle/funnel analytics dashboard (the durable events exist; no reporting surface reads them yet).
  • PARTNER/partner portfolio-scoped lifecycle visibility enforcement (any commercial-admin operator can look up any organisation's profile today; delegated-portfolio scoping per PartnerRelationshipEntity is not wired in).
  • A dedicated customer-facing UI for renewal timing, suspension notices, and offboarding notices in apps/platform (the Customer Portal projection payload carries the data; no presentation layer consumes it beyond the existing overview page).
  • Dedicated unit test coverage for the five Pass 2 services (CustomerSuccessService, CustomerHealthService, RenewalService, CommercialAmendmentService, CustomerRiskCaseService) -- correctness rests on the full-suite regression pass and code review, not new unit tests for these specific services.

See docs/engineering/rules/COM-RULE-002-CUSTOMER_LIFECYCLE_MANAGEMENT_ARCHITECTURE_AND_OPERATIONS.md section 15 and spec 052's Status section for full detail.

2026-07-19 -- Customer Acquisition & Onboarding Domain (Spec 051, continued)

Shipped

  • Lead/LeadQualification (services/backend/.../features/acquisition/): the NEW -> QUALIFYING -> QUALIFIED/DISQUALIFIED -> CONVERTED lifecycle, with a separate ARCHIVED terminal state off DISQUALIFIED. Public capture endpoint (/internal/public/acquisition/leads, no authority required) plus console-gated qualification/archive actions.
  • CommercialOpportunity: the governed path from a QUALIFIED lead to Organisation creation. Never creates an Organisation itself; closes the funnel via markConverted when PendingSubscriptionService.bindToOrganisation succeeds. Stripe Checkout Sessions created from an opportunity (StripeCheckoutService.createCheckoutSessionForOpportunity) now carry xpitroOpportunityId in metadata, so a completed checkout traces back to the lead that originated it end to end.
  • OnboardingApplication/OnboardingApplicationVersion, OnboardingJourney/OnboardingStepCompletion/OnboardingOwner/OnboardingInvitation/OnboardingReadinessDecision (.../features/onboarding/): a fixed, journey-type-scoped checklist (OnboardingChecklists) with an explicit, fail-closed readiness gate -- evaluateReadiness only returns READY when every step is complete, otherwise a specific BLOCKED_* outcome and the journey moves to BLOCKED, never silently to ready.
  • ConversionEvent + lightweight AcquisitionCampaign/AcquisitionSource registries (.../features/acquisition/): durable, append-only funnel-transition records per spec 051's Analytics And Funnel Model. Wired at lead capture, lead qualification, opportunity conversion, and onboarding completion; the subscription-lifecycle stages are explicitly deferred (see below).
  • Full internal API surface (/internal/console/acquisition/**, /internal/console/onboarding/**), BFF proxy (ConsoleAcquisitionController, ConsoleOnboardingController), and Console screens: Billing -> Leads, Billing -> Opportunities, Billing -> Onboarding.

Verified

  • Full backend test suite green after each phase (Lead/Opportunity, then Onboarding, then ConversionEvent/registries) -- no regressions to the existing suite.
  • Console TypeScript build (tsc --noEmit) clean after adding the three new screens and endpoint wiring.
  • Not yet exercised against a real Stripe test-mode checkout carrying an opportunity id (the anonymous-checkout round trip verified earlier in this file did not originate from a Lead/Opportunity) -- functional correctness here rests on code review and the unit/integration suite, not a live round trip.

Explicitly deferred (not built this pass)

  • Automatic self-service Organisation creation from a completed anonymous checkout -- an operator must still create the Organisation and call bindToOrganisation manually via Console.
  • Customer-facing UI for onboarding invitation acceptance (the endpoint exists; only a Console-side view of invitations was built).
  • ConversionEvent wiring for the subscription-lifecycle funnel stages (trial start/conversion, first app access, first governance action) -- would require touching the billing/subscriptions and customer-portal packages.
  • A dedicated Console screen for the AcquisitionCampaign/AcquisitionSource registries and the conversion-event feed (internal API exists, no UI yet).
  • Dedicated unit test coverage for LeadService, CommercialOpportunityService, OnboardingService, and ConversionEventService -- correctness so far rests on the full-suite regression pass (no existing tests broken) plus code review, not new unit tests for these specific services.
  • Partner/PARTNER-specific onboarding flows beyond the generic OnboardingJourneyType enum values.

2026-07-19 -- Billing Commercialisation (Spec 050) + Commercial Acquisition Bridge (Spec 051, partial)

Shipped

  • Versioned billing catalogue: BillingCatalogue -> CatalogueVersion -> Product -> ProductVersion -> Plan -> PlanVersion -> Price -> PriceVersion -> EntitlementSet -> EntitlementSetVersion, with a DRAFT/VALIDATED/PUBLISHED/RETIRED/ARCHIVED governance lifecycle (narrower publish authority than draft/validate authority).
  • Stripe integration: payment-provider abstraction (PaymentProvider), catalogue sync (StripeCatalogueSyncService), hosted Checkout with Adaptive Pricing (StripeCheckoutService), signature-verified webhooks (StripeWebhookController/StripeWebhookEventTranslator).
  • Vault-backed Stripe secrets administered entirely through Console (Billing -> Stripe), gated by Infrastructure Maintenance Mode for rotation.
  • Public, unauthenticated, rate-limited pricing and checkout surface (/api/public/pricing, /api/public/pricing/checkout) consumed by the landing site, resolving only the currently PUBLISHED catalogue version and failing closed on unsynced prices.
  • Entitlement-set feature/limit templates (entitlement_set_version_features) and real usage-limit enforcement in UsageMeteringService (rejects usage that would exceed a plan's configured limit).
  • Promotions: Stripe Coupon + Promotion Code sync, self-service entry at Checkout via allow_promotion_codes.
  • Console "Commercial Operations Centre": Overview, Catalogue, Products, Plans, Prices, Entitlements, Stripe Sync, Subscriptions, Customers, Invoices & Payments, Promotions, Marketplace.
  • PendingSubscription bridge (spec 051, partial slice): the deterministic hand-off between a completed external checkout and authoritative organisation provisioning, per spec 051's Payment Provider Webhook Rule. A webhook may only create/update PendingSubscription state; a separate governed bindToOrganisation action creates the real subscription.

Full architecture and section-by-section detail: docs/engineering/rules/COM-RULE-001-COMMERCIAL_BILLING_ARCHITECTURE_AND_OPERATIONS.md. Governing specs: docs/engineering/specs/COM-050-billing-commercialisation.md, docs/engineering/specs/COM-051-customer-acquisition-and-commercial-onboarding.md.

Verified

Verified end-to-end against real Stripe test mode, not mocked integrations:

  • Catalogue, product, plan, and price created via the internal API and synced to real Stripe test-mode Product/Price objects.
  • Catalogue version validated (fails-closed check) and published.
  • /api/public/pricing resolved the live, synced tier correctly.
  • A real Stripe Checkout Session was created and completed with Stripe's test card via the hosted page.
  • Webhook delivery and signature verification confirmed live via the Stripe CLI (stripe listen / stripe events resend).
  • The PendingSubscription flow was exercised end-to-end: anonymous checkout completion produced a pending row (not a subscription); an operator created an organisation and called bindToOrganisation; a real CommercialSubscriptionEntity and purchase snapshot were produced with correct Stripe identity fields.
  • Full backend and BFF test suites passing throughout (backend: 429+ tests, 0 failures; BFF: 130/131, the sole failure being a pre-existing, unrelated documentation-contract test that predates this work).

Findings (defects caught by live verification, both fixed)

  1. Stripe secret resolution could silently pick the wrong secret. Two PAYMENT_SECRET_KEY-typed Stripe secrets existed simultaneously (a publishable key mistakenly saved under the wrong type, and the real secret key). StripeClientConfig.readSecretValue resolved candidates ordered alphabetically by name, which picked the publishable key. Fixed: resolution now orders by most-recently-updated and filters to ACTIVE status; InfrastructureConfigurationService.createSecret also rejects a second ACTIVE secret of the same type outright, so this ambiguous state can no longer be created going forward.
  2. Anonymous checkout had no path to create a subscription. StripeCheckoutService's only caller (the public marketing checkout) never supplies an organisationId, since no organisation exists yet for a brand-new anonymous visitor. StripeWebhookEventTranslator.handleCheckoutCompleted required one and silently dropped the webhook event when it was missing -- a real Stripe payment could be taken with no resulting Xpitro subscription. Fixed via the PendingSubscription bridge described above; checkout for an already-known organisation is unaffected and continues to create a subscription directly.

Explicitly deferred (not built this pass)

  • The remainder of spec 051's acquisition/onboarding domain: Lead, LeadQualification, CommercialOpportunity, OnboardingApplication/Version, OnboardingJourney/Step/StepCompletion/Owner, OnboardingInvitation, OnboardingChecklist, OnboardingReadinessDecision, ConversionEvent, AcquisitionCampaign, AcquisitionSource. There is currently no self-service path that creates an Organisation automatically from a completed anonymous checkout; an operator must create it and bind manually via Console.
  • Marketplace Publisher multi-channel publication (Microsoft/AWS Marketplace) -- the existing, older marketplace feature is surfaced in the Commercial Operations Centre nav but is not the same concept.
  • Full platform-wide entitlement/limit enforcement across every feature module (currently only CustomerPortalFacade and UsageMeteringService consume EntitlementLimitService).
  • A dedicated Analytics/Usage Analytics dashboard beyond BillingOverview.tsx's current counts.

See docs/engineering/rules/COM-RULE-001-COMMERCIAL_BILLING_ARCHITECTURE_AND_OPERATIONS.md section 15 and the Status sections of specs 050/051 for full detail.

Was this helpful?