Back to Bytebase

In-App Purchase for SaaS Workspaces

docs/plans/saas/08.in-app-purchase.md

3.17.133.1 KB
Original Source

In-App Purchase for SaaS Workspaces

Move the purchase workflow from the hub codebase into the Bytebase codebase so SaaS users can purchase licenses directly from their workspace.


Scope

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.


Workflow Overview

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.

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                   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)             │
└──────────────────────────────────┘

Data Model

subscription Table

One row per workspace. All subscription data in payload JSONB to evolve without DDL migrations.

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

protobuf
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;
}

License Storage

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.

Etag

Derived from updated_at (unix millis), not a stored column. Used for optimistic concurrency in UpdatePurchase.


Purchase Flow (New Subscription)

 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:

  1. CreatePurchase is stateless — it only creates a Stripe Checkout Session and returns the URL. No subscription record is written.
  2. The subscription record is created only when the invoice.paid webhook arrives from Stripe.
  3. The frontend polls GetSubscription after redirect, using cache=false to avoid updating the Pinia store until the plan is confirmed (prevents UI flashing FREE → PAID).
  4. CreatePurchase blocks if the workspace already has an ACTIVE or PAUSED subscription.

Update Flow (Plan/Seat Change)

 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:

  1. The backend reuses the existing customer and payment method from the old Stripe subscription.
  2. The old subscription is canceled immediately (with proration), then a new one is created via CreateSubscriptionDirect.
  3. If the direct creation fails (e.g., payment method expired), the backend falls back to creating a Stripe Checkout Session and returns a paymentUrl.
  4. If paymentUrl is empty, the frontend polls until sub.seats matches the requested value.
  5. Webhooks may arrive out of order. The stale event filter in 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:

  • No existing subscription or CANCELED → falls through to createCheckout (same as new purchase).
  • Active subscription but can't fetch old Stripe sub → falls back to createCheckout.
  • Active subscription but no customer ID → falls back to createCheckout.
  • Cancel succeeds but direct create fails → falls back to createCheckout.

Cancel Flow

 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:

IntervalBehaviorRefund
MonthlyImmediate cancellation with prorationProration credit refunded across recent charges (skips refunds < $1)
AnnualCancelAtPeriodEnd = true — subscription stays active until period endNone (user keeps access until expiry)

Refund mechanics (RefundFromSubscription):

  1. Find the draft invoice generated by Stripe's proration.
  2. Sum negative line items (credits) to compute refund amount.
  3. List the customer's recent charges and refund across them (partial refunds if one charge doesn't cover the full amount).

Webhook Processing

Route

POST /hook/stripe/callback — registered in echo_routes.go under the /hook group, alongside SCIM. Only registered when profile.SaaS && profile.StripeWebhookSecret != "".

Signature Verification

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.

Event Routing

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)

Metadata Filtering

Both handlers check metadata["source"] == "bytebase" to ignore Stripe events from unrelated checkout sessions. Events without this source marker are silently skipped.

handleSubscriptionStatusChange

  1. Unmarshal stripego.Subscription from event data.
  2. Filter by source metadata. Extract workspace from metadata.
  3. Map Stripe status → internal status:
    • active, trialing → ACTIVE
    • canceled → CANCELED
    • past_due, unpaid, paused, incomplete, incomplete_expired → PAUSED
  4. Stale event guard: If the stored StripeSubscriptionId differs from the event's subscription ID, ignore the event (prevents late-arriving cancel events from an old subscription from overwriting a newer one).
  5. Upsert subscription payload with new status and Stripe IDs.
  6. On customer.subscription.created: set ExpiresAt from the first item's CurrentPeriodEnd.
  7. Update license: ACTIVE → sign and store JWT; non-ACTIVE → store empty string (reverts to FREE).

handleInvoicePaid

  1. Unmarshal stripego.Invoice. Extract metadata from Lines.Data[0].Metadata.
  2. Build payload from metadata: plan, interval, instance_count, user_count.
  3. Extract Stripe subscription ID from inv.Parent.SubscriptionDetails.Subscription.
  4. Set StartedAt and ExpiresAt from the line item's period.
  5. Upsert with status=ACTIVE.
  6. Generate and store license JWT.

This is the primary event that activates a subscription — it fires after successful payment for both new subscriptions and renewals.


License Lifecycle

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

  1. Check LRU cache (TTL = 1 minute, size = 128).
  2. On miss: singleflight-deduplicated DB read from setting table.
  3. Parse JWT, verify signature, check expiry, map claims to v1pb.Subscription.
  4. Only non-FREE subscriptions are cached (FREE might be a transient failure).

