DESIGN SPEC · DRAFT FOR REVIEW

Subscription Management Integration
TrustWeave SaaS × Accountly × Kill Bill

Date: 2026-06-01 Author: Stephane Fellah (with Claude Code) Status: Draft — pending review Scope: trustweave-saas + accountly + Kill Bill

Replace TrustWeave SaaS's direct Stripe billing with Accountly + Kill Bill as the single system of record for subscriptions and metered usage. TrustWeave integrates as a remote API client and a merchant ("Application owner") in Accountly: it declares its plan tiers into Accountly's catalog, provisions each Organization as an Accountly subscriber, meters billable activity, and enforces plan limits locally against a cached entitlement snapshot kept in sync via webhooks.

Settled decisions (from brainstorming)

Role: Full replacement — Accountly+KB are system of record Topology: Remote API client Auth: Shared Keycloak + client-credentials service JWT Metered: creds issued, verifications, blockchain tx cost, seats/DIDs Customer UX: Embedded in TrustWeave UI, proxied Enforcement: Approach A — cached snapshot + async usage + webhook Catalog: TrustWeave-declared, reconciled into Accountly

1.Context & current state

TrustWeave SaaS (trustweave-saas, Spring Boot + React) is multi-tenant via Organization with OrganizationTier { FREE, PRO, ENTERPRISE }. It currently bills directly through Stripe: StripeService, SubscriptionService (Stripe-coupled Subscription entity), and TransactionCostBillingService (blockchain transaction costs → Stripe invoice items). These are the components this integration retires.

Accountly (accountly, Next.js frontend + Kotlin/Spring backend on :9393, Kill Bill on :8080) is already a multi-tenant metered-billing platform. Its model: an owner (merchant) registers Applications; each Application has Plans (FlatRate/PerUsage) with plan_quotas mapped to Kill Bill products; subscribers get Subscriptions carrying killbill_bundle_id/killbill_subscription_id; usage events are ingested idempotently → rollups → daily analytics → entitlement snapshots. Kill Bill performs invoicing; an inbound webhook feeds Kill Bill state back into Accountly.

Why this fits Accountly already implements (a) merchant catalog write APIs, (b) idempotent usage ingestion with explicit subscriberAccountId, (c) entitlement-snapshot computation, and (d) Kill Bill account/subscription provisioning. The integration mostly wires TrustWeave into existing Accountly machinery plus a small set of merchant-facing additions.

2.Architecture & component map

TrustWeave SaaS (Spring)                       Accountly API (Spring)              Kill Bill
─────────────────────────                      ──────────────────────              ─────────
Organization                                   Owner: "TrustWeave SaaS" (service)
  ├ tier (local cache)        ┌─ AccountlyBillingClient ─►  Application
  ├ accountlySubscriberId     │   (service JWT, retry)      ├ Plans tw-free/pro/ent ──►  KB catalog
  ├ killbillAccountId         │                             │   └ plan_quotas            (products,
  └ entitlementSnapshot ◄──┐  ├─ CatalogReconciler ───────►  create/update plans         prices,
                           │  │                              usage-vars, KB mapping       usage units)
 issue / verify / anchor   │  ├─ UsageReporter (outbox) ──►  POST usage-events ──► rollups ──► KB usage
   │                       │  │                                                        │
   ▼                       │  ├─ OrganizationBillingSvc ──►  merchant subscribe/    ──►  KB bundle +
 EntitlementGate (local) ──┘  │                              change/cancel               subscription
   │                          └─ BillingProxyController ◄──  GET plans / snapshot
   ▼   (frontend)                  ▲
 React widgets:                    │
   PricingPlans · CurrentPlanCard · UsageMeter
                                   │
 AccountlyWebhookController ◄────── MerchantWebhookDispatcher ◄── KB inbound webhook
   (HMAC verify → update tier + refresh snapshot)   (subscription.updated, payment_failed)

