From ba83b35813ec65181787b79f29dead9a7115a0d3 Mon Sep 17 00:00:00 2001 From: nbkdoesntknowcoding Date: Wed, 8 Jul 2026 20:25:37 +0530 Subject: [PATCH] feat: remove billing/payment layer from open-core (cloud-only) The community self-host edition now ships with ZERO payment code and no required RAZORPAY_* env. All Razorpay/billing surface is deleted; hosted billing lives outside this repo. Deleted (API): - routes/razorpay.ts, routes/billing.ts - lib/razorpay/ (client, customer, plan-state, plans, products, webhook-handlers) - lib/billing/seats.ts + lib/billing/currency.ts (only imported by the deleted billing route) - scripts/razorpay-assign-plan.ts, create-razorpay-plans.ts, razorpay-bootstrap.ts - tests/razorpay-webhook.test.ts Deleted (web): - components/pricing/ (incl. PricingCards.tsx) - components/settings/BillingPanel.tsx - lib/pricing.ts - pages/app/settings/billing.astro Neutralized core importers (build stays green): - server.ts: dropped razorpayRoutes + billingRoutes import/register. - lib/billing/sync-seats.ts: syncSubscriptionSeats is now an exported no-op (no subscriptions on self-host); imports no razorpay module. Core call sites in members/invitations/accept-invite-pending unchanged. - config/env.ts: RAZORPAY_KEY_ID / _KEY_SECRET / _WEBHOOK_SECRET are now .optional() instead of required. - apps/api/package.json: dropped the unused razorpay dependency. - .env.example: removed the RAZORPAY_* placeholders (now optional/unset). Hid dangling billing/pricing nav (resolves intent of #3): - SettingsNav.tsx, SettingsLayout.astro: removed the Billing entry. - CommandPalette.tsx: removed the "open billing" command. - PublicHeader.astro: removed the /pricing link. Boundary: extended verify-core-only.sh denylist to include routes/razorpay|routes/billing|lib/razorpay/. Untouched (community-license entitlement path stays intact): lib/entitlements.ts, lib/entitlement-gate.ts, routes/licenses.ts, lib/license-key.ts, plugins/subscription.ts, plugins/free-limits.ts, lib/workspace.ts, db/schema.ts (billing columns stay harmlessly). Verified: pnpm --filter @boppl/api build (tsc strict) exit 0; pnpm --filter @boppl/web build (astro) exit 0; verify-core-only.sh CLEAN; license-key unit test 4/4; zero dangling imports of deleted modules. Co-Authored-By: Claude Opus 4.8 Signed-off-by: nbkdoesntknowcoding --- .env.example | 9 +- apps/api/package.json | 1 - apps/api/scripts/verify-core-only.sh | 2 +- apps/api/src/config/env.ts | 11 +- apps/api/src/lib/billing/currency.ts | 44 - apps/api/src/lib/billing/seats.ts | 36 - apps/api/src/lib/billing/sync-seats.ts | 64 +- apps/api/src/lib/razorpay/client.ts | 24 - apps/api/src/lib/razorpay/customer.ts | 61 - apps/api/src/lib/razorpay/plan-state.ts | 28 - apps/api/src/lib/razorpay/plans.ts | 89 -- apps/api/src/lib/razorpay/products.ts | 80 -- apps/api/src/lib/razorpay/webhook-handlers.ts | 323 ----- apps/api/src/routes/billing.ts | 551 --------- apps/api/src/routes/razorpay.ts | 119 -- apps/api/src/scripts/create-razorpay-plans.ts | 125 -- apps/api/src/scripts/razorpay-assign-plan.ts | 45 - apps/api/src/scripts/razorpay-bootstrap.ts | 98 -- apps/api/src/server.ts | 6 - apps/api/src/tests/razorpay-webhook.test.ts | 124 -- .../web/src/components/nav/CommandPalette.tsx | 1 - .../src/components/pricing/PricingCards.tsx | 451 ------- .../src/components/public/PublicHeader.astro | 1 - .../src/components/settings/BillingPanel.tsx | 1054 ----------------- .../src/components/settings/SettingsNav.tsx | 1 - apps/web/src/layouts/SettingsLayout.astro | 1 - apps/web/src/lib/pricing.ts | 160 --- apps/web/src/pages/app/settings/billing.astro | 21 - pnpm-lock.yaml | 107 -- 29 files changed, 25 insertions(+), 3612 deletions(-) delete mode 100644 apps/api/src/lib/billing/currency.ts delete mode 100644 apps/api/src/lib/billing/seats.ts delete mode 100644 apps/api/src/lib/razorpay/client.ts delete mode 100644 apps/api/src/lib/razorpay/customer.ts delete mode 100644 apps/api/src/lib/razorpay/plan-state.ts delete mode 100644 apps/api/src/lib/razorpay/plans.ts delete mode 100644 apps/api/src/lib/razorpay/products.ts delete mode 100644 apps/api/src/lib/razorpay/webhook-handlers.ts delete mode 100644 apps/api/src/routes/billing.ts delete mode 100644 apps/api/src/routes/razorpay.ts delete mode 100644 apps/api/src/scripts/create-razorpay-plans.ts delete mode 100644 apps/api/src/scripts/razorpay-assign-plan.ts delete mode 100644 apps/api/src/scripts/razorpay-bootstrap.ts delete mode 100644 apps/api/src/tests/razorpay-webhook.test.ts delete mode 100644 apps/web/src/components/pricing/PricingCards.tsx delete mode 100644 apps/web/src/components/settings/BillingPanel.tsx delete mode 100644 apps/web/src/lib/pricing.ts delete mode 100644 apps/web/src/pages/app/settings/billing.astro diff --git a/.env.example b/.env.example index 83cea6cc9..6a940bd31 100644 --- a/.env.example +++ b/.env.example @@ -113,8 +113,7 @@ GEMINI_API_KEY=unused_on_self_host # sentinels and the AUTH_PROVIDER=password|oidc flow never calls WorkOS. (Set # real values only if you switch AUTH_PROVIDER=workos.) # -# [placeholder ok] Hosted billing (Razorpay) is still read at boot — leave the -# dummy values exactly as they are. -RAZORPAY_KEY_ID=unused_on_self_host -RAZORPAY_KEY_SECRET=unused_on_self_host -RAZORPAY_WEBHOOK_SECRET=unused_on_self_host +# The open-core (self-host) edition ships with NO payment/billing layer, so +# no RAZORPAY_* variables are required. Leave them unset. (They are declared +# as optional in config/env.ts only so a hosted deployment that layers billing +# on top of core can supply them.) diff --git a/apps/api/package.json b/apps/api/package.json index 81580fbed..7c703ac62 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -77,7 +77,6 @@ "pino-pretty": "^13.1.3", "playwright": "^1.61.1", "postgres": "^3.4.0", - "razorpay": "^2.9.6", "resend": "^6.17.1", "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", diff --git a/apps/api/scripts/verify-core-only.sh b/apps/api/scripts/verify-core-only.sh index 2a91786a8..aaae67b4a 100755 --- a/apps/api/scripts/verify-core-only.sh +++ b/apps/api/scripts/verify-core-only.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")/.." echo "== 1. core → gated static-import scan ==" # Gated module paths. A core file importing any of these (outside src/ee/ and the # gated modules themselves) is a boundary violation to de-tangle. -VIOLATIONS=$(grep -rnE "from '.*(routes/graph|routes/meetings|routes/org|routes/admin|lib/graph/|queue/graph|tools/graph|tools/meeting-context|meeting-end/worker|graph/worker)" src --include="*.ts" \ +VIOLATIONS=$(grep -rnE "from '.*(routes/graph|routes/meetings|routes/org|routes/admin|routes/razorpay|routes/billing|lib/razorpay/|lib/graph/|queue/graph|tools/graph|tools/meeting-context|meeting-end/worker|graph/worker)" src --include="*.ts" \ | grep -v "/ee/" \ | grep -vE "src/(routes/(graph|meetings|org|admin)|mcp/tools/(graph|meeting-context)|lib/graph/|queue/graph|workers/(graph|meeting-end))" \ | grep -v "\.test\.ts" || true) diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 107f9939d..617d5333e 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -160,10 +160,13 @@ const envSchema = z.object({ RATE_LIMIT_USER_PER_DAY: z.coerce.number().int().positive().default(1000), RATE_LIMIT_TENANT_DAILY_UNITS: z.coerce.number().int().positive().default(50000), - // Billing — Razorpay - RAZORPAY_KEY_ID: z.string().min(1), - RAZORPAY_KEY_SECRET: z.string().min(1), - RAZORPAY_WEBHOOK_SECRET: z.string().min(1), + // Billing — Razorpay. The open-core (self-host) edition ships with NO + // payment/billing layer, so these are optional and unused. They remain + // declared only so a hosted deployment that layers billing on top of core + // can supply them; on self-host leave them unset. + RAZORPAY_KEY_ID: z.string().min(1).optional(), + RAZORPAY_KEY_SECRET: z.string().min(1).optional(), + RAZORPAY_WEBHOOK_SECRET: z.string().min(1).optional(), RAZORPAY_ENVIRONMENT: z.enum(['test', 'live']).default('test'), // Recall.ai workspace verification secret (whsec_…) — signs the participant-events diff --git a/apps/api/src/lib/billing/currency.ts b/apps/api/src/lib/billing/currency.ts deleted file mode 100644 index 0350e02f8..000000000 --- a/apps/api/src/lib/billing/currency.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Currency detection for billing. - * - * Razorpay India only supports INR by default. - * USD requires "International Payments" to be enabled in the Razorpay dashboard. - * - * Strategy: - * 1. Use Cloudflare's CF-IPCountry header (available when traffic flows through CF) - * 2. Fall back to INR if header is absent or country is IN - * - * India → INR - * Anywhere else → USD (if USD plans are configured) - * - * If USD plans are not configured (env vars empty), always fall back to INR. - */ - -import type { Currency } from '../razorpay/plans.js'; - -/** Country codes that bill in INR. */ -const INR_COUNTRIES = new Set(['IN']); - -/** - * Detect the billing currency from a Fastify/Node request's headers. - * - * @param headers - Request headers object (req.headers from Fastify) - * @returns 'INR' or 'USD' - */ -export function detectCurrency(headers: Record): Currency { - // Check if USD plans are configured at all - const usdConfigured = !!( - process.env.RAZORPAY_PLAN_INDIVIDUAL_USD_MONTHLY || - process.env.RAZORPAY_PLAN_TEAM_USD_MONTHLY || - process.env.RAZORPAY_PLAN_BUSINESS_USD_MONTHLY - ); - - if (!usdConfigured) return 'INR'; - - // Cloudflare sets CF-IPCountry on all proxied requests - const cfCountry = headers['cf-ipcountry']; - const country = Array.isArray(cfCountry) ? cfCountry[0] : cfCountry; - - if (!country || INR_COUNTRIES.has(country.toUpperCase())) return 'INR'; - return 'USD'; -} diff --git a/apps/api/src/lib/billing/seats.ts b/apps/api/src/lib/billing/seats.ts deleted file mode 100644 index 98ac6e55b..000000000 --- a/apps/api/src/lib/billing/seats.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Billable seat counting. - * - * Only "writer" roles count toward the billable seat total. - * Roles: owner, admin, editor → billable (can create/edit content) - * viewer → free - * - * This is the single source of truth used when creating or updating - * a Razorpay subscription's quantity. - */ - -import { eq, inArray, and } from 'drizzle-orm'; -import { db } from '../../db/index.js'; -import { workspaceMembers } from '../../db/schema.js'; - -/** Roles that are billed as writer seats. */ -export const WRITER_ROLES = ['owner', 'admin', 'editor'] as const satisfies readonly ('owner' | 'admin' | 'editor' | 'viewer')[]; -export type WriterRole = (typeof WRITER_ROLES)[number]; - -/** - * Count the billable (writer) seats in a workspace. - * Minimum of 1 — a workspace always has at least one billable member. - */ -export async function countBillableSeats(workspaceId: string): Promise { - const rows = await db - .select({ id: workspaceMembers.userId }) - .from(workspaceMembers) - .where( - and( - eq(workspaceMembers.workspaceId, workspaceId), - inArray(workspaceMembers.role, [...WRITER_ROLES]), - ), - ); - - return Math.max(1, rows.length); -} diff --git a/apps/api/src/lib/billing/sync-seats.ts b/apps/api/src/lib/billing/sync-seats.ts index 319157f24..aef2945e4 100644 --- a/apps/api/src/lib/billing/sync-seats.ts +++ b/apps/api/src/lib/billing/sync-seats.ts @@ -1,62 +1,24 @@ /** - * Seat sync utility. + * Seat sync utility (self-host / open-core edition). * * Called after any workspace membership change that could affect the * billable seat count (member added, removed, role changed). * - * If the workspace has an active Razorpay subscription, this function - * recounts billable writers and updates the subscription quantity via - * the Razorpay API, then stores the new count in our DB. - * - * No-op if the workspace is on the free plan (no active subscription). + * The open-core (self-host) edition ships with NO payment/billing layer: + * there is no active subscription to reconcile, so this is a no-op. It + * remains an exported function so core callers (routes/members.ts, + * routes/invitations.ts, routes/_internal/accept-invite-pending.ts) keep + * their call sites unchanged. Hosted seat-sync lives outside this repo. */ -import { and, desc, eq, inArray } from 'drizzle-orm'; -import { db } from '../../db/index.js'; -import { subscriptions } from '../../db/schema.js'; -import { countBillableSeats } from './seats.js'; - -const ACTIVE_STATUSES = ['active', 'trialing', 'created'] as const; - /** - * Re-count writer seats for the workspace and update the Razorpay - * subscription quantity if one is active. + * Re-count writer seats and update the payment provider's subscription + * quantity when one is active. * - * Safe to call redundantly — it will silently no-op on free workspaces. + * Self-host: always a no-op (no subscriptions, no payment provider). + * Safe to call redundantly. */ -export async function syncSubscriptionSeats(workspaceId: string): Promise { - // Find the most recent active subscription - const subRows = await db - .select({ - id: subscriptions.id, - razorpaySubscriptionId: subscriptions.razorpaySubscriptionId, - status: subscriptions.status, - }) - .from(subscriptions) - .where( - and( - eq(subscriptions.workspaceId, workspaceId), - inArray(subscriptions.status, ACTIVE_STATUSES as unknown as string[]), - ), - ) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - if (subRows.length === 0) return; // free plan — nothing to sync - - const { razorpaySubscriptionId } = subRows[0]!; - const billableSeats = await countBillableSeats(workspaceId); - - // Update quantity on Razorpay subscription - const { razorpay } = await import('../razorpay/client.js'); - await (razorpay.subscriptions.update( - razorpaySubscriptionId, - { quantity: billableSeats } as Parameters[1], - ) as Promise); - - // Persist updated seat count locally - await db - .update(subscriptions) - .set({ billableSeats, quantity: billableSeats, updatedAt: new Date() }) - .where(eq(subscriptions.razorpaySubscriptionId, razorpaySubscriptionId)); +export async function syncSubscriptionSeats(_workspaceId: string): Promise { + // No billing layer in the open-core edition — nothing to sync. + return; } diff --git a/apps/api/src/lib/razorpay/client.ts b/apps/api/src/lib/razorpay/client.ts deleted file mode 100644 index efb608fa6..000000000 --- a/apps/api/src/lib/razorpay/client.ts +++ /dev/null @@ -1,24 +0,0 @@ -import crypto from 'node:crypto'; -import Razorpay from 'razorpay'; -import { config } from '../../config/env.js'; - -export const razorpay = new Razorpay({ - key_id: config.RAZORPAY_KEY_ID, - key_secret: config.RAZORPAY_KEY_SECRET, -}); - -export function validateWebhookSignature(body: Buffer, signature: string): boolean { - const expected = crypto - .createHmac('sha256', config.RAZORPAY_WEBHOOK_SECRET) - .update(body) - .digest('hex'); - return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex')); -} - -// STRIPE: ENABLE WHEN APPROVED -// import Stripe from 'stripe'; -// export const stripe = new Stripe(config.STRIPE_SECRET_KEY, { -// apiVersion: '2026-04-22.dahlia', -// typescript: true, -// appInfo: { name: 'Mnema', version: '0.1.0', url: 'https://context.example.com' }, -// }); diff --git a/apps/api/src/lib/razorpay/customer.ts b/apps/api/src/lib/razorpay/customer.ts deleted file mode 100644 index 125211a57..000000000 --- a/apps/api/src/lib/razorpay/customer.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { desc, eq } from 'drizzle-orm'; -import { db } from '../../db/index.js'; -import { subscriptions } from '../../db/schema.js'; -import { razorpay } from './client.js'; - -interface CreateCustomerArgs { - workspaceId: string; - workspaceSlug: string; - workspaceName: string; - ownerEmail: string; -} - -export async function createRazorpayCustomerForWorkspace(args: CreateCustomerArgs): Promise { - // Attempt to create. If the customer already exists (fail_existing does not - // reliably suppress the error in test mode), fall back to a lookup by email. - try { - const customer = await (razorpay.customers.create({ - name: args.workspaceName, - email: args.ownerEmail, - fail_existing: 0, - notes: { - workspace_id: args.workspaceId, - workspace_slug: args.workspaceSlug, - }, - }) as Promise<{ id: string }>); - return customer.id; - } catch (err: unknown) { - const rzErr = err as { error?: { description?: string }; statusCode?: number }; - const desc = rzErr?.error?.description ?? ''; - // "Customer already exists for the merchant" → look up the existing record - if (typeof desc === 'string' && desc.toLowerCase().includes('already exists')) { - const list = await (razorpay.customers.all({ count: 100 }) as Promise<{ items: { id: string; email: string }[] }>); - const existing = list.items.find((c) => c.email === args.ownerEmail); - if (existing) return existing.id; - } - // Any other error — re-throw so the route's try/catch formats it properly - throw err; - } -} - -export async function getRazorpayCustomerForWorkspace(workspaceId: string): Promise { - // Customer ID is stored per-subscription in the subscriptions table. - // Return the most recent non-null customer ID for this workspace. - const rows = await db.select({ razorpayCustomerId: subscriptions.razorpayCustomerId }) - .from(subscriptions) - .where(eq(subscriptions.workspaceId, workspaceId)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - return rows[0]?.razorpayCustomerId ?? null; -} - -// STRIPE: ENABLE WHEN APPROVED -// export async function createStripeCustomerForWorkspace(args: CreateCustomerArgs): Promise { -// const customer = await stripe.customers.create({ -// email: args.ownerEmail, -// name: args.workspaceName, -// metadata: { workspace_id: args.workspaceId, workspace_slug: args.workspaceSlug }, -// }); -// await db.update(workspaces).set({ stripeCustomerId: customer.id }).where(eq(workspaces.id, args.workspaceId)); -// return customer.id; -// } diff --git a/apps/api/src/lib/razorpay/plan-state.ts b/apps/api/src/lib/razorpay/plan-state.ts deleted file mode 100644 index 01a7482fc..000000000 --- a/apps/api/src/lib/razorpay/plan-state.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { and, eq } from 'drizzle-orm'; -import { config } from '../../config/env.js'; -import { db } from '../../db/index.js'; -import { razorpayPlanIds } from '../../db/schema.js'; -import type { PlanKey } from './products.js'; - -export async function planKeyFromRazorpayPlanId(razorpayPlanId: string): Promise { - const rows = await db.select({ planKey: razorpayPlanIds.planKey }) - .from(razorpayPlanIds) - .where(and( - eq(razorpayPlanIds.razorpayPlanId, razorpayPlanId), - eq(razorpayPlanIds.environment, config.RAZORPAY_ENVIRONMENT), - )) - .limit(1); - return (rows[0]?.planKey as PlanKey | undefined) ?? null; -} - -// STRIPE: ENABLE WHEN APPROVED -// export async function planKeyFromPriceId(priceId: string): Promise { -// const rows = await db.select({ planKey: stripeProductIds.planKey }) -// .from(stripeProductIds) -// .where(and( -// eq(stripeProductIds.priceId, priceId), -// eq(stripeProductIds.environment, config.STRIPE_ENVIRONMENT), -// )) -// .limit(1); -// return (rows[0]?.planKey as PlanKey | undefined) ?? null; -// } diff --git a/apps/api/src/lib/razorpay/plans.ts b/apps/api/src/lib/razorpay/plans.ts deleted file mode 100644 index 84a3ef291..000000000 --- a/apps/api/src/lib/razorpay/plans.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Razorpay plan ID resolver + pricing constants. - * - * Plan IDs are stored in environment variables (generated by - * src/scripts/create-razorpay-plans.ts) rather than in the DB. - * This makes plan configuration explicit and reproducible from code. - * - * Plan structure: - * 3 slugs (individual, team, business) - * × 2 currencies (USD, INR) - * × 2 cycles (monthly, annual) - * = 12 plans total - */ - -export type PlanSlug = 'individual' | 'team' | 'business'; -export type BillingCycle = 'monthly' | 'annual'; -export type Currency = 'USD' | 'INR'; - -/** - * Resolve the Razorpay plan ID for a given slug, cycle, and currency. - * Throws clearly if the corresponding env var is missing. - */ -export function getPlanId(slug: PlanSlug, cycle: BillingCycle, currency: Currency): string { - const key = `RAZORPAY_PLAN_${slug.toUpperCase()}_${currency}_${cycle.toUpperCase()}`; - const id = process.env[key]; - if (!id) { - if (currency === 'USD') { - throw new Error( - `USD plans not configured. Enable International Payments in Razorpay dashboard, ` + - `then run: npx tsx src/scripts/create-razorpay-plans.ts`, - ); - } - throw new Error(`Missing env var: ${key} — run src/scripts/create-razorpay-plans.ts first`); - } - return id; -} - -/** - * Reverse-lookup: given a Razorpay plan ID from a webhook, return the plan slug. - * Scans all 12 env vars. Returns null if not found. - */ -export function planSlugFromPlanId(planId: string): PlanSlug | null { - const slugs: PlanSlug[] = ['individual', 'team', 'business']; - const currencies: Currency[] = ['USD', 'INR']; - const cycles: BillingCycle[] = ['monthly', 'annual']; - - for (const slug of slugs) { - for (const currency of currencies) { - for (const cycle of cycles) { - const key = `RAZORPAY_PLAN_${slug.toUpperCase()}_${currency}_${cycle.toUpperCase()}`; - if (process.env[key] === planId) return slug; - } - } - } - return null; -} - -/** - * Pricing display values — single source of truth for UI and API. - * Monthly amounts are per-unit. Annual amounts are per-unit per-month - * (i.e. the annual total divided by 12 for display purposes). - */ -export const PLAN_PRICING = { - individual: { - usd_monthly: 10, - usd_annual: 8, // $96/yr billed annually - inr_monthly: 899, - inr_annual: 675, // ₹8,099/yr billed annually - }, - team: { - usd_monthly: 15, - usd_annual: 12, // $144/yr billed annually - inr_monthly: 999, - inr_annual: 749, // ₹8,991/yr billed annually - }, - business: { - usd_monthly: 24, - usd_annual: 20, // $240/yr billed annually - inr_monthly: 1999, - inr_annual: 1499, // ₹17,991/yr billed annually - }, -} as const; - -/** Annual totals (what Razorpay actually charges once a year). */ -export const ANNUAL_TOTALS = { - individual: { usd: 96, inr: 8099 }, - team: { usd: 144, inr: 8991 }, - business: { usd: 240, inr: 17991 }, -} as const; diff --git a/apps/api/src/lib/razorpay/products.ts b/apps/api/src/lib/razorpay/products.ts deleted file mode 100644 index fb759925e..000000000 --- a/apps/api/src/lib/razorpay/products.ts +++ /dev/null @@ -1,80 +0,0 @@ -export type PlanKey = 'free' | 'pro' | 'team'; - -export interface PlanDefinition { - key: PlanKey; - displayName: string; - razorpayManaged: boolean; - description: string; - amountUsdCents?: number; - features: { - maxSeats: number | null; - maxDocs: number | null; - maxMcpCallsPerMonth: number | null; - autocomplete: boolean; - versionHistory: boolean; - prioritySupport: boolean; - sso: boolean; - }; -} - -// STRIPE: ENABLE WHEN APPROVED -// export interface PlanDefinition { -// key: PlanKey; -// displayName: string; -// stripeManaged: boolean; -// ... -// } - -export const PLANS: Record = { - free: { - key: 'free', - displayName: 'Free', - razorpayManaged: false, - description: 'For trying it out.', - features: { - maxSeats: 3, - maxDocs: 100, - maxMcpCallsPerMonth: 500, - autocomplete: false, - versionHistory: true, - prioritySupport: false, - sso: false, - }, - }, - pro: { - key: 'pro', - displayName: 'Pro', - razorpayManaged: true, - description: 'For small teams.', - amountUsdCents: 1500, - features: { - maxSeats: 5, - maxDocs: null, - maxMcpCallsPerMonth: 5000, - autocomplete: true, - versionHistory: true, - prioritySupport: false, - sso: false, - }, - }, - team: { - key: 'team', - displayName: 'Team', - razorpayManaged: true, - description: 'For growing companies.', - amountUsdCents: 2500, - features: { - maxSeats: null, - maxDocs: null, - maxMcpCallsPerMonth: null, - autocomplete: true, - versionHistory: true, - prioritySupport: true, - sso: false, - }, - }, -}; - -export function getPlan(key: PlanKey): PlanDefinition { - return PLANS[key]; -} diff --git a/apps/api/src/lib/razorpay/webhook-handlers.ts b/apps/api/src/lib/razorpay/webhook-handlers.ts deleted file mode 100644 index 3fb5ab0a2..000000000 --- a/apps/api/src/lib/razorpay/webhook-handlers.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { and, eq } from 'drizzle-orm'; -import { db } from '../../db/index.js'; -import { subscriptions, users, workspaceMembers, workspaces } from '../../db/schema.js'; -import { planKeyFromRazorpayPlanId } from './plan-state.js'; -import { planSlugFromPlanId } from './plans.js'; -import { emailQueue } from '../../queue/email.js'; - -/** Look up the workspace owner's email — filtered by role='owner'. */ -async function getWorkspaceOwnerEmail(workspaceId: string): Promise { - const rows = await db - .select({ email: users.email }) - .from(workspaceMembers) - .innerJoin(users, eq(users.id, workspaceMembers.userId)) - .where(and( - eq(workspaceMembers.workspaceId, workspaceId), - eq(workspaceMembers.role, 'owner'), - )) - .limit(1); - return rows[0]?.email ?? null; -} - -export interface RazorpaySubscriptionEntity { - id: string; - status: string; - plan_id: string; - customer_id: string; - quantity: number; - current_start?: number; - current_end?: number; - charge_at?: number; - ended_at?: number; - notes?: { - workspace_id?: string; - plan_slug?: string; - billing_cycle?: string; - billing_currency?: string; - [key: string]: string | undefined; - }; -} - -export interface RazorpayWebhookEvent { - entity: string; - account_id: string; - event: string; - contains: string[]; - payload: { - subscription: { - entity: RazorpaySubscriptionEntity; - }; - /** Present on subscription.charged events alongside the subscription entity. */ - payment?: { - entity: { - id: string; - amount: number; // in paise (divide by 100 for rupees) - currency: string; - status: string; - }; - }; - }; - created_at: number; -} - -// Razorpay doesn't provide stable top-level event IDs, so we derive one -// from the entity ID + event name + created_at timestamp. -export function computeEventId(event: RazorpayWebhookEvent): string { - const sub = event.payload.subscription.entity; - return `${event.event}:${sub.id}:${event.created_at}`; -} - -async function upsertSubscription( - event: RazorpayWebhookEvent, - workspaceId: string, - planKey: string, -): Promise { - const sub = event.payload.subscription.entity; - const periodStart = sub.current_start != null ? new Date(sub.current_start * 1000) : null; - const periodEnd = sub.current_end != null ? new Date(sub.current_end * 1000) : null; - const canceledAt = sub.ended_at != null ? new Date(sub.ended_at * 1000) : null; - - const currency = (sub.notes?.billing_currency ?? 'INR') as 'INR' | 'USD'; - const cycle = (sub.notes?.billing_cycle ?? 'monthly') as 'monthly' | 'annual'; - const billableSeats = sub.quantity ?? 1; - - await db.insert(subscriptions).values({ - workspaceId, - razorpaySubscriptionId: sub.id, - razorpayCustomerId: sub.customer_id || null, - status: sub.status, - planId: sub.plan_id, - planKey, - quantity: billableSeats, - currency, - cycle, - billableSeats, - currentPeriodStart: periodStart, - currentPeriodEnd: periodEnd, - cancelAtPeriodEnd: false, - canceledAt, - updatedAt: new Date(), - }).onConflictDoUpdate({ - target: subscriptions.razorpaySubscriptionId, - set: { - status: sub.status, - planId: sub.plan_id, - planKey, - quantity: billableSeats, - currency, - cycle, - billableSeats, - currentPeriodStart: periodStart, - currentPeriodEnd: periodEnd, - canceledAt, - updatedAt: new Date(), - }, - }); -} - -async function findWorkspaceBySubscription(subscriptionId: string): Promise { - const rows = await db.select({ workspaceId: subscriptions.workspaceId }) - .from(subscriptions) - .where(eq(subscriptions.razorpaySubscriptionId, subscriptionId)) - .limit(1); - return rows[0]?.workspaceId ?? null; -} - -export async function handleSubscriptionActivated(event: RazorpayWebhookEvent): Promise { - const sub = event.payload.subscription.entity; - - // Resolve plan key: try env-var map first (new system), fall back to DB table - // (old system), then fall back to the plan_slug stored in subscription notes. - const planKey = - planSlugFromPlanId(sub.plan_id) ?? - (await planKeyFromRazorpayPlanId(sub.plan_id)) ?? - sub.notes?.plan_slug ?? - null; - if (!planKey) { - console.warn('[razorpay-webhook] plan_id not mapped to a plan key', { plan_id: sub.plan_id }); - return; - } - - // Resolve workspace: check our subscriptions table first; fall back to - // notes.workspace_id (set when the subscription was created via our API). - const wsId = - (await findWorkspaceBySubscription(sub.id)) ?? - sub.notes?.workspace_id ?? - null; - if (!wsId) { - console.warn('[razorpay-webhook] no workspace_id for subscription', { id: sub.id }); - return; - } - - await upsertSubscription(event, wsId, planKey); - - // Only workspaces.plan exists on workspaces — status/period live in subscriptions. - // planKey is a plan slug ('individual' | 'team' | 'business'); workspaces.plan is - // free text, so store the slug verbatim (the previous 'pro'|'team' cast dropped - // 'business', leaving Business workspaces mislabelled). - await db.update(workspaces).set({ - plan: planKey, - }).where(eq(workspaces.id, wsId)); - - console.log(`[razorpay-webhook] subscription.activated → workspace ${wsId} plan=${planKey}`); - - // Send payment_successful email to workspace owner (fire-and-forget) - void getWorkspaceOwnerEmail(wsId).then((email) => { - if (!email) return; - const periodEnd = sub.current_end - ? new Date(sub.current_end * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) - : 'next billing cycle'; - return emailQueue.add('payment_successful', { - type: 'payment_successful', - to: email, - params: { - planName: planKey.charAt(0).toUpperCase() + planKey.slice(1), - amount: '—', - date: new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }), - nextBillingDate: periodEnd, - billingUrl: `${process.env.WEB_BASE_URL ?? 'https://mnema.example.com'}/app/settings/billing`, - }, - }); - }).catch((err) => console.error('[razorpay-webhook] failed to send payment_successful email', err)); -} - -export async function handleSubscriptionHalted(event: RazorpayWebhookEvent): Promise { - const sub = event.payload.subscription.entity; - const wsId = await findWorkspaceBySubscription(sub.id); - if (!wsId) return; - - await db.update(subscriptions) - .set({ status: 'halted', updatedAt: new Date() }) - .where(eq(subscriptions.razorpaySubscriptionId, sub.id)); - - console.log(`[razorpay-webhook] subscription.halted → workspace ${wsId} (payment failed)`); - - // Send payment_failed email to workspace owner (fire-and-forget) - void getWorkspaceOwnerEmail(wsId).then((email) => { - if (!email) return; - // Razorpay gives 7 days grace period before halting — access ends at period end - const gracePeriodEnd = sub.current_end - ? new Date(sub.current_end * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) - : 'end of billing period'; - return emailQueue.add('payment_failed', { - type: 'payment_failed', - to: email, - params: { - planName: sub.plan_id ? 'Pro' : 'Pro', // plan name resolved from planKey if needed - amount: '—', - date: new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }), - gracePeriodEnd, - updateUrl: `${process.env.WEB_BASE_URL ?? 'https://mnema.example.com'}/app/settings/billing`, - }, - }); - }).catch((err) => console.error('[razorpay-webhook] failed to send payment_failed email', err)); -} - -export async function handleSubscriptionCancelled(event: RazorpayWebhookEvent): Promise { - const sub = event.payload.subscription.entity; - const wsId = await findWorkspaceBySubscription(sub.id); - if (!wsId) return; - - // Look up planKey before updating so we can use it in the email - const planKey = await planKeyFromRazorpayPlanId(sub.plan_id); - - await db.update(subscriptions).set({ - status: 'cancelled', - canceledAt: new Date(), - updatedAt: new Date(), - }).where(eq(subscriptions.razorpaySubscriptionId, sub.id)); - - // Only plan exists on workspaces — status/period/subscription live in subscriptions - await db.update(workspaces).set({ - plan: 'free', - }).where(eq(workspaces.id, wsId)); - - console.log(`[razorpay-webhook] subscription.cancelled → workspace ${wsId} downgraded to free`); - - // Send subscription_cancelled email to workspace owner (fire-and-forget) - void getWorkspaceOwnerEmail(wsId).then((email) => { - if (!email) return; - const accessEnd = sub.ended_at - ? new Date(sub.ended_at * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) - : 'end of billing period'; - const planName = planKey - ? planKey.charAt(0).toUpperCase() + planKey.slice(1) - : 'Pro'; - return emailQueue.add('subscription_cancelled', { - type: 'subscription_cancelled', - to: email, - params: { - planName, - accessEndDate: accessEnd, - reactivateUrl: `${process.env.WEB_BASE_URL ?? 'https://mnema.example.com'}/app/settings/billing`, - }, - }); - }).catch((err) => console.error('[razorpay-webhook] failed to send subscription_cancelled email', err)); -} - -export async function handleSubscriptionPaused(event: RazorpayWebhookEvent): Promise { - const sub = event.payload.subscription.entity; - const wsId = await findWorkspaceBySubscription(sub.id); - if (!wsId) return; - - await db.update(subscriptions) - .set({ status: 'paused', updatedAt: new Date() }) - .where(eq(subscriptions.razorpaySubscriptionId, sub.id)); - - console.log(`[razorpay-webhook] subscription.paused → workspace ${wsId}`); -} - -/** - * subscription.charged fires on every successful renewal charge. - * We update currentPeriodStart/End so the billing page always shows the - * correct next-renewal date, and fire a payment_successful email. - */ -export async function handleSubscriptionCharged(event: RazorpayWebhookEvent): Promise { - const sub = event.payload.subscription.entity; - const wsId = await findWorkspaceBySubscription(sub.id); - if (!wsId) return; - - const periodStart = sub.current_start != null ? new Date(sub.current_start * 1000) : null; - const periodEnd = sub.current_end != null ? new Date(sub.current_end * 1000) : null; - - await db.update(subscriptions).set({ - status: sub.status, - currentPeriodStart: periodStart, - currentPeriodEnd: periodEnd, - updatedAt: new Date(), - }).where(eq(subscriptions.razorpaySubscriptionId, sub.id)); - - console.log(`[razorpay-webhook] subscription.charged → workspace ${wsId} period updated`); - - // Send payment_successful email (fire-and-forget) - void getWorkspaceOwnerEmail(wsId).then(async (email) => { - if (!email) return; - const planKey = await planKeyFromRazorpayPlanId(sub.plan_id); - const planName = planKey ? planKey.charAt(0).toUpperCase() + planKey.slice(1) : 'Pro'; - const nextBillingDate = periodEnd - ? periodEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) - : 'next billing cycle'; - // Payment amount from the payment entity (paise → rupees) - const payment = event.payload.payment?.entity; - const amount = payment - ? `₹${(payment.amount / 100).toLocaleString('en-IN')}` - : '—'; - return emailQueue.add('payment_successful', { - type: 'payment_successful', - to: email, - params: { - planName, - amount, - date: new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }), - nextBillingDate, - billingUrl: `${process.env.WEB_BASE_URL ?? 'https://mnema.example.com'}/app/settings/billing`, - }, - }); - }).catch((err) => console.error('[razorpay-webhook] failed to send charged email', err)); -} - -// STRIPE: ENABLE WHEN APPROVED -// export async function handleSubscriptionEvent(event: Stripe.Event): Promise { ... } -// export async function handleSubscriptionDeleted(event: Stripe.Event): Promise { ... } -// export async function handleInvoicePaymentSucceeded(event: Stripe.Event): Promise { ... } -// export async function handleInvoicePaymentFailed(event: Stripe.Event): Promise { ... } diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts deleted file mode 100644 index 52b75b1e5..000000000 --- a/apps/api/src/routes/billing.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { and, count, desc, eq, inArray } from 'drizzle-orm'; -import type { FastifyPluginAsync } from 'fastify'; -import { z } from 'zod'; -import { config } from '../config/env.js'; -import { db } from '../db/index.js'; -import { subscriptions, workspaceMembers, workspaces } from '../db/schema.js'; -import { createRazorpayCustomerForWorkspace, getRazorpayCustomerForWorkspace } from '../lib/razorpay/customer.js'; -import { requireRole } from '../lib/role.js'; -import { countBillableSeats, WRITER_ROLES } from '../lib/billing/seats.js'; -import { detectCurrency } from '../lib/billing/currency.js'; -import { getPlanId, PLAN_PRICING } from '../lib/razorpay/plans.js'; -import type { PlanSlug, BillingCycle } from '../lib/razorpay/plans.js'; - -const subscribeSchema = z.object({ - plan: z.enum(['individual', 'team', 'business']), - cycle: z.enum(['monthly', 'annual']).default('monthly'), -}); - -const cancelSchema = z.object({ - cancelAtPeriodEnd: z.boolean().default(true), -}); - -// STRIPE: ENABLE WHEN APPROVED -// import { stripe } from '../lib/stripe/client.js'; -// import { config } from '../config/env.js'; -// import { createStripeCustomerForWorkspace } from '../lib/stripe/customer.js'; - -export const billingRoutes: FastifyPluginAsync = async (app) => { - app.get('/api/billing/current', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'viewer'); - - const wsRows = await db.select({ plan: workspaces.plan }) - .from(workspaces) - .where(eq(workspaces.id, req.auth.tenant_id)) - .limit(1); - - if (wsRows.length === 0) return reply.code(404).send({ error: 'workspace_not_found' }); - - // Billing details live in the subscriptions table, not on workspaces - const subRows = await db.select({ - status: subscriptions.status, - currentPeriodEnd: subscriptions.currentPeriodEnd, - cancelAtPeriodEnd: subscriptions.cancelAtPeriodEnd, - razorpayCustomerId: subscriptions.razorpayCustomerId, - }) - .from(subscriptions) - .where(eq(subscriptions.workspaceId, req.auth.tenant_id)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - const sub = subRows[0]; - return { - plan: wsRows[0]!.plan, - subscription_status: sub?.status ?? null, - current_period_end: sub?.currentPeriodEnd?.toISOString() ?? null, - cancel_at_period_end: sub?.cancelAtPeriodEnd ?? false, - has_razorpay_customer: Boolean(sub?.razorpayCustomerId), - // STRIPE: ENABLE WHEN APPROVED - // has_stripe_customer: Boolean(rows[0]!.stripeCustomerId), - }; - }); - - // ------------------------------------------------------------------------- - // GET /api/billing/status — richer billing status for the pricing page and - // settings panel. Includes plan, seats, prices, and upgrade URL. - // ------------------------------------------------------------------------- - app.get('/api/billing/status', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'viewer'); - - const workspaceId = req.auth.tenant_id; - const currency = detectCurrency(req.headers as Record); - - // Workspace plan - const wsRows = await db.select({ plan: workspaces.plan }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - if (wsRows.length === 0) return reply.code(404).send({ error: 'workspace_not_found' }); - - // Most recent subscription - const subRows = await db.select({ - status: subscriptions.status, - planKey: subscriptions.planKey, - cycle: subscriptions.cycle, - currency: subscriptions.currency, - billableSeats: subscriptions.billableSeats, - currentPeriodEnd: subscriptions.currentPeriodEnd, - cancelAtPeriodEnd: subscriptions.cancelAtPeriodEnd, - }) - .from(subscriptions) - .where(eq(subscriptions.workspaceId, workspaceId)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - const sub = subRows[0]; - - // Count current seats - const writerCountRows = await db - .select({ c: count() }) - .from(workspaceMembers) - .where(and( - eq(workspaceMembers.workspaceId, workspaceId), - inArray(workspaceMembers.role, [...WRITER_ROLES]), - )); - const readerCountRows = await db - .select({ c: count() }) - .from(workspaceMembers) - .where(and( - eq(workspaceMembers.workspaceId, workspaceId), - eq(workspaceMembers.role, 'viewer'), - )); - - const writerSeats = Number(writerCountRows[0]?.c ?? 0); - const readerSeats = Number(readerCountRows[0]?.c ?? 0); - - // Resolved plan slug — use subscription planKey or default to workspace plan - const planSlug = (sub?.planKey as PlanSlug | undefined) ?? null; - const billingCurrency = (sub?.currency as 'INR' | 'USD' | undefined) ?? currency; - const billingCycle = (sub?.cycle as BillingCycle | undefined) ?? 'monthly'; - - // Price lookup - const prices = planSlug && planSlug in PLAN_PRICING - ? { - monthly_usd: PLAN_PRICING[planSlug].usd_monthly, - annual_usd: PLAN_PRICING[planSlug].usd_annual, - monthly_inr: PLAN_PRICING[planSlug].inr_monthly, - annual_inr: PLAN_PRICING[planSlug].inr_annual, - } - : null; - - const webBaseUrl = config.WEB_BASE_URL ?? 'https://mnema.example.com'; - - return { - plan: wsRows[0]!.plan, - subscription_status: sub?.status ?? null, - plan_slug: planSlug, - cycle: billingCycle, - currency: billingCurrency, - billable_seats: sub?.billableSeats ?? writerSeats, - writer_seats: writerSeats, - reader_seats: readerSeats, - current_period_end: sub?.currentPeriodEnd?.toISOString() ?? null, - cancel_at_period_end: sub?.cancelAtPeriodEnd ?? false, - prices, - upgrade_url: `${webBaseUrl}/app/settings/billing`, - detected_currency: currency, - }; - }); - - app.post('/api/billing/cancel', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'owner'); - - const parsed = cancelSchema.safeParse(req.body ?? {}); - const cancelAtPeriodEnd = parsed.success ? parsed.data.cancelAtPeriodEnd : true; - - // Get the active subscription for this workspace from the subscriptions table - const subRows = await db.select({ - razorpaySubscriptionId: subscriptions.razorpaySubscriptionId, - status: subscriptions.status, - }) - .from(subscriptions) - .where(and( - eq(subscriptions.workspaceId, req.auth.tenant_id), - inArray(subscriptions.status, ['active', 'trialing', 'created']), - )) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - if (subRows.length === 0) { - return reply.code(400).send({ error: 'no_active_subscription' }); - } - - const { razorpaySubscriptionId } = subRows[0]!; - - // Cancel at period end (or immediately) via Razorpay API - const razorpay = (await import('../lib/razorpay/client.js')).razorpay; - await razorpay.subscriptions.cancel(razorpaySubscriptionId, cancelAtPeriodEnd); - - await db.update(subscriptions) - .set({ cancelAtPeriodEnd, updatedAt: new Date() }) - .where(eq(subscriptions.razorpaySubscriptionId, razorpaySubscriptionId)); - - return { cancelled: true, cancel_at_period_end: cancelAtPeriodEnd }; - - // STRIPE: ENABLE WHEN APPROVED - // app.post('/api/billing/portal', async (req, reply) => { - // const session = await stripe.billingPortal.sessions.create({ - // customer: customerId, - // return_url: config.STRIPE_RETURN_URL, - // }); - // return { url: session.url }; - // }); - }); - - // ------------------------------------------------------------------------- - // POST /api/billing/update-payment-method — generate a Razorpay hosted - // page where the customer can update their saved payment method. - // ------------------------------------------------------------------------- - app.post('/api/billing/update-payment-method', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'owner'); - - const subRows = await db.select({ - razorpaySubscriptionId: subscriptions.razorpaySubscriptionId, - razorpayCustomerId: subscriptions.razorpayCustomerId, - }) - .from(subscriptions) - .where(and( - eq(subscriptions.workspaceId, req.auth.tenant_id), - inArray(subscriptions.status, ['active', 'trialing', 'created', 'halted']), - )) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - if (subRows.length === 0) { - return reply.code(400).send({ error: 'no_subscription_found' }); - } - - // Razorpay's "update payment" flow uses a short_url generated via - // the subscriptions update endpoint with the manage token. - // The simplest approach: redirect to the Razorpay subscription management page. - const { razorpaySubscriptionId } = subRows[0]!; - const razorpay = (await import('../lib/razorpay/client.js')).razorpay; - const sub = await (razorpay.subscriptions.fetch(razorpaySubscriptionId) as Promise<{ short_url: string }>); - - return { - manage_url: sub.short_url, - }; - }); - - // ------------------------------------------------------------------------- - // GET /api/billing/payments — payment / invoice history for this workspace. - // Fetches invoices live from Razorpay (no local mirror needed). - // ------------------------------------------------------------------------- - app.get('/api/billing/payments', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'viewer'); - - // Find the most recent subscription that has a Razorpay ID - const subRows = await db.select({ - razorpaySubscriptionId: subscriptions.razorpaySubscriptionId, - }) - .from(subscriptions) - .where(eq(subscriptions.workspaceId, req.auth.tenant_id)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - if (subRows.length === 0) { - return { invoices: [] }; - } - - const { razorpaySubscriptionId } = subRows[0]!; - const razorpay = (await import('../lib/razorpay/client.js')).razorpay; - - // Razorpay generates invoices automatically for subscription charges. - interface RazorpayInvoice { - id: string; - date: number; // Unix timestamp - description?: string; - amount_paid: number; // in paise - amount_due: number; - currency: string; - status: string; // 'paid' | 'issued' | 'draft' | 'cancelled' | 'expired' - short_url?: string; // hosted invoice page (PDF download link) - } - interface InvoiceListResponse { items: RazorpayInvoice[]; count: number; } - - let items: RazorpayInvoice[] = []; - try { - const result = await (razorpay.invoices.all({ - subscription_id: razorpaySubscriptionId, - count: 12, - }) as Promise); - items = result.items ?? []; - } catch (err) { - req.log.warn({ err }, 'Failed to fetch Razorpay invoices'); - // Return empty list rather than 500 — UI shows "No payments yet" - return { invoices: [] }; - } - - const invoices = items.map((inv) => ({ - id: inv.id, - date: inv.date ? new Date(inv.date * 1000).toISOString() : null, - description: inv.description ?? 'Subscription charge', - amount_paid: inv.amount_paid, // paise — frontend divides by 100 - amount_due: inv.amount_due, - currency: inv.currency ?? 'INR', - status: inv.status, - download_url: inv.short_url ?? null, - })); - - return { invoices }; - }); - - // ------------------------------------------------------------------------- - // POST /api/billing/change-plan — immediate plan change. - // Cancels current subscription right away, starts a new checkout. - // The user pays full price for the new plan from day one. - // ------------------------------------------------------------------------- - app.post('/api/billing/change-plan', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'owner'); - - const parsed = subscribeSchema.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ error: 'bad_request', details: parsed.error.flatten() }); - - const { plan, cycle } = parsed.data; - const currency = detectCurrency(req.headers as Record); - - // Resolve new Razorpay plan_id - let razorpayPlanId: string; - try { - razorpayPlanId = getPlanId(plan as PlanSlug, cycle as BillingCycle, currency); - } catch (err) { - req.log.warn({ err, plan, cycle, currency }, 'plan_not_configured'); - return reply.code(400).send({ error: 'plan_not_configured', detail: (err as Error).message }); - } - - const razorpay = (await import('../lib/razorpay/client.js')).razorpay; - - // Cancel current active subscription immediately (not at period end) - const activeSubs = await db.select({ - razorpaySubscriptionId: subscriptions.razorpaySubscriptionId, - }) - .from(subscriptions) - .where(and( - eq(subscriptions.workspaceId, req.auth.tenant_id), - inArray(subscriptions.status, ['active', 'trialing', 'created']), - )) - .orderBy(desc(subscriptions.createdAt)) - .limit(1); - - if (activeSubs.length > 0) { - const oldSubId = activeSubs[0]!.razorpaySubscriptionId; - try { - await razorpay.subscriptions.cancel(oldSubId, false); // false = immediate - await db.update(subscriptions) - .set({ status: 'cancelled', canceledAt: new Date(), updatedAt: new Date() }) - .where(eq(subscriptions.razorpaySubscriptionId, oldSubId)); - } catch (err) { - req.log.warn({ err, oldSubId }, 'Failed to cancel old subscription during plan change'); - // Continue — create the new subscription anyway - } - } - - // Count writer seats and ensure customer exists - const billableSeats = await countBillableSeats(req.auth.tenant_id); - let customerId = await getRazorpayCustomerForWorkspace(req.auth.tenant_id); - if (!customerId) { - const wsRows = await db.select({ slug: workspaces.slug, name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, req.auth.tenant_id)) - .limit(1); - if (wsRows.length === 0) return reply.code(404).send({ error: 'workspace_not_found' }); - customerId = await createRazorpayCustomerForWorkspace({ - workspaceId: req.auth.tenant_id, - workspaceSlug: wsRows[0]!.slug, - workspaceName: wsRows[0]!.name, - ownerEmail: req.auth.email, - }); - } - - const webBaseUrl = config.WEB_BASE_URL ?? 'https://mnema.example.com'; - let subscription: { id: string; short_url: string }; - try { - subscription = await (razorpay.subscriptions.create({ - plan_id: razorpayPlanId, - customer_id: customerId, - quantity: billableSeats, - total_count: 120, - notes: { - workspace_id: req.auth.tenant_id, - plan_slug: plan, - billing_cycle: cycle, - billing_currency: currency, - }, - } as Parameters[0]) as Promise<{ id: string; short_url: string }>); - } catch (err: unknown) { - const rzErr = err as { error?: { description?: string; code?: string }; statusCode?: number }; - const detail = rzErr?.error?.description ?? (err instanceof Error ? err.message : String(err)); - req.log.error({ err, plan, cycle, currency }, 'razorpay_change_plan_create_failed'); - return reply.code(502).send({ error: 'payment_provider_error', detail }); - } - - if (!subscription.short_url) { - return reply.code(502).send({ error: 'payment_provider_error', detail: 'No checkout URL returned. Please try again.' }); - } - - // Persist the new pending subscription so the webhook handler can find it - try { - await db.insert(subscriptions).values({ - workspaceId: req.auth.tenant_id, - razorpaySubscriptionId: subscription.id, - razorpayCustomerId: customerId, - status: 'created', - planId: razorpayPlanId, - planKey: plan, - quantity: billableSeats, - currency, - cycle, - billableSeats, - updatedAt: new Date(), - }).onConflictDoUpdate({ - target: subscriptions.razorpaySubscriptionId, - set: { status: 'created', updatedAt: new Date() }, - }); - } catch (err) { - req.log.warn({ err, subscriptionId: subscription.id }, 'Failed to persist pending subscription row (change-plan)'); - } - - return { - subscription_id: subscription.id, - short_url: subscription.short_url, - }; - }); - - // Lazy-create Razorpay customer (called from checkout flow in Phase 5) - app.post('/api/billing/ensure-customer', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'owner'); - - const wsRows = await db.select({ slug: workspaces.slug, name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, req.auth.tenant_id)) - .limit(1); - - if (wsRows.length === 0) return reply.code(404).send({ error: 'workspace_not_found' }); - - // Look up existing customer ID from the subscriptions table - let customerId = await getRazorpayCustomerForWorkspace(req.auth.tenant_id); - if (!customerId) { - customerId = await createRazorpayCustomerForWorkspace({ - workspaceId: req.auth.tenant_id, - workspaceSlug: wsRows[0]!.slug, - workspaceName: wsRows[0]!.name, - ownerEmail: req.auth.email, - }); - } - - return { razorpay_customer_id: customerId }; - }); - - // Create a new Razorpay subscription and return the hosted payment link. - // Detects currency from CF-IPCountry, counts writer seats, resolves plan ID - // from env vars (populated by create-razorpay-plans.ts). - app.post('/api/billing/subscribe', async (req, reply) => { - if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); - await requireRole(req, 'owner'); - - const parsed = subscribeSchema.safeParse(req.body); - if (!parsed.success) return reply.code(400).send({ error: 'bad_request', details: parsed.error.flatten() }); - - const { plan, cycle } = parsed.data; - const currency = detectCurrency(req.headers as Record); - - // Resolve Razorpay plan_id from env vars - let razorpayPlanId: string; - try { - razorpayPlanId = getPlanId(plan as PlanSlug, cycle as BillingCycle, currency); - } catch (err) { - req.log.warn({ err, plan, cycle, currency }, 'plan_not_configured'); - return reply.code(400).send({ error: 'plan_not_configured', detail: (err as Error).message }); - } - - // Count writer seats (minimum 1) - const billableSeats = await countBillableSeats(req.auth.tenant_id); - - // Ensure a Razorpay customer exists for this workspace - let customerId = await getRazorpayCustomerForWorkspace(req.auth.tenant_id); - if (!customerId) { - const wsRows = await db.select({ slug: workspaces.slug, name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, req.auth.tenant_id)) - .limit(1); - if (wsRows.length === 0) return reply.code(404).send({ error: 'workspace_not_found' }); - customerId = await createRazorpayCustomerForWorkspace({ - workspaceId: req.auth.tenant_id, - workspaceSlug: wsRows[0]!.slug, - workspaceName: wsRows[0]!.name, - ownerEmail: req.auth.email, - }); - } - - const razorpay = (await import('../lib/razorpay/client.js')).razorpay; - const webBaseUrl = config.WEB_BASE_URL ?? 'https://mnema.example.com'; - - // The Razorpay SDK throws a plain object (not an Error) on API errors: - // { statusCode, error: { code, description, ... } } - // Wrap in try/catch to return a clean string error to the frontend. - let subscription: { id: string; short_url: string }; - try { - subscription = await (razorpay.subscriptions.create({ - plan_id: razorpayPlanId, - customer_id: customerId, - quantity: billableSeats, - total_count: 120, // up to 10 years — effectively open-ended - // NOTE: callback_url is NOT accepted by Razorpay subscriptions.create — - // the post-payment redirect URL must be set in the Razorpay Dashboard - // under Settings → Checkout Settings → Redirect URL. - notes: { - workspace_id: req.auth.tenant_id, - plan_slug: plan, - billing_cycle: cycle, - billing_currency: currency, - }, - } as Parameters[0]) as Promise<{ id: string; short_url: string }>); - } catch (err: unknown) { - const rzErr = err as { error?: { description?: string; code?: string }; statusCode?: number }; - const detail = rzErr?.error?.description ?? (err instanceof Error ? err.message : String(err)); - req.log.error({ err, plan, cycle, currency }, 'razorpay_subscription_create_failed'); - return reply.code(502).send({ error: 'payment_provider_error', detail }); - } - - if (!subscription.short_url) { - req.log.error({ subscription }, 'razorpay_subscription_missing_short_url'); - return reply.code(502).send({ error: 'payment_provider_error', detail: 'No checkout URL returned. Please try again.' }); - } - - // Persist a pending subscription row immediately so the webhook handler - // can look it up by razorpaySubscriptionId when subscription.activated fires. - // Without this, findWorkspaceBySubscription() returns null and the handler exits. - try { - await db.insert(subscriptions).values({ - workspaceId: req.auth.tenant_id, - razorpaySubscriptionId: subscription.id, - razorpayCustomerId: customerId, - status: 'created', - planId: razorpayPlanId, - planKey: plan, - quantity: billableSeats, - currency, - cycle, - billableSeats, - updatedAt: new Date(), - }).onConflictDoUpdate({ - target: subscriptions.razorpaySubscriptionId, - set: { status: 'created', updatedAt: new Date() }, - }); - } catch (err) { - // Non-fatal — webhook fallback (notes.workspace_id) handles it if this fails - req.log.warn({ err, subscriptionId: subscription.id }, 'Failed to persist pending subscription row'); - } - - return { - subscription_id: subscription.id, - short_url: subscription.short_url, - }; - }); -}; diff --git a/apps/api/src/routes/razorpay.ts b/apps/api/src/routes/razorpay.ts deleted file mode 100644 index 1464416ac..000000000 --- a/apps/api/src/routes/razorpay.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { eq } from 'drizzle-orm'; -import type { FastifyPluginAsync } from 'fastify'; -import { db } from '../db/index.js'; -import { webhookEvents } from '../db/schema.js'; -import { validateWebhookSignature } from '../lib/razorpay/client.js'; -import { - computeEventId, - handleSubscriptionActivated, - handleSubscriptionCancelled, - handleSubscriptionCharged, - handleSubscriptionHalted, - handleSubscriptionPaused, - type RazorpayWebhookEvent, -} from '../lib/razorpay/webhook-handlers.js'; - -// STRIPE: ENABLE WHEN APPROVED -// import { stripe } from '../lib/stripe/client.js'; -// import { config } from '../config/env.js'; -// import { -// handleInvoicePaymentFailed, -// handleInvoicePaymentSucceeded, -// handleSubscriptionDeleted, -// handleSubscriptionEvent, -// } from '../lib/stripe/webhook-handlers.js'; - -export const razorpayRoutes: FastifyPluginAsync = async (app) => { - // Raw-buffer parser scoped to this plugin — signature verification requires - // exact bytes before any JSON parse. - app.addContentTypeParser( - 'application/json', - { parseAs: 'buffer' }, - (_req, body, done) => { - done(null, body); - }, - ); - - app.post('/api/razorpay/webhook', { - config: { skipAuth: true }, - }, async (req, reply) => { - const signature = req.headers['x-razorpay-signature']; - if (!signature || Array.isArray(signature)) { - return reply.code(400).send({ error: 'missing_signature' }); - } - - const body = req.body as Buffer; - if (!validateWebhookSignature(body, signature)) { - console.error('[razorpay-webhook] signature verification failed'); - return reply.code(400).send({ error: 'invalid_signature' }); - } - - let event: RazorpayWebhookEvent; - try { - event = JSON.parse(body.toString()) as RazorpayWebhookEvent; - } catch { - return reply.code(400).send({ error: 'invalid_payload' }); - } - - if (!event.payload?.subscription?.entity) { - console.log(`[razorpay-webhook] non-subscription event ${event.event}, ignoring`); - return reply.code(200).send({ received: true }); - } - - const eventId = computeEventId(event); - - // Idempotency — refuse to re-process the same event - const existing = await db.select({ id: webhookEvents.id }) - .from(webhookEvents) - .where(eq(webhookEvents.eventId, eventId)) - .limit(1); - if (existing.length > 0) { - console.log(`[razorpay-webhook] event ${eventId} already processed, skipping`); - return reply.code(200).send({ received: true, duplicate: true }); - } - - const [inserted] = await db.insert(webhookEvents).values({ - eventId, - eventType: event.event, - payload: event as unknown as Record, - }).returning(); - - try { - switch (event.event) { - case 'subscription.activated': - await handleSubscriptionActivated(event); - break; - case 'subscription.charged': - await handleSubscriptionCharged(event); - break; - case 'subscription.halted': - await handleSubscriptionHalted(event); - break; - case 'subscription.cancelled': - await handleSubscriptionCancelled(event); - break; - case 'subscription.paused': - await handleSubscriptionPaused(event); - break; - default: - console.log(`[razorpay-webhook] unhandled event type: ${event.event}`); - } - - await db.update(webhookEvents) - .set({ processedAt: new Date() }) - .where(eq(webhookEvents.id, inserted!.id)); - - return reply.code(200).send({ received: true }); - } catch (err) { - console.error(`[razorpay-webhook] handler error for ${event.event}`, err); - await db.update(webhookEvents) - .set({ error: err instanceof Error ? err.message : String(err) }) - .where(eq(webhookEvents.id, inserted!.id)); - // Return 200 so Razorpay doesn't retry — investigate from webhook_events table - return reply.code(200).send({ received: true, processing_error: true }); - } - }); - - // STRIPE: ENABLE WHEN APPROVED - // app.post('/api/stripe/webhook', { config: { skipAuth: true } }, async (req, reply) => { ... }); -}; diff --git a/apps/api/src/scripts/create-razorpay-plans.ts b/apps/api/src/scripts/create-razorpay-plans.ts deleted file mode 100644 index 08e814444..000000000 --- a/apps/api/src/scripts/create-razorpay-plans.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * One-time Razorpay plan creation script. - * - * Run with: npx tsx src/scripts/create-razorpay-plans.ts - * Prerequisites: RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET set in .env - * Output: prints env var block — copy into .env and set in Vercel/Railway. - * - * Idempotent: running twice will NOT create duplicate plans. If a plan - * with the same name already exists, the script prints the existing ID - * and skips creation. - * - * Plan structure: 3 slugs × 2 currencies × 2 cycles = 12 plans. - * Amounts in smallest currency unit (USD: cents, INR: paise). - */ - -import { config as dotenv } from 'dotenv'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -dotenv({ path: resolve(__dirname, '../../../../.env') }); - -import Razorpay from 'razorpay'; - -if (!process.env.RAZORPAY_KEY_ID || !process.env.RAZORPAY_KEY_SECRET) { - console.error('Error: RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET must be set in .env'); - process.exit(1); -} - -const razorpay = new Razorpay({ - key_id: process.env.RAZORPAY_KEY_ID, - key_secret: process.env.RAZORPAY_KEY_SECRET, -}); - -interface PlanDef { - slug: string; - amount: number; - currency: string; - period: 'monthly' | 'yearly'; - interval: number; - name: string; -} - -const plans: PlanDef[] = [ - // USD monthly - { slug: 'individual_usd_monthly', amount: 1000, currency: 'USD', period: 'monthly', interval: 1, name: 'Individual — Monthly (USD)' }, - { slug: 'team_usd_monthly', amount: 1500, currency: 'USD', period: 'monthly', interval: 1, name: 'Team — Monthly (USD)' }, - { slug: 'business_usd_monthly', amount: 2400, currency: 'USD', period: 'monthly', interval: 1, name: 'Business — Monthly (USD)' }, - // USD annual (20% discount: individual $10×12×0.8=$96, team $15×12×0.8=$144, business $24×12×0.8=$230.40→$240) - { slug: 'individual_usd_annual', amount: 9600, currency: 'USD', period: 'yearly', interval: 1, name: 'Individual — Annual (USD)' }, - { slug: 'team_usd_annual', amount: 14400, currency: 'USD', period: 'yearly', interval: 1, name: 'Team — Annual (USD)' }, - { slug: 'business_usd_annual', amount: 24000, currency: 'USD', period: 'yearly', interval: 1, name: 'Business — Annual (USD)' }, - // INR monthly - { slug: 'individual_inr_monthly', amount: 89900, currency: 'INR', period: 'monthly', interval: 1, name: 'Individual — Monthly (INR)' }, - { slug: 'team_inr_monthly', amount: 99900, currency: 'INR', period: 'monthly', interval: 1, name: 'Team — Monthly (INR)' }, - { slug: 'business_inr_monthly', amount: 199900, currency: 'INR', period: 'monthly', interval: 1, name: 'Business — Monthly (INR)' }, - // INR annual (25% discount: individual ₹899×12×0.75=₹8,091→₹8,099, team ₹999×12×0.75=₹8,991, business ₹1999×12×0.75=₹17,991) - { slug: 'individual_inr_annual', amount: 809900, currency: 'INR', period: 'yearly', interval: 1, name: 'Individual — Annual (INR)' }, - { slug: 'team_inr_annual', amount: 899100, currency: 'INR', period: 'yearly', interval: 1, name: 'Team — Annual (INR)' }, - { slug: 'business_inr_annual', amount: 1799100,currency: 'INR', period: 'yearly', interval: 1, name: 'Business — Annual (INR)' }, -]; - -interface RazorpayPlan { - id: string; - item?: { name?: string }; - interval?: number; - period?: string; -} - -async function getExistingPlanByName(name: string): Promise { - try { - // Razorpay returns up to 100 plans per page. For small plan counts this is fine. - const result = await razorpay.plans.all({ count: 100 }) as { items?: RazorpayPlan[] }; - const items = result.items ?? []; - return items.find((p: RazorpayPlan) => p.item?.name === name) ?? null; - } catch { - return null; - } -} - -async function main(): Promise { - console.log(`\nCreating Razorpay plans (environment: ${process.env.RAZORPAY_KEY_ID?.startsWith('rzp_test') ? 'TEST' : 'LIVE'})\n`); - - const envLines: string[] = []; - - for (const plan of plans) { - const envKey = `RAZORPAY_PLAN_${plan.slug.toUpperCase()}`; - - // Idempotency check — skip if already exists with this name - const existing = await getExistingPlanByName(plan.name); - if (existing) { - console.log(` ✓ ${plan.name} — already exists: ${existing.id}`); - envLines.push(`${envKey}=${existing.id}`); - continue; - } - - try { - const created = await razorpay.plans.create({ - period: plan.period, - interval: plan.interval, - item: { - name: plan.name, - amount: plan.amount, - currency: plan.currency, - }, - }) as RazorpayPlan; - - console.log(` ✅ ${plan.name} — created: ${created.id}`); - envLines.push(`${envKey}=${created.id}`); - } catch (err) { - console.error(` ❌ ${plan.name} — FAILED:`, err); - envLines.push(`${envKey}=CREATION_FAILED`); - } - } - - console.log('\n─────────────────────────────────────────────────────────────'); - console.log('Copy these into your .env file:\n'); - envLines.forEach(line => console.log(line)); - console.log('─────────────────────────────────────────────────────────────\n'); -} - -main().catch(err => { - console.error('Fatal:', err); - process.exit(1); -}); diff --git a/apps/api/src/scripts/razorpay-assign-plan.ts b/apps/api/src/scripts/razorpay-assign-plan.ts deleted file mode 100644 index be67f555d..000000000 --- a/apps/api/src/scripts/razorpay-assign-plan.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { db } from '../db/index.js'; -import { workspaces } from '../db/schema.js'; -import { PLANS, type PlanKey } from '../lib/razorpay/products.js'; - -// Admin CLI: manually override a workspace's plan (bypasses Razorpay). -// The next real webhook will overwrite this assignment. - -async function main(): Promise { - const [, , workspaceId, planArg] = process.argv; - if (!workspaceId || !planArg) { - console.error('Usage: pnpm razorpay:assign-plan '); - process.exit(1); - } - if (!(planArg in PLANS)) { - console.error(`Unknown plan: ${planArg}. Must be one of: ${Object.keys(PLANS).join(', ')}`); - process.exit(1); - } - const plan = planArg as PlanKey; - - // subscriptionStatus lives in the subscriptions table, not on workspaces. - // This script only patches the plan column for emergency overrides. - const result = await db - .update(workspaces) - .set({ plan }) - .where(eq(workspaces.id, workspaceId)) - .returning({ id: workspaces.id, slug: workspaces.slug }); - - if (result.length === 0) { - console.error(`Workspace ${workspaceId} not found`); - process.exit(1); - } - - console.log(`[razorpay-assign-plan] workspace ${result[0]!.slug} (${result[0]!.id}) set to plan=${plan}`); - console.log('Note: this bypasses Razorpay. The next real webhook will overwrite this assignment.'); - process.exit(0); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); - -// STRIPE: ENABLE WHEN APPROVED -// Replace with stripe-assign-plan.ts using stripe.subscriptions.* diff --git a/apps/api/src/scripts/razorpay-bootstrap.ts b/apps/api/src/scripts/razorpay-bootstrap.ts deleted file mode 100644 index 48b929c74..000000000 --- a/apps/api/src/scripts/razorpay-bootstrap.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { and, eq } from 'drizzle-orm'; -import { config } from '../config/env.js'; -import { db } from '../db/index.js'; -import { razorpayPlanIds } from '../db/schema.js'; -import { razorpay } from '../lib/razorpay/client.js'; -import { PLANS } from '../lib/razorpay/products.js'; - -// IMPORTANT: Razorpay International must be activated on your account to charge in USD. -// Without activation, plan creation will fail with a currency error. -// Contact Razorpay support to enable international payments (USD billing). - -async function main(): Promise { - const environment = config.RAZORPAY_ENVIRONMENT; - console.log(`[razorpay-bootstrap] Running in ${environment} mode (USD billing)`); - - for (const plan of Object.values(PLANS)) { - if (!plan.razorpayManaged) { - console.log(`[razorpay-bootstrap] Skipping ${plan.key} — not Razorpay-managed`); - continue; - } - - const existing = await db - .select() - .from(razorpayPlanIds) - .where( - and( - eq(razorpayPlanIds.planKey, plan.key as 'pro' | 'team'), - eq(razorpayPlanIds.environment, environment), - ), - ) - .limit(1); - - if (existing.length > 0) { - try { - // Verify the plan still exists in Razorpay - await razorpay.plans.fetch(existing[0]!.razorpayPlanId); - console.log(`[razorpay-bootstrap] ${plan.key}: existing plan ${existing[0]!.razorpayPlanId} verified`); - continue; - } catch { - console.warn(`[razorpay-bootstrap] ${plan.key}: stored plan ID invalid, recreating`); - await db.delete(razorpayPlanIds).where( - and( - eq(razorpayPlanIds.planKey, plan.key as 'pro' | 'team'), - eq(razorpayPlanIds.environment, environment), - ), - ); - } - } - - // Razorpay amounts are in the smallest currency unit. - // USD cents: $15.00 = 1500, $25.00 = 2500. - // NOTE: Razorpay International must be enabled for USD. - let razorpayPlan: { id: string }; - try { - razorpayPlan = await (razorpay.plans.create({ - period: 'monthly', - interval: 1, - item: { - name: `Mnema ${plan.displayName}`, - amount: plan.amountUsdCents!, - currency: 'USD', - description: plan.description, - }, - notes: { plan_key: plan.key, environment }, - }) as Promise<{ id: string }>); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('currency') || msg.includes('USD')) { - console.error( - `[razorpay-bootstrap] FAILED: Razorpay International (USD) is not enabled on this account.\n` + - `Contact Razorpay support to activate international payments before running this script.\n` + - `Error: ${msg}`, - ); - process.exit(1); - } - throw err; - } - - await db.insert(razorpayPlanIds).values({ - planKey: plan.key as 'pro' | 'team', - environment, - razorpayPlanId: razorpayPlan.id, - }); - - console.log(`[razorpay-bootstrap] ${plan.key}: created plan=${razorpayPlan.id}`); - } - - console.log('[razorpay-bootstrap] done'); - process.exit(0); -} - -main().catch((err) => { - console.error('[razorpay-bootstrap] failed', err); - process.exit(1); -}); - -// STRIPE: ENABLE WHEN APPROVED -// Replace with stripe-bootstrap.ts using stripe.products.create() + stripe.prices.create() diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 09a7c4fcc..f27dafce1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -15,11 +15,7 @@ import { mcpPlugin } from './mcp/plugin.js'; import { authPlugin } from './plugins/auth.js'; import { loggerOptions } from './plugins/logger.js'; import { authRoutes } from './routes/auth.js'; -import { billingRoutes } from './routes/billing.js'; import { commentsRoutes } from './routes/comments.js'; -import { razorpayRoutes } from './routes/razorpay.js'; -// STRIPE: ENABLE WHEN APPROVED -// import { stripeRoutes } from './routes/stripe.js'; import { completeRoutes } from './routes/complete.js'; import { docReadStateRoutes } from './routes/doc-read-state.js'; import { docVersionsRoutes } from './routes/doc-versions.js'; @@ -164,8 +160,6 @@ await app.register(commentsRoutes); await app.register(docVersionsRoutes); await app.register(docReadStateRoutes); await app.register(completeRoutes); -await app.register(razorpayRoutes); -await app.register(billingRoutes); await app.register(mcpTokenRoutes); await app.register(apiKeysRoutes); await app.register(publicV1Routes); diff --git a/apps/api/src/tests/razorpay-webhook.test.ts b/apps/api/src/tests/razorpay-webhook.test.ts deleted file mode 100644 index ece8ec43a..000000000 --- a/apps/api/src/tests/razorpay-webhook.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import crypto from 'node:crypto'; -import { describe, expect, it, vi } from 'vitest'; -import { - computeEventId, - handleSubscriptionActivated, - handleSubscriptionCancelled, - handleSubscriptionHalted, - handleSubscriptionPaused, - type RazorpayWebhookEvent, -} from '../lib/razorpay/webhook-handlers.js'; - -// Mock DB and plan-state so unit tests don't need a live database -vi.mock('../db/index.js', () => ({ db: { select: vi.fn(), insert: vi.fn(), update: vi.fn() } })); -vi.mock('../lib/razorpay/plan-state.js', () => ({ planKeyFromRazorpayPlanId: vi.fn() })); - -function makeEvent( - eventName: string, - sub: Partial = {}, -): RazorpayWebhookEvent { - return { - entity: 'event', - account_id: 'acc_test', - event: eventName, - contains: ['subscription'], - payload: { - subscription: { - entity: { - id: 'sub_test123', - status: eventName.split('.')[1] ?? 'active', - plan_id: 'plan_test456', - customer_id: 'cust_test789', - quantity: 1, - current_start: 1700000000, - current_end: 1702592000, - ...sub, - }, - }, - }, - created_at: 1700000001, - }; -} - -function signPayload(payload: string, secret: string): string { - return crypto.createHmac('sha256', secret).update(payload).digest('hex'); -} - -describe('computeEventId', () => { - it('produces a stable deterministic ID from event name + sub id + created_at', () => { - const event = makeEvent('subscription.activated'); - const id1 = computeEventId(event); - const id2 = computeEventId(event); - expect(id1).toBe(id2); - expect(id1).toBe('subscription.activated:sub_test123:1700000001'); - }); - - it('differs when event name differs', () => { - const a = computeEventId(makeEvent('subscription.activated')); - const b = computeEventId(makeEvent('subscription.halted')); - expect(a).not.toBe(b); - }); -}); - -describe('HMAC signature helper', () => { - it('produces a valid hex digest for known input', () => { - const body = JSON.stringify({ test: true }); - const secret = 'test_webhook_secret'; - const sig = signPayload(body, secret); - expect(sig).toMatch(/^[0-9a-f]{64}$/); - }); -}); - -describe('handleSubscriptionActivated', () => { - it('warns and returns without throwing when plan_id is not mapped', async () => { - const { planKeyFromRazorpayPlanId } = await import('../lib/razorpay/plan-state.js'); - vi.mocked(planKeyFromRazorpayPlanId).mockResolvedValueOnce(null); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await expect(handleSubscriptionActivated(makeEvent('subscription.activated'))).resolves.toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith( - '[razorpay-webhook] plan_id not mapped to a plan key', - expect.objectContaining({ plan_id: 'plan_test456' }), - ); - warnSpy.mockRestore(); - }); -}); - -describe('handleSubscriptionHalted', () => { - it('resolves without throwing when workspace not found for subscription', async () => { - const { db } = await import('../db/index.js'); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - }), - } as unknown as ReturnType); - - await expect(handleSubscriptionHalted(makeEvent('subscription.halted'))).resolves.toBeUndefined(); - }); -}); - -describe('handleSubscriptionCancelled', () => { - it('resolves without throwing when workspace not found for subscription', async () => { - const { db } = await import('../db/index.js'); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - }), - } as unknown as ReturnType); - - await expect(handleSubscriptionCancelled(makeEvent('subscription.cancelled'))).resolves.toBeUndefined(); - }); -}); - -describe('handleSubscriptionPaused', () => { - it('resolves without throwing when workspace not found for subscription', async () => { - const { db } = await import('../db/index.js'); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - }), - } as unknown as ReturnType); - - await expect(handleSubscriptionPaused(makeEvent('subscription.paused'))).resolves.toBeUndefined(); - }); -}); diff --git a/apps/web/src/components/nav/CommandPalette.tsx b/apps/web/src/components/nav/CommandPalette.tsx index d701a6ea9..054dea312 100644 --- a/apps/web/src/components/nav/CommandPalette.tsx +++ b/apps/web/src/components/nav/CommandPalette.tsx @@ -38,7 +38,6 @@ const COMMANDS: CommandItem[] = [ { group: 'WORKSPACE', name: 'open settings', label: 'Open settings', kbd: ['⌘', ','], icon: 'settings', href: '/app/settings' }, { group: 'FLOWS', name: 'walk flow', label: 'Walk a flow…', kbd: ['⌘', '⇧', 'F'], icon: 'play', href: '/app/flows' }, { group: 'FLOWS', name: 'flow editor', label: 'Open flow editor', icon: 'edit', disabled: true, meta: 'SOON · 6.3' }, - { group: 'SETTINGS', name: 'open billing', label: 'Open billing', icon: 'card', href: '/app/settings' }, { group: 'SETTINGS', name: 'change theme', label: 'Change theme…', kbd: ['⌘', '⇧', 'T'], icon: 'moon' }, { group: 'SETTINGS', name: 'sign out', label: 'Sign out', icon: 'logout', danger: true, href: '/auth/logout' }, { group: 'CONNECTIONS', name: 'open claude', label: 'Open Claude connector', icon: 'claude', href: '/app/connections/claude' }, diff --git a/apps/web/src/components/pricing/PricingCards.tsx b/apps/web/src/components/pricing/PricingCards.tsx deleted file mode 100644 index 0f8f830bd..000000000 --- a/apps/web/src/components/pricing/PricingCards.tsx +++ /dev/null @@ -1,451 +0,0 @@ -/** - * PricingCards — client-side React island. - * - * Renders all 5 pricing tiers with: - * - Monthly / Annual cycle toggle - * - INR / USD currency toggle - * - Glassmorphism card style - * - "Most popular" badge on Team card - * - "Readers free" callout on Team + Business - * - Smart CTA: if user is logged in, upgrades go to billing settings - */ - -import { useState, useEffect } from 'react'; -import { TIERS } from '../../lib/pricing'; -import type { BillingCycle, Currency, PricingTier } from '../../lib/pricing'; - -interface PricingCardsProps { - /** Server-detected currency from CF-IPCountry */ - defaultCurrency: Currency; -} - -function CheckIcon() { - return ( - - ); -} - -function formatPrice(amount: number, currency: Currency): string { - if (currency === 'INR') { - return `₹${amount.toLocaleString('en-IN')}`; - } - return `$${amount}`; -} - -function PricingCard({ - tier, - cycle, - currency, - isLoggedIn, -}: { - tier: PricingTier; - cycle: BillingCycle; - currency: Currency; - isLoggedIn: boolean; -}) { - const isHighlighted = tier.highlighted; - const isEnterprise = tier.slug === 'enterprise'; - const isFree = tier.slug === 'free'; - - const monthlyPrice = - currency === 'INR' ? tier.inr_monthly : tier.usd_monthly; - const annualDisplayPrice = - currency === 'INR' ? tier.inr_annual : tier.usd_annual; - - const displayPrice = cycle === 'monthly' ? monthlyPrice : annualDisplayPrice; - - // Build CTA href - let ctaHref = tier.cta.href; - if (!isFree && !isEnterprise) { - const billingDest = `/app/settings/billing?upgrade=${tier.slug}&cycle=${cycle}`; - if (isLoggedIn) { - // Already signed in → go straight to billing settings - ctaHref = billingDest; - } else { - // Not signed in → /login with next= so post-auth lands on billing - ctaHref = `/login?next=${encodeURIComponent(billingDest)}`; - } - } - - const cardStyle: React.CSSProperties = isHighlighted - ? { - position: 'relative', - background: 'rgba(255,255,255,0.07)', - backdropFilter: 'blur(24px)', - WebkitBackdropFilter: 'blur(24px)', - border: '0.5px solid rgba(255,255,255,0.15)', - borderRadius: 20, - padding: '32px 28px', - overflow: 'hidden', - // Top-edge glow - boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.12), 0 0 40px rgba(255,255,255,0.04)', - } - : { - position: 'relative', - background: 'rgba(255,255,255,0.04)', - backdropFilter: 'blur(24px)', - WebkitBackdropFilter: 'blur(24px)', - border: '0.5px solid rgba(255,255,255,0.10)', - borderRadius: 20, - padding: '32px 28px', - overflow: 'hidden', - }; - - return ( -
- {/* Most popular badge */} - {isHighlighted && ( -
- Most popular -
- )} - - {/* Header */} -

- {tier.name} -

-

- {tier.tagline} -

- - {/* Price */} - {isEnterprise ? ( -
- - Custom - -
- ) : ( -
-
- - {isFree ? (currency === 'INR' ? '₹0' : '$0') : formatPrice(displayPrice!, currency)} - - {!isFree && ( - - {tier.perSeat ? '/ writer / mo' : '/ mo'} - - )} -
- - {/* Annual savings note */} - {!isFree && cycle === 'annual' && ( -

- Billed{' '} - {currency === 'INR' - ? `₹${tier.inr_annual_total!.toLocaleString('en-IN')}` - : `$${tier.usd_annual_total}`}{' '} - / year -

- )} - {!isFree && cycle === 'monthly' && ( -

- or{' '} - {currency === 'INR' - ? `₹${tier.inr_annual!} / mo` - : `$${tier.usd_annual!} / mo`}{' '} - billed annually -

- )} -
- )} - - {/* Readers free callout */} - {tier.readersCallout && ( -
- ✦ Viewers (readers) are always free -
- )} - -
- - {/* Features */} -
    - {tier.features.map((feat) => ( -
  • - - {feat.label} -
  • - ))} -
- - {/* CTA */} - { - (e.currentTarget as HTMLElement).style.opacity = '0.85'; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.opacity = '1'; - }} - > - {tier.cta.label} - -
- ); -} - -export function PricingCards({ defaultCurrency }: PricingCardsProps) { - const [cycle, setCycle] = useState('monthly'); - const [currency, setCurrency] = useState(defaultCurrency); - const [isLoggedIn, setIsLoggedIn] = useState(false); - - // Check auth status client-side - useEffect(() => { - fetch('/api/billing/current', { credentials: 'include' }) - .then((r) => { if (r.ok) setIsLoggedIn(true); }) - .catch(() => {}); - }, []); - - const toggleStyle = (active: boolean): React.CSSProperties => ({ - padding: '4px 14px', - borderRadius: 8, - fontSize: 12, - fontWeight: 500, - cursor: 'pointer', - border: 'none', - transition: 'background 0.15s, color 0.15s', - ...(active - ? { - background: 'rgba(255,255,255,0.12)', - color: 'rgba(255,255,255,0.90)', - } - : { - background: 'transparent', - color: 'rgba(255,255,255,0.35)', - }), - }); - - return ( -
- {/* Toggles */} -
- {/* Cycle toggle */} -
- - -
- - {/* Currency toggle */} -
- - -
-
- - {/* Cards grid */} -
- {TIERS.map((tier) => ( - - ))} -
-
- ); -} diff --git a/apps/web/src/components/public/PublicHeader.astro b/apps/web/src/components/public/PublicHeader.astro index e09c97e5d..c1199a06a 100644 --- a/apps/web/src/components/public/PublicHeader.astro +++ b/apps/web/src/components/public/PublicHeader.astro @@ -4,7 +4,6 @@ const pathname = Astro.url.pathname; const navLinks = [ { href: '/docs', label: 'Docs' }, - { href: '/pricing', label: 'Pricing' }, ]; --- diff --git a/apps/web/src/components/settings/BillingPanel.tsx b/apps/web/src/components/settings/BillingPanel.tsx deleted file mode 100644 index 5d008c18d..000000000 --- a/apps/web/src/components/settings/BillingPanel.tsx +++ /dev/null @@ -1,1054 +0,0 @@ -import { type JSX, useEffect, useRef, useState } from 'react'; -import { TIERS } from '../../lib/pricing'; -import type { BillingCycle, Currency } from '../../lib/pricing'; -import { ToastContainer, useToast } from '../ui/Toast'; - -type PlanSlug = 'individual' | 'team' | 'business'; - -interface BillingStatus { - plan: string; - subscription_status: string | null; - plan_slug: string | null; - cycle: BillingCycle; - currency: Currency; - billable_seats: number; - writer_seats: number; - reader_seats: number; - current_period_end: string | null; - cancel_at_period_end: boolean; - prices: { - monthly_usd: number; - annual_usd: number; - monthly_inr: number; - annual_inr: number; - } | null; - detected_currency: Currency; -} - -interface Invoice { - id: string; - date: string | null; - description: string; - amount_paid: number; // paise or cents - amount_due: number; - currency: string; - status: string; - download_url: string | null; -} - -// Tier rank for upgrade / downgrade labelling -const TIER_RANK: Record = { free: 0, individual: 1, team: 2, business: 3 }; - -/** Safely extract a human-readable message from whatever the API returns. */ -function extractErrorMessage(body: Record, fallback: string): string { - // Prefer detail (Razorpay description) over generic error code - if (typeof body.detail === 'string' && body.detail) return body.detail; - if (typeof body.message === 'string' && body.message) return body.message; - const e = body.error; - if (typeof e === 'string' && e) return e; - if (e && typeof e === 'object') { - const obj = e as Record; - if (typeof obj.description === 'string') return obj.description; - if (typeof obj.message === 'string') return obj.message; - return JSON.stringify(obj); - } - return fallback; -} - -const PAID_PLAN_SLUGS: PlanSlug[] = ['individual', 'team', 'business']; - -function formatAmount(paise: number, currency: string): string { - // Razorpay amounts are in smallest unit (paise for INR, cents for USD) - const amount = paise / 100; - if (currency === 'INR' || currency === 'inr') { - return `₹${amount.toLocaleString('en-IN')}`; - } - return `$${amount.toFixed(2)}`; -} - -function formatPrice(amount: number, currency: Currency): string { - if (currency === 'INR') return `₹${amount.toLocaleString('en-IN')}`; - return `$${amount}`; -} - -function StatusBadge({ status }: { status: string }): JSX.Element { - const map: Record = { - active: { label: 'Active', color: '#4ade80', bg: 'rgba(74,222,128,0.12)' }, - created: { label: 'Active', color: '#4ade80', bg: 'rgba(74,222,128,0.12)' }, - trialing: { label: 'Trial', color: '#60a5fa', bg: 'rgba(96,165,250,0.12)' }, - halted: { label: 'Past due', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, - cancelled: { label: 'Cancelled', color: '#f87171', bg: 'rgba(248,113,113,0.12)' }, - expired: { label: 'Expired', color: '#f87171', bg: 'rgba(248,113,113,0.12)' }, - pending: { label: 'Pending', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, - cancelling:{ label: 'Cancelling', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, - }; - const style = map[status] ?? { label: status, color: 'rgba(255,255,255,0.55)', bg: 'rgba(255,255,255,0.07)' }; - return ( - - {style.label} - - ); -} - -function InvoiceStatusBadge({ status }: { status: string }): JSX.Element { - const paid = status === 'paid'; - const failed = status === 'cancelled' || status === 'expired'; - const color = paid ? '#4ade80' : failed ? '#f87171' : '#f59e0b'; - const bg = paid ? 'rgba(74,222,128,0.12)' : failed ? 'rgba(248,113,113,0.12)' : 'rgba(245,158,11,0.12)'; - const label = paid ? 'Paid' : failed ? 'Failed' : 'Pending'; - return ( - - {label} - - ); -} - -const cardStyle = { - padding: '1.25rem', - borderRadius: '0.5rem', - border: '1px solid var(--border-default)', - background: 'var(--surface-overlay)', -} satisfies React.CSSProperties; - -const sectionHeaderStyle = { - fontSize: '0.6875rem', - fontWeight: 600, - textTransform: 'uppercase' as const, - letterSpacing: '0.08em', - color: 'var(--text-tertiary)', - marginBottom: '0.75rem', -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Main component -// ───────────────────────────────────────────────────────────────────────────── -export function BillingPanel(): JSX.Element { - const [status, setStatus] = useState(null); - const [invoices, setInvoices] = useState(null); - const [invoicesLoading, setInvoicesLoading] = useState(false); - const [cycle, setCycle] = useState('monthly'); - const [currency, setCurrency] = useState('INR'); - const [cancelling, setCancelling] = useState(false); - const [changingPlan, setChangingPlan] = useState(null); - const [updatingPayment, setUpdatingPayment] = useState(false); - // Activation polling (Razorpay subscriptions don't support callback_url redirect) - const [activationPolling, setActivationPolling] = useState(false); - const [justActivated, setJustActivated] = useState(false); - const pollTimerRef = useRef | null>(null); - const pollAttemptsRef = useRef(0); - const autoTriggered = useRef(false); - // In-app toasts (replaces browser alert/confirm) - const { toasts, dismiss, toast } = useToast(); - // Inline cancel confirmation state (replaces browser confirm()) - const [cancelConfirmPending, setCancelConfirmPending] = useState(false); - - // Detect return from Razorpay Checkout.js (callback_url redirect after payment) - const checkoutSuccess = - typeof window !== 'undefined' && - new URLSearchParams(window.location.search).get('checkout') === 'success'; - - // Read ?upgrade=PLAN&cycle=CYCLE — set by pricing page CTA after post-login redirect - const upgradeParam = - typeof window !== 'undefined' - ? (new URLSearchParams(window.location.search).get('upgrade') as PlanSlug | null) - : null; - const cycleParam = - typeof window !== 'undefined' - ? (new URLSearchParams(window.location.search).get('cycle') as BillingCycle | null) - : null; - const validUpgrade: PlanSlug | null = - upgradeParam && PAID_PLAN_SLUGS.includes(upgradeParam as PlanSlug) - ? (upgradeParam as PlanSlug) - : null; - const validCycle: BillingCycle = cycleParam === 'annual' ? 'annual' : 'monthly'; - - // ── Load billing status ─────────────────────────────────────────────────── - useEffect(() => { - void (async () => { - const res = await fetch('/api/billing/status', { credentials: 'include' }); - if (res.ok) { - const data = await res.json() as BillingStatus; - setStatus(data); - setCycle(data.cycle ?? 'monthly'); - setCurrency(data.detected_currency ?? 'INR'); - } - })(); - }, []); - - // ── Load payment history ────────────────────────────────────────────────── - useEffect(() => { - setInvoicesLoading(true); - void fetch('/api/billing/payments', { credentials: 'include' }) - .then((r) => r.json() as Promise<{ invoices: Invoice[] }>) - .then((d) => setInvoices(d.invoices ?? [])) - .catch(() => setInvoices([])) - .finally(() => setInvoicesLoading(false)); - }, []); - - // ── Strip ?checkout=success from URL after Razorpay redirect ───────────── - useEffect(() => { - if (!checkoutSuccess) return; - const url = new URL(window.location.href); - url.searchParams.delete('checkout'); - // Also strip Razorpay's appended payment params (razorpay_payment_id etc.) - url.searchParams.delete('razorpay_payment_id'); - url.searchParams.delete('razorpay_subscription_id'); - url.searchParams.delete('razorpay_signature'); - window.history.replaceState({}, '', url.toString()); - }, [checkoutSuccess]); - - // ── Activation polling ──────────────────────────────────────────────────── - // Razorpay subscriptions don't support callback_url, so there's no redirect - // back from checkout. Instead, poll for status change when in created/authenticated - // state (meaning the user just paid and we're waiting for the webhook to fire). - useEffect(() => { - const isPending = - status?.subscription_status === 'created' || - status?.subscription_status === 'authenticated'; - - if (!isPending) { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - setActivationPolling(false); - } - return; - } - - setActivationPolling(true); - pollAttemptsRef.current = 0; - - pollTimerRef.current = setInterval(() => { - void (async () => { - pollAttemptsRef.current += 1; - // Give up after ~2 min (24 × 5s) - if (pollAttemptsRef.current > 24) { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - setActivationPolling(false); - return; - } - try { - const res = await fetch('/api/billing/status', { credentials: 'include' }); - if (!res.ok) return; - const data = await res.json() as BillingStatus; - if (['active', 'trialing'].includes(data.subscription_status ?? '')) { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - setStatus(data); - setActivationPolling(false); - setJustActivated(true); - // Refresh invoices too — first payment invoice should now exist - void fetch('/api/billing/payments', { credentials: 'include' }) - .then((r) => r.json() as Promise<{ invoices: Invoice[] }>) - .then((d) => setInvoices(d.invoices ?? [])) - .catch(() => null); - } - } catch { /* ignore transient errors */ } - })(); - }, 5000); - - return () => { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status?.subscription_status]); - - // ── Auto-trigger checkout when ?upgrade= is present ─────────────────────── - useEffect(() => { - if (!status || !validUpgrade || autoTriggered.current) return; - const isAlreadySubscribed = - status.plan !== 'free' && - status.subscription_status && - ['active', 'trialing', 'created'].includes(status.subscription_status); - if (isAlreadySubscribed) return; - autoTriggered.current = true; - const url = new URL(window.location.href); - url.searchParams.delete('upgrade'); - url.searchParams.delete('cycle'); - window.history.replaceState({}, '', url.toString()); - void startNewSubscription(validUpgrade, validCycle); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, validUpgrade]); - - // ── Checkout helpers ────────────────────────────────────────────────────── - - async function callSubscribeAPI( - endpoint: string, - plan: PlanSlug, - billingCycle: BillingCycle, - ): Promise<{ subscriptionId: string } | null> { - const res = await fetch(endpoint, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plan, cycle: billingCycle }), - }); - // If the response isn't JSON (e.g. Vercel 504 HTML timeout page), surface - // the HTTP status instead of a generic "Network error" message. - const text = await res.text(); - let body: Record; - try { - body = JSON.parse(text) as Record; - } catch { - throw new Error(`Server returned HTTP ${res.status}. The request may have timed out — please try again.`); - } - if (typeof body.subscription_id === 'string') return { subscriptionId: body.subscription_id }; - throw new Error(extractErrorMessage(body, `Could not start checkout (HTTP ${res.status}). Please email mnema@theboringpeople.in.`)); - } - - /** - * Open the Razorpay checkout modal (Checkout.js) with a subscription_id. - * This is the correct integration for web apps — the hosted short_url approach - * requires a separately-activated Razorpay product ("Subscription Links") and - * shows "Hosted page is not available" if that product isn't enabled. - * - * After successful payment the handler navigates client-side to ?checkout=success. - * If the user dismisses without paying, onDismiss() is called. - */ - function openRazorpayCheckout( - subscriptionId: string, - planName: string, - onDismiss: () => void, - ): void { - const keyId = ((import.meta.env as Record).PUBLIC_RAZORPAY_KEY_ID ?? '').trim(); - if (!keyId) { - toast('error', 'Payment system is not configured. Please contact mnema@theboringpeople.in.'); - onDismiss(); - return; - } - const options = { - key: keyId, - subscription_id: subscriptionId, - name: 'Mnema', - description: `${planName} subscription`, - // handler fires on successful payment — navigate client-side to avoid - // the cross-site POST that callback_url triggers (blocked by Astro CSRF). - handler: () => { - window.location.href = `${window.location.origin}/app/settings/billing?checkout=success`; - }, - theme: { color: '#6366f1' }, - modal: { ondismiss: onDismiss }, - }; - const doOpen = () => { - const rzp = new (window as unknown as { - Razorpay: new (o: unknown) => { open(): void }; - }).Razorpay(options); - rzp.open(); - }; - if (typeof (window as unknown as Record).Razorpay !== 'undefined') { - doOpen(); - } else { - const s = document.createElement('script'); - s.src = 'https://checkout.razorpay.com/v1/checkout.js'; - s.onload = doOpen; - s.onerror = () => { - onDismiss(); - toast('error', 'Failed to load the payment form. Please check your connection and try again.'); - }; - document.body.appendChild(s); - } - } - - async function startNewSubscription(plan: PlanSlug, billingCycle: BillingCycle = 'monthly'): Promise { - setChangingPlan(plan); - try { - const result = await callSubscribeAPI('/api/billing/subscribe', plan, billingCycle); - if (result) { - const planName = TIERS.find((t) => t.slug === plan)?.name ?? plan; - openRazorpayCheckout(result.subscriptionId, planName, () => setChangingPlan(null)); - } - } catch (err: unknown) { - toast('error', err instanceof Error ? err.message : 'Could not start checkout. Please email mnema@theboringpeople.in.'); - setChangingPlan(null); - } - } - - async function changePlan(plan: PlanSlug, billingCycle: BillingCycle): Promise { - setChangingPlan(plan); - try { - const result = await callSubscribeAPI('/api/billing/change-plan', plan, billingCycle); - if (result) { - const planName = TIERS.find((t) => t.slug === plan)?.name ?? plan; - openRazorpayCheckout(result.subscriptionId, planName, () => setChangingPlan(null)); - } - } catch (err: unknown) { - toast('error', err instanceof Error ? err.message : 'Could not change plan. Please email mnema@theboringpeople.in.'); - setChangingPlan(null); - } - } - - async function cancelSubscription(): Promise { - // First click arms the confirmation; second click confirms. - if (!cancelConfirmPending) { - setCancelConfirmPending(true); - return; - } - setCancelConfirmPending(false); - setCancelling(true); - try { - const res = await fetch('/api/billing/cancel', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cancelAtPeriodEnd: true }), - }); - const body = await res.json() as { cancelled?: boolean; error?: string; cancel_at_period_end?: boolean }; - if (body.cancelled) { - setStatus((prev) => prev ? { ...prev, cancel_at_period_end: true } : prev); - const until = status?.current_period_end - ? new Date(status.current_period_end).toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' }) - : 'end of billing period'; - toast('info', `Subscription cancelled. You keep access until ${until}.`, { duration: 6000 }); - } else { - toast('error', body.error ?? 'Could not cancel. Please email mnema@theboringpeople.in.'); - } - } catch { - toast('error', 'Network error. Please check your connection and try again.'); - } finally { - setCancelling(false); - } - } - - async function updatePaymentMethod(): Promise { - setUpdatingPayment(true); - try { - const res = await fetch('/api/billing/update-payment-method', { - method: 'POST', - credentials: 'include', - }); - const body = await res.json() as { manage_url?: string; error?: string }; - if (body.manage_url) { - window.open(body.manage_url, '_blank', 'noopener,noreferrer'); - toast('info', 'Payment management page opened in a new tab.'); - } else { - toast('error', body.error ?? 'Could not open payment management page. Please email mnema@theboringpeople.in.'); - } - } catch { - toast('error', 'Network error. Please check your connection and try again.'); - } finally { - setUpdatingPayment(false); - } - } - - // ── Loading / redirect states ───────────────────────────────────────────── - - if (!status || (validUpgrade && !autoTriggered.current)) { - const tierLabel = validUpgrade - ? (TIERS.find((t) => t.slug === validUpgrade)?.name ?? validUpgrade) - : null; - return ( -
- {tierLabel ? `Opening ${tierLabel} checkout…` : 'Loading…'} -
- ); - } - - if (changingPlan && validUpgrade) { - const tierLabel = TIERS.find((t) => t.slug === validUpgrade)?.name ?? validUpgrade; - return ( -
- Redirecting to {tierLabel} checkout… -
- ); - } - - // ── Derived state ───────────────────────────────────────────────────────── - - const isFreePlan = status.plan === 'free' || !status.subscription_status || !['active', 'trialing', 'created'].includes(status.subscription_status); - const isActiveSub = !isFreePlan; - const isCancelPending = status.cancel_at_period_end; - const currentPlanSlug = (status.plan_slug ?? status.plan) as string; - - // Price to display for current subscription - function getCurrentPriceDisplay(): string { - if (!status?.prices) return '—'; - const pricePerSeat = cycle === 'annual' - ? (currency === 'INR' ? status.prices.annual_inr : status.prices.annual_usd) - : (currency === 'INR' ? status.prices.monthly_inr : status.prices.monthly_usd); - const total = pricePerSeat * (status.billable_seats ?? 1); - return formatPrice(total, currency); - } - - // ── Section B helpers ───────────────────────────────────────────────────── - - function getPlanPrice(slug: PlanSlug): number | null { - const tier = TIERS.find((t) => t.slug === slug); - if (!tier) return null; - if (cycle === 'annual') return currency === 'INR' ? tier.inr_annual : tier.usd_annual; - return currency === 'INR' ? tier.inr_monthly : tier.usd_monthly; - } - - function getPlanButtonLabel(slug: PlanSlug): string { - if (changingPlan === slug) return 'Opening checkout…'; - if (slug === currentPlanSlug) return 'Current plan'; - const currentRank = TIER_RANK[currentPlanSlug] ?? 0; - const targetRank = TIER_RANK[slug] ?? 0; - if (isFreePlan) return 'Subscribe →'; - return targetRank > currentRank ? 'Upgrade →' : 'Downgrade →'; - } - - function handlePlanClick(slug: PlanSlug): void { - if (slug === currentPlanSlug || changingPlan) return; - if (isFreePlan) { - void startNewSubscription(slug, cycle); - } else { - void changePlan(slug, cycle); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Render - // ───────────────────────────────────────────────────────────────────────── - return ( -
- - {/* ── Payment received banner (Checkout.js callback_url redirect) ── */} - {checkoutSuccess && !justActivated && ( -
- - Payment received — your subscription is activating. This page will update automatically. -
- )} - - {/* ── Activation pending banner (polling) ── */} - {activationPolling && !checkoutSuccess && ( -
- - Waiting for payment confirmation — this page will update automatically once your subscription is active. -
- )} - - {/* ── Just activated success banner ── */} - {justActivated && ( -
- - Payment confirmed — your subscription is now active! -
- )} - - {/* ══ SECTION A — Current Plan ══════════════════════════════════════════ */} -
-
Current plan
- -
- - {TIERS.find((t) => t.slug === (status.plan_slug ?? status.plan))?.name - ?? status.plan_slug - ?? status.plan} - - {status.subscription_status && ( - - )} -
- - {/* Seat breakdown */} - {isActiveSub && ( -
-
-
- {status.writer_seats} -
-
- {status.writer_seats === 1 ? 'writer' : 'writers'} (billed) -
-
-
-
- {status.reader_seats} -
-
- {status.reader_seats === 1 ? 'reader' : 'readers'} (free) -
-
- {status.prices && !isCancelPending && ( -
-
- {getCurrentPriceDisplay()} -
-
- next charge -
-
- )} -
- )} - - {/* Renewal / expiry date */} - {status.current_period_end && ( -

- {isCancelPending - ? `Access until ${new Date(status.current_period_end).toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' })}.` - : `Renews on ${new Date(status.current_period_end).toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' })}.`} -

- )} - {isFreePlan && !status.current_period_end && ( -

- No active subscription. -

- )} -
- - {/* ══ SECTION B — Change Plan ═══════════════════════════════════════════ */} -
-
- {isFreePlan ? 'Upgrade plan' : 'Change plan'} -
- - {/* Cycle + Currency toggles */} -
- {/* Cycle */} -
- {(['monthly', 'annual'] as BillingCycle[]).map((c) => ( - - ))} -
- - {/* Currency */} -
- {(['INR', 'USD'] as Currency[]).map((c) => ( - - ))} -
-
- - {/* Plan mini-cards */} -
- {PAID_PLAN_SLUGS.map((slug) => { - const tier = TIERS.find((t) => t.slug === slug)!; - const price = getPlanPrice(slug); - const isCurrent = slug === currentPlanSlug; - const isHighlighted = tier.highlighted; - const buttonLabel = getPlanButtonLabel(slug); - const isRedirecting = changingPlan === slug; - - return ( -
- {isHighlighted && ( -
- Popular -
- )} -
- {tier.name} -
-
- {price !== null ? formatPrice(price, currency) : '—'} - - / {tier.perSeat ? 'writer' : ''} mo - -
- {cycle === 'annual' && price !== null && ( -
- Billed{' '} - {currency === 'INR' - ? `₹${(tier.inr_annual_total ?? 0).toLocaleString('en-IN')}` - : `$${tier.usd_annual_total ?? 0}` - } / yr -
- )} - -
- ); - })} -
- -

