Subscription Owner Provider
RESTHeart|
Note
|
|
A subscription belongs to an entity, and SubscriptionOwnerProvider is what decides what that entity is.
By default it is the restheart-accounts team.
If your application bills something else — an individual user, a workspace, a project, an organisation in your own schema — implement this interface and the rest of the module follows.
Every billing operation goes through it: no service, interceptor or ACL variable in the module reaches billing state any other way.
The default implementation
DefaultSubscriptionOwnerProvider resolves the paying entity as the team from the caller’s JWT team claim:
-
the entity is the team named by
accountsConfig.team-claim-name(defaultteam); -
canManageBillingistruewhen the caller’s role in that team equalsaccountsConfig.ownership-role(defaultowner); -
Stripe linkage, subscription state and seat licences are stored on the team document;
-
the collection is
stripeConfig.teams-collection, in the database frommongoRealmAuthenticator.users-db.
Document shape:
{
"_id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" },
"name": "Acme Corp",
"createdBy": "owner@acme.example",
"stripe_customer_id": "cus_QxYz123",
"subscription": {
"plan": "gold",
"price_id": "price_1AbC…",
"status": "active",
"stripe_subscription_id": "sub_1XyZ…",
"trial_end": { "$date": 1772323200000 },
"current_period_end": { "$date": 1774915200000 },
"seats": 1,
"cancel_at_period_end": false,
"over_limit_since": null,
"last_applied_event_at": { "$date": 1771063200000 }
},
"members": [
{ "userId": "alice@acme.example", "role": "owner", "licensed": true },
{ "userId": "bob@acme.example", "role": "member", "licensed": false }
]
}
|
Note
|
It is stored next to the subscription state rather than inside it, so it can never leak into |
stripe_customer_id carries a unique, sparse index, created at startup.
Sparse because with lazy Customer provisioning most entities never have the field at all, and a plain unique index would collide on the missing value.
When to replace it
Implement your own provider when:
-
the paying entity is not a
restheart-accountsteam; -
billing state must live in a different collection or shape (an existing
organizationscollection, say); -
who may manage billing follows a rule of your own — a permission flag, a separate admin list, a role from an external system;
-
the entity is resolved from something other than a JWT claim: a header, a subdomain, an API key.
If none of these apply, the default is likely fine.
The interface
| Method | Purpose |
|---|---|
|
Resolve the paying entity for an incoming request. Takes the whole request because implementations differ in what they read. |
|
Resolve the entity from a |
|
Resolve by entity id — used for a Checkout session’s |
|
May this caller start a checkout, open the Portal, or grant/revoke licences? |
|
Persist the Stripe Customer linkage. Must be atomic and idempotent. |
|
Current state; never |
|
Replace the state, unless a newer update was already applied. |
|
Partial update, same staleness rule. |
|
Grant a seat licence. Must test availability and write atomically. |
|
Revoke a licence. A no-op if not licensed. |
|
Seat queries. |
Three contracts that matter
These are the places where a plausible-looking implementation is subtly wrong.
1. linkStripeCustomer must be atomic and idempotent.
Two simultaneous first checkouts must not produce two Stripe Customers for one entity — that means two subscriptions, two invoices, and a customer who is billed twice.
Link only if none is set yet, and return whatever is now persisted rather than the argument.
Callers use the return value, so a losing racer transparently proceeds with the winner’s Customer.
2. grantLicense must test-and-write in one operation.
A read followed by a separate write is a race: two concurrent grants on the last seat both see one free and both succeed.
Recompute the count inside the same conditional update.
3. writeSubscription / patchSubscription must honour appliedAt.
Stripe delivers out of order and retries.
Apply the change only if appliedAt is newer than the last applied timestamp, and return false when you skip.
Returning false is not an error — the webhook answers 200 either way, because a superseded event needs no retry.
|
Warning
|
Ignoring It will not show up in testing, because events arrive in order when you are the only user. |
Registering a custom provider
Implement both SubscriptionOwnerProvider and Initializer, and register at startup:
package com.example.billing;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.bson.BsonDocument;
import org.restheart.exchange.ServiceRequest;
import org.restheart.plugins.Initializer;
import org.restheart.plugins.Inject;
import org.restheart.plugins.RegisterPlugin;
import org.restheart.plugins.stripe.BillingScope;
import org.restheart.plugins.stripe.LicenseGrantResult;
import org.restheart.plugins.stripe.SubscriptionOwner;
import org.restheart.plugins.stripe.SubscriptionOwnerProvider;
import org.restheart.plugins.stripe.SubscriptionOwnerProviderRegistry;
import org.restheart.plugins.stripe.SubscriptionState;
import com.mongodb.client.MongoClient;
@RegisterPlugin(
name = "workspaceOwnerProvider",
description = "Bills workspaces instead of teams",
// BEFORE_STARTUP: registered before the server accepts any request
initPoint = org.restheart.plugins.InitPoint.BEFORE_STARTUP)
public class WorkspaceOwnerProvider implements SubscriptionOwnerProvider, Initializer {
@Inject("stripeService")
private SubscriptionOwnerProviderRegistry registry;
@Inject("mclient")
private MongoClient mclient;
@Override
public void init() {
registry.registerSubscriptionOwnerProvider(this);
}
@Override
public Optional<SubscriptionOwner> fromRequest(ServiceRequest<?> req, BillingScope scope) {
var workspaceId = req.getHeader("X-Workspace-Id");
return workspaceId == null ? Optional.empty() : byId(scope, workspaceId);
}
@Override
public boolean canManageBilling(ServiceRequest<?> req, SubscriptionOwner owner) {
// your own rule here — never default to true
return false;
}
// ... remaining methods
}
Enable it like any other plugin:
workspaceOwnerProvider:
enabled: true
|
Important
|
Register at
|
If no custom provider is registered, the module falls back to DefaultSubscriptionOwnerProvider.
Scope and multi-tenancy
Every resolution method receives a BillingScope — a (db, collection) pair the module resolves per request, applying any per-tenant override.
A custom provider is free to use it, or to ignore it entirely if its storage does not have that shape. The module never assumes the provider stores anything in the scope it passes; the scope exists so the default provider can be multi-tenant without the module having to know how.
See Multi-tenancy.
Related types
| Type | Description |
|---|---|
|
|
|
|
|
The immutable state snapshot. Carries no bookkeeping. |
|
|
|
Note
|
|
Indexes
stripeInitializer creates the stripe_customer_id index only for the default provider, against the static configuration.
A custom provider is responsible for its own indexes. So is a multi-tenant deployment whose databases vary per request — the startup index creation only covers the statically configured database.