New in TrustWeave SaaS — package billing/accountly/ (replaces billing/ Stripe code)

ComponentResponsibility
AccountlyBillingClientTyped HTTP client to Accountly /api/v1; obtains & caches a Keycloak client-credentials service token; handles errors → domain results.
CatalogReconcilerReconciles the declarative tier catalog (FREE/PRO/ENTERPRISE + quotas + KB mapping + usage variables) into Accountly. Idempotent create-or-update; runs on startup and via admin trigger.
OrganizationBillingServiceOrchestrates provisioning (subscriber + default plan), upgrade/downgrade, cancel; updates local mapping & tier cache.
UsageReporterDrains a durable usage_outbox; posts usage-events to Accountly with stable idempotency keys and retry/backoff.
EntitlementGateLocal pre-flight check of a billable action against the cached entitlement snapshot (allow / soft-warn / hard-block).
AccountlyWebhookControllerReceives Accountly merchant webhooks (HMAC-verified); updates OrganizationTier and refreshes the snapshot.
BillingProxyControllerFrontend-facing endpoints (the embedded-proxy UX): list plans, current subscription/usage, subscribe/upgrade/cancel. Never exposes Accountly directly to the browser.

Retired in TrustWeave SaaS

remove StripeService remove SubscriptionService (Stripe) refactor TransactionCostBillingService → emit usage events, not Stripe invoice items refactor Subscription entity → Accountly/KB references, drop stripe_*

3.Identity, catalog & tier mapping

TrustWeave conceptAccountly conceptNotes
The TrustWeave company (1 service identity)Owner of Application "TrustWeave SaaS"Authenticated via shared-Keycloak client-credentials service JWT; mapped to an Accountly owner app_user.
OrganizationSubscriber (app_users) + Kill Bill accountMapping accountlySubscriberId + killbillAccountId stored on Organization. Subscriber identified by external ref trustweave:org:<id>.
OrganizationTier FREE / PRO / ENTERPRISEPlan tw-free / tw-pro / tw-enterpriseTier stays in TrustWeave only for local feature-gating; the authoritative plan lives in Accountly and drives the tier via webhook sync.
Tier limits (e.g. Free = 500 creds/mo)plan_quotas (event_type, hard_limit, period)Declared by TrustWeave's CatalogReconciler.
Billable actionsdeclared usage variables + usage-eventsEvent types in next section.

Metered event types

Event typeSource in TrustWeavePricing intent
credential.issuedCredential issuance serviceCore quota (Free = 500/mo); overage on metered plans.
credential.verifiedVerifier Gateway / OID4VPMetered; may be free up to a quota.
blockchain.txTransactionCostService (anchoring/on-chain)Cost passthrough — quantity = unit cost (USD-cents) in properties.
wallet.seat / did.createdDID creation / active holder walletsSeat-style metric (periodic high-water or creation count).

Each event type is declared once per Application as an Accountly usage variable and referenced by plan_quotas.event_type. blockchain.tx carries its native/USD cost in the event's properties JSON so Kill Bill can bill the passthrough amount.

4.Catalog-as-code & plan widgets

4.1 TrustWeave declares the catalog

Rather than seeding Accountly by hand, TrustWeave owns a declarative tier definition (config / Kotlin DSL). CatalogReconciler pushes it into Accountly through existing owner APIs, idempotently:

ensure Application "TrustWeave SaaS"            // POST /api/v1/applications (once)
for each tier in [FREE, PRO, ENTERPRISE]:
    ensure Plan (name, pricingModel, monthlyFee, level, isDefault)   // POST …/plans
    set Kill Bill mapping (product, plan, priceList)                 // PATCH …/killbill-mapping
    for each quota (eventType, hardLimit, period):                   // POST …/quotas
        ensure quota
ensure usage variables [credential.issued, credential.verified,      // POST …/usage-variables
                        blockchain.tx, wallet.seat]
