Subscriptions & Billing
How subscription management works with Polar or Stripe Managed Payments
Payment Provider: Polar
You don’t need anything else.
goilerplate uses Polar.sh as the default payment provider because it handles sales tax, invoicing, and compliance automatically. Polar is open source and built by developers, for developers. For 99.9% of SaaS apps, this is all you need.
Why Polar over Stripe? See Philosophy → Why Polar for the full story. TL;DR: both providers now act as merchant of record and handle sales tax. Polar is simpler, cheaper, and developer first.
Alternative: Stripe (with Managed Payments)
Stripe is supported as a first-class alternative. With Stripe Managed Payments, Stripe acts as the merchant of record and handles VAT, GST, and US sales tax across 80+ countries, exactly like Polar. goilerplate enables this automatically when you select the Stripe provider, so you don’t have to deal with Stripe Tax setup, tax registrations, or remittance.
Constraint: Stripe Managed Payments only supports digital products (SaaS, software, e-books, online courses, electronically delivered B2B services). Physical goods, 1:1 coaching, and live events are not supported. For a typical SaaS, this is a non-issue.
Switch in one ENV variable:
# .env
PAYMENT_PROVIDER=stripe # Default: polar
Setup requirements:
- Accept the Managed Payments Terms of Service in your Stripe Dashboard
- Create products & prices in the Stripe Dashboard (assign each a digital-product tax code)
- Activate the Customer Portal (required for subscription management)
- Configure a webhook endpoint pointing to
/webhooks/payment - Set price IDs in
.env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
STRIPE_PRICE_ID_PRO_MONTHLY=price_xxxxx
STRIPE_PRICE_ID_PRO_YEARLY=price_xxxxx
STRIPE_PRICE_ID_ENTERPRISE_MONTHLY=price_xxxxx
STRIPE_PRICE_ID_ENTERPRISE_YEARLY=price_xxxxx
That’s it. Same code, different provider, and tax compliance is taken care of either way.
Subscription System
Every user has exactly one subscription with three tiers:
- Free - 3 goals maximum
- Pro - 25 goals + Export feature
- Enterprise - Unlimited goals + Export + Priority Support
Lifecycle
- Free Tier - Created automatically on registration
- Paid Subscription - User upgrades via Polar checkout, webhook updates database
- Cancellation - Status becomes
"cancelled", but user keeps access untilCurrentPeriodEnd - Expiration - User is downgraded to free tier after period ends
Account Deletion Protection
Users cannot delete their account while they have an active paid subscription or active billing period.
The Logic
Account deletion is blocked if user has a paid plan (not free) AND:
- Subscription status is
"active", OR - Current billing period is still running (even if cancelled)
Example Scenario:
User cancels Pro subscription on Jan 1st, but paid until Jan 31st:
- Status =
"cancelled" CurrentPeriodEnd= Jan 31st- ❌ Account deletion blocked until Feb 1st
- ✅ User keeps Pro access until Jan 31st
This prevents accidental deletion during paid periods and potential revenue/support issues.
User Experience
When attempting to delete account with active subscription:
- Request blocked in
UserService.DeleteAccount() - Toast shown: “Please cancel your subscription before deleting your account”
- User must cancel via billing page and wait for period to end
Feature Gating
goilerplate demonstrates two common SaaS patterns:
1. Limits (Quantitative)
Use GetGoalLimit() to check quantity restrictions:
limit := subscription.GetGoalLimit()
if limit != -1 && count >= limit {
return ErrGoalLimitReached
}
Limits by plan:
- Free: 3 goals
- Pro: 25 goals
- Enterprise: Unlimited goals (-1)
2. Features (Qualitative)
Use HasFeature() to check binary access:
if !subscription.HasFeature(model.FeatureExport) {
// Show upgrade prompt or deny access
}
Features by plan:
- Free: Basic goal tracking only
- Pro: Export goals as JSON
- Enterprise: Export + Priority Support badge
Payment Integration
See Philosophy → Why Polar for why Polar is default.
Webhook Events (Polar)
subscription.created- Upgrade to paid plansubscription.updated- Update subscription detailssubscription.canceled- Mark as cancelled (keep access until period end)subscription.uncanceled- Reactivate subscriptionsubscription.revoked- Immediate downgrade to free
Webhook Events (Stripe)
checkout.session.completed- Store customer IDcustomer.subscription.created- Activate paid plancustomer.subscription.updated- Update subscription detailscustomer.subscription.deleted- Downgrade to freeinvoice.payment_succeeded- Ensure subscription activeinvoice.payment_failed- Log warning (Stripe retries automatically)
Both providers use the same endpoint: /webhooks/payment
Database
CREATE TABLE subscriptions (
user_id TEXT UNIQUE NOT NULL,
plan_id TEXT DEFAULT 'free',
status TEXT DEFAULT 'active',
provider TEXT NOT NULL DEFAULT 'polar',
provider_customer_id TEXT,
provider_subscription_id TEXT,
current_period_end TIMESTAMP,
amount INTEGER,
currency TEXT,
interval TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
The provider column tracks which payment service is used (polar or stripe). This enables:
- Easy provider switching per user
- Support for migrations between providers
- Clean separation of payment provider logic
Subscriptions are automatically deleted when user is deleted (CASCADE).