- Readers (viewers) are always free.{' '} - - See full pricing → - -

-
- - {/* ══ SECTION C — Payment Method ════════════════════════════════════════ */} - {isActiveSub && ( -
-
Payment method
-

- To update your saved card or UPI, open the Razorpay-hosted payment management page. -

- -
- )} - - {/* ══ SECTION D — Payment History ═══════════════════════════════════════ */} -
-
Payment history
- - {invoicesLoading ? ( -
Loading payments…
- ) : !invoices || invoices.length === 0 ? ( -

- No payments yet. -

- ) : ( -
- - - - {['Date', 'Description', 'Amount', 'Status', ''].map((h) => ( - - ))} - - - - {invoices.map((inv) => ( - - - - - - - - ))} - -
- {h} -
- {inv.date - ? new Date(inv.date).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) - : '—'} - - {inv.description} - - {inv.amount_paid > 0 - ? formatAmount(inv.amount_paid, inv.currency) - : inv.amount_due > 0 - ? formatAmount(inv.amount_due, inv.currency) - : '—'} - - - - {inv.download_url ? ( - - ↓ Invoice - - ) : ( - — - )} -
-
- )} -
- - {/* ══ SECTION E — Danger Zone ═══════════════════════════════════════════ */} - {isActiveSub && !isCancelPending && ( -
-
Danger zone
-

- Cancelling ends your subscription at the close of the current billing period. - You will keep access until then. -

- - {cancelConfirmPending ? ( - /* Inline confirm row — replaces browser confirm() dialog */ -
- - Are you sure? This cannot be undone. - - - -
- ) : ( - - )} -
- )} - - {/* Cancellation pending notice (replaces danger zone) */} - {isActiveSub && isCancelPending && ( -
-

- - Your subscription is set to cancel at the end of the billing period. You retain access until then. - {' '}To reactivate, email{' '} - - mnema@theboringpeople.in - . - -

-
- )} - - {/* Toast notifications — self-hosted so no layout changes needed */} - - -
- ); -} diff --git a/apps/web/src/components/settings/SettingsNav.tsx b/apps/web/src/components/settings/SettingsNav.tsx index 3716c888a..4707e3527 100644 --- a/apps/web/src/components/settings/SettingsNav.tsx +++ b/apps/web/src/components/settings/SettingsNav.tsx @@ -14,7 +14,6 @@ const ITEMS: ReadonlyArray = [ { href: '/app/settings/members', label: 'Members' }, { href: '/app/settings/workspace', label: 'Workspace' }, { href: '/app/settings/account', label: 'Account' }, - { href: '/app/settings/billing', label: 'Billing' }, { href: '/app/settings/api-keys', label: 'API Keys' }, { href: '/app/settings/connect-apps', label: 'Connect Apps' }, { href: '/app/settings/byok', label: 'Meeting AI' }, diff --git a/apps/web/src/layouts/SettingsLayout.astro b/apps/web/src/layouts/SettingsLayout.astro index f0967bdef..bb42d323d 100644 --- a/apps/web/src/layouts/SettingsLayout.astro +++ b/apps/web/src/layouts/SettingsLayout.astro @@ -51,7 +51,6 @@ const NAV_SECTIONS = [ label: 'Account', items: [ { href: '/app/settings/account', label: 'Account' }, - { href: '/app/settings/billing', label: 'Billing' }, ], }, ]; diff --git a/apps/web/src/lib/pricing.ts b/apps/web/src/lib/pricing.ts deleted file mode 100644 index 9bf193893..000000000 --- a/apps/web/src/lib/pricing.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Pricing tier definitions — single source of truth for the pricing page, - * settings panel CTAs, and billing flow. - * - * Prices must stay in sync with: - * apps/api/src/lib/razorpay/plans.ts → PLAN_PRICING / ANNUAL_TOTALS - */ - -export type PlanSlug = 'free' | 'individual' | 'team' | 'business' | 'enterprise'; -export type BillingCycle = 'monthly' | 'annual'; -export type Currency = 'INR' | 'USD'; - -export interface PricingFeature { - label: string; - /** Optional tooltip or explanatory note */ - note?: string; -} - -export interface PricingTier { - slug: PlanSlug; - name: string; - tagline: string; - /** Per-seat pricing in USD. Null for free/enterprise. */ - usd_monthly: number | null; - usd_annual: number | null; // per-seat per-month display (annual total / 12) - usd_annual_total: number | null; - /** Per-seat pricing in INR. */ - inr_monthly: number | null; - inr_annual: number | null; // per-seat per-month display (annual total / 12) - inr_annual_total: number | null; - /** True for team/business where only writers pay */ - perSeat: boolean; - /** Highlight as most popular */ - highlighted: boolean; - /** Show "Readers free" callout */ - readersCallout: boolean; - features: PricingFeature[]; - cta: { - label: string; - href: string; - }; -} - -export const TIERS: PricingTier[] = [ - { - slug: 'free', - name: 'Free', - tagline: 'Personal use, forever.', - usd_monthly: 0, - usd_annual: 0, - usd_annual_total: 0, - inr_monthly: 0, - inr_annual: 0, - inr_annual_total: 0, - perSeat: false, - highlighted: false, - readersCallout: false, - features: [ - { label: 'Up to 3 teammates' }, - { label: '50 docs' }, - { label: '5 flows' }, - { label: 'MCP connector' }, - { label: 'Real-time collaboration' }, - { label: 'Hybrid search' }, - ], - cta: { label: 'Get started', href: '/signup?plan=free' }, - }, - { - slug: 'individual', - name: 'Individual', - tagline: 'For solo builders and freelancers.', - usd_monthly: 10, - usd_annual: 8, - usd_annual_total: 96, - inr_monthly: 899, - inr_annual: 675, - inr_annual_total: 8099, - perSeat: false, - highlighted: false, - readersCallout: false, - features: [ - { label: '1 workspace' }, - { label: 'Unlimited docs' }, - { label: 'Unlimited flows' }, - { label: 'AI autocomplete' }, - { label: 'Version history' }, - { label: 'Email support' }, - ], - cta: { label: 'Start Individual', href: '/signup?plan=individual' }, - }, - { - slug: 'team', - name: 'Team', - tagline: 'For growing engineering teams.', - usd_monthly: 15, - usd_annual: 12, - usd_annual_total: 144, - inr_monthly: 999, - inr_annual: 749, - inr_annual_total: 8991, - perSeat: true, - highlighted: true, - readersCallout: true, - features: [ - { label: 'Unlimited teammates' }, - { label: 'Unlimited docs' }, - { label: 'Unlimited flows' }, - { label: 'AI autocomplete' }, - { label: 'Priority support' }, - { label: 'SSO (coming soon)' }, - ], - cta: { label: 'Start Team', href: '/signup?plan=team' }, - }, - { - slug: 'business', - name: 'Business', - tagline: 'For scaling product orgs.', - usd_monthly: 24, - usd_annual: 20, - usd_annual_total: 240, - inr_monthly: 1999, - inr_annual: 1499, - inr_annual_total: 17991, - perSeat: true, - highlighted: false, - readersCallout: true, - features: [ - { label: 'Everything in Team' }, - { label: 'Multiple workspaces' }, - { label: 'Audit log' }, - { label: 'Custom retention' }, - { label: 'SLA guarantee' }, - { label: 'Dedicated support' }, - ], - cta: { label: 'Start Business', href: '/signup?plan=business' }, - }, - { - slug: 'enterprise', - name: 'Enterprise', - tagline: 'Custom contracts and compliance.', - usd_monthly: null, - usd_annual: null, - usd_annual_total: null, - inr_monthly: null, - inr_annual: null, - inr_annual_total: null, - perSeat: false, - highlighted: false, - readersCallout: false, - features: [ - { label: 'Everything in Business' }, - { label: 'On-prem / VPC option' }, - { label: 'Custom SLA' }, - { label: 'SAML SSO' }, - { label: 'Data residency choice' }, - { label: 'Volume pricing' }, - ], - cta: { label: 'Contact sales', href: 'mailto:mnema@theboringpeople.in' }, - }, -]; diff --git a/apps/web/src/pages/app/settings/billing.astro b/apps/web/src/pages/app/settings/billing.astro deleted file mode 100644 index 882d5ce8c..000000000 --- a/apps/web/src/pages/app/settings/billing.astro +++ /dev/null @@ -1,21 +0,0 @@ ---- -import SettingsLayout from '../../../layouts/SettingsLayout.astro'; -import { BillingPanel } from '../../../components/settings/BillingPanel'; -import { RedeemLicense } from '../../../components/settings/RedeemLicense'; -import { GetCommunityLicense } from '../../../components/settings/GetCommunityLicense'; - -const auth = Astro.locals.auth; -if (!auth) { - return Astro.redirect('/auth/login'); -} ---- - - -

Settings

-

Billing

-

Your subscription, invoices, and payment method.

- - -
- -
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d3467897..51129081d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,9 +207,6 @@ importers: postgres: specifier: ^3.4.0 version: 3.4.9 - razorpay: - specifier: ^2.9.6 - version: 2.9.6 remark-parse: specifier: ^11.0.0 version: 11.0.0 @@ -3286,9 +3283,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -3296,9 +3290,6 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3479,10 +3470,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3816,10 +3803,6 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -4054,10 +4037,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -4295,15 +4274,6 @@ packages: resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} engines: {node: '>=12'} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -4315,10 +4285,6 @@ packages: resolution: {integrity: sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==} engines: {node: '>=12'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -4452,10 +4418,6 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -5093,18 +5055,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -5586,10 +5540,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -5615,9 +5565,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - razorpay@2.9.6: - resolution: {integrity: sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==} - react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -10155,8 +10102,6 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: {} - atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -10164,16 +10109,6 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios@1.16.1: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.6 - https-proxy-agent: 5.0.1 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - axobject-query@4.1.0: {} bail@2.0.2: {} @@ -10370,10 +10305,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -10707,8 +10638,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - delayed-stream@1.0.0: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -10840,13 +10769,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - es-toolkit@1.49.0: {} esbuild@0.18.20: @@ -11223,8 +11145,6 @@ snapshots: kapsule: 1.16.3 preact: 10.29.2 - follow-redirects@1.16.0: {} - fontace@0.4.1: dependencies: fontkitten: 1.0.3 @@ -11251,14 +11171,6 @@ snapshots: kapsule: 1.16.3 lodash-es: 4.18.1 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - format@0.2.2: {} forwarded@0.2.0: {} @@ -11393,10 +11305,6 @@ snapshots: has-symbols@1.1.0: {} - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hash.js@1.1.7: dependencies: inherits: 2.0.4 @@ -12294,14 +12202,8 @@ snapshots: transitivePeerDependencies: - supports-color - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -12778,8 +12680,6 @@ snapshots: proxy-from-env@1.1.0: {} - proxy-from-env@2.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -12804,13 +12704,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - razorpay@2.9.6: - dependencies: - axios: 1.16.1 - transitivePeerDependencies: - - debug - - supports-color - react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7