Caveat — two catalog layers Accountly plans reference the Kill Bill catalog by name (killbillPlanName / killbillProductName / killbillPriceList, default price-list "DEFAULT"); they do not create priced products. The monetary catalog (recurring prices, usage-tier rates) lives in Kill Bill's own catalog (uploaded catalog.xml or KB catalog API). "TrustWeave configures tiers" covers the Accountly plan metadata + quotas + mapping automatically; provisioning the matching Kill Bill catalog is a related dependency — see §9.

4.2 Widgets render tiers from Accountly

Accountly is the single source of truth for plan presentation. GET …/applications/{appId}/plans returns PlanResponse with name, monthlyFee, level, isDefault, KB mapping, and the full quotas list — enough to render price + feature bullets. Two widget families, both served via the TrustWeave backend proxy (browser never calls Accountly):

WidgetData sourceBehavior
<PricingPlans/> (public)TrustWeave GET /billing/plans → proxied Accountly plan list (sanitized)Renders tier cards from live Accountly data; price + quota-derived feature bullets; edit a tier in Accountly → page updates with no redeploy.
<CurrentPlanCard/> (in-app)Cached entitlement snapshotCurrent plan badge, renewal, "Upgrade" CTA.
<UsageMeter/> (in-app)Entitlement snapshot + daily analyticsPer-quota used/remaining ("412 / 500 credentials"), near-limit warning state.

5.Metering & entitlement enforcement (Approach A)

The core paths (issue / verify / anchor) must stay fast and resilient to Accountly being briefly unavailable, so enforcement is local against a cached entitlement snapshot; usage is reported asynchronously; authoritative state changes flow back via webhook.

Read path — gating

  1. Billable action requested → EntitlementGate.check(org, eventType, qty) against the cached snapshot.
  2. Allow (under limit) → proceed. Soft-warn (near limit) → proceed + surface upgrade nudge. Hard-block (over a hard limit on a non-overage tier) → reject with an "upgrade to continue" error.
  3. Snapshot is refreshed on a TTL and on webhook events; an optional tunable forces a synchronous refresh when within N% of a hard limit to tighten the overshoot bound.

Write path — reporting

  1. On a successful billable action, append a row to a durable usage_outbox (same DB tx as the business write — no lost/double events).
  2. UsageReporter drains the outbox and calls POST …/applications/{appId}/usage-events with a stable idempotency key (org:eventType:businessId) and explicit subscriberAccountId; Accountly de-dupes replays.
  3. Retry with backoff; mark rows sent/failed; expose a metric for outbox lag.
Consistency note Enforcement is eventually consistent: a burst can slightly overshoot a hard limit before the next refresh. Bound is configurable via snapshot TTL + the near-limit synchronous refresh. Acceptable for these metrics (credentials, verifications, seats); financial truth is always reconciled in Accountly/Kill Bill.

6.Lifecycle flows

A. Catalog reconciliation (startup / admin)

TrustWeave CatalogReconciler ──► Accountly (owner JWT)
  ensure Application → ensure Plans + quotas + KB mapping + usage variables  (idempotent)

B. Organization provisioning (lazy — on org creation or first billable use)

OrganizationBillingService.provision(org)
  → POST …/applications/{appId}/subscribers { externalRef: "trustweave:org:" }   (NEW endpoint)
       Accountly: create app_user + Kill Bill account → returns subscriberId, killbillAccountId
  → POST …/applications/{appId}/subscribers/{subscriberId}/subscriptions { plan: tw-free }  (NEW)
  → cache subscriberId, killbillAccountId, tier=FREE, initial entitlement snapshot on Organization

C. Billable action (e.g. issue credential)

issueCredential()
  → EntitlementGate.check(org, "credential.issued", 1)         // local, cached snapshot
  → [allowed] persist credential + append usage_outbox row     // single DB tx
  → UsageReporter → POST …/usage-events (idempotent)           // async
       Accountly → rollups → daily analytics → Kill Bill usage

D. Upgrade / downgrade / cancel (embedded proxy UX)

