ZTS Docs

Payments (Polar)

How payments and subscriptions are handled using Polar.

This document outlines the current approach to handling payments and subscriptions within the application, focusing on the primary integration with Polar.

Philosophy & Primary Provider: Polar

Currently, the main integrated payment provider is Polar. It offers a developer-focused platform that simplifies accepting payments and managing subscriptions.

Key Advantages of Polar:

  • Merchant of Record: Polar acts as the Merchant of Record. They handle global sales tax, VAT, and compliance complexities. You receive consolidated payouts minus their fee, avoiding the need to manage tax collection yourself (unlike Stripe).
  • Simplified Subscription Logic (Benefits): Polar uses "Benefits." Instead of checking complex subscription statuses, you grant a "Benefit" (e.g., "Pro Access") upon purchase. Your app logic only checks if the user has this Benefit. Polar automatically manages granting/revoking based on subscription status, refunds, etc., simplifying access control.
    • Checking Benefit Status: The application checks if a user has the required benefit (e.g., the "Pro" benefit) using the polar.getBillingState tRPC procedure. This procedure fetches the user's granted benefits from Polar and compares them against the POLAR_BENEFIT_PRO_ID_* environment variables.
    • Benefit IDs: You define these Benefits in your Polar dashboard and configure their unique IDs in the .env file (POLAR_BENEFIT_PRO_ID_SANDBOX and POLAR_BENEFIT_PRO_ID_PROD).
    • Automatic Revocation: Polar automatically revokes benefits if a subscription expires, is cancelled, or refunded, ensuring your application logic remains simple.
  • Integrated Customer Portal: Polar provides a customer portal integrated with Better Auth (/api/auth/portal). Users manage subscriptions, plans, and payment methods on a Polar-hosted page.
  • Fast Setup: Integration typically takes only minutes by configuring environment variables.

Future Providers (Stripe)

Integration with Stripe might be added later, but the current focus is Polar.

Important: Enable Polar Early!

If you plan to charge users, enable the Polar integration and configure environment variables before users sign up.

  • createCustomerOnSignUp Hook: Better Auth (src/server/auth/index.ts) automatically creates a Polar customer record on signup if Polar is enabled. This links the user in your app to Polar.
  • Linking Purchases: Future purchases are automatically associated with this Polar customer.
  • Problem with Late Setup: If a user signs up before Polar is enabled, no Polar customer record is created. Later purchases for this user will fail because there's no associated Polar customer.
  • Current Mitigation: While migrating existing users might be possible later, the current flow depends on customer creation at signup.

Recommendation: Configure Polar from the start if you anticipate charging users to avoid issues.

Sandbox vs. Production Environments

