billing

package module
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: MIT Imports: 21 Imported by: 0

README

kite-mcp-billing

Go Reference

Billing engine + Stripe checkout + tier middleware for the algo2go ecosystem. Provides subscription tier management (Free / Trader / Premium), Stripe Checkout + Customer Portal flows, webhook handling (checkout.session.completed, customer.subscription.updated/deleted), tier-aware MCP middleware for tool gating, and event emission via algo2go/kite-mcp-domain.TierChangedEvent.

Used by Sundeepg98/kite-mcp-server for tier-gated MCP tools (Telegram trading, trailing stops, MF orders, native alerts), Stripe billing flows, and admin tier overrides.

Why a separate module?

Billing is a substantial commercial-tier surface (~6K LOC) that unrelated algo2go projects (broker dashboards, premium analytics, future trading bots) may need independent of kite-mcp-server. Hosting as a module:

  • Centralizes the tier definition + Stripe integration across consumers
  • Lets billing logic + tier semantics version independently
  • Pairs cleanly with algo2go/kite-mcp-domain (Money + TierChangedEvent), kite-mcp-alerts (Store backend), kite-mcp-oauth (admin gating), kite-mcp-logger (structured logging) for the full commercial stack

Closes original Path A.8 halt

This module's promotion was originally halted at Path A.8 (commit 71f17eb in kite-mcp-server) due to a 5+ internal-dep cluster (templates + domain + alerts + users + oauth all in-tree). Path A.9 through A.13 unblocked each cluster member sequentially:

  • Path A.8' (kc/templates @ 1db565a)
  • Path A.10 (kc/domain @ 9ee8212)
  • Path A.11 (kc/alerts @ fd9d9fb)
  • Path A.12 (kc/users @ e96b1c0)
  • Path A.13 (oauth @ 6f2a2b0)

This is Path A.14 — the FINAL step that closes the chain.

Stability promise

v0.x — unstable. Type signatures may evolve as billing patterns mature. Pin v0.1.0 deliberately. v1.0 ships only after the public API is reviewed for stability and at least one external consumer ships against it.

Install

go get github.com/algo2go/kite-mcp-billing@v0.1.0

Public API (selected)

Tiers
  • Tier (int): TierFree, TierTrader, TierPremium
  • TierMonthlyINR(t Tier) domain.Money — pricing
  • TierName(t Tier) string — display name
Store
  • Store — subscription CRUD with *alerts.DB backend
  • NewStore(db *alerts.DB) *Store
  • Store.SetEventDispatcher(d *domain.EventDispatcher) — emits domain.TierChangedEvent on subscription changes
Stripe integration
  • CheckoutSession(ctx, email, tier) (url, error) — initiates Stripe Checkout for tier upgrade
  • CustomerPortal(ctx, email) (url, error) — Stripe billing portal
  • WebhookHandler(...) — verifies + dispatches Stripe webhooks
Middleware
  • TierGate(minTier Tier) mcp.Middleware — gates MCP tools by tier

Dependencies

  • github.com/algo2go/kite-mcp-alerts v0.1.0 — Store backend
  • github.com/algo2go/kite-mcp-domain v0.1.0 — Money + TierChangedEvent
  • github.com/algo2go/kite-mcp-logger v0.1.0 — structured logging
  • github.com/algo2go/kite-mcp-oauth v0.1.0 — admin gating + JWT context
  • github.com/algo2go/kite-mcp-broker, kite-mcp-isttz, kite-mcp-money, kite-mcp-templates, kite-mcp-users v0.1.0 (transitive)
  • github.com/stripe/stripe-go/v82 — Stripe SDK
  • github.com/mark3labs/mcp-go — MCP middleware contract

All algo2go deps are published modules; no upstream replace directives needed.

Reference consumer

Sundeepg98/kite-mcp-server — consumed by:

  • app/wire.go — service wiring
  • app/providers/billing.go — Fx provider for the DI graph
  • mcp/admin/admin_billing_tools.go — admin tier override tools
  • kc/ops/admin_edge_billing_test.go — admin dashboard tests
  • 37 files total reference kc/billing types

License

MIT — see LICENSE.

Authors

Original design: Sundeepg98 (Zerodha Tech). Multi-module promotion (2026-05-10): algo2go contributors.

Documentation

Index

Constants

View Source
const (
	StatusActive   = "active"
	StatusCanceled = "canceled"
	StatusPastDue  = "past_due"
	StatusTrialing = "trialing"
)

Subscription status constants.

Variables

This section is empty.

Functions

func CheckoutHandler

func CheckoutHandler(store *Store, logger *slog.Logger) http.HandlerFunc

CheckoutHandler creates a Stripe Checkout Session and returns the URL. Protected by RequireAuthBrowser. Accepts plan query param (solo_pro/pro/premium).

Reads STRIPE_PRICE_* and EXTERNAL_URL from the environment. Prefer CheckoutHandlerWithConfig when you can pass a Config constructed at app wiring time — it makes tests t.Parallel-safe.

func CheckoutHandlerWithConfig

func CheckoutHandlerWithConfig(store *Store, logger *slog.Logger, cfg Config) http.HandlerFunc

