zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Subscription Flow

Overview

Sync features in zudo-text require a Pro subscription. This document covers the authentication, payment, subscription lifecycle, and feature gating architecture.

Authentication

Provider

  • Better Auth mounted in sync-server for email/password identity and sessions

  • RS256 service JWTs for API authentication

  • The opaque seven-day sliding session is persisted by the client; the 15-minute service JWT is cached separately. There is no refresh-token grant. See Better Auth.

Auth Flow

User clicks "Sign In"
    → Client opens the sync-server Better Auth handoff
    → User authenticates with email and password
    → Handoff returns a single-use OTT plus the client's CSRF state
    → Client verifies the OTT and receives the sliding session token
    → Client calls GET /api/auth/token for a 15-minute service JWT
    → Frontend receives AuthState { isAuthenticated: true, user: { ... } }

Auth State

The BackendAPI.auth domain manages authentication state:

interface AuthUser {
  id: string;
  email: string;
  name: string;
  picture?: string;
}

interface AuthState {
  isAuthenticated: boolean;
  user: AuthUser | null;
}

Components subscribe to auth changes via auth.onStateChanged(). The SyncContext gates all sync operations behind isAuthenticated.

Payment

Provider

  • Stripe for subscription billing

  • Stripe Customer Portal for self-service subscription management (update card, cancel, view invoices)

Plans

PlanPriceSync AccessNotes
Free$0NoDefault for all new accounts
ProPaidFullFile Sync + Cloud Sync + real-time WebSocket

Trial

  • 30-day free trial with full Pro access

  • No credit card required to start

  • Trial starts when the user explicitly clicks "Start Trial"

  • Countdown visible in settings when 7 days or fewer remaining

Subscription States

type SubscriptionStatus =
  | "free"
  | "trial"
  | "active"
  | "past_due"
  | "cancelled"
  | "expired";

type SubscriptionPlan = "free" | "pro";
StatusDescriptionSync Access
freeDefault state, no subscriptionNo
trial30-day free trial in progressYes
activePaid subscription, billing currentYes
past_duePayment failed, retry in progressYes (temporary)
cancelledWill expire at end of current billing periodNo
expiredTrial or subscription endedNo

Subscription Info

interface SubscriptionInfo {
  plan: SubscriptionPlan;
  status: SubscriptionStatus;
  trialStartDate: string | null;
  trialEndDate: string | null;
  currentPeriodEnd: string | null;
  cancelAtPeriodEnd: boolean;
}

State Transitions

                        ┌─────────────────────┐
                        │       free           │ ← default for new accounts
                        └──────┬──────┬───────┘
                               │      │
                  Start Trial  │      │  Subscribe directly
                               ▼      ▼
                        ┌──────────┐  ┌──────────┐
                        │  trial   │  │  active   │◀─── payment succeeds
                        └────┬─────┘  └────┬──────┘
                             │             │
              ┌──────────────┤             ├──────────────┐
              │              │             │              │
    Trial expires    Subscribe    Payment fails     Cancels
              │              │             │              │
              ▼              ▼             ▼              ▼
        ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌───────────┐
        │ expired  │  │  active  │  │ past_due │  │ cancelled │
        └──────────┘  └──────────┘  └─────┬────┘  └─────┬─────┘
                                          │              │
                                   Retry succeeds   Period ends
                                          │              │
                                          ▼              ▼
                                    ┌──────────┐   ┌──────────┐
                                    │  active  │   │ expired  │
                                    └──────────┘   └──────────┘

Transition Details

FromToTrigger
freetrialUser clicks "Start Trial"
freeactiveUser subscribes directly (Stripe Checkout)
trialactiveUser subscribes during trial
trialexpired30 days elapsed without subscribing
activepast_dueStripe payment fails (auto-retry begins)
activecancelledUser cancels (remains active until period end)
past_dueactiveRetry payment succeeds
past_dueexpiredAll retry attempts exhausted
cancelledexpiredCurrent billing period ends
expiredactiveUser resubscribes

Feature Gating

Sync Access

Sync features are available only when the subscription status grants access:

function canAccessSync(info: SubscriptionInfo): boolean {
  return info.isDeveloperAccount === true ||
    info.status === "trial" || info.status === "active" || info.status === "past_due";
}

Publish Access

Publishing supports both Free and Pro accounts. The server resolves a capability snapshot with resolveCapabilities in workers/publish-server/src/handlers/capabilities.ts; client capability checks only guide the UI.

Active Pro subscriptions and Pro trials with a valid end date within the three-day grace window receive Pro capabilities. Recognized expired, cancelled, past-due, and Free subscription states receive Free capabilities. Missing or malformed subscription records return 403; database failures return 503. Free publishing includes four reading themes, three picture patterns, two palettes, and required attribution. Pro unlocks additional design choices and branding controls.

An account whose users.sub matches publish-server's DEV_ACCOUNT_SUBS receives Pro capabilities. This secret is separate from sync-server's, so keep the two lists in sync. The server checks each requested design change against its capabilities, while allowing an unchanged, previously saved Pro design after a downgrade.

UI Behavior by Status

StatusSync SettingsSync OperationsUI Indicators
freeHidden (shows benefits dialog)Blocked"Upgrade to Pro" prompt
trialVisibleEnabledTrial countdown (days remaining)
activeVisibleEnabled"Pro" badge
past_dueVisibleEnabled"Payment issue" warning
cancelledVisible (read-only)Disabled"Expires on (date)" notice
expiredVisible (read-only)Blocked"Resubscribe" prompt

Benefits Dialog

When a free user attempts to access sync settings, a benefits dialog is shown instead of the sync configuration panel. The dialog highlights:

  • Cross-device sync with E2E encryption

  • Real-time collaboration via WebSocket

  • Offline support with automatic queue

  • 30-day free trial, no credit card required

Trial Warning

When the trial has 7 or fewer days remaining, a warning banner appears in the sync settings section showing the remaining days and a link to subscribe.

Graceful Degradation

When a subscription expires:

  • All local files are preserved — nothing is deleted

  • Sync is disabled but previously synced data remains intact

  • The user can still read, edit, and manage all their local content

  • Re-subscribing immediately restores sync with the existing workspace

Server-Side Integration

Better Auth identity mapping

Better Auth mints sub: better_auth|<user.id>. Protected application routes resolve that exact subject against the existing users table, which remains authoritative for subscriptions and workspace ownership. A missing mapping fails closed rather than silently provisioning access. After the handoff establishes the Better Auth session, GET /api/auth/token returns the short-lived service JWT used by the subscription routes.

Stripe Webhook

The server listens for Stripe webhook events to update subscription status:

Stripe EventAction
customer.subscription.createdSet status to active
customer.subscription.updatedUpdate status, period dates
customer.subscription.deletedSet status to expired
invoice.payment_failedSet status to past_due
customer.subscription.trial_will_endSend trial ending notification

Customer Portal

The GET /subscription/portal-url endpoint generates a Stripe Customer Portal session URL where users can:

  • Update payment method

  • View billing history

  • Cancel or resubscribe

  • Download invoices