Polar provides two distinct environments:

  • Production: The live environment (https://polar.sh/).
  • Sandbox: A testing environment (https://sandbox.polar.sh/) that mirrors production functionality but uses test data.

Development Recommendation: Use Sandbox

It is highly recommended to use the Sandbox environment during local development.

  • Isolation: Sandbox has its own separate projects, products, benefits, customers, and transactions. Actions in sandbox do not affect your live production data.
  • Testing: You can safely test the entire checkout flow using fake card numbers (e.g., 4242 repeated) provided by Polar/Stripe without incurring real charges.

Environment Switching (Local Development)

The application allows you to switch between Polar environments locally using the NEXT_PUBLIC_POLAR_ENV environment variable (sandbox or production).

⚠️ Important Caveat: While you can test the production environment locally, be aware of how customer linking works. When a user signs up, their polarCustomerId (stored in your database via the better-auth plugin) is tied to the environment active at the time of signup.

  • Example: If a user signs up while NEXT_PUBLIC_POLAR_ENV=sandbox, their polarCustomerId points to a sandbox customer. If you then switch NEXT_PUBLIC_POLAR_ENV to production locally and that same user tries to purchase, the checkout will likely fail because the stored polarCustomerId (from sandbox) does not exist in Polar's production environment.
  • Recommendation: For consistent testing, stick to the sandbox environment locally. If you need to test production flows, consider using separate user accounts for production testing or utilize discount codes (see below).

Deployment: Production vs. Staging Environments

When deploying your application:

  • For your live, user-facing production deployment: It is strongly recommended to set NEXT_PUBLIC_POLAR_ENV=production and use your corresponding Production Access Token and Benefit ID.
  • For staging or testing deployments: You can technically use NEXT_PUBLIC_POLAR_ENV=sandbox with your Sandbox keys. This allows you to test against the sandbox environment in a deployed setting. The application does not enforce the environment based on deployment status.

Important: Regardless of the environment used in deployment (production or sandbox for staging), ensure you are using the matching set of keys (Access Token, Benefit ID) for that environment (production or sandbox) in your environment variables.

Obtaining Keys & IDs

You need to obtain separate keys and IDs for both the Sandbox and Production environments from their respective dashboards (sandbox.polar.sh and polar.sh).

  • Benefit ID (POLAR_BENEFIT_PRO_ID_SANDBOX / POLAR_BENEFIT_PRO_ID_PROD):
    1. Go to the "Benefits" section in the relevant Polar dashboard.
    2. Create a new Benefit (e.g., name it "Pro Access").
    3. Copy the generated Benefit ID.
    4. Paste it into the corresponding variable in your .env file.
  • Access Token (POLAR_ACCESS_TOKEN_SANDBOX / POLAR_ACCESS_TOKEN_PROD):
    1. Go to Settings -> Developer section in the relevant Polar dashboard.
    2. Click "New Token".
    3. Give it a descriptive name.
    4. Select the necessary scopes. For simplicity, you can often select all scopes, but review them to ensure you grant only necessary permissions.
    5. Copy the generated token.
    6. Paste it into the corresponding variable in your .env file. Keep this token secret!

Testing Production Checkouts

To test the production checkout flow without making real payments:

  1. Go to your Production Polar dashboard.
  2. Create a Discount code that provides a 100% discount.
  3. Use this discount code during the checkout process in your application (while configured with production keys).

Webhooks: Optional

Webhooks are generally optional for the core functionality in this template.

  • Benefit System: Access control relies primarily on checking the user's granted "Benefits" via the Polar API (polar.getBillingState), not webhooks.
  • Use Cases: You might implement webhooks for advanced custom automation based on specific payment events (e.g., sending a custom email on subscription renewal, storing customers in the database, etc).

Technical Integration Details

Setup & Configuration

  1. Environment Variables: Defined in src/env/schemas/polar.ts and set in .env.

    • Core: NEXT_PUBLIC_ENABLE_POLAR, NEXT_PUBLIC_POLAR_ENV, POLAR_ACCESS_TOKEN_*, POLAR_WEBHOOK_SECRET_*, POLAR_BENEFIT_PRO_ID_*.
    • Optional: POLAR_CREATE_CUSTOMER_ON_SIGNUP, POLAR_ENABLE_CUSTOMER_PORTAL, POLAR_ENABLE_CHECKOUT.
  2. Better Auth Plugin (src/server/auth/index.ts): The @polar-sh/better-auth plugin is added if Polar is enabled.

    • Initializes the @polar-sh/sdk client.
    • Handles customer creation, customer portal route (/api/auth/portal), checkout flow, webhook handling (basic), and the state endpoint (/api/auth/state).
    • Uses product IDs defined in src/lib/payment-utils.ts (getPlansForPolarPlugin).
    • Redirects to /checkout-success?checkout_id={CHECKOUT_ID} on success.

Fetching Billing State (tRPC)

  • Router (src/server/api/routers/polar.ts): Exposes polar.getBillingState.
  • getBillingState Procedure: Fetches data from /api/auth/state, parses it (using Zod schemas), checks for the configured "Pro" benefit ID, and returns the state including an isPro flag.

Configuring Payment Plans

The application comes with a default set of payment plans defined in src/config/payment-plans.ts:

  • Pro Monthly: A monthly subscription (pro-monthly).
  • Pro Annual: An annual subscription (pro-annual).
  • Lifetime Access: A one-time payment (lifetime).

You will need to configure the specific Product IDs from your Polar dashboard for these plans.

  1. Create Products in Polar: Go to your Polar dashboard (both sandbox.polar.sh and polar.sh) and create the corresponding Products for each plan you intend to offer (e.g., a monthly subscription product, an annual subscription product, a one-time payment product).

  2. Copy Product IDs: For each product you create in Polar, copy its unique Product ID.

  3. Edit src/config/payment-plans.ts: Open this file and paste the corresponding Product IDs into the productIdsSandbox and productIdsProduction objects:

    export const productIdsSandbox: Record<string, string> = {
      "pro-monthly": "prod_sandbox_monthly_id_from_polar", // 👈 Add Sandbox ID
      "pro-annual": "prod_sandbox_annual_id_from_polar", // 👈 Add Sandbox ID
      lifetime: "prod_sandbox_lifetime_id_from_polar", // 👈 Add Sandbox ID
    };
     
    export const productIdsProduction: Record<string, string> = {
      "pro-monthly": "prod_live_monthly_id_from_polar", // 👈 Add Production ID
      "pro-annual": "prod_live_annual_id_from_polar", // 👈 Add Production ID
      lifetime: "prod_live_lifetime_id_from_polar", // 👈 Add Production ID
    };

Why Edit the File Directly?

Product IDs are configured directly in the code rather than via environment variables because:

  • Flexibility: Most applications will have unique pricing structures (e.g., different tiers like Team, Enterprise, variations). Managing potentially many Product IDs via environment variables would be cumbersome.
  • Clarity: Defining the plan structure and linking IDs directly in the code keeps the configuration clear and co-located with the plan definitions (basePlans).

Feel free to modify the basePlans array and the corresponding Product ID mappings in src/config/payment-plans.ts to match your specific product offerings.