CheckoutHandlerWithConfig is the injected-config variant of CheckoutHandler. Production wiring passes ConfigFromEnv(); tests pass a hand-built Config so they can run with t.Parallel() instead of t.Setenv.

func HasExplicitTier

func HasExplicitTier(toolName string) bool

HasExplicitTier reports whether the given tool name has an explicit entry in the billing tier map. This is exported for cross-package tests that need to verify all tools are mapped without accessing the unexported map.

func Middleware

func Middleware(store *Store, adminEmailFn func(string) string) server.ToolHandlerMiddleware

Middleware returns an MCP tool handler middleware that enforces billing tier requirements. Tools that require a higher tier than the user's current subscription are rejected with an upgrade prompt.

func PortalHandler

func PortalHandler(store *Store, logger *slog.Logger) http.HandlerFunc

PortalHandler redirects the user to their Stripe Customer Portal where they can manage payment methods, view invoices, and cancel their subscription. If the user has no Stripe customer ID, they are redirected to /pricing.

Reads EXTERNAL_URL from the environment. Prefer PortalHandlerWithConfig when you can pass a Config constructed at app wiring time — it makes tests t.Parallel-safe.

func PortalHandlerWithConfig

func PortalHandlerWithConfig(store *Store, logger *slog.Logger, cfg Config) http.HandlerFunc

PortalHandlerWithConfig is the injected-config variant of PortalHandler. Only the Config.ExternalURL field is consulted here; the price IDs are only used by CheckoutHandler.

func TierMonthlyINR

func TierMonthlyINR(t Tier) domain.Money

TierMonthlyINR returns the canonical monthly INR amount for the given tier as a domain.Money. TierFree is the zero Money — callers that want to detect "no paid plan" should use the IsZero() sentinel rather than comparing against a magic float, mirroring Slice 1's UserLimits zero-Money convention.

Unknown tiers fall through to zero Money rather than panicking — a future tier added without a price entry behaves like Free until the table is updated, which is the safer failure mode for audit-event annotation (downstream MRR consumers see "no contribution" rather than a corrupt figure).

func WebhookHandler

func WebhookHandler(store *Store, signingSecret string, logger *slog.Logger, adminUpgrade func(email string)) http.HandlerFunc

WebhookHandler returns an http.HandlerFunc that verifies and processes Stripe webhook events. It handles:

  • checkout.session.completed → create subscription (email + customer mapping)
  • customer.subscription.updated → update tier/status/expiry
  • customer.subscription.deleted → downgrade to Free, mark canceled
  • invoice.payment_failed → mark subscription past_due

Events are checked for idempotency via the webhook_events table before processing. The handler returns 200 immediately; processing is synchronous but fast (no API calls).

Reads STRIPE_PRICE_* from the environment via ConfigFromEnv. Prefer WebhookHandlerWithConfig when you can pass a Config constructed at app wiring time — it makes tests t.Parallel-safe.

func WebhookHandlerWithConfig

func WebhookHandlerWithConfig(store *Store, signingSecret string, logger *slog.Logger, adminUpgrade func(email string), cfg Config) http.HandlerFunc

WebhookHandlerWithConfig is the injected-config variant of WebhookHandler. Production wiring passes ConfigFromEnv(); tests pass a hand-built Config so they can run with t.Parallel() instead of relying on os.Setenv. Mirrors CheckoutHandlerWithConfig (kc/billing/checkout.go:36) — same pattern, same rationale.

Types

type Config

type Config struct {
	// PriceSoloPro is the Stripe price ID for the Solo Pro plan.
	PriceSoloPro string
	// PricePro is the Stripe price ID for the Pro plan (5 users).
	PricePro string
	// PricePremium is the Stripe price ID for the Premium plan (20 users).
	PricePremium string
	// ExternalURL is the public base URL used when constructing Stripe
	// success/cancel/return URLs. Falls back to http://localhost:8080
	// when empty.
	ExternalURL string
}

Config carries the Stripe price IDs and the external URL used by the checkout and portal HTTP handlers. It replaces the former pattern of reading STRIPE_PRICE_* / EXTERNAL_URL via os.Getenv at every request, which forced tests to use t.Setenv and therefore blocked t.Parallel.

Construct a Config once at app wiring time (via ConfigFromEnv) and pass it into CheckoutHandlerWithConfig / PortalHandlerWithConfig. Tests can construct a zero-valued Config or populate only the fields they care about and run with t.Parallel().

func ConfigFromEnv

func ConfigFromEnv() Config

ConfigFromEnv builds a Config from the STRIPE_PRICE_* and EXTERNAL_URL environment variables. Intended for production wiring (called once at startup from app/). Tests should build Config directly.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is a thread-safe in-memory billing store backed by SQLite.

func NewStore

func NewStore(db *alerts.DB, logger *slog.Logger) *Store

NewStore creates a new billing store with SQLite persistence.

Public signature retains *slog.Logger for backward-compat with app/wire.go's call site; the value is wrapped via logport.NewSlog onto loggerPort. The duplicate *slog.Logger field was retired during the SOLID 99→100 deprecation-shim sweep (see commit body).

