diff --git a/README.md b/README.md
index af5e8d4..b7f52d3 100644
--- a/README.md
+++ b/README.md
@@ -56,12 +56,12 @@ While the Chargebee [docs](https://chargebee.com/docs) and [apidocs](https://api
| Topic | Status | Last Updated |
|-------|--------|--------------|
-| [Integrating Chargebee webhooks](./how-to/webhooks.md) | In Review | |
-| [Enforcing entitlement checks](./how-to/entitlement-checks.md) | In Review | |
-| [Sync Chargebee entities](./how-to/sync-chargebee-entities.md) | Draft | |
-| [Usage-based billing](./how-to/usage-based-billing.md) | Draft | |
-| [Usage alerts](./how-to/usage-alerts.md) | In Progress | |
-| [Checkout experience](./how-to/checkout-experience.md) | In Progress | |
+| [Integrating Chargebee webhooks](./how-to/webhooks.md) | Pending Review | |
+| [Enforcing entitlement checks](./how-to/entitlement-checks.md) | Pending Review | |
+| [Sync Chargebee entities](./how-to/sync-chargebee-entities.md) | Pending Review | |
+| [Usage-based billing](./how-to/usage-based-billing.md) | Pending Review | |
+| [Usage alerts](./how-to/usage-alerts.md) | Pending Review | |
+| [Checkout experience](./how-to/checkout-experience.md) | Pending Review | |
## Reference App
diff --git a/pointer/ARCHITECTURE.md b/pointer/ARCHITECTURE.md
index ee3d9ab..7ed2ec2 100644
--- a/pointer/ARCHITECTURE.md
+++ b/pointer/ARCHITECTURE.md
@@ -64,7 +64,7 @@ flowchart TB
The web and API service is responsible for:
- **Authentication and accounts**: Better Auth with the organization plugin. Personal accounts bill against the individual user; team accounts bill against the organization.
-- **Chargebee provisioning**: On user creation, a Chargebee customer record is created (or reused), its ID is recorded on the `user` table, and an idempotent free subscription is provisioned with `billing_cycles: 0`. The home page waits for the initial subscription webhook before rendering, using free-tier defaults while the queued entitlement snapshot completes.
+- **Chargebee provisioning**: On user creation, a Chargebee customer record is created (or reused), its ID is recorded on the `user` table, and an idempotent free subscription is provisioned with `billing_cycles: 0`. The home page does not wait for the subscription webhook: it pulls the subscription and its entitlements from the Chargebee API and mirrors them, falling back to polling for the webhook if that fails.
- **Webhook ingress**: The Chargebee webhook endpoint verifies HTTP Basic Auth, parses payloads, and immediately pushes them to AWS SQS. It executes no database operations, preventing webhook traffic spikes from exhausting connection pools or slowing HTTP requests.
- **Live event stream**: Publishes domain events to a Redis stream, exposed via Server-Sent Events (SSE) for the `/admin/flow` diagnostic visualization.
diff --git a/pointer/app/(auth)/sign-up/page.tsx b/pointer/app/(auth)/sign-up/page.tsx
index 6b4f00f..fa27dd3 100644
--- a/pointer/app/(auth)/sign-up/page.tsx
+++ b/pointer/app/(auth)/sign-up/page.tsx
@@ -40,8 +40,8 @@ function SignUpForm() {
const password = String(formData.get("password") ?? "");
// Email verification is disabled, so Better Auth signs the user in
- // immediately. The home page waits for the automatically-created free
- // subscription webhook and entitlement mirror before rendering.
+ // immediately. The home page pulls the automatically-created free
+ // subscription and its entitlements from Chargebee before rendering.
const { error: signUpError } = await authClient.signUp.email({
name,
email,
diff --git a/pointer/app/page.tsx b/pointer/app/page.tsx
index 5d6a350..56572ba 100644
--- a/pointer/app/page.tsx
+++ b/pointer/app/page.tsx
@@ -3,11 +3,12 @@ import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
-import { auth } from "@/lib/auth";
+import { auth, type Session } from "@/lib/auth";
import {
findSelfServicePlan,
selfServicePlans,
} from "@/lib/self-service-plans";
+import { syncSignupBilling } from "@/lib/signup-sync";
import { getActiveUserSubscription } from "@/lib/subscriptions";
import { AccountProvisioning } from "./_components/account-provisioning";
@@ -19,10 +20,8 @@ export default async function Home({ searchParams }: PageProps<"/">) {
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session) return ;
- // Signing up provisions a free subscription in the background, so a brand
- // new account lands here before its local record exists.
const { provisioning, plan } = await searchParams;
- const subscription = await getActiveUserSubscription(session.user.id);
+ const subscription = await loadSubscription(session, provisioning);
const requestedPlan = findSelfServicePlan(plan);
// A paid plan picked on the pricing page can only go to checkout once that
@@ -43,6 +42,24 @@ export default async function Home({ searchParams }: PageProps<"/">) {
return ;
}
+/**
+ * A brand-new account lands here before the subscription webhook mirrors its
+ * free plan. Pull it from Chargebee instead; the provisioning page only
+ * covers a failed pull.
+ */
+async function loadSubscription(
+ session: Session,
+ provisioning: string | string[] | undefined,
+) {
+ const subscription = await getActiveUserSubscription(session.user.id);
+ if (subscription || provisioning !== "1") {
+ return subscription;
+ }
+
+ await syncSignupBilling(session);
+ return getActiveUserSubscription(session.user.id);
+}
+
function SignedInHome() {
return (
diff --git a/pointer/lib/entitlements/sync.ts b/pointer/lib/entitlements/sync.ts
index 4d475a2..a083e8d 100644
--- a/pointer/lib/entitlements/sync.ts
+++ b/pointer/lib/entitlements/sync.ts
@@ -77,7 +77,11 @@ async function assertLocalSubscription(
}
}
-export type EntitlementSyncTrigger = "webhook" | "checkout" | "reconcile";
+export type EntitlementSyncTrigger =
+ | "webhook"
+ | "checkout"
+ | "signup"
+ | "reconcile";
/**
* Refetches a subscription's entitlements from Chargebee and rewrites the
diff --git a/pointer/lib/free-subscription.ts b/pointer/lib/free-subscription.ts
index aaffa09..fe78896 100644
--- a/pointer/lib/free-subscription.ts
+++ b/pointer/lib/free-subscription.ts
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import type Chargebee from "chargebee";
-import type { Subscription } from "chargebee";
+import type { Customer, Subscription } from "chargebee";
import { itemPriceIdFor } from "@/scripts/catalog";
@@ -20,6 +20,24 @@ export type FreeSubscriptionResult = {
created: boolean;
};
+export type LiveSubscription = {
+ subscription: Subscription;
+ customer: Customer;
+};
+
+/** The customer's live subscription, with the customer it bills. */
+export async function findLiveSubscription(
+ client: SubscriptionClient,
+ customerId: string,
+): Promise {
+ const existing = await client.subscription.list({
+ customer_id: { is: customerId },
+ status: { in: [...LIVE_SUBSCRIPTION_STATUSES] },
+ limit: 1,
+ });
+ return existing.list?.[0] ?? null;
+}
+
function signupSubscriptionId(userId: string): string {
const digest = createHash("sha256").update(userId).digest("hex").slice(0, 32);
return `pointer-free-${digest}`;
@@ -37,12 +55,8 @@ export async function ensureFreeSubscription(
client: SubscriptionClient,
input: { customerId: string; userId: string },
): Promise {
- const existing = await client.subscription.list({
- customer_id: { is: input.customerId },
- status: { in: [...LIVE_SUBSCRIPTION_STATUSES] },
- limit: 1,
- });
- const current = existing.list?.[0]?.subscription;
+ const existing = await findLiveSubscription(client, input.customerId);
+ const current = existing?.subscription;
if (current) return { subscription: current, created: false };
const id = signupSubscriptionId(input.userId);
diff --git a/pointer/lib/signup-sync.test.ts b/pointer/lib/signup-sync.test.ts
new file mode 100644
index 0000000..4b75a44
--- /dev/null
+++ b/pointer/lib/signup-sync.test.ts
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Session } from "@/lib/auth";
+
+const mocks = vi.hoisted(() => ({
+ chargebeeCustomerId: "cust-1" as string | null,
+ list: vi.fn(),
+ process: vi.fn(async () => undefined),
+ syncEntitlements: vi.fn(async () => undefined),
+}));
+
+vi.mock("@/lib/auth", () => ({ auth: { $context: Promise.resolve({}) } }));
+vi.mock("@chargebee/better-auth", () => ({
+ createChargebeeWebhookProcessor: () => ({ process: mocks.process }),
+}));
+vi.mock("@/plugins/chargebee-plugin", () => ({
+ chargebeeClient: { subscription: { list: mocks.list } },
+ chargebeePluginOptions: {},
+}));
+vi.mock("@/lib/entitlements/subject", () => ({
+ resolveBillingSubject: async () => ({
+ customerType: "user",
+ referenceId: "user-1",
+ chargebeeCustomerId: mocks.chargebeeCustomerId,
+ }),
+}));
+vi.mock("@/lib/entitlements/sync", () => ({
+ syncSubscriptionEntitlements: mocks.syncEntitlements,
+}));
+
+import { syncSignupBilling } from "./signup-sync";
+
+const session = { user: { id: "user-1" }, session: {} } as unknown as Session;
+const live = {
+ subscription: { id: "sub-free", status: "active" },
+ customer: { id: "cust-1" },
+};
+
+describe("syncSignupBilling", () => {
+ beforeEach(() => {
+ mocks.chargebeeCustomerId = "cust-1";
+ mocks.list.mockReset();
+ mocks.list.mockResolvedValue({ list: [live] });
+ mocks.process.mockClear();
+ mocks.syncEntitlements.mockClear();
+ });
+
+ it("mirrors the live subscription and fetches its entitlements", async () => {
+ await syncSignupBilling(session);
+
+ expect(mocks.list).toHaveBeenCalledWith(
+ expect.objectContaining({ customer_id: { is: "cust-1" }, limit: 1 }),
+ );
+ expect(mocks.process).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event_type: "subscription_created",
+ content: live,
+ }),
+ );
+ expect(mocks.syncEntitlements).toHaveBeenCalledWith("sub-free", {
+ trigger: "signup",
+ });
+ });
+
+ it("skips users without a Chargebee customer", async () => {
+ mocks.chargebeeCustomerId = null;
+
+ await syncSignupBilling(session);
+
+ expect(mocks.list).not.toHaveBeenCalled();
+ expect(mocks.syncEntitlements).not.toHaveBeenCalled();
+ });
+
+ it("skips customers without a live subscription", async () => {
+ mocks.list.mockResolvedValue({ list: [] });
+
+ await syncSignupBilling(session);
+
+ expect(mocks.process).not.toHaveBeenCalled();
+ expect(mocks.syncEntitlements).not.toHaveBeenCalled();
+ });
+
+ it("leaves the webhook path in charge when Chargebee fails", async () => {
+ mocks.list.mockRejectedValue(new Error("chargebee down"));
+
+ await expect(syncSignupBilling(session)).resolves.toBeUndefined();
+ expect(mocks.syncEntitlements).not.toHaveBeenCalled();
+ });
+});
diff --git a/pointer/lib/signup-sync.ts b/pointer/lib/signup-sync.ts
new file mode 100644
index 0000000..29383ba
--- /dev/null
+++ b/pointer/lib/signup-sync.ts
@@ -0,0 +1,70 @@
+import {
+ createChargebeeWebhookProcessor,
+ type ChargebeeWebhookProcessorSource,
+} from "@chargebee/better-auth";
+import type { WebhookEvent } from "chargebee";
+
+import { auth, type Session } from "@/lib/auth";
+import { resolveBillingSubject } from "@/lib/entitlements/subject";
+import { syncSubscriptionEntitlements } from "@/lib/entitlements/sync";
+import {
+ findLiveSubscription,
+ type LiveSubscription,
+} from "@/lib/free-subscription";
+import {
+ chargebeeClient,
+ chargebeePluginOptions,
+} from "@/plugins/chargebee-plugin";
+
+/**
+ * A list entry carries the same `subscription` + `customer` content as the
+ * `subscription_created` webhook, so the plugin's own hook writes the mirror
+ * rows. The real webhook later finds them and skips.
+ */
+async function mirrorSubscription(live: LiveSubscription): Promise {
+ const processor = createChargebeeWebhookProcessor(chargebeePluginOptions, {
+ auth,
+ } as unknown as ChargebeeWebhookProcessorSource);
+ await processor.process({
+ id: `signup-${live.subscription.id}`,
+ event_type: "subscription_created",
+ content: live,
+ } as unknown as WebhookEvent);
+}
+
+/**
+ * Read-your-writes for sign-up. The free subscription was created through the
+ * API moments ago, so its webhook is still in flight. Pull the subscription
+ * and its entitlements from Chargebee instead of waiting for it.
+ *
+ * sign-up ──► Chargebee: create subscription
+ * home ──► Chargebee: list subscription ──► mirror rows
+ * ──► Chargebee: fetch entitlements ──► snapshot
+ * webhook ──► rows exist, skipped
+ */
+export async function syncSignupBilling(session: Session): Promise {
+ try {
+ const subject = await resolveBillingSubject(session, {
+ customerType: "user",
+ });
+ if (!subject.chargebeeCustomerId) {
+ return;
+ }
+
+ const live = await findLiveSubscription(
+ chargebeeClient,
+ subject.chargebeeCustomerId,
+ );
+ if (!live) {
+ return;
+ }
+
+ await mirrorSubscription(live);
+ await syncSubscriptionEntitlements(live.subscription.id, {
+ trigger: "signup",
+ });
+ } catch (error) {
+ // The webhook remains the correctness path; the provisioning page polls.
+ console.error("[signup] direct billing sync failed", error);
+ }
+}