Blog

LenDen subscriptions with Supabase RLS and Razorpay webhooks

How LenDen stores tier in profiles, verifies Razorpay subscriptions server-side, and keeps ledger data isolated with row-level security.

LenDen is a ledger for shopkeepers—parties, Given/Received entries, multi-business workspaces. Pro adds exports, batch grids, and unlimited parties. Free must stay usable without leaking one user's khata to another. That combination pushed us toward Supabase for auth + Postgres with row-level security (RLS), and Razorpay for recurring billing in India.

This post walks through the shape of the system: what lives in profiles, how webhooks update tier, and why client-side "isPro" is never enough for exports.

Data model: tier on the profile

Each user has a profiles row keyed to auth.users. Tier is free or pro, with Razorpay fields for audit and support:

  • razorpay_subscription_id
  • subscription_status
  • subscription_current_period_end

The app reads tier through React Query on profile load. SubscriptionProvider derives gates: canExport, canUseBatchEntry, party limits.

Active Pro is not only tier === "pro" in the client—you align status with webhook-driven fields so expired subs downgrade even if someone tampers with local state.

RLS: businesses and parties first

Ledger tables tie to business_id. Policies generally follow:

  • Users access rows where business_id belongs to a membership they own.
  • Inserts/updates check the same membership.
  • Service role bypasses RLS only in API routes and webhooks—never ship service keys to the client.

RLS mistakes are security incidents. Test policies with two test users in SQL or Supabase policy tests before shipping a migration.

-- Illustrative pattern (simplify to match your schema)
create policy "members read own business parties"
on parties for select
using (
  exists (
    select 1 from business_members m
    where m.business_id = parties.business_id
      and m.user_id = auth.uid()
  )
);

Exports and batch RPCs should run as the authenticated user (RLS on) or validate business_id explicitly in a security definer function—pick one strategy and document it.

Razorpay: create subscription on the server

Client calls your API route with the chosen plan id. Server:

  1. Authenticates the Supabase session.
  2. Creates or reuses Razorpay customer.
  3. Creates subscription with plan id from server config (mirrors the public pricing page).
  4. Returns checkout params to the client.

Secrets stay server-side in razorpay-server.ts. Never expose key secret in NEXT_PUBLIC_*.

Webhooks are the source of truth

subscription.activated, subscription.charged, subscription.cancelled (and payment failures) hit /api/razorpay/webhook. Handler steps:

  1. Verify signature with webhook secret.
  2. Idempotency—store event id or use Razorpay payment id to ignore duplicates.
  3. Map subscription_id → user via profiles.razorpay_subscription_id or notes metadata you set at create time.
  4. Update tier, subscription_status, subscription_current_period_end.

Client-side "payment success" redirect is UX; webhook is billing truth. Users who close the tab early still get Pro if Razorpay charged.

Verify routes for client optimism

LenDen exposes verify-payment / verify-subscription routes so the app can refresh profile immediately after checkout while webhook propagates. Pattern:

  • Client completes Razorpay checkout.
  • Calls verify endpoint with payment/subscription ids.
  • Server fetches Razorpay status, updates profile if paid.
  • Client invalidates profile query.

Webhook still wins on conflicts; verify is a latency shortcut.

Gating exports and Pro RPCs

CSV/PDF export spans can exfiltrate years of ledger. Check tier in the route handler:

if (profile.tier !== "pro") {
  return NextResponse.json({ error: "upgrade_required" }, { status: 403 });
}

Same for batch entry mutations if they touch server-side validation. UI hiding the button is not security.

Free tier limits without embarrassing leaks

Party count uses a lightweight count query—do not load all parties to check length. At five parties on free, block create with upgrade CTA. Server insert policy can also enforce max parties via trigger or RPC if you need hard limits.

Operational notes

  • Rotate webhook secrets in Razorpay dashboard with deploy coordination.
  • Log webhook failures to something you read—silent tier drift creates angry Pro users.
  • Support needs a script: lookup user email → profile → Razorpay subscription status.
  • Migrations for new columns on profiles ship before frontend that depends on them.

What we did not do

  • Store card data—Razorpay Checkout handles PCI scope.
  • Trust client-only tier in localStorage.
  • Mix portfolio marketing analytics with LenDen auth cookies on one domain—product stays on lenden.satanilabs.com.

Takeaway

Supabase RLS keeps ledgers private; Razorpay webhooks keep tiers honest; Next.js API routes sit between them with verification and exports enforcement. Founder-led products can ship this stack without a platform team if you treat webhooks and policies as product features, not afterthoughts.