docs/plans/saas/08.in-app-purchase.md
Move the purchase workflow from the hub codebase into the Bytebase codebase so SaaS users can purchase licenses directly from their workspace.
In scope: Stripe checkout, webhooks, subscription CRUD, pricing, license activation — SaaS only.
Out of scope: GCP deployment, Paddle, self-host license download, Auth0, Mailchimp, Slack notifications, cloud workspace provisioning.
Guiding principle: Non-SaaS hides the purchase UI and rejects purchase APIs. The existing UploadLicense (manual JWT upload) remains for self-hosted.
The purchase system has three actors: the frontend (Vue SPA), the backend (gRPC API + Echo webhook handler), and Stripe (payment processor). All subscription state flows through Stripe webhooks — the backend never writes subscription records directly from API calls (except during webhook processing). This ensures Stripe remains the single source of truth for payment state.
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ SettingWorkspaceSubscription.vue │
│ ├── Plan info, expiry, user stats (both modes) │
│ ├── [SaaS] SettingWorkspacePurchase.vue │
│ │ ├── PlanCard (FREE / Pro / Enterprise) │
│ │ ├── Seat counter, billing interval, terms checkbox │
│ │ ├── Subscribe / Update / Cancel buttons │
│ │ └── Pending payment spinner + polling │
│ └── [Self-hosted] License textarea upload │
│ │
│ Pinia Store: subscription.ts │
│ ├── createPurchase(plan, interval, seats) → paymentUrl │
│ ├── updatePurchase(plan, interval, seats, etag) → paymentUrl │
│ ├── cancelPurchase() │
│ ├── verifyCheckoutSession(sessionId) → status │
│ ├── fetchPaymentInfo() → PaymentInfo │
│ └── fetchPurchasePlans() → PurchasePlan[] │
└──────────────┬──────────────────────────────────────────────────┬────────────────────┘
│ gRPC (ConnectRPC) │ HTTP redirect
▼ ▼
┌──────────────────────────────────┐ ┌────────────────────────────────────────┐
│ BACKEND │ │ STRIPE │
│ │ │ │
│ SubscriptionService (gRPC) │────────▶│ Checkout Session │
│ ├── CreatePurchase │ API │ ├── Dynamic PriceData │
│ ├── UpdatePurchase │ calls │ ├── Metadata (workspace, plan, ...) │
│ ├── CancelPurchase │ │ └── Success/Cancel redirect URLs │
│ ├── GetPaymentInfo │ │ │
│ ├── VerifyCheckoutSession │ │ Subscription lifecycle │
│ └── ListPurchasePlans │ │ ├── customer.subscription.created │
│ │ │ ├── customer.subscription.updated │
│ WebhookHandler (Echo) │◀────────│ ├── customer.subscription.deleted │
│ POST /hook/stripe/callback │ webhook │ ├── customer.subscription.paused │
│ ├── Signature verification │ │ ├── customer.subscription.resumed │
│ ├── handleSubscriptionStatus │ │ └── invoice.paid │
│ └── handleInvoicePaid │ │ │
│ │ └────────────────────────────────────────┘
│ Store layer │
│ ├── subscription table (PK=workspace) │
│ └── UpsertSubscription (INSERT ON CONFLICT DO UPDATE) │
│ │
│ LicenseService │
│ ├── CreateLicense (sign JWT) │
│ ├── StoreLicense (setting table)│
│ └── LoadSubscription (JWT → v1pb.Subscription, cached w/ singleflight) │
└──────────────────────────────────┘
subscription TableOne row per workspace. All subscription data in payload JSONB to evolve without DDL migrations.
CREATE TABLE subscription (
workspace text NOT NULL REFERENCES workspace(resource_id) PRIMARY KEY,
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
SubscriptionPayload (proto → JSONB via protojson.Marshal, camelCase keys)message SubscriptionPayload {
enum Status { STATUS_UNSPECIFIED=0; PENDING=1; ACTIVE=2; PAUSED=3; CANCELED=4; }
enum Plan { PLAN_UNSPECIFIED=0; TEAM=1; ENTERPRISE=2; }
enum BillingInterval { BILLING_INTERVAL_UNSPECIFIED=0; MONTH=1; YEAR=2; }
Status status = 1;
google.protobuf.Timestamp started_at = 2;
google.protobuf.Timestamp expires_at = 3;
Plan plan = 4;
BillingInterval interval = 5;
int32 seat = 6;
int32 instance_count = 7;
string stripe_subscription_id = 8;
string stripe_customer_id = 9;
}
The license JWT is stored separately in the setting table (key bb.workspace.license). The webhook handler generates a JWT from the subscription payload and stores it via LicenseService.StoreLicense. LicenseService.LoadSubscription parses the JWT to produce v1pb.Subscription, cached in an LRU with singleflight dedup.
Derived from updated_at (unix millis), not a stored column. Used for optimistic concurrency in UpdatePurchase.
User Frontend Backend Stripe
│ │ │ │
│ Select plan, seats │ │ │
│ Accept terms │ │ │
│ Click "Subscribe" │ │ │
│ ───────────────────────▶│ │ │
│ │ CreatePurchase(plan, │ │
│ │ interval, seats) │ │
│ │ ─────────────────────────▶│ │
│ │ │ Check no active sub │
│ │ │ Validate plan + pricing │
│ │ │ CreateCheckoutSession() │
│ │ │ ─────────────────────────▶│
│ │ │ ◀── session URL ─────────│
│ │ ◀── { paymentUrl } ──────│ │
│ ◀── redirect ──────────│ │ │
│ │ │ │
│ ══════════════════ Stripe Checkout (hosted page) ═══════════════════════════════
│ Enter card, complete │ │ │
│ ───────────────────────────────────────────────────────────────────────────────▶│
│ │ │ │
│ ◀── redirect to /setting/subscription?session_id=cs_xxx ──────────────────────│
│ ───────────────────────▶│ │ │
│ │ VerifyCheckoutSession │ │
│ │ (session_id) │ │
│ │ ─────────────────────────▶│ GetCheckoutSessionStatus │
│ │ │ ─────────────────────────▶│
│ │ │ ◀── "complete" ──────────│
│ │ ◀── "complete" ──────────│ │
│ │ │ │
│ Show spinner │ │ ┌─── Webhook ───────────│
│ "Activating..." │ │ │ invoice.paid │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ Verify signature │
│ │ │ Parse metadata │
│ │ │ UpsertSubscription │
│ │ │ (status=ACTIVE, │
│ │ │ plan, seats, expiry) │
│ │ │ CreateLicense (JWT) │
│ │ │ StoreLicense │
│ │ │ │
│ │ Poll GetSubscription │ │
│ │ (every 2s, max 30x, │ │
│ │ no store update to │ │
│ │ avoid FREE flash) │ │
│ │ ─────────────────────────▶│ │
│ │ ◀── plan=TEAM ───────────│ │
│ │ Update store │ │
│ Show active sub ◀──────│ │ │
Key details:
CreatePurchase is stateless — it only creates a Stripe Checkout Session and returns the URL. No subscription record is written.invoice.paid webhook arrives from Stripe.GetSubscription after redirect, using cache=false to avoid updating the Pinia store until the plan is confirmed (prevents UI flashing FREE → PAID).CreatePurchase blocks if the workspace already has an ACTIVE or PAUSED subscription. User Frontend Backend Stripe
│ │ │ │
│ Change seats │ │ │
│ Click "Update" │ │ │
│ ───────────────────────▶│ │ │
│ │ UpdatePurchase(plan, │ │
│ │ interval, seats, etag) │ │
│ │ ─────────────────────────▶│ │
│ │ │ GetSubscriptionByWorkspace│
│ │ │ (etag check if provided) │
│ │ │ │
│ │ │ ┌─ Has active sub? ──────┐
│ │ │ │ YES │
│ │ │ │ GetSubscription(old) │
│ │ │ │ ─────────────────────▶ │
│ │ │ │ ◀── old sub + PM ──── │
│ │ │ │ │
│ │ │ │ CancelSubscription │
│ │ │ │ (old, prorate=true) │
│ │ │ │ ─────────────────────▶ │
│ │ │ │ │
│ │ │ │ CreateSubscription │
│ │ │ │ Direct(customer, │
│ │ │ │ payment method, │
│ │ │ │ new price) │
│ │ │ │ ─────────────────────▶ │
│ │ │ │ ◀── new sub ──────── │
│ │ │ └───────────────────────┘
│ │ │ │
│ │ ◀── { paymentUrl: "" } ──│ (empty = direct success)│
│ │ │ │
│ Show spinner │ │ ┌─── Webhooks ──────────│
│ "Activating..." │ │ │ sub.deleted (old) │
│ │ │ │ invoice.paid (new) │
│ │ │ ▼ │
│ │ │ Stale event filter: │
│ │ │ old sub.deleted ignored │
│ │ │ if StripeSubscriptionId │
│ │ │ already points to new │
│ │ │ │
│ │ Poll GetSubscription │ │
│ │ (match seats to confirm) │ │
│ │ ─────────────────────────▶│ │
│ │ ◀── updated sub ─────────│ │
│ Show updated sub ◀─────│ │ │
Key details:
CreateSubscriptionDirect.paymentUrl.paymentUrl is empty, the frontend polls until sub.seats matches the requested value.handleSubscriptionStatusChange compares the event's StripeSubscriptionId against the stored one — if they differ, the event is from an old subscription and is ignored.Fallback paths in UpdatePurchase:
createCheckout (same as new purchase).createCheckout.createCheckout.createCheckout. User Frontend Backend Stripe
│ │ │ │
│ Click "Cancel" │ │ │
│ Confirm in dialog │ │ │
│ ───────────────────────▶│ │ │
│ │ CancelPurchase() │ │
│ │ ─────────────────────────▶│ │
│ │ │ GetSubscriptionByWorkspace│
│ │ │ │
│ │ │ ┌─ Monthly? ─────────────┐
│ │ │ │ YES: Cancel immediately│
│ │ │ │ CancelSubscription │
│ │ │ │ (prorate=true) │
│ │ │ │ + RefundFromSub │
│ │ │ │ (proration credit │
│ │ │ │ across charges) │
│ │ │ ├─ Annual? ──────────────┤
│ │ │ │ NO: Cancel at period │
│ │ │ │ end (CancelAtPeriodEnd│
│ │ │ │ = true) │
│ │ │ └───────────────────────┘
│ │ │ ─────────────────────────▶│
│ │ │ │
│ │ ◀── success ─────────────│ │
│ │ fetchSubscription() │ │
│ │ ─────────────────────────▶│ │
│ Show updated plan ◀────│ │ │
│ │ │ │
│ │ │ ┌─── Webhook ───────────│
│ │ │ │ sub.deleted │
│ │ │ ▼ │
│ │ │ UpsertSubscription │
│ │ │ (status=CANCELED) │
│ │ │ StoreLicense("") │
│ │ │ → reverts to FREE │
Cancellation rules:
| Interval | Behavior | Refund |
|---|---|---|
| Monthly | Immediate cancellation with proration | Proration credit refunded across recent charges (skips refunds < $1) |
| Annual | CancelAtPeriodEnd = true — subscription stays active until period end | None (user keeps access until expiry) |
Refund mechanics (RefundFromSubscription):
POST /hook/stripe/callback — registered in echo_routes.go under the /hook group, alongside SCIM. Only registered when profile.SaaS && profile.StripeWebhookSecret != "".
Every incoming request is verified via webhook.ConstructEvent(body, signature, secret) using the Stripe-Signature header against the configured STRIPE_WEBHOOK_SECRET. Invalid signatures return 400.
processEvent(event)
├── customer.subscription.created ─┐
├── customer.subscription.updated │
├── customer.subscription.deleted ├──▶ handleSubscriptionStatusChange
├── customer.subscription.paused │
├── customer.subscription.resumed ─┘
├── invoice.paid ──────────────────────▶ handleInvoicePaid
└── (other) ───────────────────────────▶ ignored (return nil)
Both handlers check metadata["source"] == "bytebase" to ignore Stripe events from unrelated checkout sessions. Events without this source marker are silently skipped.
handleSubscriptionStatusChangestripego.Subscription from event data.source metadata. Extract workspace from metadata.active, trialing → ACTIVEcanceled → CANCELEDpast_due, unpaid, paused, incomplete, incomplete_expired → PAUSEDStripeSubscriptionId differs from the event's subscription ID, ignore the event (prevents late-arriving cancel events from an old subscription from overwriting a newer one).customer.subscription.created: set ExpiresAt from the first item's CurrentPeriodEnd.handleInvoicePaidstripego.Invoice. Extract metadata from Lines.Data[0].Metadata.inv.Parent.SubscriptionDetails.Subscription.StartedAt and ExpiresAt from the line item's period.status=ACTIVE.This is the primary event that activates a subscription — it fires after successful payment for both new subscriptions and renewals.
Stripe webhook arrives
│
▼
handleInvoicePaid / handleSubscriptionStatusChange
│
├── status == ACTIVE
│ │
│ ▼
│ LicenseService.CreateLicense(LicenseParams{
│ Plan, Seats, Instances, WorkspaceID, ExpiresAt
│ })
│ │
│ ▼
│ Sign RS256 JWT (private key, SaaS only)
│ │
│ ▼
│ LicenseService.StoreLicense(ctx, workspace, jwt)
│ ├── Validate JWT (parse + verify signature)
│ ├── UPDATE setting SET value = jwt WHERE workspace = ? AND name = 'bb.workspace.license'
│ └── Invalidate LRU cache for workspace
│
└── status != ACTIVE
│
▼
LicenseService.StoreLicense(ctx, workspace, "")
└── Empty license → LoadSubscription returns FREE plan
LoadSubscription (called on every authenticated request for plan/feature checks):
setting table.v1pb.Subscription.SettingWorkspaceSubscription.vue is the main settings page. It renders:
SettingWorkspacePurchase.vue — shown in SaaS mode (actuatorStore.isSaaSMode).SettingWorkspacePurchase.vue
├── Pending Payment (spinner)
│ └── Shown during polling after Stripe redirect or direct update
│
├── Active Subscription Management
│ ├── Payment info (price, period, invoice link via Stripe Billing Portal)
│ ├── Cancel-pending warning (annual plan with CancelAtPeriodEnd)
│ └── Cancel button (opens confirmation dialog)
│
└── Plan Cards Grid (FREE / Pro / Enterprise)
├── FREE: static, no action for current plan
├── Pro (TEAM): seat counter, terms checkbox, subscribe/update button
│ └── Discount badge if promotion available (e.g., "90% off first month")
└── Enterprise: "Contact Us" button (not self-service)
Plan cards are driven by the ListPurchasePlans API response, not hardcoded. The API returns:
PurchasePlan.selfServicePurchase — whether the plan supports checkout.PurchasePlan.additionals[] — configurable dimensions (USER type: unit price, min/max count).PurchasePlan.billingMethods[] — available intervals with optional discounts.The FREE plan card is always added client-side (not returned by API).
After Stripe redirects back with ?session_id=cs_xxx:
VerifyCheckoutSession(session_id) to confirm Stripe reports "complete".GetSubscription every 2 seconds, up to 30 attempts (60 seconds max).router.replace({ query: {} })) to prevent re-polling on page refresh.When UpdatePurchase returns empty paymentUrl (direct subscription swap succeeded):
GetSubscription with cache=false (does not update Pinia store during polling to avoid UI flashing PAID → FREE → PAID as webhooks process).sub.seats === state.seats to confirm the new subscription is active.All purchase APIs require bb.subscription.manage permission (IAM). All are SaaS-only (return Unimplemented for non-SaaS).
| RPC | HTTP | Description |
|---|---|---|
CreatePurchase | POST /v1/subscription:purchase | Create Stripe Checkout Session for new subscription |
UpdatePurchase | POST /v1/subscription:updatePurchase | Change plan/seats — cancel old sub + create new, or fallback to checkout |
CancelPurchase | POST /v1/subscription:cancelPurchase | Cancel subscription (monthly=immediate, annual=period end) |
GetPaymentInfo | GET /v1/subscription/paymentInfo | Payment details + Stripe Billing Portal URL |
VerifyCheckoutSession | GET /v1/subscription/checkoutSession/{session_id} | Check if checkout completed |
ListPurchasePlans | GET /v1/subscription/plans | Available plans with pricing (no auth) |
GetSubscription | GET /v1/subscription | Current subscription (no auth) |
UploadLicense | PUT /v1/subscription/license | Manual JWT upload (self-hosted only) |
Defined in backend/enterprise/pricing/plan.go:
| Plan | Self-Service | Price | Instances | Max Seats |
|---|---|---|---|---|
| TEAM | Yes | $20/user/month | 10 (fixed) | Unlimited |
| ENTERPRISE | No (contact sales) | Custom | Custom | Custom |
PriceModel.GetPrice() calculates: (seats - freeSeatCount) × pricePerSeat × intervalMonths.
Annual billing multiplies the monthly price by 12 (no annual discount in the price itself; discounts are handled via Stripe promotion codes).
Environment variables (only used when profile.SaaS == true):
| Variable | Purpose |
|---|---|
STRIPE_API_SECRET | Stripe secret key for API calls |
STRIPE_WEBHOOK_SECRET | Webhook signing secret for signature verification |
BB_LICENSE_PRIVATE_KEY | RSA private key (PEM or base64-encoded PEM) for signing license JWTs |
| Aspect | Hub | Bytebase SaaS |
|---|---|---|
| License source | JWT generated by hub, injected via GCP or downloaded | JWT signed by backend (private key in env), stored in setting table |
| Instance pricing | Per-instance additional | Fixed 10 instances (TEAM) |
| Payment providers | Stripe + Paddle | Stripe only |
| Grace period | +7 days (self-host), +12h (cloud) | None needed (same-process read) |
| Subscription owner | org entity | workspace directly |
| Subscription table | Separate id PK, many top-level columns | workspace PK, everything in payload JSONB |
| Etag | Stored column | Derived from updated_at |
Stripe-Signature header against STRIPE_WEBHOOK_SECRET. Rejects unsigned requests with 400.profile.SaaS and return Unimplemented for non-SaaS. Stripe routes are only registered when both API key and webhook secret are configured.bb.subscription.manage permission.source == "bytebase" to filter out unrelated Stripe events.StripeSubscriptionId to prevent late-arriving events from old subscriptions from overwriting newer state.UpdatePurchase supports etag comparison to detect concurrent modifications.