DEV Community

AmazingSly
AmazingSly Subscriber

Posted on

Polar.sh + BetterAuth for Organizations

Recently, I came across a challenge integrating Polar.sh with BetterAuth for organization-based subscription.

The Problem

Polar customer email is unique, and if the user manages multiple organizations, you can not have each organization as a customer on Polar since you would need a unique email address for each.

If you have a user who manages multiple organizations within your app, those organizations will have to share the same user customer email, and the BetterAuth plugin from Polar does not have a workaround as it stands.

The Solution

The best workaround I found as I was building my SaaS Idea Validation app Venturate.

Before we start

If you face any challenges and would like some assistance, DM me on X - https://x.com/amazing_sly

Make sure to have the BetterAuth plugin installed yarn add @polar-sh/better-auth and also install the Polar SDK yarn add @polar-sh/sdk

Better Auth + Polar + Orgs

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { organization } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "@/prisma/prisma";
import { Polar } from "@polar-sh/sdk";
import { polar } from "@polar-sh/better-auth";

export const polarClient = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: "sandbox",
});

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  plugins: [
    polar({
      client: polarClient,
      createCustomerOnSignUp: true, // IMPORTANT: Make sure this is enabled.
      enableCustomerPortal: false, // There is no need for this, we will create our own
      checkout: {
        enabled: false, // This is important since we're going to implement a custom checkout flow
        products: [], // Required for some reasons I dont understand, but leave it empty for now
      },
      /**
       * Webhook configuration for handling Polar subscription events.
       * @property {string} secret - The webhook secret from Polar for verifying webhook authenticity.
       * @property {Function} onPayload - Async handler for processing webhook events.
       * @param {Object} params - The webhook payload parameters
       * @param {Object} params.data - The subscription event data from Polar

       * @param {string} params.data.metadata.org - Organization ID from the metadata
       * @param {string} params.type - Type of subscription event. Can be one of:
       *   - 'subscription.created' - New subscription created
       *   - 'subscription.active' - Subscription became active
       *   - 'subscription.canceled' - Subscription was canceled
       *   - 'subscription.revoked' - Subscription access was revoked
       *   - 'subscription.uncanceled' - Subscription cancellation was reversed
       *   - 'subscription.updated' - Subscription details were updated
       * @throws {Error} Throws error if organization cannot be found
       */
      webhooks: {
        secret: process.env.POLAR_WEBHOOK_SECRET!, // We need to enable webhooks on Polar as well
        onPayload: async ({ data, type }) => {
          if (
            type === "subscription.created" ||
            type === "subscription.active" ||
            type === "subscription.canceled" ||
            type === "subscription.revoked" ||
            type === "subscription.uncanceled" ||
            type === "subscription.updated"
          ) {
            const org = await prisma.organization.findUnique({
              where: { id: data.metadata.org as string },
            });
            if (!org) throw new Error("Error, something happened");
            await prisma.subscription.upsert({
              create: {
                status: data.status,
                organisation_id: org?.id,
                subscription_id: data.id,
                product_id: data.productId,
              },
              update: {
                status: data.status,
                organisation_id: org?.id,
                subscription_id: data.id,
                product_id: data.productId,
              },
              where: {
                organisation_id: org.id,
              },
            });
          }
        },
      },
    }),
    organization(), // Make sure you have the Org plugin initialized as well
    nextCookies(), // We need this is using NextJS for better cookie handling
  ],
  //   ... REST OF YOUR CONFIG
});

Enter fullscreen mode Exit fullscreen mode

IMPORTANT

  • Make sure you have enabled Webhooks on Polar
  • Set your Webhooks URL to APP_URL/api/auth/polar/webhooks
  • Allow subscription events on Webhooks

Let's Proceed

We need to create a custom getSession that will return our user details and current organization details as well

export async function getSession() {


  const headersList = await headers();
  const sessionData = await auth.api.getSession({ headers: headersList });

  if (!sessionData?.session) {
    redirect("/auth/sign-in");
  }

  const { session, user } = sessionData;

  const [member, activeOrg] = await Promise.all([
    auth.api.getActiveMember({ headers: headersList }),
    session.activeOrganizationId
      ? prisma.organization.findFirst({
          where: {
            id: session.activeOrganizationId,
          },
        })
      : Promise.resolve(null),
  ]);

  if (!session.activeOrganizationId || !activeOrg || !member) {
    redirect("/switch-org");
  }

  return {
    userId: user.id,
    org: session.activeOrganizationId!,
    email: user.email,
    name: user.name,
    image: user.image,
    role: member.role,
    orgName: activeOrg.name,
    orgDomain: activeOrg.domain,
  };
}
Enter fullscreen mode Exit fullscreen mode

Moving on

Remember the customer portal we disabled from BetterAuth plugin?
We did that because we have to create a custom portal for each organization so anyone who is part of the organization/admins can manage subscriptions, not just the single user who created it.

Generates a customer portal URL for managing subscription settings.

This function creates a session URL that allows customers to access their subscription management portal through Polar. The portal enables users to view and modify their subscription settings, billing information, and more.

export async function generateCustomerURL() {
  // Get the current organization ID from the session
  const { org } = await getSession();

  // Look up the subscription record for this organization
  const subscription = await prisma.subscription.findFirst({
    where: {
      organisation_id: org,
    },
  });

  if (!subscription) return null;

  // Fetch the full subscription details from Polar
  const polarSubscription = await polarClient.subscriptions.get({
    id: subscription.subscription_id!, // Assert non-null since we found a subscription
  });

  if (!polarSubscription) return null;

  // Create a new customer portal session and get the access URL
  const portalSession = await polarClient.customerSessions.create({
    customerId: polarSubscription.customerId,
  });

  const url = portalSession.customerPortalUrl;

  return url;
}
Enter fullscreen mode Exit fullscreen mode