Frontend UX

Page Structure

SettingWorkspaceSubscription.vue is the main settings page. It renders:

  • Plan info header (current plan, expiry, user count) — always shown.
  • SettingWorkspacePurchase.vue — shown in SaaS mode (actuatorStore.isSaaSMode).
  • License upload textarea — shown in self-hosted mode.

Purchase Page States

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 Data Source

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

Post-Checkout Polling

After Stripe redirects back with ?session_id=cs_xxx:

  1. Call VerifyCheckoutSession(session_id) to confirm Stripe reports "complete".
  2. Show pending payment spinner.
  3. Poll GetSubscription every 2 seconds, up to 30 attempts (60 seconds max).
  4. When plan changes from FREE → paid, update the store and dismiss spinner.
  5. Clean up query params (router.replace({ query: {} })) to prevent re-polling on page refresh.

Direct Update Polling

When UpdatePurchase returns empty paymentUrl (direct subscription swap succeeded):

  1. Show pending payment spinner.
  2. Poll GetSubscription with cache=false (does not update Pinia store during polling to avoid UI flashing PAID → FREE → PAID as webhooks process).
  3. Match sub.seats === state.seats to confirm the new subscription is active.
  4. Update store only after confirmation.

API Reference

All purchase APIs require bb.subscription.manage permission (IAM). All are SaaS-only (return Unimplemented for non-SaaS).

RPCHTTPDescription
CreatePurchasePOST /v1/subscription:purchaseCreate Stripe Checkout Session for new subscription
UpdatePurchasePOST /v1/subscription:updatePurchaseChange plan/seats — cancel old sub + create new, or fallback to checkout
CancelPurchasePOST /v1/subscription:cancelPurchaseCancel subscription (monthly=immediate, annual=period end)
GetPaymentInfoGET /v1/subscription/paymentInfoPayment details + Stripe Billing Portal URL
VerifyCheckoutSessionGET /v1/subscription/checkoutSession/{session_id}Check if checkout completed
ListPurchasePlansGET /v1/subscription/plansAvailable plans with pricing (no auth)
GetSubscriptionGET /v1/subscriptionCurrent subscription (no auth)
UploadLicensePUT /v1/subscription/licenseManual JWT upload (self-hosted only)

Pricing

Defined in backend/enterprise/pricing/plan.go:

PlanSelf-ServicePriceInstancesMax Seats
TEAMYes$20/user/month10 (fixed)Unlimited
ENTERPRISENo (contact sales)CustomCustomCustom

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


Configuration

Environment variables (only used when profile.SaaS == true):

VariablePurpose
STRIPE_API_SECRETStripe secret key for API calls
STRIPE_WEBHOOK_SECRETWebhook signing secret for signature verification
BB_LICENSE_PRIVATE_KEYRSA private key (PEM or base64-encoded PEM) for signing license JWTs

Key Differences from Hub

AspectHubBytebase SaaS
License sourceJWT generated by hub, injected via GCP or downloadedJWT signed by backend (private key in env), stored in setting table
Instance pricingPer-instance additionalFixed 10 instances (TEAM)
Payment providersStripe + PaddleStripe only
Grace period+7 days (self-host), +12h (cloud)None needed (same-process read)
Subscription ownerorg entityworkspace directly
Subscription tableSeparate id PK, many top-level columnsworkspace PK, everything in payload JSONB
EtagStored columnDerived from updated_at

Security Considerations

  1. Stripe webhook signature verification — Validates Stripe-Signature header against STRIPE_WEBHOOK_SECRET. Rejects unsigned requests with 400.
  2. SaaS gate — All purchase APIs check profile.SaaS and return Unimplemented for non-SaaS. Stripe routes are only registered when both API key and webhook secret are configured.
  3. IAM permission — All purchase/cancel/payment APIs require bb.subscription.manage permission.
  4. Metadata filtering — Webhook handler checks source == "bytebase" to filter out unrelated Stripe events.
  5. Stale event guard — Subscription status webhooks compare StripeSubscriptionId to prevent late-arriving events from old subscriptions from overwriting newer state.
  6. Etag-based optimistic lockingUpdatePurchase supports etag comparison to detect concurrent modifications.
  7. Private key isolation — License signing key is only loaded in SaaS mode. Self-hosted deployments only have the public key (for verification).