React → TrustWeave BillingProxyController → OrganizationBillingService.changePlan(org, tw-pro)
  → PATCH …/subscribers/{id}/subscriptions/{subId} { plan: tw-pro }   (NEW merchant-mediated)
       Accountly → Kill Bill change plan
  → MerchantWebhookDispatcher → TrustWeave AccountlyWebhookController  (authoritative confirm)
       → update OrganizationTier + refresh snapshot

E. Inbound state sync (payment failure, cancellation, KB-driven change)

Kill Bill → Accountly inbound webhook → MerchantWebhookDispatcher (HMAC)
  → TrustWeave AccountlyWebhookController
       subscription.updated  → set tier, refresh snapshot
       invoice.payment_failed → mark PAST_DUE, optionally restrict features

7.Changes — TrustWeave SaaS

AreaChange
billing/accountly/*New package: client, reconciler, billing service, usage reporter, entitlement gate, webhook controller, proxy controller (per §2).new
billing/StripeService.kt, Stripe SubscriptionService.ktDelete after migration cut-over.remove
TransactionCostBillingServiceEmit blockchain.tx usage events with cost passthrough instead of Stripe invoice items.refactor
Subscription entity + repoReplace stripe_* columns with accountly_subscription_id, killbill_subscription_id, killbill_bundle_id, plan_code, status.migrate
Organization entityAdd accountlySubscriberId, killbillAccountId, entitlementSnapshotJson, snapshotRefreshedAt; keep tier as local cache.migrate
React frontend<PricingPlans/>, <CurrentPlanCard/>, <UsageMeter/> wired to BillingProxyController.new
ConfigAccountly base URL, application id, Keycloak client-credentials (client id/secret/scope), webhook HMAC secret, snapshot TTL, near-limit threshold.new

8.Changes — Accountly

The integration relies mostly on existing Accountly APIs, plus this focused set of additions:

ChangeDetail
Merchant-mediated subscriber provisioningPOST /api/v1/applications/{appId}/subscribers — owner-authenticated; creates an app_user for an external ref (not the JWT subject) + Kill Bill account; returns ids.new
Merchant-mediated subscribe / change / cancelPOST|PATCH|DELETE /api/v1/applications/{appId}/subscribers/{subscriberId}/subscriptions — owner subscribes its subscriber to its plan.new
Relax demo-sandbox guardSubscriptionLifecycleService.subscribe() currently requires subscriber == plan owner ("demo sandbox"). Allow the merchant-mediated path where owner ≠ subscriber.change
External-ref identityIdentityProvisioningService + app_users: add external_ref column & lookup so owner-provisioned subscribers have stable identity independent of a Keycloak subject.migrate
Outbound merchant webhooksMerchantWebhookDispatcher: on subscription state change / payment failure, POST an HMAC-signed event to the owner's configured webhook URL. (Today Accountly only receives the KB inbound webhook.)new
Service principal as ownerAccept the TrustWeave Keycloak client-credentials principal as an Accountly owner and bind it to the "TrustWeave SaaS" Application.config

9.Changes — Kill Bill catalog

Provide the priced catalog that Accountly plans reference. Author a catalog.xml (versioned, e.g. in accountly or an ops repo) with:

Provisioning method (uploaded XML vs KB catalog API automation) and price authoring ownership are open questions — see §14.

10.Data model changes

TrustWeave — organizations

+ accountly_subscriber_id   text
+ killbill_account_id       text
+ entitlement_snapshot_json jsonb
+ snapshot_refreshed_at     timestamptz
  tier                      (kept — local feature-gate cache, synced from Accountly)

TrustWeave — subscriptions (refactor)

- stripe_subscription_id, stripe_customer_id
+ accountly_subscription_id text
+ killbill_subscription_id  text
+ killbill_bundle_id        text
+ plan_code                 text   // tw-free | tw-pro | tw-enterprise
  status                    enum   // ACTIVE | PAST_DUE | CANCELED | TRIALING

TrustWeave — usage_outbox (new)

id, organization_id, event_type, quantity, unit, occurred_at,
idempotency_key (unique), correlation_id, properties_json,
status (PENDING|SENT|FAILED), attempts, created_at, sent_at

Accountly — app_users

+ external_ref text  (unique per owner/application)   // "trustweave:org:"

11.API contracts

Accountly — existing, reused

POST   /api/v1/applications
POST   /api/v1/applications/{appId}/plans
POST   /api/v1/plans/{planId}/quotas
PATCH  /api/v1/plans/{planId}/killbill-mapping
POST   /api/v1/applications/{appId}/usage-variables
GET    /api/v1/applications/{appId}/plans               // drives PricingPlans widget
POST   /api/v1/applications/{appId}/usage-events        // idempotent usage ingest
GET    /api/v1/applications/{appId}/subscribers/{id}/entitlement

Accountly — new (merchant-mediated)

POST   /api/v1/applications/{appId}/subscribers
       { externalRef } → { subscriberId, killbillAccountId }
POST   /api/v1/applications/{appId}/subscribers/{subscriberId}/subscriptions
       { planId | planCode } → 201
PATCH  /api/v1/applications/{appId}/subscribers/{subscriberId}/subscriptions/{subId}
       { planId | planCode }            // upgrade/downgrade
DELETE /api/v1/applications/{appId}/subscribers/{subscriberId}/subscriptions/{subId}   // cancel

TrustWeave — frontend-facing proxy

GET    /billing/plans                 // sanitized Accountly plan list (public)
GET    /billing/me/subscription       // current plan + snapshot (in-app)
GET    /billing/me/usage              // per-quota used/remaining
POST   /billing/me/subscribe          { planCode }
POST   /billing/me/change-plan        { planCode }
POST   /billing/me/cancel
POST   /billing/webhooks/accountly    // inbound, HMAC-verified

12.Testing strategy

13.Migration & rollout

PhaseWhatUser impact
0 — CatalogAuthor Kill Bill catalog; CatalogReconciler creates the Accountly Application/plans/quotas/usage-vars.None.
1 — ShadowProvision subscribers + report usage to Accountly while Stripe stays authoritative; compare Accountly entitlement vs Stripe state for parity.None (dual-write).
2 — Cut-overMigrate existing orgs: create matching Accountly subscriptions from current tier; flip enforcement to the snapshot; switch UI to proxy widgets.Billing source of truth changes; communicate.
3 — DecommissionRemove Stripe code paths and stripe_* columns. (The payment processor behind Kill Bill — Stripe or otherwise — is a separate concern, out of scope here.)None.

Data migration: for each existing Organization, provision an Accountly subscriber and a subscription matching its current OrganizationTier; backfill mapping ids; reconcile any open TransactionCostAllocation balances before turning off the Stripe invoice path.

14.Risks & open questions

#ItemDisposition
R1Kill Bill catalog ownership. Who authors/maintains prices and how is the catalog provisioned (XML upload vs API)?Open — needs a decision before Phase 0.
R2Outbound merchant webhook is net-new in Accountly (only inbound KB webhook exists today).Scoped in §8; alternative is TrustWeave polling the entitlement endpoint on a timer if webhook is deferred.
R3Eventual-consistency overshoot on hard limits during bursts.Mitigated by snapshot TTL + near-limit synchronous refresh; bound is configurable.
R4Multi-user organizations. One Accountly subscriber per Organization via external ref; individual TrustWeave members are not Accountly subscribers.Confirm this is the intended granularity.
R5Service principal → Accountly owner binding in Keycloak (client scope/role, mapping to the owner app_user + Application).Define client-credentials client & claim mapping during Phase 0.
R6Transaction-cost passthrough modeling in Kill Bill (usage unit with variable amount).Validate the catalog can express cost-passthrough; otherwise bill via Kill Bill credits/charges.