Next up

Now we need to configure our custom checkout in our app.
Create a checkout page on app/checkout and add the following.

By default, we're fetching all products from Polar, hence we didn't specify any products when initializing the plugin.

import { polarClient } from "@/lib/auth";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import Subscribe from "./Subscribe";
import { getSession } from "@/actions/account/user";
import { generateCustomerURL } from "@/actions/account/subscription";

export default async function Page() {
  //
  const { org, email } = await getSession();

  const [{ result }, portalUrl] = await Promise.all([
    polarClient.products.list({
      isArchived: false,
    }),
    generateCustomerURL(),
  ]);

  const sortedProducts = [...result.items].sort((a, b) => {
    const priceA = a.prices[0]?.priceAmount || 0;
    const priceB = b.prices[0]?.priceAmount || 0;
    return priceA - priceB;
  });

  return (
    <div>
      <h1>Join Venturate</h1>
      <p>Choose the plan that's right for you</p>

      <div>
        {sortedProducts.map((product) => {
          const price =
            product.prices[0]?.amountType === "fixed"
              ? `$${product.prices[0].priceAmount / 100}/month`
              : product.prices[0]?.amountType === "free"
              ? "Free"
              : "Pay what you want";

          return (
            <div key={product.id}>
              <h3>{product.name}</h3>
              <p>{product.description}</p>
              <div>{price}</div>

              <Subscribe
                product={product.id}
                products={sortedProducts.map((p) => p.id)}
                org={org}
                email={email}
              />
            </div>
          );
        })}
      </div>

      <div>
        {/* IMPORTANT: if we have portal URL, that means we already have an existing subscription, so we can redirect to that. */}
        {portalUrl && (
          <Link href={portalUrl}>
            <Button variant="outline">Manage Subscription</Button>
          </Link>
        )}

        {/* OTHERWISE ALLOW USERS TO SWITCH THE ORGANISATION */}
        <Link href="/switch-org">
          <Button variant="dark">Switch Organisation</Button>
        </Link>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Let's move on

Remember when I said we must disable the BetterAuth plugin for checkout?
We need to have a custom check which will allow us to pass our custom orgID as Metadata.

If you noticed, we have a Checkout component that we are importing in the checkout page, let's create that next.

"use client";
import { Button } from "@/components/ui/button";
import { polarClient } from "@/lib/auth";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { toast } from "react-toastify";

const Subscribe = ({
  product,
  products,
  org,
  email,
}: {
  product: string;
  products: string[];
  org: string;
  email: string;
}) => {
  //
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubscribe = async () => {
    try {
      setLoading(true);
      const result = await polarClient.checkouts.create({
        productId: product,
        products: products,
        productPriceId: "",
        customerEmail: email,
        // We are passing the ORG as metadata since it's required by our Webhook handler we created ealier
        metadata: {
          org: org,
        },
      });

      router.push(result.url);

      setLoading(false);
    } catch (error) {
      toast.error("Failed to create checkout session.");
      setLoading(false);
    }
  };

  return (
    <Button
      className="w-full mb-6"
      variant={isPopular ? "default" : "outline"}
      onClick={handleSubscribe}
      loading={loading}
    >
      Get Started
    </Button>
  );
};

export default Subscribe;
Enter fullscreen mode Exit fullscreen mode

Finally

You can get the subscription status from our database since we're saving that using the Webhooks, and use that to get full subscription details from Polar.

export async function getSubscription() {
  const { org } = await getSession();

  const subscription = await prisma.subscription.findUnique({
    where: {
      organisation_id: org,
    },
  });

  const sub = subscription?.subscription_id
    ? await polarClient.subscriptions.get({
        id: subscription.subscription_id!,
      })
    : await Promise.resolve(null);

  if (!sub || sub.status !== "active") redirect("/checkout");

  return sub;
}

Enter fullscreen mode Exit fullscreen mode

BONUS

I also added feature management to restrict which features an organisation has based on their product id

export async function checkFeatureAccess(feature: Feature): Promise<boolean> {


  enum Feature {
    LiveChat = "livechat",
    AiTools = "ai tools",
    AiAnalysis = "ai analysis",
  }
  const POLAR_STARTER_PRICING = process.env.POLAR_STARTER_PRICING!;
  const POLAR_BUSINESS_PRICING = process.env.POLAR_BUSINESS_PRICING!;
  const POLAR_ENTERPRICE_PRICING = process.env.POLAR_ENTERPRICE_PRICING!;

 const featureAccessConfig: { [productId: string]: Feature[] } = {
  [POLAR_STARTER_PRICING]: [
    // ENUMS OF FEATURES
  ],
  [POLAR_BUSINESS_PRICING]: [
    // ENUMS OF FEATURES
  ],
  [POLAR_ENTERPRICE_PRICING]: [
  // ENUMS OF FEATURES
  ],
};

  const subscription = await getSubscription();
  if (!subscription) {
    return false; // or redirect, but getSubscription already redirects for no active sub
  }

  const allowedFeatures = featureAccessConfig[subscription.productId];

  if (!allowedFeatures) {
    return false; // Default deny if productId not in config
  }

  return allowedFeatures.includes(feature);
}
Enter fullscreen mode Exit fullscreen mode

Enjoy, and remember to follow on X
X - https://x.com/amazing_sly

Code copied from: Venturate

Top comments (0)