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_idsubscription_statussubscription_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_idbelongs 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:
- Authenticates the Supabase session.
- Creates or reuses Razorpay customer.
- Creates subscription with plan id from server config (mirrors the public pricing page).
- 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:
- Verify signature with webhook secret.
- Idempotency—store event id or use Razorpay payment id to ignore duplicates.
- Map
subscription_id→ user viaprofiles.razorpay_subscription_idor notes metadata you set at create time. - 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
profilesship 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.