Status: Draft Date: 2026-03-24 References: pricing-models-assessment.md
1. Overview
Glyphoxa operates in two deployment modes with distinct billing implications:
- SaaS (Managed): Glyphoxa hosts everything. DMs pay a subscription. Billing system manages subscriptions, enforces limits, and processes payments via Stripe.
- Self-Hosted: User runs Glyphoxa with their own API keys. No subscription required for core features. Optional license key unlocks managed-service features (priority support, hosted knowledge graph, voice cloning).
The billing system gates session creation based on the DMβs subscription tier. It wraps the existing QuotaGuard β Orchestrator chain β no changes to the voice pipeline itself.
Design Principles
- DM pays, players benefit. Only the DM (session owner) needs a subscription. Players join for free.
- Never charge per-message. Session caps are the only consumption limit. Once a session starts, it runs without metering anxiety.
- Hard caps, not soft. When you hit your session limit, the next
/session startis rejected with a clear upgrade prompt. No surprise bills, no overages. - Sessions are sacred. A running session is never interrupted for billing reasons. Caps are checked at session start only.
- Self-hosted is genuinely free. Bring-your-own-keys users get the full voice pipeline. Billing only applies to the managed service.
2. Subscription Tiers
Tier Matrix
| Β | Apprentice (Free) | Adventurer ($9/mo) | Dungeon Master ($19/mo) | Guild ($29/mo) |
|---|---|---|---|---|
| Sessions/month | 2 | 8 | Unlimited | Unlimited |
| Max session length | 2 hours | 4 hours | 8 hours | 8 hours |
| NPCs per campaign | 2 | 10 | Unlimited | Unlimited |
| LLM | Gemini Flash | GPT-4o-mini | GPT-4o | GPT-4o |
| Voice quality | Basic (gTTS/Piper) | Standard (ElevenLabs) | Premium (ElevenLabs HD) | Premium + custom cloning |
| Knowledge graph | No | No | Yes | Yes |
| Player seats | β | β | β | 5 (shared management) |
| Priority support | β | β | β | Yes |
| Annual price | β | $90/yr (2 mo free) | $190/yr (2 mo free) | $290/yr (2 mo free) |
Cost/Margin Analysis
| Tier | Infra cost/session (4h avg) | Sessions/mo | Monthly cost | Price | Margin |
|---|---|---|---|---|---|
| Apprentice | ~$0.80 (Flash) | 2 | ~$1.60 | $0 | -$1.60 (acquisition) |
| Adventurer | ~$2.00 (4o-mini) | 8 | ~$16.00 | $9 | -$7.00 (subsidized) |
| Dungeon Master | ~$6.40 (4o) | ~8 avg | ~$51.20 | $19 | -$32.20 (subsidized) |
| Guild | ~$6.40 (4o) | ~8 avg | ~$51.20 | $29 | -$22.20 (subsidized) |
Note: These margins are negative at current API prices. This is expected for an early-stage product focused on adoption. Mitigation strategies:
- Negotiate volume pricing with providers as usage grows
- Session length caps limit worst-case cost per session
- βUnlimitedβ tiers will have soft abuse detection (e.g. >50 sessions/month triggers review)
- As self-hosted users bring their own keys, the managed service only bears cost for users who want convenience
What Counts as a Session?
A session is counted when:
- A DM invokes
/session startand the gateway creates a session record insessionstable - The session transitions to
SessionActive(worker confirms pipeline is running)
A session is not counted if:
- It fails to start (stays in
SessionPendingand is cleaned up) - It ends within 60 seconds (grace period β accidental starts)
Session length caps are enforced by the gateway via a timer. When the cap is reached, the DM gets a 5-minute warning, then the session ends gracefully (final NPC goodbyes, transcript saved).
NPC Count Enforcement
NPC count is checked at two points:
- NPC creation β Web management API rejects creation if campaign NPC count >= tier limit
- Session start β Gateway validates NPC count in
StartSessionRequest.NPCConfigsagainst tier limit
The NPC store (npcstore) already returns NPCs per campaign. The billing layer adds a count check.
3. Data Model
New Tables
-- Subscription plans (seeded, not user-editable)
CREATE TABLE subscription_plans (
id TEXT PRIMARY KEY, -- 'apprentice', 'adventurer', 'dungeon_master', 'guild'
name TEXT NOT NULL,
price_monthly INTEGER NOT NULL, -- cents (e.g. 900 = $9.00)
price_yearly INTEGER NOT NULL, -- cents
stripe_price_id_monthly TEXT, -- Stripe Price ID for monthly
stripe_price_id_yearly TEXT, -- Stripe Price ID for annual
session_cap INTEGER NOT NULL, -- 0 = unlimited
max_session_hours NUMERIC(4,1) NOT NULL, -- per-session length cap
max_npcs INTEGER NOT NULL, -- 0 = unlimited
llm_tier TEXT NOT NULL, -- 'budget', 'standard', 'premium'
voice_tier TEXT NOT NULL, -- 'basic', 'standard', 'premium'
knowledge_graph BOOLEAN NOT NULL DEFAULT FALSE,
player_seats INTEGER NOT NULL DEFAULT 0,
priority_support BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- One subscription per tenant (1:1 with tenants table)
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::TEXT,
tenant_id TEXT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES subscription_plans(id),
stripe_customer_id TEXT, -- Stripe Customer ID
stripe_subscription_id TEXT, -- Stripe Subscription ID
billing_interval TEXT NOT NULL DEFAULT 'monthly', -- 'monthly' or 'yearly'
status TEXT NOT NULL DEFAULT 'active',
-- active: in good standing
-- trialing: trial period (no card required)
-- past_due: payment failed, in grace period
-- suspended: grace period expired, sessions blocked
-- cancelled: user cancelled, active until period end
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_end TIMESTAMPTZ, -- null if no trial
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
grace_period_end TIMESTAMPTZ, -- set when payment fails
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
-- Session billing events (extends existing usage_records with granular tracking)
CREATE TABLE billing_events (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
session_id TEXT NOT NULL,
event_type TEXT NOT NULL, -- 'session_start', 'session_end'
session_minutes NUMERIC(10,2), -- actual duration
plan_id TEXT NOT NULL, -- plan at time of session
period DATE NOT NULL, -- billing period (1st of month)
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_billing_events_tenant_period ON billing_events(tenant_id, period);
-- Payment history (synced from Stripe webhooks)
CREATE TABLE payment_history (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
stripe_invoice_id TEXT UNIQUE,
stripe_charge_id TEXT,
amount INTEGER NOT NULL, -- cents
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL, -- 'succeeded', 'failed', 'refunded'
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
failure_reason TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_payment_history_tenant ON payment_history(tenant_id);
Tenant Model Extension
The existing tenants table gains no new columns. Instead, billing state lives in the subscriptions table, joined by tenant_id. The existing monthly_session_hours field on tenants becomes a fallback for self-hosted deployments without a subscription record.
// Subscription represents a tenant's billing state.
type Subscription struct {
ID string
TenantID string
PlanID string
StripeCustomerID string
StripeSubscriptionID string
BillingInterval string // "monthly" or "yearly"
Status SubscriptionStatus
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
TrialEnd *time.Time
CancelAtPeriodEnd bool
GracePeriodEnd *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type SubscriptionStatus string
const (
StatusActive SubscriptionStatus = "active"
StatusTrialing SubscriptionStatus = "trialing"
StatusPastDue SubscriptionStatus = "past_due"
StatusSuspended SubscriptionStatus = "suspended"
StatusCancelled SubscriptionStatus = "cancelled"
)
4. Architecture
Component Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Web Management UI β
β (React β subscription management, usage dashboard, etc.) β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β REST API
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Billing API Service β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β
β β Subscription β β Usage β β Stripe Webhook β β
β β Management β β Dashboard β β Handler β β
β ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ β
β β β β β
β βΌ βΌ βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Billing Store (PostgreSQL) β β
β β subscription_plans | subscriptions | billing_events β β
β β payment_history β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββ
βΌ βΌ βΌ
βββββββββββββββ ββββββββββββββββ βββββββββββββ
β Stripe β β Gateway β β Admin β
β (extern) β β (session β β API β
β β β auth gate) β β β
βββββββββββββββ ββββββββββββββββ βββββββββββββ
Session Start Authorization Flow
DM: /session start
β
βΌ
βββββββββββββββββββββββββββββββ
β GatewaySessionController β
β .Start() β
ββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ βββββββββββββββββββββ
β BillingAuthorizer ββββββΆβ SubscriptionCache β
β .ValidateAndCreate() β β (in-memory, TTL) β
β β βββββββββββββββββββββ
β 1. Load subscription β
β 2. Check status: β
β - suspended β REJECT β
β - cancelled β check β
β period_end β
β - past_due β ALLOW β
β (grace period) β
β - active β ALLOW β
β 3. Check plan limits: β
β - session count this β
β period < cap? β
β - NPC count <= max? β
β 4. Validate model/voice β
β tier matches plan β
ββββββββββββ¬βββββββββββββββββββ
β (if allowed)
βΌ
βββββββββββββββββββββββββββββββ
β QuotaGuard β
β .ValidateAndCreate() β
β β
β (existing β checks β
β monthly_session_hours β
β from usage_records) β
ββββββββββββ¬βββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Orchestrator β
β .ValidateAndCreate() β
β β
β (existing β checks license β
β constraints, concurrent β
β session limits) β
ββββββββββββ¬βββββββββββββββββββ
β
βΌ
Session Created
How the Gateway Knows the DMβs Subscription
The gateway resolves subscription status through this chain:
- Discord command arrives β extract
guildIDfrom interaction event - Tenant lookup β
SELECT * FROM tenants WHERE $1 = ANY(guild_ids)(existing) - Subscription lookup β
SELECT * FROM subscriptions WHERE tenant_id = $1(new) - Plan lookup β
SELECT * FROM subscription_plans WHERE id = $1(new, cached)
Caching strategy:
subscription_plansβ cached indefinitely (static seed data, invalidate on deploy)subscriptionsβ cached per-tenant with 5-minute TTL- Cache invalidated immediately on Stripe webhook events
- Cache key:
billing:sub:{tenant_id}
// SubscriptionCache wraps subscription lookups with in-memory TTL cache.
type SubscriptionCache struct {
store BillingStore
cache sync.Map // tenant_id β cachedEntry
ttl time.Duration // 5 minutes
}
type cachedEntry struct {
sub *Subscription
plan *SubscriptionPlan
fetchedAt time.Time
}
func (c *SubscriptionCache) Get(ctx context.Context, tenantID string) (*Subscription, *SubscriptionPlan, error) {
if entry, ok := c.cache.Load(tenantID); ok {
e := entry.(*cachedEntry)
if time.Since(e.fetchedAt) < c.ttl {
return e.sub, e.plan, nil
}
}
// Cache miss or expired β hit DB
sub, err := c.store.GetSubscription(ctx, tenantID)
// ...
plan, err := c.store.GetPlan(ctx, sub.PlanID)
// ...
c.cache.Store(tenantID, &cachedEntry{sub: sub, plan: plan, fetchedAt: time.Now()})
return sub, plan, nil
}
// Invalidate is called from Stripe webhook handler.
func (c *SubscriptionCache) Invalidate(tenantID string) {
c.cache.Delete(tenantID)
}
5. Stripe Integration
Why Stripe
- Industry standard for SaaS subscription billing
- Native support for subscription lifecycle (create, upgrade, downgrade, cancel, retry)
- Webhook-driven β no polling required
- Stripe Checkout for PCI-compliant payment collection (no card data touches our servers)
- Stripe Customer Portal for self-service billing management
- Go SDK:
github.com/stripe/stripe-go/v82
Stripe Object Mapping
| Glyphoxa Concept | Stripe Object |
|---|---|
| DM (tenant owner) | Customer |
| Subscription plan | Product + Price |
| Active subscription | Subscription |
| Monthly payment | Invoice β PaymentIntent |
| Payment method | PaymentMethod (attached to Customer) |
Checkout Flow
ββββββββββββ ββββββββββββββββ ββββββββββββββ ββββββββββββ
β Web UI β β Billing API β β Stripe β β Webhook β
β β β β β β β Handler β
ββββββ¬ββββββ ββββββββ¬ββββββββ βββββββ¬βββββββ ββββββ¬ββββββ
β β β β
β 1. Click β β β
β "Subscribe" β β β
βββββββββββββββββββΆβ β β
β β β β
β β 2. Create Stripe β β
β β Checkout Session β β
β ββββββββββββββββββββΆβ β
β β β β
β β 3. Return β β
β β checkout URL β β
β βββββββββββββββββββββ€ β
β β β β
β 4. Redirect to β β β
β Stripe Checkoutβ β β
ββββββββββββββββββββ€ β β
β β β β
ββββββββββββββββββββββββββββββββββββββββΆβ β
β 5. Customer enters payment β β
βββββββββββββββββββββββββββββββββββββββββ€ β
β 6. Redirect to success URL β β
β β β β
β β β 7. Webhook: β
β β β checkout.session β
β β β .completed β
β β βββββββββββββββββββΆβ
β β β β
β β β 8. Webhook: β
β β β customer β
β β β .subscription β
β β β .created β
β β βββββββββββββββββββΆβ
β β β β
β β 9. Create/update β β
β β subscription β β
β β record β β
β ββββββββββββββββββββββββββββββββββββββββ€
β β β β
β β 10. Invalidate β β
β β cache β β
β β β β
Webhook Events to Handle
| Stripe Event | Action |
|---|---|
checkout.session.completed | Link Stripe Customer to tenant, create subscription record |
customer.subscription.created | Create/update local subscription, set status active |
customer.subscription.updated | Update plan, interval, status, period dates |
customer.subscription.deleted | Set status cancelled |
invoice.paid | Record in payment_history, clear past_due status |
invoice.payment_failed | Set status past_due, set grace_period_end (+7 days) |
customer.subscription.trial_will_end | (Optional) Send DM a reminder via Discord |
Webhook Handler
// StripeWebhookHandler processes Stripe webhook events.
type StripeWebhookHandler struct {
store BillingStore
cache *SubscriptionCache
secret string // Stripe webhook signing secret
logger *slog.Logger
}
func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil { ... }
event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), h.secret)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
switch event.Type {
case "customer.subscription.created", "customer.subscription.updated":
h.handleSubscriptionChange(r.Context(), event)
case "customer.subscription.deleted":
h.handleSubscriptionDeleted(r.Context(), event)
case "invoice.paid":
h.handleInvoicePaid(r.Context(), event)
case "invoice.payment_failed":
h.handlePaymentFailed(r.Context(), event)
}
w.WriteHeader(http.StatusOK)
}
Idempotency
All webhook handlers are idempotent β processing the same event twice produces the same result. This is achieved by:
- Using
stripe_subscription_idas a natural key (UPSERT, not INSERT) - Storing
stripe_invoice_idwith UNIQUE constraint inpayment_history - Checking event timestamps against
updated_atto skip stale events
6. Payment Failure & Grace Period
Timeline
Day 0: Invoice created, payment attempted
ββ Success β invoice.paid β all good
ββ Failure β invoice.payment_failed
ββ Status β past_due
ββ grace_period_end = now + 7 days
ββ Stripe retries automatically (Smart Retries)
Day 1-7: Grace period
ββ Sessions still allowed (past_due permits session start)
ββ Web UI shows banner: "Payment failed β update your card"
ββ DM can update payment method via Stripe Customer Portal
ββ Stripe retries on days 1, 3, 5
Day 7: Grace period expires
ββ If still unpaid β status = suspended
ββ Sessions blocked
ββ Web UI shows: "Subscription suspended β update payment to resume"
Day 30: Final cancellation
ββ If still unpaid β status = cancelled
ββ Stripe subscription deleted
ββ Tenant downgraded to Apprentice (free)
Why 7-Day Grace Period
- TTRPG sessions are often weekly. A 7-day grace ensures the DMβs next session isnβt disrupted by a transient payment failure.
- Stripeβs Smart Retries handle re-attempts automatically.
- The DM sees a warning but isnβt punished immediately.
7. Trial Period
Design
- 14-day trial of Adventurer tier for new signups
- No credit card required during trial
- Trial starts when tenant is created via web management signup
- At trial end:
- If card added β convert to paid Adventurer subscription
- If no card β downgrade to Apprentice (free)
- Trial users get full Adventurer features (8 sessions, standard voices, 10 NPCs)
- One trial per Discord account (tracked by Discord user ID)
Implementation
func (s *BillingService) CreateTrialSubscription(ctx context.Context, tenantID string) error {
now := time.Now()
sub := &Subscription{
TenantID: tenantID,
PlanID: "adventurer",
Status: StatusTrialing,
BillingInterval: "monthly",
CurrentPeriodStart: now,
CurrentPeriodEnd: now.AddDate(0, 0, 14),
TrialEnd: ptr(now.AddDate(0, 0, 14)),
}
return s.store.CreateSubscription(ctx, sub)
}
8. Tier Upgrade / Downgrade
Upgrade (Mid-Cycle)
- Immediate effect. DM gets higher-tier features right away.
- Prorated billing. Stripe calculates the proration automatically.
- Session count resets to 0 for the new tier on the current period.
- Cache invalidated immediately.
Downgrade (End-of-Cycle)
- Takes effect at period end. DM keeps current tier until the billing period expires.
- Set
cancel_at_period_end = falseon old plan (Stripe handles this withproration_behavior: none). - If DM has more NPCs than the new tier allows, they canβt create new ones but existing NPCs are preserved (soft limit, not deletion).
Implementation via Stripe
func (s *BillingService) ChangePlan(ctx context.Context, tenantID, newPlanID string) error {
sub, err := s.store.GetSubscription(ctx, tenantID)
if err != nil { return err }
newPlan, err := s.store.GetPlan(ctx, newPlanID)
if err != nil { return err }
oldPlan, err := s.store.GetPlan(ctx, sub.PlanID)
if err != nil { return err }
isUpgrade := newPlan.PriceMonthly > oldPlan.PriceMonthly
params := &stripe.SubscriptionParams{
Items: []*stripe.SubscriptionItemsParams,
}
if isUpgrade {
params.ProrationBehavior = stripe.String("create_prorations")
} else {
params.ProrationBehavior = stripe.String("none")
// Downgrade applied at period end via webhook
}
_, err = subscription.Update(sub.StripeSubscriptionID, params)
return err
}
9. Usage Metering & Dashboard
Whatβs Tracked
The existing usage_records table already tracks session_hours per tenant per period. The billing layer extends this with the billing_events table for per-session granularity.
| Metric | Source | Storage |
|---|---|---|
| Sessions used this period | Count of billing_events with event_type='session_start' | billing_events |
| Total session hours | Sum of billing_events.session_minutes / 60 | billing_events |
| NPCs active | Count from npcstore per campaign | Live query |
| Current plan | subscriptions.plan_id | subscriptions |
| Payment status | subscriptions.status | subscriptions |
BillingRecorder (Session End Hook)
Wraps the existing RecordingBridge to capture billing-specific data:
type BillingRecorder struct {
inner gateway.GatewayCallback
orch sessionorch.Orchestrator
billing BillingStore
}
func (b *BillingRecorder) ReportState(ctx context.Context, sessionID string, state gateway.SessionState, errMsg string) error {
if state == gateway.SessionEnded {
sess, err := b.orch.GetSession(ctx, sessionID)
if err == nil {
duration := time.Since(sess.StartedAt)
// Don't count sessions < 60 seconds (accidental starts)
if duration >= 60*time.Second {
sub, _ := b.billing.GetSubscription(ctx, sess.TenantID)
planID := "apprentice"
if sub != nil {
planID = sub.PlanID
}
event := BillingEvent{
TenantID: sess.TenantID,
SessionID: sessionID,
EventType: "session_end",
SessionMinutes: duration.Minutes(),
PlanID: planID,
Period: currentPeriod(),
}
b.billing.RecordBillingEvent(ctx, event)
}
}
}
return b.inner.ReportState(ctx, sessionID, state, errMsg)
}
Usage Dashboard API
GET /api/v1/billing/usage?tenant_id={id}&period=2026-03
β {
"plan": "adventurer",
"sessions_used": 5,
"sessions_cap": 8,
"total_hours": 14.5,
"npcs_active": 7,
"npcs_cap": 10,
"period_start": "2026-03-01T00:00:00Z",
"period_end": "2026-03-31T23:59:59Z",
"status": "active",
"sessions": [
{
"id": "sess_abc123",
"started_at": "2026-03-15T19:00:00Z",
"duration_minutes": 185,
"campaign": "Curse of Strahd"
}
]
}
10. Self-Hosted vs SaaS
Deployment Mode Detection
The binary already supports --mode=full|gateway|worker. Self-hosted vs SaaS is orthogonal β itβs determined by a build flag and config:
// config.yaml
deployment:
mode: full # full | gateway | worker
hosting: selfhosted # selfhosted | managed
license_key: "" # optional, for premium self-hosted features
Feature Matrix
| Feature | Self-Hosted (Free) | Self-Hosted (Licensed) | SaaS (Managed) |
|---|---|---|---|
| Voice pipeline (VADβSTTβLLMβTTS) | Yes (own keys) | Yes (own keys) | Yes (included) |
| NPC creation & management | Yes | Yes | Yes |
| Discord bot integration | Yes | Yes | Yes |
| Web management UI | Yes (local) | Yes (local) | Yes (hosted) |
| Knowledge graph | Yes (own PostgreSQL) | Yes | Yes (tier-gated) |
| Custom voice cloning | No | Yes | Guild tier only |
| Priority support | No | Yes | Guild tier only |
| Automatic updates | No | Yes | Yes |
| Multi-tenant gateway | No | Yes | Yes |
| Session analytics | Basic | Full | Full |
| Subscription billing | N/A | N/A | Yes (Stripe) |
License Key System (Self-Hosted Premium)
For self-hosted users who want premium features without the managed service:
// License key is a signed JWT with claims:
type LicenseClaims struct {
TenantID string `json:"tid"`
Features []string `json:"features"` // ["voice_cloning", "priority_support", "analytics"]
ExpiresAt time.Time `json:"exp"`
IssuedAt time.Time `json:"iat"`
}
- Keys are generated by the Glyphoxa admin dashboard
- Validated offline (no phone-home) β public key embedded in binary
- Expiry checked at startup and periodically (daily)
- Grace period: 30 days after expiry before features are disabled
Code-Level Differentiation
// internal/billing/mode.go
type DeploymentMode int
const (
ModeSelfHostedFree DeploymentMode = iota
ModeSelfHostedLicensed
ModeManaged
)
func (m DeploymentMode) RequiresSubscription() bool {
return m == ModeManaged
}
func (m DeploymentMode) HasFeature(feature string) bool {
switch m {
case ModeSelfHostedFree:
return false // Only core features
case ModeSelfHostedLicensed:
return true // License claims checked separately
case ModeManaged:
return true // Plan-gated via subscription
}
return false
}
The BillingAuthorizer checks deployment mode first:
func (a *BillingAuthorizer) ValidateAndCreate(ctx context.Context, req SessionRequest) (string, error) {
if !a.mode.RequiresSubscription() {
// Self-hosted: skip billing checks, delegate to QuotaGuard directly
return a.inner.ValidateAndCreate(ctx, req)
}
// Managed: full subscription + plan validation
sub, plan, err := a.cache.Get(ctx, req.TenantID)
// ...check status, session count, NPC count, model/voice tier...
return a.inner.ValidateAndCreate(ctx, req)
}
11. Billing API Endpoints
Subscription Management
POST /api/v1/billing/checkout
β Create Stripe Checkout session, return URL
Body: { "tenant_id": "...", "plan_id": "adventurer", "interval": "monthly" }
GET /api/v1/billing/subscription?tenant_id={id}
β Current subscription details + plan info
POST /api/v1/billing/subscription/change
β Upgrade or downgrade plan
Body: { "tenant_id": "...", "new_plan_id": "dungeon_master" }
POST /api/v1/billing/subscription/cancel
β Cancel at end of current period
Body: { "tenant_id": "..." }
POST /api/v1/billing/subscription/reactivate
β Undo pending cancellation
Body: { "tenant_id": "..." }
GET /api/v1/billing/portal?tenant_id={id}
β Create Stripe Customer Portal session, return URL
(self-service: update card, view invoices, etc.)
Usage & History
GET /api/v1/billing/usage?tenant_id={id}&period=2026-03
β Session count, hours, NPC count for period
GET /api/v1/billing/payments?tenant_id={id}&limit=10
β Payment history from payment_history table
Stripe Webhook
POST /api/v1/billing/stripe/webhook
β Stripe webhook endpoint (signature-verified)
12. Wiring Into the Gateway
Integration Point: cmd/glyphoxa/main.go
The billing layer inserts between the existing QuotaGuard and the GatewaySessionController. Minimal changes to runGateway():
func runGateway(ctx context.Context, cfg *config.Config) error {
// ... existing setup ...
// Existing orchestrator + quota guard
orch := sessionorch.NewPostgresOrchestrator(db)
usageStore := usage.NewPostgresStore(db)
quotaGuard := usage.NewQuotaGuard(orch, usageStore, tenantQuotaLookup)
// NEW: Billing layer wraps QuotaGuard
var sessionAuth sessionorch.Orchestrator
if cfg.Deployment.Hosting == "managed" {
billingStore := billing.NewPostgresStore(db)
subCache := billing.NewSubscriptionCache(billingStore, 5*time.Minute)
billingAuth := billing.NewBillingAuthorizer(quotaGuard, subCache)
sessionAuth = billingAuth
// Stripe webhook handler
stripeHandler := billing.NewStripeWebhookHandler(billingStore, subCache, cfg.Stripe.WebhookSecret)
mux.Handle("POST /api/v1/billing/stripe/webhook", stripeHandler)
// Billing API
billingAPI := billing.NewAPI(billingStore, subCache, cfg.Stripe.SecretKey)
billingAPI.Register(mux)
} else {
sessionAuth = quotaGuard
}
// NEW: BillingRecorder wraps existing RecordingBridge
var callback gateway.GatewayCallback
recordingBridge := usage.NewRecordingBridge(gatewayCallback, orch, usageStore)
if cfg.Deployment.Hosting == "managed" {
callback = billing.NewBillingRecorder(recordingBridge, orch, billingStore)
} else {
callback = recordingBridge
}
// ... rest of setup uses sessionAuth instead of quotaGuard ...
}
Config Addition
# config.yaml β new billing section
stripe:
secret_key: "${STRIPE_SECRET_KEY}"
webhook_secret: "${STRIPE_WEBHOOK_SECRET}"
publishable_key: "${STRIPE_PUBLISHABLE_KEY}" # passed to frontend
deployment:
hosting: managed # or selfhosted
13. Package Layout
internal/billing/
βββ api.go # HTTP handlers for billing endpoints
βββ authorizer.go # BillingAuthorizer (wraps QuotaGuard)
βββ cache.go # SubscriptionCache (in-memory TTL)
βββ mode.go # DeploymentMode (selfhosted/managed)
βββ models.go # Subscription, SubscriptionPlan, BillingEvent, etc.
βββ recorder.go # BillingRecorder (wraps RecordingBridge)
βββ store.go # BillingStore interface
βββ store_postgres.go # PostgreSQL implementation
βββ stripe_webhook.go # Stripe webhook handler
βββ stripe_checkout.go # Stripe Checkout session creation
βββ migrations/
β βββ 000001_billing.up.sql
β βββ 000001_billing.down.sql
βββ mock/
βββ store.go # Mock for testing
14. Implementation Plan
Phase 1: Foundation (Week 1-2)
- Create
internal/billing/package with models and store interface - Write database migrations (
subscription_plans,subscriptions,billing_events,payment_history) - Implement
BillingStore(PostgreSQL) - Implement
SubscriptionCache - Seed
subscription_planstable with 4 tiers - Write unit tests with mock store
Phase 2: Stripe Integration (Week 2-3)
- Set up Stripe account, create Products + Prices for each tier
- Implement
StripeWebhookHandlerwith event processing - Implement Stripe Checkout session creation
- Implement Stripe Customer Portal integration
- Wire webhook endpoint into gateway HTTP mux
- Test with Stripe CLI (
stripe listen --forward-to)
Phase 3: Authorization Gate (Week 3-4)
- Implement
BillingAuthorizerwrapper - Implement
BillingRecorderwrapper - Wire both into
cmd/glyphoxa/main.gogateway startup - Implement deployment mode detection (
selfhostedvsmanaged) - Integration test: session start β billing check β session created/rejected
- Test grace period and suspension flows
Phase 4: Billing API (Week 4-5)
- Implement billing REST API endpoints
- Usage dashboard endpoint with session history
- Plan change (upgrade/downgrade) endpoint
- Cancellation and reactivation endpoints
- Wire into web management UI (React components)
Phase 5: Trial & Polish (Week 5-6)
- Implement 14-day trial flow
- Add session length enforcement (timer-based caps)
- Add NPC count enforcement in npcstore
- Discord notifications for billing events (payment failed, trial ending)
- License key validation for self-hosted premium
- End-to-end testing across all tiers
15. Error Messages
User-facing errors returned to Discord when /session start is rejected:
| Condition | Discord Response |
|---|---|
| Session cap reached | βYouβve used all {cap} sessions this month. Upgrade your plan at {url} or wait until {period_end}.β |
| Subscription suspended | βYour subscription is suspended due to a payment issue. Update your payment method at {url}.β |
| Subscription cancelled | βYour subscription has been cancelled. Resubscribe at {url} to start sessions.β |
| NPC limit exceeded | βYour plan allows {max} NPCs. Remove some NPCs or upgrade at {url}.β |
| No subscription (managed) | βYou need a Glyphoxa subscription to start sessions. Sign up at {url} β free tier available!β |
| Trial expired | βYour free trial has ended. Subscribe at {url} to keep using Glyphoxa β plans start free!β |
16. Open Questions
-
Session length enforcement UX β Should the 5-minute warning be a DM-only whisper or announced to the voice channel? Lean toward DM-only to avoid breaking immersion.
-
Free tier abuse β Multiple Discord accounts to farm free sessions? Mitigation: tie free tier to Discord account age (>30 days) or require email verification.
-
Group billing (Guild tier) β Do the 5 βplayer seatsβ mean 5 additional DMs who can start sessions, or 5 players who get their own web dashboard? Lean toward 5 DM seats (co-DM model).
-
Session pack add-ons β Should Adventurer users be able to buy extra sessions without upgrading? Could be a nice middle ground β $2 per additional session.
-
Currency β USD only at launch? EUR for European market? Stripe handles multi-currency, but pricing page needs thought.
-
Tax handling β Stripe Tax for automated VAT/sales tax, or handle manually? Stripe Tax recommended for simplicity.