Status: agreed design, ready to implement. Supersedes
./tmp/SUBSCRIPTION_PLAN.md. Written 2026-10-10 on branchkevinnitrog/billing-service. Part 1 is the newbillingservice. Part 2 lists every change needed in other services, including pre-existing gaps found while designing. Part 3 is build order and TODO.
| Topic | Decision |
|---|---|
| Billed entity | Workspace. One payer (payerId, AWS term) per workspace; payer must be a workspace owner. Any owner may manage billing. |
| Provider | Stripe, test mode / sandbox only. Stripe does not onboard Vietnam-registered businesses for live payouts. Provider sits behind app.PaymentProvider so a VN gateway can replace it. |
| SDK | github.com/stripe/stripe-go/v87 (MIT, free). |
| Stripe Customer | One per workspace, mapping stored only in billing DB. Authentik is not touched. |
| Stripe UI | Stripe-hosted Checkout (purchase, card replacement) + Stripe-hosted Customer Portal (card, invoices, cancel at period end, plan switch). Our API only does what those cannot. |
| Product type | Stripe Billing subscriptions (mode=subscription) for month/year; one-time Price (mode=payment) for lifetime. No raw PaymentIntents. |
| Tiers | free, plus, pro. Lifetime grants pro. |
| Intervals | month, year (≈20% off, separate Price), lifetime. |
| Currency | VND only (zero-decimal). Schema keeps currency so other currencies can be added as rows. |
| Catalog | Feature/limit keys are enums in code. Their values per plan, the billing policy, and all prices live in the billing DB and are admin-editable via API. Prices are grandfathered; plan limits apply immediately. |
| Admin | One app-wide admin group from config (ADMIN_GROUP, default notopia-admins), read from X-Forwarded-Groups. |
| Entitlements | Billing returns plan + resolved features + limits. Frontend hardcodes nothing numeric. |
| Enforcement | note / document (later AI) call billing gRPC, cache 60 s, flush on billing Kafka events. Errors → HTTP 402 conforming to Error. |
| Expiry | No "expire at period end" job. Webhooks drive state; hourly reconcile cron is the safety net; past_due keeps access for a grace period. |
| Refund | Immediate cancel → prorated refund. Lifetime → full refund within a window, none after. |
| Cron | Inside each service, controller/cron/, Redis-locked. // NOTE: to move into cmd/{svc}-worker later. |
| System actor | Authentik service account per service (note-service 1001, document-service 1002, billing-service 1003). No "system" string. |
| Trial | None (TODO). |
Mirrors internal/note (DDD + CQRS, sqlc + goose, Watermill outbox, wire).
api/billing/ # OpenAPI (paths/ + components/), oapi-codegen-server.yaml, oapi-codegen-model.yaml
proto/billing/billing.proto
cmd/billing/{main,wire,wire_gen,metadata}.go # subcommands: serve, seed
internal/billing/
AGENTS.md
config/
domain/
plan.go planrepo.go
price.go pricerepo.go
billingaccount.go billingaccountrepo.go
subscription.go refund.go subscriptionrepo.go
billingpolicy.go billingpolicyrepo.go
entitlement.go # FeatureKey, LimitKey, Entitlements VO, EntitlementResolver
money.go refundquote.go providersnapshot.go
event.go uow.go wire.go
app/
cmd_*.go q_*.go e_*.go
svc_paymentprovider.go # port
svc_authorization.go # port (owner / member checks)
svc_providereventinbox.go
integrationevent.go readmodel.go model.go wire.go
errs/
infra/
payment/stripe/ # PaymentProvider adapter, webhook signature verify, snapshot mapping
persistence/pgmigration/ persistence/pgsqlc/
outbox/ # copy of note's Watermill outbox
integrationpublisher/
service/authorization.go # gRPC client
controller/
http/ grpc/ event/ cron/ health/
Infra: deploy/compose/services/billing.yaml (billing_postgresql, billing_redis),
deploy/compose/infrastructure/traefik/dynamic/billing.yaml, cmd/billing/project.json,
api/billing/project.json, registration in api/openapi.yaml, api/redocly.yaml.
| Stripe object | Created by | Key / metadata |
|---|---|---|
| Product per paid tier | seed |
metadata.tier, id notopia_plus, notopia_pro |
| Price | seed, admin CreatePrice |
lookup_key = {tier}_{interval}, transfer_lookup_key=true on replace, metadata.price_id (our uuid). Recurring for month/year, one-time for lifetime. |
| Customer | StartCheckout (first time) |
idempotency key customer:{workspaceId}, metadata.workspace_id, email/name = payer |
| Checkout Session | StartCheckout, ChangePayer |
client_reference_id = workspaceId; metadata {workspace_id, payer_id, price_id, purpose} where purpose ∈ subscribe, lifetime, payer_change |
| Subscription | Stripe (from Checkout) | subscription_data.metadata.workspace_id |
| Portal configuration | seed, after every price change |
id stored in billing_policy.portal_configuration_id |
| Portal session | StartPortal |
configuration, return_url |
| Refund | CancelNow, CompleteCheckout (duplicate), lifetime refund |
idempotency key refund:{refundId}, metadata.refund_id |
Prices are immutable in Stripe. "Changing a price" = new Price + archive old. Existing
subscriptions keep the old Price (grandfathered). New checkouts resolve by lookup_key.
payment_method_update: enabledinvoice_history: enabledcustomer_update: disabled (email/name managed by us from the payer)subscription_cancel: enabled,mode=at_period_endsubscription_update: enabled; products = paid tiers with their active recurring prices;proration_behavior=always_invoice(upgrade charges the difference now);schedule_at_period_end.conditions = [decreasing_item_amount, shortening_interval](downgrade and year→month take effect at period end, never refund)
- Since
2025-03-31.basil,current_period_start/endlive on subscription items (items.data[0].current_period_end), not the subscription. - Since basil, invoices expose payments via
invoice.payments(InvoicePayment), notinvoice.payment_intent. Refund target = the PaymentIntent of the latest paid invoice payment. - VND is zero-decimal:
amount: 49000= 49.000₫.
- Stripe sandbox (free), test cards.
stripe listen --forward-to api.notopia.localhost/billing/webhooks/stripe→ prints the webhook secret for.env.local.just billing-seed→billing seed(idempotent; inserts defaults only when empty).stripe-mockis not used (no webhooks).
Keys are enums in code (domain/entitlement.go). Admin edits values only; unknown keys are
rejected. -1 = unlimited. Defaults written by seed:
| Key | Kind | Meaning | free | plus | pro |
|---|---|---|---|---|---|
auto_commit |
feature | document auto-commits revisions | ✗ | ✓ | ✓ |
workspace_graph |
feature | whole-workspace graph endpoint | ✗ | ✓ | ✓ |
members |
limit | max members per workspace | 3 | 10 | -1 |
published_notes |
limit | max published notes per workspace | 3 | -1 | -1 |
note_graph_max_depth |
limit | depth clamp on note graph | 5 | -1 | -1 |
auto_commit_min_interval_seconds |
limit | min gap between auto revisions | 0 | 300 | 60 |
revision_history_days |
limit | revisions older than this are purged (latest always kept) | 7 | 30 | -1 |
trash_retention_days |
limit | trashed items older than this are purged | 7 | 30 | 90 |
ai_credits_monthly |
limit | AI quota, enforced by the future AI service | 0 | 200 | 2000 |
Custom slug is free for everyone (not a key). Storage quota and owned-workspace count are not limits.
Default prices (VND, admin-editable):
| lookup_key | amount |
|---|---|
plus_month |
49 000 |
plus_year |
470 400 (49 000 × 12 × 0.8) |
pro_month |
99 000 |
pro_year |
950 400 |
pro_lifetime |
2 490 000 (placeholder) |
Billing policy defaults (admin-editable): pastDueGraceDays = 3,
lifetimeRefundWindowDays = 14.
| Aggregate root | Contains | Repo |
|---|---|---|
Subscription |
child entity Refund; VOs Money, ProviderSnapshot, RefundQuote |
SubscriptionRepo |
BillingAccount |
payer + provider customer | BillingAccountRepo |
Price |
Money |
PriceRepo |
Plan |
features / limits maps | PlanRepo |
BillingPolicy |
singleton | BillingPolicyRepo |
Refund lives inside Subscription because "refunded total ≤ paid" is a subscription invariant.
Idempotency of webhooks is not domain: it is an app port ProviderEventInbox.
Entitlements is a value object computed by the domain service EntitlementResolver from
Subscription (nullable → free) + Plan + BillingPolicy.
classDiagram
direction TB
class Subscription {
<<aggregate root>>
-id uuid
-workspaceID uuid
-plan PlanTier
-interval Interval
-priceID uuid
-status Status
-providerSubscriptionID *string
-providerPaymentID *string
-currentPeriodStart *time
-currentPeriodEnd *time
-cancelAtPeriodEnd bool
-canceledAt *time
-paidAt *time
-providerSyncedAt time
-refunds []*Refund
+NewSubscription(...) SubscriptionCreated
+NewLifetimeSubscription(...) SubscriptionCreated
+ApplySnapshot(ProviderSnapshot, actor) SubscriptionActivated|Renewed|PastDue|PlanChanged|CancelScheduled|CancelResumed|Ended
+ScheduleCancel(actor) SubscriptionCancelScheduled
+ResumeCancel(actor) SubscriptionCancelResumed
+QuoteCancelNow(now, policy) RefundQuote
+CancelNow(now, reason, quote, actor) SubscriptionCanceledNow + RefundRequested
+MarkRefundSucceeded(refundID, providerRefundID) RefundSucceeded
+MarkRefundFailed(refundID, msg) RefundFailed
+RecordExternalRefund(...) RefundSucceeded
+GrantsAccess(now, policy) bool
+AccessUntil(policy) *time
+IsCurrent() bool
+PopEvents() []Event
}
class Refund {
<<entity>>
-id uuid
-providerRefundID *string
-amount Money
-reason RefundReason
-status RefundStatus
-failureMessage *string
-createdAt time
}
class BillingAccount {
<<aggregate root>>
-workspaceID uuid
-providerCustomerID string
-payerID string
-payerEmail string
-payerName string
-payerMissingSince *time
+OpenBillingAccount(...) BillingAccountOpened
+ChangePayer(userID, email, name, actor) PayerChanged
+MarkPayerMissing(now, actor) PayerMissing
+UpdateContact(email, name, actor) BillingContactUpdated
+PopEvents() []Event
}
class Price {
<<aggregate root>>
-id uuid
-plan PlanTier
-interval Interval
-amount Money
-providerPriceID string
-lookupKey string
-active bool
-createdAt time
-archivedAt *time
+NewPrice(...) PriceCreated
+Archive(actor) PriceArchived
+PopEvents() []Event
}
class Plan {
<<aggregate root>>
-tier PlanTier
-features map~FeatureKey,bool~
-limits map~LimitKey,int64~
-updatedAt time
+Update(features, limits, actor) PlanUpdated
+Feature(FeatureKey) bool
+Limit(LimitKey) int64
+PopEvents() []Event
}
class BillingPolicy {
<<aggregate root, singleton>>
-pastDueGraceDays int
-lifetimeRefundWindowDays int
-portalConfigurationID *string
+Update(...) BillingPolicyUpdated
+SetPortalConfiguration(id) PortalConfigurationChanged
+PopEvents() []Event
}
class Money {
<<value>>
amount int64
currency string
}
class RefundQuote {
<<value>>
amount Money
paymentRef string
unusedSeconds int64
periodSeconds int64
}
class ProviderSnapshot {
<<value>>
providerSubscriptionID string
status Status
priceLookupKey string
currentPeriodStart time
currentPeriodEnd time
cancelAtPeriodEnd bool
canceledAt *time
}
class Entitlements {
<<value>>
workspaceID uuid
plan PlanTier
status Status
accessUntil *time
features map~FeatureKey,bool~
limits map~LimitKey,int64~
}
class EntitlementResolver {
<<domain service>>
+Resolve(sub *Subscription, plans, policy, now) Entitlements
}
class PlanTier { <<enum>> free plus pro }
class Interval { <<enum>> month year lifetime }
class Status { <<enum>> incomplete active past_due unpaid canceled incomplete_expired }
class RefundReason { <<enum>> cancel_now lifetime_window replaced_by_lifetime workspace_deleted duplicate_checkout external }
class RefundStatus { <<enum>> pending succeeded failed canceled }
class FeatureKey { <<enum>> auto_commit workspace_graph }
class LimitKey { <<enum>> members published_notes note_graph_max_depth auto_commit_min_interval_seconds revision_history_days trash_retention_days ai_credits_monthly }
Subscription *-- Refund
Subscription --> Price : priceID
Subscription ..> RefundQuote
Subscription ..> ProviderSnapshot
BillingAccount "1" -- "0..*" Subscription : workspaceID
Price --> Plan : tier
EntitlementResolver ..> Subscription
EntitlementResolver ..> Plan
EntitlementResolver ..> BillingPolicy
EntitlementResolver ..> Entitlements
Every mutating method emits an event (BaseEvent{ID, OccurredAt, AggregateID, UserID};
UserID = acting user or the billing service account 1003).
| Aggregate | Events |
|---|---|
| Subscription | SubscriptionCreated, SubscriptionActivated, SubscriptionRenewed, SubscriptionPastDue, SubscriptionPlanChanged, SubscriptionCancelScheduled, SubscriptionCancelResumed, SubscriptionCanceledNow, SubscriptionEnded, RefundRequested, RefundSucceeded, RefundFailed |
| BillingAccount | BillingAccountOpened, PayerChanged, PayerMissing, BillingContactUpdated |
| Price | PriceCreated, PriceArchived |
| Plan | PlanUpdated |
| BillingPolicy | BillingPolicyUpdated, PortalConfigurationChanged |
ApplySnapshot diffs old vs new state and emits only the events that apply.
Access (GrantsAccess):
interval = lifetimeandstatus = active→ true,AccessUntil = nil.status = active→ true (also whilecancelAtPeriodEnd; Stripe sendscustomer.subscription.deletedat period end).status = past_dueandnow < currentPeriodEnd + pastDueGraceDays→ true.- otherwise false → resolver returns the
freeplan withstatuspreserved for UI.
Why no local expiry: at period end Stripe creates the renewal invoice, finalizes it about 1 h
later, then charges; failures move to past_due and Smart Retries continue for up to ~2 weeks.
Our stored period end always passes before the renewal webhook arrives.
Refund quote (QuoteCancelNow):
- recurring: latest paid invoice payment →
amount × unusedSeconds / periodSeconds, floored (VND integer).unusedSeconds = max(0, currentPeriodEnd − now). - lifetime: full
amountifnow − paidAt ≤ lifetimeRefundWindowDays, else errorrefundWindowExpired(cancel-now rejected). - Invariant: sum of non-failed refunds ≤ amount paid.
One current subscription per workspace: current = status not in
(canceled, incomplete_expired). History rows are kept; re-subscribe creates a new row.
Payer must be a workspace owner. Lifetime subscriptions never charge again, so a missing payer has no effect on them.
// domain
type SubscriptionRepo interface {
GetByID(ctx context.Context, id uuid.UUID, forUpdate bool) (*Subscription, error)
GetCurrentByWorkspace(ctx context.Context, workspaceID uuid.UUID, forUpdate bool) (*Subscription, error)
GetByProviderSubscriptionID(ctx context.Context, id string, forUpdate bool) (*Subscription, error)
GetByProviderRefundID(ctx context.Context, id string, forUpdate bool) (*Subscription, error)
ListCurrentByWorkspaces(ctx context.Context, workspaceIDs []uuid.UUID) ([]*Subscription, error)
ListNeedingReconcile(ctx context.Context, now time.Time, limit int) ([]*Subscription, error)
Save(ctx context.Context, s *Subscription) error // upserts refunds too
}
type BillingAccountRepo interface {
GetByWorkspace(ctx context.Context, workspaceID uuid.UUID, forUpdate bool) (*BillingAccount, error)
ListByPayer(ctx context.Context, payerID string) ([]*BillingAccount, error)
Save(ctx context.Context, a *BillingAccount) error
}
type PriceRepo interface {
GetByID(ctx context.Context, id uuid.UUID, forUpdate bool) (*Price, error)
GetByProviderPriceID(ctx context.Context, id string) (*Price, error)
GetActive(ctx context.Context, plan PlanTier, interval Interval, currency string) (*Price, error)
ListActive(ctx context.Context) ([]*Price, error)
Save(ctx context.Context, p *Price) error
}
type PlanRepo interface {
Get(ctx context.Context, tier PlanTier, forUpdate bool) (*Plan, error)
List(ctx context.Context) ([]*Plan, error)
Save(ctx context.Context, p *Plan) error
}
type BillingPolicyRepo interface {
Get(ctx context.Context, forUpdate bool) (*BillingPolicy, error)
Save(ctx context.Context, p *BillingPolicy) error
}
type RepoRegistry interface {
Subscription() SubscriptionRepo
BillingAccount() BillingAccountRepo
Price() PriceRepo
Plan() PlanRepo
BillingPolicy() BillingPolicyRepo
}
type UnitOfWork interface {
Execute(ctx context.Context, fn func(RepoRegistry) error) error
}// app ports
type PaymentProvider interface {
CreateCustomer(ctx context.Context, in CreateCustomerInput) (customerID string, err error)
UpdateCustomerContact(ctx context.Context, customerID, email, name string) error
CreateCheckoutSession(ctx context.Context, in CheckoutInput) (url string, err error) // subscribe | lifetime | payer_change
CreatePortalSession(ctx context.Context, customerID, configurationID, returnURL string) (url string, err error)
GetSubscriptionSnapshot(ctx context.Context, providerSubscriptionID string) (domain.ProviderSnapshot, error)
GetCheckoutSession(ctx context.Context, id string) (CheckoutSessionResult, error)
CancelSubscriptionNow(ctx context.Context, providerSubscriptionID string) error
LatestPayment(ctx context.Context, providerSubscriptionID string) (PaymentInfo, error) // amount, paymentRef, period
CreateRefund(ctx context.Context, refundID uuid.UUID, paymentRef string, amount domain.Money) (providerRefundID string, err error)
SetDefaultPaymentMethod(ctx context.Context, customerID, paymentMethodID string, providerSubscriptionID *string) error
DetachPaymentMethodsExcept(ctx context.Context, customerID, keepPaymentMethodID string) error
DetachAllPaymentMethods(ctx context.Context, customerID string) error
UpsertProductAndPrice(ctx context.Context, p *domain.Price) (providerPriceID string, err error)
ArchivePrice(ctx context.Context, providerPriceID string) error
UpsertPortalConfiguration(ctx context.Context, existingID *string, prices []*domain.Price) (id string, err error)
ParseWebhook(payload []byte, signature string) (WebhookEvent, error)
}
type ProviderEventInbox interface {
MarkProcessed(ctx context.Context, eventID, eventType string) (firstTime bool, err error)
}
type AuthorizationService interface { // gRPC to authorization
GetWorkspaceRole(ctx context.Context, userID string, workspaceID uuid.UUID) (WorkspaceRole, error)
}WebhookEvent is a provider-neutral struct: {ID, Type, ObjectID, Metadata} with Type one of
our own constants (CheckoutCompleted, SubscriptionChanged, InvoicePaid,
InvoicePaymentFailed, RefundUpdated, Ignored). The Stripe adapter does the mapping.
| File | Trigger | Behaviour |
|---|---|---|
cmd_startcheckout.go |
HTTP | Owner check. Load or open BillingAccount (creates Stripe Customer). Reject 409 subscriptionAlreadyActive if a current subscription exists, unless buying lifetime. Resolve active Price. Create Checkout (subscription or payment). Return URL. |
cmd_startportal.go |
HTTP | Owner check. Requires a BillingAccount. Return Portal URL. |
cmd_cancelnow.go |
HTTP | Owner check. QuoteCancelNow → Stripe cancel (recurring) → CancelNow (adds pending Refund) → save → CreateRefund → MarkRefundSucceeded/Failed from response (webhook confirms later). |
q_cancelnowpreview.go |
HTTP | Returns the RefundQuote. |
cmd_transferpayer.go |
HTTP | Owner check; target must be an owner. Create Checkout mode=setup for the target; target must complete it. |
cmd_claimpayer.go |
HTTP | Owner check; only allowed while payerMissingSince != nil. Checkout mode=setup for caller. |
cmd_completecheckout.go |
webhook checkout.session.completed |
By metadata.purpose: subscribe → fetch snapshot, create Subscription; if another current one exists → cancel new one at Stripe + full refund duplicate_checkout. lifetime → if a current recurring exists, CancelNow(replaced_by_lifetime) it first (prorated refund), then NewLifetimeSubscription. payer_change → set default payment method on customer + subscription, detach other methods, ChangePayer, update Stripe contact, and if the payer was missing: ResumeCancel. |
cmd_syncsubscription.go |
webhook subscription/invoice events, reconcile cron | Fetch snapshot from Stripe (payload ignored → ordering irrelevant) → ApplySnapshot. |
cmd_syncrefund.go |
webhook refund.updated, refund.failed, charge.refunded |
Match by metadata.refund_id or provider refund id → mark succeeded/failed. Unknown refunds (dashboard) → RecordExternalRefund with reason external. |
cmd_handlepayerremoved.go |
Kafka authorization role/member events | If the user is payer of a workspace with a current recurring subscription: MarkPayerMissing, Stripe cancel_at_period_end=true, detach all payment methods. |
cmd_handleworkspacedeleted.go |
Kafka note.workspace.deleted |
Recurring → CancelNow(workspace_deleted) prorated. Lifetime → refund only inside window. |
cmd_createprice.go |
Admin HTTP | New Price + Stripe Price (lookup key transfer) + archive previous active one for same (tier, interval, currency) + update Portal configuration. |
cmd_archiveprice.go |
Admin HTTP | Archive in Stripe + DB, update Portal configuration. |
cmd_updateplan.go |
Admin HTTP | Plan.Update → emits PlanUpdated → integration plan.changed. |
cmd_updatepolicy.go |
Admin HTTP | BillingPolicy.Update. |
cmd_seed.go |
billing seed CLI |
Insert default plans, policy, prices (+ Stripe products/prices), Portal configuration — only what is missing. |
cmd_reconcilesubscriptions.go |
cron hourly | ListNeedingReconcile (current, recurring, and currentPeriodEnd + 1h < now or providerSyncedAt < now − 24h) → SyncSubscription each. |
q_getentitlements.go |
HTTP (any member), gRPC | |
q_batchgetentitlements.go |
gRPC (≤ 500 ids) | |
q_getsubscription.go |
HTTP (owner) | Subscription + billing account (payer, payerMissingSince) + refunds. |
q_listplans.go |
HTTP public, gRPC | Plans with features, limits, active prices. |
q_listprices.go |
Admin HTTP | All prices including archived. |
q_getworkspacepayer.go |
gRPC | {payerId, requiresPayer}; requiresPayer = current recurring subscription exists. |
q_getpolicy.go |
Admin HTTP |
- Route
POST /billing/webhooks/stripe, kept in the generated server (not excluded). Spec declares headerStripe-Signatureand a free-formobjectbody, tagwebhook, no security. - A gin middleware registered for this operation buffers the raw body into the context before the strict handler decodes JSON. The handler reads the raw bytes from the context.
- Traefik: separate router for
/billing/webhooks/stripewithoutjwt-middleware. - Flow: verify signature →
ProviderEventInbox.MarkProcessed(event.id, event.type)(duplicate → 200) → map toWebhookEvent→ dispatch to the use case → 200. Failure → 500 so Stripe retries (inbox row is written in the same transaction as the use case, so a failed use case does not mark the event processed). - Synchronous inside the request.
| Stripe event | Use case |
|---|---|
checkout.session.completed |
CompleteCheckout |
customer.subscription.created, .updated, .deleted |
SyncSubscription |
invoice.paid, invoice.payment_failed |
SyncSubscription (subscription id from invoice) |
refund.updated, refund.failed, charge.refunded |
SyncRefund |
| anything else | ignored, 200 |
| Method & path | Who | Body / result |
|---|---|---|
GET /plans |
any authenticated | plans + features + limits + active prices |
GET /workspaces/{workspaceId}/entitlements |
workspace member | Entitlements |
GET /workspaces/{workspaceId}/subscription |
owner | subscription, payer, refunds; 404-free (null when none) |
POST /workspaces/{workspaceId}/checkout-session |
owner | {priceId} → {url} |
POST /workspaces/{workspaceId}/portal-session |
owner | → {url} |
GET /workspaces/{workspaceId}/subscription/cancel-now/preview |
owner | RefundQuote |
POST /workspaces/{workspaceId}/subscription/cancel-now |
owner | → subscription |
POST /workspaces/{workspaceId}/payer/transfer |
owner | {userId} → {url} (setup checkout for target) |
POST /workspaces/{workspaceId}/payer/claim |
owner | → {url} |
GET /admin/plans / PATCH /admin/plans/{tier} |
admin | {features?, limits?} |
GET /admin/policy / PATCH /admin/policy |
admin | {pastDueGraceDays?, lifetimeRefundWindowDays?} |
GET /admin/prices / POST /admin/prices |
admin | {plan, interval, amount, currency} |
POST /admin/prices/{priceId}/archive |
admin | |
POST /webhooks/stripe |
Stripe (no JWT) | raw body |
Errors use api/common Error. New codes: subscriptionAlreadyActive,
noBillingAccount, refundWindowExpired, payerNotMissing, targetNotOwner,
priceNotActive.
Owner/member checks go through authorization gRPC. Add permissions billing_read (all roles)
and billing_manage (owner) to internal/authorization policy.csv instead of checking role
names.
syntax = "proto3";
package billing;
import "buf/validate/validate.proto";
import "google/protobuf/timestamp.proto";
service BillingService {
rpc GetWorkspaceEntitlements(GetWorkspaceEntitlementsRequest) returns (GetWorkspaceEntitlementsResponse);
rpc BatchGetWorkspaceEntitlements(BatchGetWorkspaceEntitlementsRequest) returns (BatchGetWorkspaceEntitlementsResponse);
rpc GetWorkspacePayer(GetWorkspacePayerRequest) returns (GetWorkspacePayerResponse);
rpc ListPlans(ListPlansRequest) returns (ListPlansResponse);
}
message Entitlements {
string workspace_id = 1;
string plan = 2;
string status = 3;
optional google.protobuf.Timestamp access_until = 4;
map<string, bool> features = 5;
map<string, int64> limits = 6;
}
message GetWorkspaceEntitlementsRequest { string workspace_id = 1 [(buf.validate.field).string.uuid = true]; }
message GetWorkspaceEntitlementsResponse { Entitlements entitlements = 1; }
message BatchGetWorkspaceEntitlementsRequest {
repeated string workspace_ids = 1 [(buf.validate.field).repeated = {min_items: 1, max_items: 500, items: {string: {uuid: true}}}];
}
message BatchGetWorkspaceEntitlementsResponse { map<string, Entitlements> entitlements = 1; }
message GetWorkspacePayerRequest { string workspace_id = 1 [(buf.validate.field).string.uuid = true]; }
message GetWorkspacePayerResponse {
optional string payer_id = 1;
bool requires_payer = 2;
}
message Plan {
string tier = 1;
map<string, bool> features = 2;
map<string, int64> limits = 3;
}
message ListPlansRequest {}
message ListPlansResponse { repeated Plan plans = 1; }Callers check maps with a small helper in each consumer (ent.Feature("auto_commit"),
ent.Limit("members"); missing key → feature false, limit 0).
Schemas in api/share/components/schemas/. Published through the Watermill outbox.
| Topic | Payload | Consumers |
|---|---|---|
events.integration.billing.subscription.changed |
{workspaceId, plan, status, accessUntil?} |
note, document (flush cache for workspace) |
events.integration.billing.plan.changed |
{tier} |
note, document (flush whole cache) |
Billing consumes:
| Topic | Use case |
|---|---|
events.integration.note.workspace.deleted (new, see Part 2) |
HandleWorkspaceDeleted |
events.integration.authorization.user_workspace_role_updated |
HandlePayerRemoved (payer no longer owner) |
events.integration.authorization.workspace_member_removed |
HandlePayerRemoved |
Authentik user deletion is not observed: Authentik emits no event we consume, and
UserDeletedEvent in api/share has no producer. A deleted payer is only caught through the
authorization events above (e.g. when the user is removed from the workspace).
CREATE TYPE plan_tier AS ENUM ('free', 'plus', 'pro');
CREATE TYPE billing_interval AS ENUM ('month', 'year', 'lifetime');
CREATE TYPE subscription_status AS ENUM ('incomplete', 'active', 'past_due', 'unpaid', 'canceled', 'incomplete_expired');
CREATE TYPE refund_reason AS ENUM ('cancel_now', 'lifetime_window', 'replaced_by_lifetime', 'workspace_deleted', 'duplicate_checkout', 'external');
CREATE TYPE refund_status AS ENUM ('pending', 'succeeded', 'failed', 'canceled');
CREATE TABLE plans (
tier plan_tier PRIMARY KEY,
features JSONB NOT NULL,
limits JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE billing_policy (
id SMALLINT PRIMARY KEY CHECK (id = 1),
past_due_grace_days INT NOT NULL,
lifetime_refund_window_days INT NOT NULL,
portal_configuration_id TEXT,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE prices (
id UUID PRIMARY KEY,
plan plan_tier NOT NULL CHECK (plan <> 'free'),
interval billing_interval NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
currency TEXT NOT NULL,
provider_price_id TEXT NOT NULL UNIQUE,
lookup_key TEXT NOT NULL,
active BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
archived_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_prices_active ON prices(plan, interval, currency) WHERE active;
CREATE TABLE billing_accounts (
workspace_id UUID PRIMARY KEY,
provider_customer_id TEXT NOT NULL UNIQUE,
payer_id TEXT NOT NULL,
payer_email TEXT NOT NULL,
payer_name TEXT NOT NULL,
payer_missing_since TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_billing_accounts_payer ON billing_accounts(payer_id);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES billing_accounts(workspace_id),
plan plan_tier NOT NULL,
interval billing_interval NOT NULL,
price_id UUID NOT NULL REFERENCES prices(id),
status subscription_status NOT NULL,
provider_subscription_id TEXT UNIQUE,
provider_payment_id TEXT UNIQUE,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
canceled_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
provider_synced_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CHECK ((interval = 'lifetime') = (provider_subscription_id IS NULL))
);
CREATE UNIQUE INDEX uq_subscriptions_current ON subscriptions(workspace_id)
WHERE status NOT IN ('canceled', 'incomplete_expired');
CREATE INDEX idx_subscriptions_reconcile ON subscriptions(current_period_end, provider_synced_at)
WHERE status NOT IN ('canceled', 'incomplete_expired') AND interval <> 'lifetime';
CREATE TABLE refunds (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
provider_refund_id TEXT UNIQUE,
amount BIGINT NOT NULL CHECK (amount > 0),
currency TEXT NOT NULL,
reason refund_reason NOT NULL,
status refund_status NOT NULL,
failure_message TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE processed_provider_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL
);Plus the Watermill outbox tables (same as note).
| Job | Schedule (Asia/Ho_Chi_Minh) |
Command |
|---|---|---|
| reconcile subscriptions | hourly | ReconcileSubscriptions (batch 100) |
prune processed_provider_events older than 30 days |
daily 04:00 | PruneProviderEvents |
controller/cron/ registers jobs on pkg/cron.Scheduler. Locking: gocron-redis-lock on
billing_redis. // NOTE: move controller/cron into cmd/billing-worker when splitting.
BILLING_HTTP_PORT, BILLING_GRPC_PORT
BILLING_POSTGRES_* BILLING_REDIS_URL BILLING_KAFKA_BROKERS
BILLING_AUTHORIZATION_GRPC_ADDR
BILLING_STRIPE_SECRET_KEY # .env.local
BILLING_STRIPE_WEBHOOK_SECRET # .env.local
BILLING_WEB_BASE_URL # success/cancel/return URLs
BILLING_CURRENCY=vnd
ADMIN_GROUP=notopia-admins
SERVICE_ACCOUNT_USER_ID=1003
Includes pre-existing gaps discovered during design (marked [gap]).
| Where | Change |
|---|---|
pkg/common/http/user.go |
User.Groups []string from X-Forwarded-Groups; RequireGroup(group string) gin.HandlerFunc. |
pkg/cron (new) |
Scheduler wrapping github.com/go-co-op/gocron/v2; NewRedisLocker (via github.com/go-co-op/gocron-redis-lock); NewNoopLocker; default TZ Asia/Ho_Chi_Minh. |
packages/lib-server |
NestJS cron lock helper: RedisLocker (SET key NX PX) and NoopLocker, chosen by config. |
api/common/components/schemas/PaymentRequiredError.yaml |
allOf: [Error] + details (below). |
api/common/components/responses/PaymentRequired.yaml |
402 response referencing it. |
api/share/ |
SubscriptionChangedEvent, PlanChangedEvent, WorkspaceDeletedEvent (note integration), regenerate @notopia-uit/api-share-gen + Go pkg/api/share. |
users.yaml blueprint |
Service accounts document-service (pk 1002), billing-service (pk 1003), same shape as note-service. Group notopia-admins. |
| Each service config | ADMIN_GROUP (default notopia-admins), SERVICE_ACCOUNT_USER_ID. Cron / system commands use this id as actor; no "system" string. |
# PaymentRequiredError.yaml
allOf:
- $ref: ./Error.yaml
- type: object
properties:
details:
type: object
properties:
kind:
type: string
enum: [feature, limit]
key:
type: string
example: members
current:
type: integer
format: int64
max:
type: integer
format: int64
requiredPlan:
type: string
enum: [plus, pro]
required: [kind, key, requiredPlan]Codes: featureNotInPlan, planLimitExceeded. requiredPlan = cheapest tier that unlocks it,
computed from ListPlans.
Billing integration
| Item | Change |
|---|---|
app/svc_entitlement.go + infra/service/billing.go |
Port + gRPC adapter (mirrors authorization). 60 s in-memory cache per workspace; flush on billing.subscription.changed (one workspace) and billing.plan.changed (all). |
controller/event/ |
Consume the two billing topics. |
errs/ |
PlanLimitExceeded, FeatureNotInPlan → 402 in controller/http/err.go; BillingPayerMustTransfer → 409. |
cmd_updateworkspacemembers.go |
members limit when the resulting member count grows past the limit (existing members are never removed). Payer guard: demoting or removing the payer while requiresPayer → 409 billingPayerMustTransfer. |
cmd_leaveworkspace.go |
Payer guard (same). |
cmd_publishnote.go |
published_notes count. |
q_getnotegraph.go |
Clamp Depth to note_graph_max_depth (-1 → no clamp; existing <= 0 means unlimited, so clamp before that rule). |
q_getworkspacegraph.go |
Require workspace_graph. |
cmd_changeworkspaceslug.go |
No gate (custom slug is free). |
api/note/paths/* |
Add one line to gated operations' descriptions (e.g. "Limited by plan limit members.") and 402 response. |
Downgrade policy ("block growth, never delete user data except retention"): members, published notes stay; adding beyond limit is blocked. Graph and search change at read time.
Trash purge cron
app/cmd_purgeexpiredtrash.go— dedicated command, no permission check, actor = note service account. Reuses domainTrashService/Note.PermanentlyDeleteso domain events fire.- Flow:
SELECT DISTINCT workspace_idof root trashed items (trashed_by='purpose') withtrashed_at < now − 1 day→ batches of 500 →BatchGetWorkspaceEntitlements→ per workspace delete root items older thantrash_retention_days(children cascade as today). controller/cron/, daily 03:00,gocron-redis-lockonnote_redis,// NOTE:move tocmd/note-worker.
New gRPC (proto/note/note.proto)
rpc BatchGetNoteWorkspaces(BatchGetNoteWorkspacesRequest) returns (BatchGetNoteWorkspacesResponse);
message BatchGetNoteWorkspacesRequest {
repeated string note_ids = 1 [(buf.validate.field).repeated = {min_items: 1, max_items: 500, items: {string: {uuid: true}}}];
}
message BatchGetNoteWorkspacesResponse { map<string, string> workspace_ids = 1; } // missing = note gone[gap] Integration events never emitted
IntegrationEventNoteDeletedis declared and mapped toevents.integration.note.note.deletedbut no handler produces it. Addapp/e_notedeletedintegration.goon domainNoteDeletedEvent(same pattern ase_notecreatedintegration.go).- No workspace-deleted integration event exists. Add
IntegrationEventWorkspaceDeleted→events.integration.note.workspace.deletedfrom domainWorkspaceDeletedEvent. Needed by billing. - Workspace delete stays soft (
deleted_atonly); notes are not deleted. Permanent delete is TODO.
[gap] Document rows are never created by the app. No insert exists in apps/document;
only internal/notecreateseed/seed.gen.sql inserts documents. updateDataById uses
repo.update, which is a no-op for a missing row, so content of notes created in the app is not
persisted. Fix: updateDataById becomes an upsert (INSERT … ON CONFLICT (id) DO UPDATE).
onAuthenticate already rejects unknown notes.
[gap] modified flag loop. updateDataById hardcodes modified: true. In Hocuspocus v4,
onStoreDocument fires on any change, so the server-side setModified(false) after a
commit triggers a store that flips the column back to true. Fix:
// store: column mirrors Yjs metadata
store: async ({ documentName: id, state, document }) => {
const modified = (document.getMap('metadata') as YDocMetadataMap).get('metadata')?.modified ?? false;
await documentService.upsertData(id, state, modified, this.contributors.drain(id));
},
// onLoadDocument, after fetch: column -> Yjs without re-storing
document.transact(
() => metadata.set('metadata', { modified: entity.modified }),
{ source: 'local', skipStoreHooks: true } satisfies LocalTransactionOrigin,
);Yjs metadata is truth while loaded; the column is truth while unloaded (sweeper commits touch
only the column, next load syncs it). setModified(false) after a commit keeps a normal origin
so the store persists bytes + modified=false together.
[gap] Note deletion not handled. Nothing consumes note.deleted. Add a consumer that
hard-deletes the documents row; revisions cascade via the existing onDelete: 'CASCADE'.
Entities
| Entity | New columns |
|---|---|
DocumentEntity |
lastCommittedAt timestamptz null, updatedAt timestamptz (@UpdateDateColumn), pendingContributorIds text[] default '{}' |
RevisionEntity |
authorId text, trigger enum('manual','auto','sweeper'), contributorIds text[], contentHash text (sha256 of blocks JSON) |
Contributors: onChange with connection set → context.user.id appended to an in-memory
Map<docId, string[]> (moved to the end if present, so the most recent editor is last). store
merges it into pendingContributorIds (SQL dedupe, works across instances). Commit copies it to
RevisionEntity.contributorIds and clears it.
Author: manual → HTTP user; auto / sweeper → last element of pendingContributorIds
(fallback: document service account). lastContext is not used: local-origin changes carry an
empty context, so it is unreliable.
Auto-commit
- New
AutoCommitExtension implements ExtensionwithafterStoreDocument(runs in the samesaveMutexright after the Database extension's store). - If the workspace has
auto_commit:DocumentService.commitIfDue({documentId, trigger: 'auto', force: clientsCount === 0}). commitIfDuetakes thepessimistic_writelock, then requiresmodifiedand (forceornow − lastCommittedAt ≥ auto_commit_min_interval_seconds). IfcontentHashequals the latest revision's hash → only clearmodified. Otherwise save revision, setlastCommittedAt, clearmodified, emitdocument.committed(userId = authorId). After commit,setModified(false)if the document is loaded.- Last client leaving forces a commit, so the final state is always a revision.
- Workspace id for the entitlement check:
NoteService.getWorkspaceByNote(cached alongside entitlements). - Hocuspocus
debounce/maxDebouncefrom config (defaults 2 000 / 10 000). - Manual
POST /documents/{id}/commitstays for every plan (trigger: 'manual'). Free plans keep storing Yjs state; only commit is gated.
Sweeper cron (every 10 min): documents with modified = true and updatedAt < now − 10 min
→ map to workspaces via note BatchGetNoteWorkspaces → billing batch entitlements → commitIfDue
with trigger: 'sweeper', force: true for workspaces with auto_commit.
Revision purge cron (daily 03:30), no workspaceId column on documents:
- billing
ListPlans→minDays = min(revision_history_days ≥ 0). SELECT DISTINCT document_id FROM revisions WHERE created_at < now − minDays.- Batches of 500 → note
BatchGetNoteWorkspaces→ billingBatchGetWorkspaceEntitlements. - Per document: hard delete revisions older than its plan's days, always keeping the latest. Documents missing from the note map are skipped.
Scaling toggle
DOCUMENT_SCALING_ENABLED=false # default: single instance
DOCUMENT_REDIS_URL= # required only when true (zod refine)
false: no Redis; cron locker =NoopLocker.true:@hocuspocus/extension-redis(servername= hostname; Redis blocks concurrent stores across instances), cron locker =RedisLocker.commitIfDue's row lock keeps auto-commit safe across instances.
Other
src/entitlement/gRPC client module to billing (mirrorssrc/authorization/), 60 s cache, Kafka consumers for the two billing topics.src/cron/*.cron.tswith@nestjs/schedule.- Revision list API: optional
triggerfilter; response includesauthorId,trigger,contributorIds. api/document/descriptions: commit/auto-commit note, revision retention note.- Config:
SERVICE_ACCOUNT_USER_ID=1002,ADMIN_GROUP, billing + note gRPC addresses.
- [gap] Consume
events.integration.note.note.deleted→ delete the note from thenotesindex. - Search is not plan-gated (title-only search would need a second Meilisearch index, since tenant tokens can only filter; rejected).
policy.csv:billing_read(owner, editor, viewer),billing_manage(owner).- No other change. Its existing role/member events are consumed by billing.
- Reads
ai_credits_monthlythrough billing gRPC; owns its own usage counter. Not designed here.
- Workspace settings → Billing tab: current plan, status, renewal / access-until, payer, buttons: Upgrade (Checkout), Manage (Portal), Cancel now (shows refund preview), Transfer payer.
- "Claim billing" banner for owners when
payerMissingSinceis set. - Pricing page from
GET /billing/plans, prices shown in VND. - Global 402 handler on the api-gen client → upgrade dialog using
details.requiredPlan. <FeatureGate>readingGET /billing/workspaces/{id}/entitlements.- Downgrade warning (revisions/trash beyond new retention will be purged).
- Editor: "Saved automatically" when
auto_commit, manual Commit button otherwise; revision list collapsesautorevisions. - Admin pages: plans, prices, policy.
deploy/compose/services/billing.yaml: billing service,billing_postgresql,billing_redis.deploy/compose/services/document.yaml: optionaldocument_redis(profile) for scaling mode.- Traefik
dynamic/billing.yaml:/billingwith JWT;/billing/webhooks/stripeand/billing/healthwithout. justfile:billing-seed,stripe-listen.docs/: billing context diagram, webhook sequence, subscription state diagram.
- Gaps first: document upsert +
modifiedfix, notenote.deletedemission + consumers in document and search-worker. - Contracts:
api/billing,proto/billing,api/common402,api/shareevents, note protoBatchGetNoteWorkspaces→nx gen api,nx gen proto. pkg/cron,pkg/common/httpgroups, blueprint service accounts and admin group.- Billing domain + persistence + seed (plans/policy only) + entitlement queries (everything resolves to free).
- Entitlement clients + gates in note and document, all-free → verify nothing breaks.
- Stripe adapter, checkout, webhook, portal, cancel-now, payer flows, reconcile cron.
- Auto-commit, contributors, sweeper, revision purge, trash purge.
- Admin APIs.
- Web.
- Free trial.
- Live payments (Stripe live mode not available for VN businesses; consider PayOS / VNPay / MoMo
behind
PaymentProvider). - Additional currencies (USD rows).
- Permanent deletion of soft-deleted workspaces (and cascading note/document/search cleanup).
- Move crons into
cmd/{svc}-workerdeployables with their own service accounts (e.g.note-worker). - Whether
cmd_publishworkspace.gocounts againstpublished_notes. - Authentik user deletion →
UserDeletedEventemission (only possible once we own a user management UI); billing would then mark the payer missing. - AI service quota enforcement.