Skip to content

Instantly share code, notes, and snippets.

@KevinNitroG
Created October 10, 2026 15:58
Show Gist options
  • Select an option

  • Save KevinNitroG/f04ace7175c07a33b660c252d5706f28 to your computer and use it in GitHub Desktop.

Select an option

Save KevinNitroG/f04ace7175c07a33b660c252d5706f28 to your computer and use it in GitHub Desktop.
Notopia billing spec

Billing & Subscription — Spec

Status: agreed design, ready to implement. Supersedes ./tmp/SUBSCRIPTION_PLAN.md. Written 2026-10-10 on branch kevinnitrog/billing-service. Part 1 is the new billing service. Part 2 lists every change needed in other services, including pre-existing gaps found while designing. Part 3 is build order and TODO.


0. Decision summary

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).

Part 1 — Billing service

1. Placement

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.

2. Stripe usage

2.1 Objects we create (all through code, never the dashboard)

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.

2.2 Portal configuration

  • payment_method_update: enabled
  • invoice_history: enabled
  • customer_update: disabled (email/name managed by us from the payer)
  • subscription_cancel: enabled, mode=at_period_end
  • subscription_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)

2.3 API-version gotchas (verify against v87 while coding)

  • Since 2025-03-31.basil, current_period_start/end live on subscription items (items.data[0].current_period_end), not the subscription.
  • Since basil, invoices expose payments via invoice.payments (InvoicePayment), not invoice.payment_intent. Refund target = the PaymentIntent of the latest paid invoice payment.
  • VND is zero-decimal: amount: 49000 = 49.000₫.

2.4 Local development

  • 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-mock is not used (no webhooks).

3. Plans, features, limits

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.

4. Domain model

4.1 Aggregates

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.

4.2 Class diagram

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
Loading

4.3 Domain events

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.

4.4 Rules

Access (GrantsAccess):

  • interval = lifetime and status = active → true, AccessUntil = nil.
  • status = active → true (also while cancelAtPeriodEnd; Stripe sends customer.subscription.deleted at period end).
  • status = past_due and now < currentPeriodEnd + pastDueGraceDays → true.
  • otherwise false → resolver returns the free plan with status preserved 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 amount if now − paidAt ≤ lifetimeRefundWindowDays, else error refundWindowExpired (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.

5. Repositories and ports

// 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.

6. Use cases

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

7. Webhook

  • Route POST /billing/webhooks/stripe, kept in the generated server (not excluded). Spec declares header Stripe-Signature and a free-form object body, tag webhook, 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/stripe without jwt-middleware.
  • Flow: verify signature → ProviderEventInbox.MarkProcessed(event.id, event.type) (duplicate → 200) → map to WebhookEvent → 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

8. HTTP API (api/billing/, base /billing)

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.

9. gRPC (proto/billing/billing.proto)

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).

10. Integration events

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).

11. Persistence (pgmigration/00001_init.sql)

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).

12. Cron (billing)

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.

13. Config (billing)

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

Part 2 — Changes to other services

Includes pre-existing gaps discovered during design (marked [gap]).

2.1 Shared

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.

2.2 note (internal/note)

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 domain TrashService / Note.PermanentlyDelete so domain events fire.
  • Flow: SELECT DISTINCT workspace_id of root trashed items (trashed_by='purpose') with trashed_at < now − 1 day → batches of 500 → BatchGetWorkspaceEntitlements → per workspace delete root items older than trash_retention_days (children cascade as today).
  • controller/cron/, daily 03:00, gocron-redis-lock on note_redis, // NOTE: move to cmd/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

  • IntegrationEventNoteDeleted is declared and mapped to events.integration.note.note.deleted but no handler produces it. Add app/e_notedeletedintegration.go on domain NoteDeletedEvent (same pattern as e_notecreatedintegration.go).
  • No workspace-deleted integration event exists. Add IntegrationEventWorkspaceDeleted → events.integration.note.workspace.deleted from domain WorkspaceDeletedEvent. Needed by billing.
  • Workspace delete stays soft (deleted_at only); notes are not deleted. Permanent delete is TODO.