func (*Store) GetEmailByCustomerID

func (s *Store) GetEmailByCustomerID(customerID string) string

GetEmailByCustomerID returns the email associated with a Stripe customer ID. Returns "" if no mapping exists. This is populated when checkout.session.completed fires.

func (*Store) GetSubscription

func (s *Store) GetSubscription(email string) *Subscription

GetSubscription returns the subscription for an email. Returns nil if not found.

func (*Store) GetTier

func (s *Store) GetTier(email string) Tier

GetTier returns the current billing tier for an email. Returns TierFree if the user has no subscription or the subscription is not active.

func (*Store) GetTierForUser

func (s *Store) GetTierForUser(email string, adminEmailFn func(string) string) Tier

GetTierForUser returns the billing tier for a user, checking both direct subscription and admin parent linkage via adminEmailFn.

func (*Store) InitEventLogTable

func (s *Store) InitEventLogTable() error

InitEventLogTable creates the webhook_events idempotency table.

func (*Store) InitTable

func (s *Store) InitTable() error

InitTable creates the billing table if it does not exist.

func (*Store) IsEventProcessed

func (s *Store) IsEventProcessed(eventID string) bool

IsEventProcessed returns true if the event has already been handled.

func (*Store) LoadFromDB

func (s *Store) LoadFromDB() error

LoadFromDB populates the in-memory store from the database.

monthly_amount is read as a REAL (float64) and reconstructed via domain.NewINR — this is the Slice 4 SQLite boundary: persistence stays primitive, the in-memory representation is Money. COALESCE guards pre-migration rows where the column might not have a value.

func (*Store) MarkEventProcessed

func (s *Store) MarkEventProcessed(eventID, eventType string) error

MarkEventProcessed records that an event has been processed.

func (*Store) SetChangeReason

func (s *Store) SetChangeReason(reason string)

SetChangeReason labels the next TierChangedEvent with the supplied reason string ("stripe_checkout", "stripe_subscription_updated", "stripe_subscription_deleted", "admin_set_billing_tier"). The reason is consumed by the next SetSubscription call and cleared afterwards, so each call site must opt in by pairing this with its mutation. Concurrency-safe (held under store lock) but not transactional — if two callers race, the second overrides the first; given that SetSubscription is per-email and tier changes are infrequent, this is acceptable for an audit-log-only signal.

func (*Store) SetEventDispatcher

func (s *Store) SetEventDispatcher(d *domain.EventDispatcher)

SetEventDispatcher attaches a domain event dispatcher to the store. Once set, SetSubscription emits TierChangedEvent on every effective tier transition. Pattern mirrors usecases.CreateAlertUseCase / SessionService — the store stays usable without a dispatcher (older tests, in-memory wirings) and gains domain-event emission once wired.

func (*Store) SetSubscription

func (s *Store) SetSubscription(sub *Subscription) error

SetSubscription creates or updates a subscription for the given email. On every successful write where the **effective** tier has changed (computed via effectiveTierOf below — applies the same status/expiry gating as GetTier), the store dispatches a domain.TierChangedEvent describing the transition. Identical-tier writes (idempotent webhook replays, status-only updates that don't shift the access tier) are silent so the audit log only records real state changes.

type Subscription

type Subscription struct {
	AdminEmail       string       `json:"admin_email"`
	Tier             Tier         `json:"tier"`
	StripeCustomerID string       `json:"stripe_customer_id,omitempty"`
	StripeSubID      string       `json:"stripe_sub_id,omitempty"`
	Status           string       `json:"status"`
	ExpiresAt        time.Time    `json:"expires_at,omitempty"`
	UpdatedAt        time.Time    `json:"updated_at"`
	MaxUsers         int          `json:"max_users"`
	MonthlyAmount    domain.Money `json:"monthly_amount"`
}

Subscription holds a user's billing subscription details.

MonthlyAmount carries the per-month rupee amount for the active plan as a domain.Money value (Slice 4 of the Money VO sweep). The zero Money is the "no paid plan / Free" sentinel — IsZero() returns true for free or unset subscriptions; paid tiers carry the canonical TierMonthlyINR(Tier) amount unless an explicit override is supplied (enterprise contract, custom price). Stripe remains the source of truth for what the user is actually charged; MonthlyAmount is the in-process mirror used to annotate TierChangedEvent and to render the dashboard plan card.

type Tier

type Tier int

Tier represents a billing subscription level.

const (
	TierFree    Tier = 0
	TierPro     Tier = 1
	TierPremium Tier = 2
	TierSoloPro Tier = 3 // Solo Pro: same tool access as Pro, max_users=1
)

func RequiredTier

func RequiredTier(toolName string) Tier

RequiredTier returns the minimum billing tier needed to invoke the named tool. Unknown tools default to TierFree (fail open).

func (Tier) EffectiveTier

func (t Tier) EffectiveTier() Tier

EffectiveTier returns the tier used for tool-access comparisons. TierSoloPro grants the same tool access as TierPro (the difference is max_users, not feature gates), so it maps down to TierPro here.

func (Tier) String

func (t Tier) String() string

String returns the human-readable name of the tier.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL