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 subscriptionincomplete- Payment pendingcancelled- Subscription cancelledunpaid- Payment failed
Quota Tracking
Track usage for metered billing.
How it works:
- User performs action (API call, file upload, etc.)
- System increments local quota counter
- Periodically sync usage to Polar meters
- 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:
- Checks organization has active subscription
- Verifies quota available
- Increments usage counter
- 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.createdsubscription.updatedsubscription.canceledcheckout.createdcheckout.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:
- Accumulate usage locally
- Batch send to Polar meters API
- 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/