Deploying
The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.
Running a single-user deployment?
If your deployment has no Dhaga Cloud features (billing, admin panel, invite-only signup), read Self-hosting first — it's simpler than this whole page and you can skip the "Hosted-mode extras" section below entirely.
Two very different things deploy from this codebase today:
- The landing page (
/) — static, deploys anywhere, zero config. - The app (
/app,/api) — stores data in an embedded database (PGlite) on the server's filesystem. That single fact decides where you can run it.
Read this first
The default storage is an embedded database on the server's filesystem —
great on a laptop/VPS, impossible on serverless (read-only, ephemeral, many
instances). To run the app on Vercel, set DATABASE_URL to a hosted
Postgres (Neon/Supabase free tiers work; the database must support
CREATE EXTENSION vector). With it set, the app uses hosted Postgres
everywhere; without it, the embedded DB — and on serverless only the landing
page + waitlist (email fallback) function.
Option A — Vercel
- Create a Vercel project from the licensed source bundle — push it to a
private Git repository you control and import that, or run
vercel deployfrom the unpacked bundle. Either way the framework auto-detects as Next.js. (Dhaga's own repository is private; a self-hosted deployment starts from the bundle that comes with your agreement — see Self-hosting.) - Set Root Directory to
apps/weband keep the default build command. - Env vars:
RESEND_API_KEY,RESEND_FROM_EMAIL,DHAGA_OWNER_EMAIL— emails + the waitlist's fallback path.DATABASE_URL— hosted Postgres (create a free Neon or Supabase project, copy its connection string). Without this,/appcannot store anything on Vercel; with it, the full app works.BETTER_AUTH_SECRET,BETTER_AUTH_URL— onceDATABASE_URLis set.BETTER_AUTH_TRUSTED_ORIGINS— optional; extra origins allowed on sign-in/sign-up beyondBETTER_AUTH_URL(comma-separated or a wildcard), to avoidINVALID_ORIGIN. Vercel preview URLs are auto-trusted, so this is usually unneeded.ANTHROPIC_API_KEY— AI features.DHAGA_EMBEDDINGS=offrecommended on Vercel for now: the local embedding model (~100 MB of native runtime) is a poor fit for serverless functions; search falls back to keyword matching.CRON_SECRET— optional, job-change detection + news watchlist.apps/web/vercel.jsonalready declares the nightly cron; Vercel sendsAuthorization: Bearer $CRON_SECRETto it automatically once the var is set (unset = the route 401s to everyone, including Vercel's own cron — the feature is simply off). The sweep's web search needs no separate provider: it defaults to Anthropic's own server-sideweb_searchtool on theANTHROPIC_API_KEYabove.FIRECRAWL_API_KEYis optional and takes precedence only where you set it. Two caveats — the Anthropic search path has never been run against a live key, and searches are billed $10/1k on top of the input tokens every retrieved page costs.NEXT_PUBLIC_SITE_URL— set once a custom domain is attached (step 4): the canonical origin for the sitemap, robots.txt, OG tags, and llms.txt. Defaults to the Vercel preview URL until then.
- Deploy. Add your domain under Settings → Domains.
Hosted-mode extras (Dhaga Cloud only — skip for plain self-hosting)
Add these on top of the above only if you want invite-gated signup, the admin panel, and Stripe billing. See Self-hosting for what each of these actually turns on, and how to create your first admin account once it's live.
DHAGA_HOSTED_MODE=true— the master switch; every EE feature stays off without it, regardless of whether the other vars below are set.DHAGA_ADMIN_EMAILS— comma-separated emails that bootstrap into admins on signup (see Self-hosting's "Creating the first admin user").STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, and one price id per (tier, cadence):STRIPE_PRICE_PRO_MONTHLY,STRIPE_PRICE_PRO_ANNUAL,STRIPE_PRICE_POWER_MONTHLY,STRIPE_PRICE_POWER_ANNUAL. Any left blank is simply not offered — the picker renders only combinations that have a price. OmitSTRIPE_SECRET_KEYentirely to run hosted mode without billing (e.g. a free beta). Reference prices: Pro $5/mo or $48/yr, Power $8.99/mo or $89.99/yr (BRD §8.3 carries the margins these imply — they are materially thinner than the figures the same section quoted before 2026-08-19). Every tier is recurring — there is no one-time purchase.- Local currency is Stripe Adaptive Pricing, a Dashboard setting on Checkout — not code and not extra env vars. Turn it on there and a visitor in the UAE is quoted dirhams off the same price id.
RAZORPAY_KEY_ID,RAZORPAY_KEY_SECRET,RAZORPAY_WEBHOOK_SECRET, one Plan id per (tier, cadence) —RAZORPAY_PLAN_PRO_MONTHLY,RAZORPAY_PLAN_PRO_YEARLY,RAZORPAY_PLAN_POWER_MONTHLY,RAZORPAY_PLAN_POWER_YEARLY. Optional and independent of Stripe: either processor alone is enough for the billing UI to render. Reference prices: Pro ₹499/mo or ₹4,799/yr, Power ₹899/mo or ₹8,999/yr — roughly parity with the USD figures, so BRD §8.3's recomputed margins carry over.RAZORPAY_PLAN_PRO_MONTHLY_LEGACY,RAZORPAY_PLAN_PRO_YEARLY_LEGACY,RAZORPAY_PLAN_POWER_MONTHLY_LEGACY,RAZORPAY_PLAN_POWER_YEARLY_LEGACY— set these whenever you reprice a standing tier, to the plan id the var above held before the change. A Razorpay Plan is immutable, so repricing means minting a new Plan and repointing the env var, and every subscriber still billing against the old id becomes unrecognisable the moment it leaves the table: the renewal webhook resolves no tier and silently drops a paying customer to free.RAZORPAY_LEGACY_PLAN_ENV(packages/ee/src/billing/catalog/plan-env/tables.ts) is consulted by the reverse lookup only —isSellableCadencerefuses these, so an older, cheaper id can never become purchasable again.RAZORPAY_PLAN_PRO_INTRO_MONTHLY,RAZORPAY_PLAN_PRO_INTRO_YEARLY,RAZORPAY_PLAN_POWER_INTRO_MONTHLY,RAZORPAY_PLAN_POWER_INTRO_YEARLY— no longer a purchase path. An introductory price used to be a second Plan at the lower amount; it is now the standing Plan plus a Razorpay Offer (next bullet), so nothing reads these four to sell anything and a fresh deployment can leave all four unset and still sell every introductory price. They are kept in the resolve-only table beside the_LEGACYids, for the same reason those are kept: nobody ever bought one, but a plan id that could appear on a historical row must still resolve to a tier rather than throw.RAZORPAY_OFFER_PRO_MONTHLY,RAZORPAY_OFFER_PRO_YEARLY,RAZORPAY_OFFER_POWER_MONTHLY,RAZORPAY_OFFER_POWER_YEARLY— this is how an introductory price is sold now. Each holds a Razorpay Offer id, which checkout attaches tosubscriptions.createasoffer_idon the standing Plan; the offer discounts the first N cycles and Razorpay steps the price up by itself when they run out. Reference prices: Pro ₹199/mo or ₹1,999/yr. There is no Power introductory price any more — since the 2026-08-24 reprice Power's introductory rows and its standing rows are the same ₹899 / ₹8,999, so an offer there would advertise 0% off; noRAZORPAY_OFFER_POWER_*id is configured andgetIntroOffers()withholds Power, so nothing sells or renders one. INR/Razorpay only and deliberately so: there is no Stripe equivalent, because a USD checkout would mint a rate at an offer priced only in INR. Offers are Dashboard-created — the API cannot create, list or read one — so an environment variable is the only way an offer id reaches the app, and one id per (tier, cadence) is enough: Razorpay resolves the payment-rail variant itself, so the code never selects by card vs UPI. Each pair is checked on its own (getIntroOffers()/hasIntroOffer()), so an instance may configure two of the four and simply not offer the others. With none of them set, every surface shows the standing price and every button sells the standing plan — that is the correct default, not a degraded one. Checkout fails closed: an introductory cadence that reaches it with no offer id configured is REFUSED, visibly, rather than silently charging the standing amount at a button that advertised the introductory one.- Four Dashboard settings on each offer were measured to matter, and no code path can compensate for getting one wrong: redemption type limited number of cycles (forever discounts forever); 12 cycles on a monthly plan and 1 on a yearly plan — a yearly plan bills once a year, so 12 would discount twelve years; Block on payment failure; and a minimum amount below the cheapest plan, or the subscription is refused at creation. The offer must also be enabled, or payment fails with "Payment method used is not eligible for offer".
- The mandate registers at the STANDING amount, not the discounted one. That is precisely what makes the step-up automatic, and it is why an introductory buyer's bank shows a cap higher than their first charge. Nothing to configure — but it is the line to have ready when a customer asks.
- The offer is a per-customer 12-month term and carries no closing date at
all. A buyer holds their introductory price for their own first twelve
months (
INTRO_TERM_MONTHSinpackages/ee/src/billing/intro/term.ts), counted from their own subscription start, then pays the standing price for that tier and cadence; a 12-cycle monthly offer and a 1-cycle yearly offer both mean "your first year". It is a guarantee, not a lock-in: they may cancel in any month, and a yearly subscriber keeps the year they paid for. It stays first-purchase-only — nothing on the plan ladder moves an existing subscriber onto an introductory price, and changing plan forfeits it, which is what the plan-change forfeit warning says. - Whether the introductory prices are still sold is a runtime admin toggle —
still not an env var, so you configure nothing here for it.
introOfferOpen()/setIntroOfferOpen()(packages/ee/src/billing/intro/availability.ts) read and write theintro_offer_availabilitykey in EE'sbilling_settingstable, from/app/admin/subscriptions. It is a screen rather than a deploy precisely so an owner can close the offer at a moment of their choosing. The default is open — absence of a row means open, so the deploy that introduced the table changed nothing and no operator has to switch anything on. Closing it stops new customers getting introductory prices and changes nothing for anyone already subscribed. - New schema, applied by the usual boot-time DDL replay — no migration step
and nothing to run by hand: the
billing_settingstable (packages/ee/src/db/tables-ddl/billing-settings.ts; text key PK, text value,updated_at, no RLS — a control-plane table with no tenant column, exactly likesubscriptionsandpaymentsbeside it), plus three nullable columns onsubscriptions:intro_offer_id, which records the Razorpay Offer id a purchase was made with — the subscription itself is on the standing Plan, so this column is the only thing that can tell an introductory purchase from an ordinary one — andintro_price_held_at/intro_price_held_bybehind the admin "keep this customer on the introductory price" hold. Every column statement is additiveIF NOT EXISTS. - There is no nightly step-up sweep any more, and nothing to schedule for it.
Razorpay steps the price up itself when the offer's cycles run out, against a
mandate that was registered at the standing amount, so no booking is made on
our side.
runIntroPriceStepUps,packages/ee/src/billing/intro/step-up/andupdateSubscriptionPlan()are deleted, and the daily job route no longer carries an entry for them. - What survives on the cron is the warning, re-keyed.
/api/jobs/dailystill runs the ~30-day term-end notice email (runIntroStepUpNotices,apps/web/src/lib/jobs/intro-step-up/notices.ts, template inapps/web/src/lib/email/intro-step-up.ts), and the final-month in-app banner still ships beside it. Both used to read a plan change booked at the processor; both now derive the term fromsubscriptions.intro_offer_idplus the deterministicintroTermEndsAt()(subscription start +INTRO_TERM_MONTHS). The old "sweep before notice" ordering went with the sweep — there is no booked date to wait for, so the date both channels quote is the computed anniversary. Deleting them alongside the sweep would have been less work and a silent regression: a customer whose price is about to rise deserves the warning whichever mechanism raises it. No new env var and no second cron: it is the same authenticated GET behind the sameCRON_SECRET. It reports zeros withoutpackages/ee, and the notice is transactional mail on its own idempotence key, so it sits outside the daily brief's one-email-a-day budget and the two cannot suppress each other. RAZORPAY_PLAN_PRO_FOUNDING_YEARLY— retired, but do not unset it. Founding Pro is no longer sold: it is on no public surface, andisSellableCadencerefuses the cadence, so this id can no longer mint a subscription. What survives is a reverse lookup only. A Razorpay Plan is immutable, so an existing founding subscriber renews against this exact plan id forever, and the renewal webhook resolves which tier to grant by looking the id up inpackages/ee/src/billing/catalog/plan-env/tables.ts. Remove the variable and that renewal resolves no tier, grants nothing, and silently drops a paying customer to free. Leave it set for as long as one founding subscription is live.- Processor routing is by
x-vercel-ip-country: India leads with Razorpay, everywhere else with Stripe (RAZORPAY_COUNTRIES,apps/web/src/lib/billing/processor.ts). It only reorders the buttons — both stay clickable wherever both are configured, because IP geo is wrong often enough that locking someone out of paying is the worse failure. - Register the Razorpay webhook at
https://your-domain/api/razorpay/webhook, subscribed to at leastsubscription.activated,subscription.charged,subscription.halted,subscription.cancelled. Its secret is per-endpoint and not the API key secret. Without it, a customer who closes the tab mid-redirect is charged and never upgraded, and renewals never update the stored status. Razorpay-paid customers also have no billing portal — cancelling is dashboard-side. - Register the webhook in Stripe pointing at
https://your-domain/api/stripe/webhook, subscribed to at leastcheckout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed. DATABASE_URLis mandatory in hosted mode regardless of platform — the multi-tenant isolation is Postgres Row-Level Security, which the embedded PGlite database doesn't support.
⚠ The Postgres role DATABASE_URL connects as matters — a lot
Hosted mode's tenant isolation is entirely Row-Level Security (see
packages/ee/src/db/rls-ddl.ts) — every query core code runs is
tenant-agnostic by design and relies on RLS policies to filter rows. RLS is
enforced per role, not per connection: a role with the BYPASSRLS
attribute ignores every RLS policy on every table, silently, regardless of
FORCE ROW LEVEL SECURITY. Managed Postgres providers' default
admin/owner role commonly has BYPASSRLS out of the box — Supabase's
postgres role does. If DATABASE_URL connects as that role, every signed-in
user sees every other user's data, with no error anywhere, because nothing
is malformed — RLS is just never evaluated for that role. This is exactly
what shipped originally on this project's own hosted deployment and leaked
one account's contacts into another's.
Before going live in hosted mode on any provider (Supabase, Neon, self-hosted — this isn't Supabase-specific, it's how Postgres RLS works everywhere):
- Run
packages/ee/scripts/create-app-role.sqlagainst the target database. It creates a dedicateddhaga_approle withNOBYPASSRLSand moves table ownership to it (ownership, not justGRANTs, is required — the app's own boot-time DDL runsALTER TABLEstatements to evolve the schema and enable RLS). - Point
DATABASE_URLatdhaga_app, not the provider's default role. Supabase specifically: the pooler (pooler.supabase.com) routes by a<role>.<project_ref>username — e.g.dhaga_app.zgnpoeddgsrgpivqpkdk— not the plain role name; only the connection username changes shape, the role itself is still justdhaga_app. - As of this project's
packages/ee/src/db/bootstrap.ts, the app checks this itself at boot —SELECT rolbypassrls, rolsuper FROM pg_roles WHERE rolname = current_user, and refuses to start (loud error naming this doc) if the connecting role hasBYPASSRLSorSUPERUSER(a superuser bypasses RLS unconditionally even whilerolbypassrlsreads false). Don't rely on this alone for a fresh setup, though — check via the script's own verification query before traffic hits it, not after. - To prove the isolation itself rather than just the role's attributes,
run
packages/ee/src/db/__tests__/rls-isolation.integration.test.ts— it asserts, per tenant table, that a second tenant's scoped read sees nothing of the first's. Its file header carries the run instructions. It writes and then deletes rows, so point it at a disposable Postgres (thecompose.ymldatabase is one) and never at production.
Migrating off Supabase (or any hosted Postgres) later
Low-risk by design: the app never uses Supabase-specific APIs (no
@supabase/supabase-js, no Supabase Auth/Storage/Realtime) — it's Better
Auth + plain pg/Drizzle against a connection string, using only the
standard pgvector and pg_trgm Postgres extensions. The schema is
self-provisioning (initHosted() in apps/web/src/lib/db/index.ts runs the
app's own idempotent DDL on every boot), so this is mostly a data
migration, not a code change:
- On the new Postgres target: install the
pgvectorandpg_trgmextensions, then runpackages/ee/scripts/create-app-role.sqlagainst it to createdhaga_appthere too — it runs unmodified on both, no editing step: the one Supabase-specific statement (GRANT USAGE ON SCHEMA extensions) is guarded and no-ops on a self-hosted Postgres, which keeps extensions inpublicrather than Supabase's separateextensionsschema. pg_dump --no-owner --no-acl <source> | psql <target>(orpg_restore --no-owner --no-aclif using the custom format) —--no-ownermatters because the dump would otherwise try to assign ownership to adhaga_approle that doesn't exist yet in the target cluster (roles are cluster-level, not part of a database dump).- Re-run just the ownership-reassignment loop from
create-app-role.sql(theALTER TABLE ... OWNER TO dhaga_apploop + grants) against the target —--no-ownerleft everything owned by whichever role ran the restore. - Point
DATABASE_URLat the new target (withdhaga_app's credentials) and redeploy. The boot-time check inpackages/ee/src/db/bootstrap.tswill refuse to start if anything about the role is wrong — treat that as the migration's final verification, not just a safety net.
Option B — a single persistent server (the real deployment today)
Any Linux VPS (Hetzner/DigitalOcean/EC2) or PaaS with a volume (Railway / Render / Fly.io). Requirements: Node 20+, a directory that survives restarts, HTTPS in front. (Prefer containers? See Option C below.)
cd dhaga # the licensed source bundle, unpacked
npm ci
npm run buildCreate apps/web/.env.local (or export the vars in your process manager):
BETTER_AUTH_SECRET=<long random string, e.g. `openssl rand -hex 32`>
BETTER_AUTH_URL=https://dhaga.example.com
DHAGA_DATA_DIR=/var/lib/dhaga # persisted dir OUTSIDE the repo
ANTHROPIC_API_KEY=sk-ant-... # optional; enables AI features
DHAGA_AI_MONTHLY_CAP=500 # optional; seeds the default AI CREDITS/month until an admin sets one at /app/admin/ai-credits (default 10)
NODE_ENV=productionRun it (systemd example — pm2 start "npm run start" works too):
# /etc/systemd/system/dhaga.service
[Unit]
Description=Dhaga
After=network.target
[Service]
WorkingDirectory=/opt/dhaga
ExecStart=/usr/bin/npm run start
Restart=always
EnvironmentFile=/etc/dhaga.env
[Install]
WantedBy=multi-user.targetPut Caddy or nginx in front for HTTPS — required in production because the
session cookie is Secure (login will not stick over plain HTTP).
# Caddyfile — automatic HTTPS
dhaga.example.com {
reverse_proxy localhost:3000
}Backups & migration
- Backup = copy the
DHAGA_DATA_DIRdirectory (stop the service first), plus a periodiccurl -H "x-api-key: …" .../api/export/json(create a personal access token from/app/settings). - Leaving = the JSON/CSV/vCard export endpoints give you everything;
no lock-in is a feature. That is the default and it is not filtered: called
plainly,
/api/export/json,/api/export/csvand/api/export/vcardeach return every contact, whatever its provenance. The CSV and vCard routes also accept an opt-in?scope=authoredused for seeding an address book — a deliberately narrower file for a different job. Omit it and you get everything; nothing about the portability guarantee changed.
Option C — Production deployment with Docker
The repo root has a multi-stage Dockerfile (deps → build →
slim runtime; Next.js standalone output on node:22-slim, non-root user,
built-in healthcheck against /login) and a compose.yml
that runs the app plus a Postgres 16 + pgvector database:
cd dhaga # the licensed source bundle, unpacked
echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" > .env
docker compose up --build -dOpen http://localhost:3000. On first boot the app applies its own idempotent
DDL over the DATABASE_URL connection (including CREATE EXTENSION vector /
pg_trgm) — there is no migration step, and the same schema self-heal
re-runs harmlessly on every upgrade.
-
Contact data lives in the
dhaga-dbvolume —docker compose downkeeps it,down -vdeletes it. The database is also reachable from the host atlocalhost:54329for backups (pg_dump). -
Set
BETTER_AUTH_URLin.envto your public URL and terminate HTTPS in front (same reason as Option B: the session cookie isSecure, so login won't stick over plain HTTP on anything but localhost). -
Zero-config variant (no Postgres): run the image without
DATABASE_URLand it uses the embedded database (PGlite), persisted at/datainside the container — mount a volume there or the data dies with the container:docker build -t dhaga . docker run -d -p 3000:3000 \ -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ -v dhaga-data:/data dhagacompose.ymlcarries this as a commented-out variant too. Backup = thedhaga-datavolume, exactly likeDHAGA_DATA_DIRin Option B.
Hosted mode (Dhaga Cloud) in Docker
The stock compose.yml is the plain self-host path — none of the
packages/ee vars are wired in. To run DHAGA_HOSTED_MODE=true
multi-tenant in containers, additionally:
- Run
packages/ee/scripts/create-app-role.sqlagainst the database once, e.g.docker compose exec -T db psql -U dhaga -d dhaga < packages/ee/scripts/create-app-role.sql, and pointDATABASE_URLat the resulting non-BYPASSRLSdhaga_approle — see "The Postgres role DATABASE_URL connects as matters" above. - Pooling mode doesn't matter here. Tenant scoping is transaction-local
(
set_config('app.current_user_id', …, true)inside oneBEGIN … COMMIT, which self-clears at COMMIT —packages/ee/src/tenant/scoped-db.ts), so it is correct whether you connect directly (what the compose file does), through a session-mode pooler, or through a transaction-mode pooler (Supabase's port 6543, PgBouncer, Supavisor, Neon's-pooler). There is no pooling-mode boot guard to satisfy — the earlier one, and itsDHAGA_ALLOW_TRANSACTION_POOLERoverride, were removed as obsolete. (Supabase specifically: 5432 vs 6543 is just aDATABASE_URLchange — the 6543 path is designed-correct but not yet verified against a live transaction pooler; seedocs/SCALING.md§2.) - Add the hosted-mode env vars (
DHAGA_HOSTED_MODE,DHAGA_ADMIN_EMAILS,STRIPE_*) to theappservice'senvironmentblock yourself.
Checklist before going live
- Random
BETTER_AUTH_SECRET, correctBETTER_AUTH_URL -
DHAGA_DATA_DIRon persistent storage, owned by the service user - HTTPS terminating in front of port 3000
- Backup cron for the data dir / JSON export
-
ANTHROPIC_API_KEYset as a server env var only — never in client code - Verified the app with Testing against the deployed URL
What's deliberately not here yet
- Manual click-through verification of the Stripe checkout/webhook flow — the code is typechecked/linted/built/tested, but nobody has run a real test-mode purchase against a live Stripe account yet.
- A real subscriber seeing either term-end warning. Both channels are built —
the ~30-day notice email and the final-month in-app banner
(
apps/web/src/components/app/billing/, mounted inapps/web/src/app/app/layout.tsx, reading the same computed anniversary so the two quote one date) — and so are the admin surfaces, the availability toggle on/app/admin/subscriptionsand the per-customer hold on/app/admin/users/[id]. What has not happened is anyone receiving one: no purchase has ever been made at an introductory price, so no notice has been sent and no banner has been rendered to a paying customer. - The step-up blocker is RESOLVED — but by removing the call, not by making it
work. This entry used to open with 🛑 and read "THE STEP-UP IS A CONFIRMED
DEFECT — it cannot run". Both halves were tested against Razorpay on
2026-08-20 and both came back against us. (1) The e-mandate
max_amountwas registered at the plan amount — ₹199, on card and on UPI AutoPay alike — so a ₹499 step-up debit would have been refused at charge time. The ₹99,000 SDK default previously cited belongs to the registration flow, not to Subscriptions; that inference was wrong. (2)PATCH /subscriptions/{id}with{plan_id, schedule_change_at:"cycle_end"}is refused with a 400 on both Indian payment rails — card: "Only offers can be updated for subscriptions when payment mode is domestic card."; UPI: "subscriptions cannot be updated when payment mode is upi". Sandbox result, so no real bank rail was touched, but these are structural payment-mode constraints with explicit error messages that agree across two rails. Neither is a constraint any code path now meets: the subscription is created on the standing Plan with an Offer attached, so the mandate registers at the standing amount and no update call is ever made.updateSubscriptionPlan()and the sweep that was its only caller are deleted. Be precise about how far that goes, though: the offer path was measured end to end in the sandbox, on both rails (₹499 plan + limited-cycle offer → ₹199 invoice paid, mandatemax_amount₹499), and nothing has yet been sold at an introductory price, so no real customer has reached a term end and no price has actually stepped up in the field. - Ordinary plan changes on Razorpay had the same cause and are now FIXED
(2026-08-21) — but by a different design, not by the offer work above.
changePlanno longer makes the refused call at all: a tier or cadence change mints a second Razorpay subscription starting at the current one's renewal date, the customer authorises its mandate in checkout, the old subscription is set to end at that boundary, and the new one takes over when it first charges (an upgrade is granted free in the meantime). Two things an operator needs to know: an account with a change in flight legitimately holds two Razorpay subscriptions, and authorising takes a small refundable debit (₹5 observed) — so no screen may say "nothing is charged today". Built and unit-tested; never exercised against a live Razorpay subscription. The introductory term end was a different call and was fixed differently again, by making no call at all; see the bullet above.
Already done (see Self-hosting and the "Hosted-mode extras" section above)
- Hosted Postgres (Supabase/Neon) —
DATABASE_URLswitches the driver automatically; see Option A above. - Real user accounts, per-user API keys, multi-tenant RLS, billing, admin panel, early-access gating — all built. Self-hosted instances run without any of the hosted-only pieces by default (open registration, no billing UI, no admin nav) — see Self-hosting.