web-production-saas-starter/go-b2b-starter/docs/billing.md
2025-12-16 18:54:41 +04:00

5.4 KiB

Billing Guide

The billing system integrates with Polar.sh for subscription management, usage-based billing, and payment processing with a hybrid sync strategy.

Architecture

Polar.sh: Payment provider for subscriptions and metering Hybrid Sync: Webhooks + on-demand fetching Paywall Middleware: Protects routes based on subscription status Quota Tracking: Usage-based billing with meters

Core Concepts

Subscriptions

Managed in Polar.sh, synced to local database.

Subscription states:

  • active - Valid subscription
  • incomplete - Payment pending
  • cancelled - Subscription cancelled
  • unpaid - Payment failed

Quota Tracking

Track usage for metered billing.

How it works:

  1. User performs action (API call, file upload, etc.)
  2. System increments local quota counter
  3. Periodically sync usage to Polar meters
  4. Polar charges based on usage

Billing Status

Represents organization's billing state:

  • Subscription: Active subscription details
  • Quota Usage: Current usage vs limits
  • Payment Status: Last payment result
  • Metering: Usage meters for billing

Hybrid Sync Strategy

Combines webhooks with on-demand fetching for reliability.

Webhook Path (Real-time)

Polar Event → Webhook → Update Database

Handles:

  • Subscription created/updated/cancelled
  • Payment succeeded/failed
  • Customer created/updated

Lazy Guarding (On-demand)

API Request → Check Subscription → Fetch if stale → Update Database

When used:

  • Webhook delivery failed
  • Data drift detected
  • Initial subscription fetch

Benefits:

  • Self-healing system
  • No critical webhook dependency
  • Always up-to-date data

Paywall Middleware

Protects routes based on subscription requirements.

Basic Usage

router.POST("/premium-feature",
    paywallMiddleware.RequireActiveSubscription(),
    handler.PremiumFeature)

Quota-Based Protection

router.POST("/api-call",
    paywallMiddleware.RequireQuota("api_calls", 1),
    handler.APICall)

What it does:

  1. Checks organization has active subscription
  2. Verifies quota available
  3. Increments usage counter
  4. Returns 402 (Payment Required) if quota exceeded

Feature-Based Protection

router.POST("/advanced-feature",
    paywallMiddleware.RequireFeature("advanced_analytics"),
    handler.AdvancedFeature)

Checks if subscription plan includes specific feature.

Webhook Processing

Polar sends webhooks for billing events.

Webhook Handler

Located in src/api/webhooks/polar_handler.go.

Events handled:

  • subscription.created
  • subscription.updated
  • subscription.canceled
  • checkout.created
  • checkout.updated

Verification

Webhooks are verified using Polar webhook secret:

POLAR_WEBHOOK_SECRET=whsec_xxx

Invalid signatures are rejected.

Usage Tracking

Track resource usage for billing.

Recording Usage

func (s *service) ProcessAction(ctx context.Context, orgID int32) error {
    // Perform action
    result, err := s.doAction(ctx)
    if err != nil {
        return err
    }

    // Record usage
    err = s.billingService.IncrementQuota(ctx, orgID, "actions", 1)
    if err != nil {
        // Log error but don't fail the operation
        log.Error("failed to record usage", zap.Error(err))
    }

    return nil
}

Meter Ingestion

Usage synced to Polar periodically:

  1. Accumulate usage locally
  2. Batch send to Polar meters API
  3. Polar charges based on metered usage

Configured in src/app/billing/app/services/metering_service.go.

Configuration

# Polar.sh
POLAR_ACCESS_TOKEN=polar_xxx
POLAR_WEBHOOK_SECRET=whsec_xxx
POLAR_ORGANIZATION_ID=org_xxx

Common Patterns

Check Subscription Status

func (h *Handler) GetFeature(c *gin.Context) {
    orgID := auth.GetOrganizationID(c)

    status, err := h.billingService.GetBillingStatus(ctx, orgID)
    if err != nil {
        c.JSON(500, gin.H{"error": "failed to get billing status"})
        return
    }

    if status.Subscription == nil || !status.Subscription.IsActive() {
        c.JSON(402, gin.H{"error": "active subscription required"})
        return
    }

    // Proceed with feature
}

Track Usage

func (s *service) ProcessFile(ctx context.Context, orgID int32, file *File) error {
    // Process file
    err := s.processor.Process(file)
    if err != nil {
        return err
    }

    // Record usage
    s.billingService.IncrementQuota(ctx, orgID, "files_processed", 1)

    return nil
}

Handle Payment Failures

func (h *WebhookHandler) HandlePaymentFailed(ctx context.Context, event *Event) error {
    // Update subscription status
    err := h.billingService.UpdateSubscriptionStatus(ctx, event.SubscriptionID, "unpaid")
    if err != nil {
        return err
    }

    // Notify organization
    h.notificationService.SendPaymentFailure(ctx, event.OrganizationID)

    return nil
}

File Locations

Component Path
Billing domain src/app/billing/domain/
Billing service src/app/billing/app/services/
Polar adapter src/app/billing/infra/adapters/polar/
Paywall middleware src/pkg/paywall/
Polar client src/pkg/polar/
Webhook handlers src/api/webhooks/

Next Steps

  • API protection: Use paywall middleware in routes
  • Usage tracking: Implement quota consumption
  • Polar documentation: https://docs.polar.sh/