Skip to content

[billing-core] Reconcile licensed subscription quantity from membership seats #1570

Description

@kang-heewon

Related: #1511, #1520, #1524, #1525, #1538

Priority

P1 — add after immutable plan versions and explicit provider capabilities are available.

Problem

@croco/membership-core can enforce a local seat limit through SeatLimitChecker, while @croco/billing-core subscriptions do not model or reconcile a licensed quantity. A tenant can therefore have a different active-seat count, entitlement limit, and provider-billed quantity.

Updating the billing provider synchronously inside MembershipManager.addMember() or removeMember() would create a false cross-system transaction: provider failure could either block valid membership changes or leave local state committed with no inspectable repair path.

Desired outcome

Add a provider-neutral licensed-quantity reconciliation contract that derives the desired quantity from application-owned membership state and asynchronously converges the billing provider quantity with idempotent, observable evidence.

const reconciler = new SubscriptionQuantityReconciler({
  source: membershipSeatQuantitySource,
  gateway: billingProvider.require("licensed-quantity"),
  store: reconciliationStore,
  eventPublisher,
});

await reconciler.reconcile({
  tenantId,
  subscriptionId,
  planVersionRef,
  reason: "membership.changed",
});

Scope

Core contracts

  • Define a SubscriptionQuantitySource contract that returns the desired billable quantity and source version/evidence.
  • Define an explicit licensed-quantity provider capability rather than adding permissive optional methods to the existing checkout gateway.
  • Add SubscriptionQuantitySnapshot and reconciliation states such as in_sync, pending, drifted, retryable_failed, terminal_failed, and unsupported.
  • Make quantity policy explicit per plan version, including minimum quantity, included seats, and which locally active memberships count as billable seats.
  • Derive a stable reconciliation identity from tenant, external subscription, plan version, desired quantity, and source version.

Execution semantics

  • Trigger reconciliation only after the local membership transaction commits.
  • Do not call a remote billing provider from membership request execution.
  • Support event-driven reconciliation plus a bounded periodic repair scan.
  • Treat repeated requests for the same desired quantity as idempotent success.
  • Handle concurrent membership changes by superseding stale desired quantities rather than applying them after a newer source version.
  • Persist provider-observed quantity, desired quantity, last attempt, last success, retry classification, and actionable Problem evidence.

Composition boundary

Diagnostics and telemetry

  • Expose quantity drift, oldest pending reconciliation, retry exhaustion, unsupported capability, and source/provider mismatch.
  • Emit stable events for drift detected, reconciliation succeeded, reconciliation failed, and drift recovered.
  • Add provider conformance fixtures for idempotent quantity updates and stale-source rejection where supported.

Acceptance criteria

  • Active membership count, billable seat policy, entitlement seat quota, and provider quantity resolve from the same immutable plan version.
  • Membership commit succeeds independently of temporary provider unavailability.
  • A committed membership change always creates or updates an inspectable reconciliation intent after commit.
  • Replaying the same desired quantity does not cause duplicate provider-side effects.
  • A stale reconciliation cannot overwrite a newer desired quantity.
  • Removing a member can reduce provider quantity without deleting or revoking unrelated subscriptions.
  • Provider failure leaves deterministic retry state and does not silently report in_sync.
  • A provider without licensed-quantity capability fails with a stable public Problem and profile diagnostic.
  • Periodic repair converges missed event delivery without unbounded tenant scans.
  • Tests cover add/remove bursts, concurrent changes, provider outage, duplicate delivery, stale source versions, minimum-seat policy, and recovery.
  • README, API docs, public API snapshots, provider certification metadata, and changesets are updated.

Verification

  • pnpm --filter @croco/billing-core test
  • provider capability/conformance tests
  • membership-to-billing integration fixtures in the SaaS golden path
  • pnpm public-api:check
  • pnpm docs:api:check
  • pnpm provider-certification:check

Non-goals

  • Changing membership ownership or role semantics.
  • Replacing SeatLimitChecker or entitlement enforcement.
  • Rolling back membership state when a provider is unavailable.
  • Automatically removing members to match a lower purchased quantity.
  • Calculating invoices or proration locally.
  • Adding the operator UI; [admin-react] Add a cross-domain Tenant 360 business workspace #1538 should consume this read model when available.

Metadata

Metadata

Assignees

Labels

P1Priority 1 issuearchitectureArchitecture and module boundary issuesfeatureProduct feature or roadmap capabilityreliability

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions