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.
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.
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.
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)
billing/accountly/ (replaces billing/ Stripe code)| Component | Responsibility |
|---|---|
AccountlyBillingClient | Typed HTTP client to Accountly /api/v1; obtains & caches a Keycloak client-credentials service token; handles errors → domain results. |
CatalogReconciler | Reconciles 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. |
OrganizationBillingService | Orchestrates provisioning (subscriber + default plan), upgrade/downgrade, cancel; updates local mapping & tier cache. |
UsageReporter | Drains a durable usage_outbox; posts usage-events to Accountly with stable idempotency keys and retry/backoff. |
EntitlementGate | Local pre-flight check of a billable action against the cached entitlement snapshot (allow / soft-warn / hard-block). |
AccountlyWebhookController | Receives Accountly merchant webhooks (HMAC-verified); updates OrganizationTier and refreshes the snapshot. |
BillingProxyController | Frontend-facing endpoints (the embedded-proxy UX): list plans, current subscription/usage, subscribe/upgrade/cancel. Never exposes Accountly directly to the browser. |
StripeService
remove SubscriptionService (Stripe)
refactor TransactionCostBillingService → emit usage events, not Stripe invoice items
refactor Subscription entity → Accountly/KB references, drop stripe_*
| TrustWeave concept | Accountly concept | Notes |
|---|---|---|
| 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. |
Organization | Subscriber (app_users) + Kill Bill account | Mapping accountlySubscriberId + killbillAccountId stored on Organization. Subscriber identified by external ref trustweave:org:<id>. |
OrganizationTier FREE / PRO / ENTERPRISE | Plan tw-free / tw-pro / tw-enterprise | Tier 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 actions | declared usage variables + usage-events | Event types in next section. |
| Event type | Source in TrustWeave | Pricing intent |
|---|---|---|
credential.issued | Credential issuance service | Core quota (Free = 500/mo); overage on metered plans. |
credential.verified | Verifier Gateway / OID4VP | Metered; may be free up to a quota. |
blockchain.tx | TransactionCostService (anchoring/on-chain) | Cost passthrough — quantity = unit cost (USD-cents) in properties. |
wallet.seat / did.created | DID creation / active holder wallets | Seat-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.
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]
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.
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):
| Widget | Data source | Behavior |
|---|---|---|
<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 snapshot | Current plan badge, renewal, "Upgrade" CTA. |
<UsageMeter/> (in-app) | Entitlement snapshot + daily analytics | Per-quota used/remaining ("412 / 500 credentials"), near-limit warning state. |
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.
EntitlementGate.check(org, eventType, qty) against the cached snapshot.usage_outbox (same DB tx as the business write — no lost/double events).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.sent/failed; expose a metric for outbox lag.TrustWeave CatalogReconciler ──► Accountly (owner JWT)
ensure Application → ensure Plans + quotas + KB mapping + usage variables (idempotent)
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
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
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
Kill Bill → Accountly inbound webhook → MerchantWebhookDispatcher (HMAC)
→ TrustWeave AccountlyWebhookController
subscription.updated → set tier, refresh snapshot
invoice.payment_failed → mark PAST_DUE, optionally restrict features
| Area | Change | |
|---|---|---|
billing/accountly/* | New package: client, reconciler, billing service, usage reporter, entitlement gate, webhook controller, proxy controller (per §2). | new |
billing/StripeService.kt, Stripe SubscriptionService.kt | Delete after migration cut-over. | remove |
TransactionCostBillingService | Emit blockchain.tx usage events with cost passthrough instead of Stripe invoice items. | refactor |
Subscription entity + repo | Replace stripe_* columns with accountly_subscription_id, killbill_subscription_id, killbill_bundle_id, plan_code, status. | migrate |
Organization entity | Add accountlySubscriberId, killbillAccountId, entitlementSnapshotJson, snapshotRefreshedAt; keep tier as local cache. | migrate |
| React frontend | <PricingPlans/>, <CurrentPlanCard/>, <UsageMeter/> wired to BillingProxyController. | new |
| Config | Accountly base URL, application id, Keycloak client-credentials (client id/secret/scope), webhook HMAC secret, snapshot TTL, near-limit threshold. | new |
The integration relies mostly on existing Accountly APIs, plus this focused set of additions:
| Change | Detail | |
|---|---|---|
| Merchant-mediated subscriber provisioning | POST /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 / cancel | POST|PATCH|DELETE /api/v1/applications/{appId}/subscribers/{subscriberId}/subscriptions — owner subscribes its subscriber to its plan. | new |
| Relax demo-sandbox guard | SubscriptionLifecycleService.subscribe() currently requires subscriber == plan owner ("demo sandbox"). Allow the merchant-mediated path where owner ≠ subscriber. | change |
| External-ref identity | IdentityProvisioningService + app_users: add external_ref column & lookup so owner-provisioned subscribers have stable identity independent of a Keycloak subject. | migrate |
| Outbound merchant webhooks | MerchantWebhookDispatcher: 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 owner | Accept the TrustWeave Keycloak client-credentials principal as an Accountly owner and bind it to the "TrustWeave SaaS" Application. | config |
Provide the priced catalog that Accountly plans reference. Author a catalog.xml (versioned, e.g. in
accountly or an ops repo) with:
trustweave-free / trustweave-pro / trustweave-enterprise with recurring monthly prices.credential.issued, credential.verified, blockchain.tx, wallet.seat with tiered/usage pricing (and overage where applicable).DEFAULT (matches Accountly's default mapping).Provisioning method (uploaded XML vs KB catalog API automation) and price authoring ownership are open questions — see §14.
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)
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
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
app_users+ external_ref text (unique per owner/application) // "trustweave:org:"
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
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
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
EntitlementGate (allow/warn/block boundaries), UsageReporter (idempotency, retry/backoff, outbox state machine), CatalogReconciler (idempotent create-vs-update), webhook HMAC verification.AccountlyBillingClient against a WireMock stub of Accountly's contract (success, 4xx, 5xx, replay).MerchantWebhookDispatcher emission + signature.Subscription row maps to an Accountly subscription with correct tier (see §13).| Phase | What | User impact |
|---|---|---|
| 0 — Catalog | Author Kill Bill catalog; CatalogReconciler creates the Accountly Application/plans/quotas/usage-vars. | None. |
| 1 — Shadow | Provision subscribers + report usage to Accountly while Stripe stays authoritative; compare Accountly entitlement vs Stripe state for parity. | None (dual-write). |
| 2 — Cut-over | Migrate 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 — Decommission | Remove 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.
| # | Item | Disposition |
|---|---|---|
| R1 | Kill Bill catalog ownership. Who authors/maintains prices and how is the catalog provisioned (XML upload vs API)? | Open — needs a decision before Phase 0. |
| R2 | Outbound 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. |
| R3 | Eventual-consistency overshoot on hard limits during bursts. | Mitigated by snapshot TTL + near-limit synchronous refresh; bound is configurable. |
| R4 | Multi-user organizations. One Accountly subscriber per Organization via external ref; individual TrustWeave members are not Accountly subscribers. | Confirm this is the intended granularity. |
| R5 | Service 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. |
| R6 | Transaction-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. |