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:

  1. Accept the Managed Payments Terms of Service in your Stripe Dashboard
  2. Create products & prices in the Stripe Dashboard (assign each a digital-product tax code)
  3. Activate the Customer Portal (required for subscription management)
  4. Configure a webhook endpoint pointing to /webhooks/payment
  5. 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

  1. Free Tier - Created automatically on registration
  2. Paid Subscription - User upgrades via Polar checkout, webhook updates database
  3. Cancellation - Status becomes "cancelled", but user keeps access until CurrentPeriodEnd
  4. 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:

  1. Request blocked in UserService.DeleteAccount()
  2. Toast shown: “Please cancel your subscription before deleting your account”
  3. 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 plan
  • subscription.updated - Update subscription details
  • subscription.canceled - Mark as cancelled (keep access until period end)
  • subscription.uncanceled - Reactivate subscription
  • subscription.revoked - Immediate downgrade to free

Webhook Events (Stripe)

  • checkout.session.completed - Store customer ID
  • customer.subscription.created - Activate paid plan
  • customer.subscription.updated - Update subscription details
  • customer.subscription.deleted - Downgrade to free
  • invoice.payment_succeeded - Ensure subscription active
  • invoice.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).