2.3 document (apps/document)

[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 Extension with afterStoreDocument (runs in the same saveMutex right after the Database extension's store).
  • If the workspace has auto_commit: DocumentService.commitIfDue({documentId, trigger: 'auto', force: clientsCount === 0}).
  • commitIfDue takes the pessimistic_write lock, then requires modified and (force or now − lastCommittedAt ≥ auto_commit_min_interval_seconds). If contentHash equals the latest revision's hash → only clear modified. Otherwise save revision, set lastCommittedAt, clear modified, emit document.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/maxDebounce from config (defaults 2 000 / 10 000).
  • Manual POST /documents/{id}/commit stays 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:

  1. billing ListPlans → minDays = min(revision_history_days ≥ 0).
  2. SELECT DISTINCT document_id FROM revisions WHERE created_at < now − minDays.
  3. Batches of 500 → note BatchGetNoteWorkspaces → billing BatchGetWorkspaceEntitlements.
  4. 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 (server name = 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 (mirrors src/authorization/), 60 s cache, Kafka consumers for the two billing topics.
  • src/cron/*.cron.ts with @nestjs/schedule.
  • Revision list API: optional trigger filter; response includes authorId, trigger, contributorIds.
  • api/document/ descriptions: commit/auto-commit note, revision retention note.
  • Config: SERVICE_ACCOUNT_USER_ID=1002, ADMIN_GROUP, billing + note gRPC addresses.

2.4 search-worker (apps/search-worker)

  • [gap] Consume events.integration.note.note.deleted → delete the note from the notes index.
  • Search is not plan-gated (title-only search would need a second Meilisearch index, since tenant tokens can only filter; rejected).

2.5 authorization (internal/authorization)

  • policy.csv: billing_read (owner, editor, viewer), billing_manage (owner).
  • No other change. Its existing role/member events are consumed by billing.

2.6 Future AI service

  • Reads ai_credits_monthly through billing gRPC; owns its own usage counter. Not designed here.

2.7 Web (apps/web) — outline only

  • 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 payerMissingSince is 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> reading GET /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 collapses auto revisions.
  • Admin pages: plans, prices, policy.

2.8 Infrastructure

  • deploy/compose/services/billing.yaml: billing service, billing_postgresql, billing_redis.
  • deploy/compose/services/document.yaml: optional document_redis (profile) for scaling mode.
  • Traefik dynamic/billing.yaml: /billing with JWT; /billing/webhooks/stripe and /billing/health without.
  • justfile: billing-seed, stripe-listen.
  • docs/: billing context diagram, webhook sequence, subscription state diagram.

Part 3 — Build order and TODO

Build order

  1. Gaps first: document upsert + modified fix, note note.deleted emission + consumers in document and search-worker.
  2. Contracts: api/billing, proto/billing, api/common 402, api/share events, note proto BatchGetNoteWorkspaces → nx gen api, nx gen proto.
  3. pkg/cron, pkg/common/http groups, blueprint service accounts and admin group.
  4. Billing domain + persistence + seed (plans/policy only) + entitlement queries (everything resolves to free).
  5. Entitlement clients + gates in note and document, all-free → verify nothing breaks.
  6. Stripe adapter, checkout, webhook, portal, cancel-now, payer flows, reconcile cron.
  7. Auto-commit, contributors, sweeper, revision purge, trash purge.
  8. Admin APIs.
  9. Web.

TODO (not designed / out of scope)

  • 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}-worker deployables with their own service accounts (e.g. note-worker).
  • Whether cmd_publishworkspace.go counts against published_notes.
  • Authentik user deletion → UserDeletedEvent emission (only possible once we own a user management UI); billing would then mark the payer missing.
  • AI service quota enforcement.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment