Просмотр исходного кода

Improved stripe config and fix subscription feature gating (#26)

* imporoved subscriptions

* improved subscription workflow

* remove console log

* stripe tests

* test

* lint
Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
ca63f745d1

+ 4 - 2
.devcontainer/docker-compose.yml

@@ -11,8 +11,6 @@ services:
     command: sleep infinity
     ports:
       - 3000:3000
-    environment:
-      - DATABASE_URL=postgresql://torqvoice:torqvoice@db:5432/torqvoice
     networks:
       - default
       - torqvoice-shared
@@ -21,6 +19,7 @@ services:
         condition: service_healthy
 
   db:
+    container_name: torqvoice-db
     image: postgres:16-alpine
     restart: unless-stopped
     volumes:
@@ -36,6 +35,9 @@ services:
       interval: 5s
       timeout: 5s
       retries: 5
+    networks:
+      - default
+      - torqvoice-shared
 
   pgadmin:
     image: dpage/pgadmin4:latest

+ 56 - 0
messages/nb/settings.json

@@ -726,5 +726,61 @@
     "failedSave": "Kunne ikke lagre innstillinger",
     "testSentTo": "Test-SMS sendt til {phone}",
     "testFailed": "SMS-test mislyktes"
+  },
+  "subscription": {
+    "title": "Abonnementsplan",
+    "description": "Din organisasjons gjeldende Torqvoice-abonnement og tilgjengelige funksjoner.",
+    "currentPlan": "Gjeldende plan",
+    "planFree": "Gratis",
+    "planPro": "Torq Pro",
+    "planEnterprise": "Enterprise",
+    "price": "{price}/{interval}",
+    "perYear": "ar",
+    "perMonth": "mnd",
+    "statusActive": "Aktiv",
+    "statusCanceling": "Avbestilles",
+    "statusPastDue": "Forfalt",
+    "statusCanceled": "Kansellert",
+    "billingPeriod": "Faktureringsperiode",
+    "billingPeriodDates": "{start} — {end}",
+    "nextRenewal": "Neste fornyelse",
+    "cancelingWarning": "Abonnementet ditt vil bli kansellert den {date}. Etter det blir du nedgradert til Gratis.",
+    "pastDueWarning": "Betalingen din er forfalt. Oppdater betalingsmetoden for a unnga avbrudd i tjenesten.",
+    "featuresTitle": "Planfunksjoner",
+    "featuresDescription": "Hva som er inkludert i din gjeldende plan.",
+    "featureCustomers": "Kunder",
+    "featureTeamMembers": "Teammedlemmer",
+    "featureSmtp": "Tilpasset e-post (SMTP)",
+    "featureApi": "API-tilgang",
+    "featurePayments": "Nettbetalinger",
+    "featureCustomFields": "Egendefinerte felt",
+    "featureSms": "SMS-meldinger",
+    "featureCustomerPortal": "Kundeportal",
+    "featureCustomTemplates": "Egendefinerte maler",
+    "featureBrandingRemoved": "Merkevarebygging fjernet",
+    "usageOf": "{used} / {limit}",
+    "usageUnlimited": "{used} / Ubegrenset",
+    "included": "Inkludert",
+    "notIncluded": "Ikke inkludert",
+    "manageTitle": "Administrer abonnement",
+    "manageBilling": "Administrer fakturering",
+    "manageBillingDescription": "Oppdater betalingsmetode, se fakturaer og administrer faktureringsdetaljer.",
+    "cancelSubscription": "Kanseller abonnement",
+    "resumeSubscription": "Gjenoppta abonnement",
+    "cancelDialogTitle": "Kanseller abonnement",
+    "cancelDialogDescription": "Abonnementet ditt forblir aktivt til {date}. Etter det blir du nedgradert til Gratis.",
+    "cancelDialogConfirm": "Ja, kanseller",
+    "cancelDialogCancel": "Behold abonnement",
+    "cancelSuccess": "Abonnementet kanselleres ved slutten av faktureringsperioden",
+    "cancelError": "Kunne ikke kansellere abonnementet",
+    "resumeSuccess": "Abonnementet er gjenopptatt",
+    "resumeError": "Kunne ikke gjenoppta abonnementet",
+    "billingPortalError": "Kunne ikke apne faktureringsportalen",
+    "upgradeTitle": "Oppgrader plan",
+    "upgradeDescription": "Las opp flere funksjoner ved a oppgradere planen din.",
+    "upgradeToProDescription": "Oppgrader for a lase opp ubegrensede kunder, teammedlemmer, rapporter, SMTP, API-tilgang og mer.",
+    "upgradeToEnterpriseDescription": "Trenger du mer enn 5 teammedlemmer? Oppgrader til Enterprise for opptil 50 brukere.",
+    "upgradeToPro": "Oppgrader til Pro — $99/ar",
+    "upgradeToEnterprise": "Enterprise — $140/ar"
   }
 }

+ 168 - 0
src/__tests__/lib/features.test.ts

@@ -0,0 +1,168 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    subscription: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+  },
+}));
+
+import { db } from "@/lib/db";
+import { getFeatures, PLAN_FEATURES } from "@/lib/features";
+
+const mockFindUnique = vi.mocked(db.subscription.findUnique);
+const mockFindMany = vi.mocked(db.appSetting.findMany);
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  vi.unstubAllEnvs();
+});
+
+describe("getFeatures — cloud mode", () => {
+  beforeEach(() => {
+    vi.stubEnv("TORQVOICE_MODE", "cloud");
+  });
+
+  it("returns free features when no subscription exists", async () => {
+    mockFindUnique.mockResolvedValue(null);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.free);
+  });
+
+  it("returns free features when subscription status is canceled", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "canceled",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: null,
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.free);
+  });
+
+  it("returns pro features for plan named 'Torq Pro'", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: new Date(Date.now() + 86400000),
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.pro);
+  });
+
+  it("returns pro features for plan named 'Pro'", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Pro" },
+      currentPeriodEnd: new Date(Date.now() + 86400000),
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.pro);
+  });
+
+  it("returns enterprise features for plan named 'Enterprise'", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Enterprise" },
+      currentPeriodEnd: new Date(Date.now() + 86400000),
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.enterprise);
+  });
+
+  it("returns pro features for trialing subscription", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "trialing",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: new Date(Date.now() + 86400000),
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.pro);
+  });
+
+  it("returns free features when past_due", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "past_due",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: null,
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.free);
+  });
+
+  it("returns free features when period ended and grace period elapsed", async () => {
+    const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000);
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: fourDaysAgo,
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.free);
+  });
+
+  it("returns pro features within grace period after period end", async () => {
+    const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Torq Pro" },
+      currentPeriodEnd: oneHourAgo,
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.pro);
+  });
+
+  it("returns free features for unknown plan name", async () => {
+    mockFindUnique.mockResolvedValue({
+      status: "active",
+      plan: { name: "Unknown Plan" },
+      currentPeriodEnd: new Date(Date.now() + 86400000),
+    } as any);
+    const features = await getFeatures("org-1");
+    expect(features).toEqual(PLAN_FEATURES.free);
+  });
+});
+
+describe("getFeatures — self-hosted mode", () => {
+  beforeEach(() => {
+    vi.stubEnv("TORQVOICE_MODE", "self-hosted");
+  });
+
+  it("unlocks all features except branding when no license", async () => {
+    mockFindMany.mockResolvedValue([]);
+    const features = await getFeatures("org-1");
+    expect(features.smtp).toBe(true);
+    expect(features.api).toBe(true);
+    expect(features.payments).toBe(true);
+    expect(features.brandingRemoved).toBe(false);
+    expect(features.customPlatformName).toBe(false);
+  });
+
+  it("unlocks branding when license is valid and not expired", async () => {
+    const future = new Date(Date.now() + 86400000).toISOString();
+    mockFindMany.mockResolvedValue([
+      { key: "license.valid", value: "true" },
+      { key: "license.expiresAt", value: future },
+    ] as any);
+    const features = await getFeatures("org-1");
+    expect(features.brandingRemoved).toBe(true);
+    expect(features.customPlatformName).toBe(true);
+  });
+
+  it("does not unlock branding when license is expired", async () => {
+    const past = new Date(Date.now() - 86400000).toISOString();
+    mockFindMany.mockResolvedValue([
+      { key: "license.valid", value: "true" },
+      { key: "license.expiresAt", value: past },
+    ] as any);
+    const features = await getFeatures("org-1");
+    expect(features.brandingRemoved).toBe(false);
+  });
+
+  it("does not unlock branding when license.valid is false", async () => {
+    mockFindMany.mockResolvedValue([
+      { key: "license.valid", value: "false" },
+    ] as any);
+    const features = await getFeatures("org-1");
+    expect(features.brandingRemoved).toBe(false);
+  });
+});

+ 129 - 0
src/__tests__/lib/stripe-config.test.ts

@@ -0,0 +1,129 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    systemSetting: { findMany: vi.fn() },
+  },
+}));
+
+import { db } from "@/lib/db";
+import { getStripeConfig } from "@/lib/stripe-config";
+
+const mockFindMany = vi.mocked(db.systemSetting.findMany);
+
+beforeEach(() => {
+  vi.resetAllMocks();
+  vi.unstubAllEnvs();
+});
+
+describe("getStripeConfig", () => {
+  it("reads test mode keys from DB when mode is test", async () => {
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.mode", value: "test" },
+      { id: "2", key: "stripe.test.secretKey", value: "sk_test_db" },
+      { id: "3", key: "stripe.test.webhookSecret", value: "whsec_test_db" },
+      { id: "4", key: "stripe.test.proPriceId", value: "price_pro_test" },
+      { id: "5", key: "stripe.test.enterprisePriceId", value: "price_ent_test" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_test_db");
+    expect(config.webhookSecret).toBe("whsec_test_db");
+    expect(config.proPriceId).toBe("price_pro_test");
+    expect(config.enterprisePriceId).toBe("price_ent_test");
+  });
+
+  it("reads live mode keys from DB when mode is live", async () => {
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.mode", value: "live" },
+      { id: "2", key: "stripe.live.secretKey", value: "sk_live_db" },
+      { id: "3", key: "stripe.live.webhookSecret", value: "whsec_live_db" },
+      { id: "4", key: "stripe.live.proPriceId", value: "price_pro_live" },
+      { id: "5", key: "stripe.live.enterprisePriceId", value: "price_ent_live" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_live_db");
+    expect(config.webhookSecret).toBe("whsec_live_db");
+    expect(config.proPriceId).toBe("price_pro_live");
+    expect(config.enterprisePriceId).toBe("price_ent_live");
+  });
+
+  it("falls back to env vars when DB values are empty", async () => {
+    vi.stubEnv("STRIPE_SECRET_KEY", "sk_env");
+    vi.stubEnv("STRIPE_WEBHOOK_SECRET", "whsec_env");
+    vi.stubEnv("STRIPE_PRO_PRICE_ID", "price_pro_env");
+    vi.stubEnv("STRIPE_ENTERPRISE_PRICE_ID", "price_ent_env");
+
+    mockFindMany.mockResolvedValue([]);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_env");
+    expect(config.webhookSecret).toBe("whsec_env");
+    expect(config.proPriceId).toBe("price_pro_env");
+    expect(config.enterprisePriceId).toBe("price_ent_env");
+  });
+
+  it("defaults to live mode when no mode is set", async () => {
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.live.secretKey", value: "sk_live_default" },
+      { id: "2", key: "stripe.test.secretKey", value: "sk_test_should_not_use" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_live_default");
+  });
+
+  it("uses STRIPE_MODE env var when no DB mode is set", async () => {
+    vi.stubEnv("STRIPE_MODE", "test");
+
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.test.secretKey", value: "sk_test_via_env_mode" },
+      { id: "2", key: "stripe.live.secretKey", value: "sk_live_should_not_use" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_test_via_env_mode");
+  });
+
+  it("DB mode takes priority over STRIPE_MODE env var", async () => {
+    vi.stubEnv("STRIPE_MODE", "test");
+
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.mode", value: "live" },
+      { id: "2", key: "stripe.test.secretKey", value: "sk_test_no" },
+      { id: "3", key: "stripe.live.secretKey", value: "sk_live_yes" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_live_yes");
+  });
+
+  it("returns empty strings when nothing is configured", async () => {
+    vi.stubEnv("STRIPE_SECRET_KEY", "");
+    vi.stubEnv("STRIPE_WEBHOOK_SECRET", "");
+    vi.stubEnv("STRIPE_PRO_PRICE_ID", "");
+    vi.stubEnv("STRIPE_ENTERPRISE_PRICE_ID", "");
+    vi.stubEnv("STRIPE_MODE", "");
+
+    mockFindMany.mockResolvedValue([]);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("");
+    expect(config.webhookSecret).toBe("");
+    expect(config.proPriceId).toBe("");
+    expect(config.enterprisePriceId).toBe("");
+  });
+
+  it("ignores DB rows with empty values and falls back to env", async () => {
+    vi.stubEnv("STRIPE_SECRET_KEY", "sk_fallback");
+
+    mockFindMany.mockResolvedValue([
+      { id: "1", key: "stripe.mode", value: "test" },
+      { id: "2", key: "stripe.test.secretKey", value: "" },
+    ] as any);
+
+    const config = await getStripeConfig();
+    expect(config.secretKey).toBe("sk_fallback");
+  });
+});

+ 5 - 14
src/app/api/internal/cron/validate-subscriptions/route.ts

@@ -2,17 +2,7 @@ import { NextResponse } from "next/server";
 import Stripe from "stripe";
 import { db } from "@/lib/db";
 import { isCloudMode } from "@/lib/features";
-
-let stripeClient: Stripe | null = null;
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY;
-    if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
-    stripeClient = new Stripe(key);
-  }
-  return stripeClient;
-}
+import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
 
 /**
  * Map Stripe subscription status to our internal status.
@@ -86,14 +76,15 @@ export async function GET(request: Request) {
   }
 
   // --- Guard: Stripe must be configured ---
-  if (!process.env.STRIPE_SECRET_KEY) {
+  const config = await getStripeConfig();
+  if (!config.secretKey) {
     return NextResponse.json(
-      { error: "STRIPE_SECRET_KEY is not configured" },
+      { error: "Stripe secret key is not configured" },
       { status: 500 },
     );
   }
 
-  const stripe = getStripe();
+  const stripe = await getStripeClient();
   const results: ValidationResult[] = [];
 
   // Fetch all subscriptions that are not already in a terminal state.

+ 2 - 13
src/app/api/protected/subscription/billing-portal/route.ts

@@ -1,19 +1,8 @@
 import { NextResponse } from "next/server";
-import Stripe from "stripe";
 import { auth } from "@/lib/auth";
 import { headers } from "next/headers";
 import { db } from "@/lib/db";
-
-let stripeClient: Stripe | null = null;
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY;
-    if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
-    stripeClient = new Stripe(key);
-  }
-  return stripeClient;
-}
+import { getStripeClient } from "@/lib/stripe-config";
 
 export async function POST() {
   try {
@@ -47,7 +36,7 @@ export async function POST() {
     }
 
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
-    const stripe = getStripe();
+    const stripe = await getStripeClient();
 
     const portalSession = await stripe.billingPortal.sessions.create({
       customer: subscription.stripeCustomerId,

+ 6 - 18
src/app/api/protected/subscription/checkout/route.ts

@@ -1,19 +1,8 @@
 import { NextResponse } from "next/server";
-import Stripe from "stripe";
 import { auth } from "@/lib/auth";
 import { headers } from "next/headers";
 import { db } from "@/lib/db";
-
-let stripeClient: Stripe | null = null;
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY;
-    if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
-    stripeClient = new Stripe(key);
-  }
-  return stripeClient;
-}
+import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
 
 export async function POST(request: Request) {
   try {
@@ -44,10 +33,9 @@ export async function POST(request: Request) {
       );
     }
 
+    const config = await getStripeConfig();
     const priceId =
-      plan === "pro"
-        ? process.env.STRIPE_PRO_PRICE_ID
-        : process.env.STRIPE_ENTERPRISE_PRICE_ID;
+      plan === "pro" ? config.proPriceId : config.enterprisePriceId;
 
     if (!priceId) {
       return NextResponse.json(
@@ -57,7 +45,7 @@ export async function POST(request: Request) {
     }
 
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
-    const stripe = getStripe();
+    const stripe = await getStripeClient();
 
     const checkoutSession = await stripe.checkout.sessions.create({
       mode: "subscription",
@@ -74,8 +62,8 @@ export async function POST(request: Request) {
           organizationId: membership.organizationId,
         },
       },
-      success_url: `${appUrl}/admin/settings?subscription=success`,
-      cancel_url: `${appUrl}/admin/settings`,
+      success_url: `${appUrl}/settings/subscription?subscription=success`,
+      cancel_url: `${appUrl}/settings/subscription`,
     });
 
     return NextResponse.json({ url: checkoutSession.url });

+ 8 - 17
src/app/api/webhooks/subscription/route.ts

@@ -1,17 +1,7 @@
 import { NextResponse } from "next/server";
 import Stripe from "stripe";
 import { db } from "@/lib/db";
-
-let stripeClient: Stripe | null = null;
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY;
-    if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
-    stripeClient = new Stripe(key);
-  }
-  return stripeClient;
-}
+import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
 
 export async function POST(request: Request) {
   try {
@@ -25,19 +15,19 @@ export async function POST(request: Request) {
       );
     }
 
-    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
-    if (!webhookSecret) {
+    const config = await getStripeConfig();
+    if (!config.webhookSecret) {
       return NextResponse.json(
         { error: "Webhook secret not configured" },
         { status: 500 },
       );
     }
 
-    const stripe = getStripe();
+    const stripe = await getStripeClient();
     let event: Stripe.Event;
 
     try {
-      event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
+      event = stripe.webhooks.constructEvent(body, signature, config.webhookSecret);
     } catch {
       return NextResponse.json(
         { error: "Invalid signature" },
@@ -79,11 +69,12 @@ export async function POST(request: Request) {
         const subscription = await stripe.subscriptions.retrieve(subscriptionId);
 
         // Find or create the plan record
+        const stripePriceId = plan === "pro" ? config.proPriceId : config.enterprisePriceId;
         const subscriptionPlan = await db.subscriptionPlan.upsert({
-          where: { stripePriceId: plan === "pro" ? process.env.STRIPE_PRO_PRICE_ID! : process.env.STRIPE_ENTERPRISE_PRICE_ID! },
+          where: { stripePriceId: stripePriceId || "unknown" },
           create: {
             name: plan === "pro" ? "Torq Pro" : "Enterprise",
-            stripePriceId: plan === "pro" ? process.env.STRIPE_PRO_PRICE_ID : process.env.STRIPE_ENTERPRISE_PRICE_ID,
+            stripePriceId: stripePriceId || null,
             price: plan === "pro" ? 99 : 140,
             interval: "year",
             maxMembers: plan === "pro" ? 5 : 50,

+ 6 - 13
src/cronTasks.ts

@@ -1,6 +1,7 @@
 import { CronJob } from 'cron'
 import Stripe from 'stripe'
 import { db } from './lib/db'
+import { getStripeClient, getStripeConfig } from './lib/stripe-config'
 
 const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
 
@@ -9,17 +10,6 @@ const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://
 // Runs daily at 01:00 UTC (offset from license check at 00:00 to spread load)
 // ---------------------------------------------------------------------------
 
-let stripeClient: Stripe | null = null
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY
-    if (!key) throw new Error('STRIPE_SECRET_KEY is not configured')
-    stripeClient = new Stripe(key)
-  }
-  return stripeClient
-}
-
 function mapStripeStatus(stripeStatus: string): string {
   switch (stripeStatus) {
     case 'active':
@@ -45,11 +35,14 @@ function resolveLicensePlan(internalStatus: string, planName: string): string {
 
 export function checkSubscriptions() {
   const isCloud = process.env.TORQVOICE_MODE === 'cloud'
-  if (!isCloud || !process.env.STRIPE_SECRET_KEY) return
+  if (!isCloud) return
 
   const job = new CronJob('0 1 * * *', async () => {
     try {
-      const stripe = getStripe()
+      const config = await getStripeConfig()
+      if (!config.secretKey) return
+
+      const stripe = await getStripeClient()
 
       const subscriptions = await db.subscription.findMany({
         where: {

+ 3 - 14
src/features/subscription/Actions/subscriptionActions.ts

@@ -1,19 +1,8 @@
 "use server";
 
-import Stripe from "stripe";
 import { withAuth } from "@/lib/with-auth";
 import { db } from "@/lib/db";
-
-let stripeClient: Stripe | null = null;
-
-function getStripe(): Stripe {
-  if (!stripeClient) {
-    const key = process.env.STRIPE_SECRET_KEY;
-    if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
-    stripeClient = new Stripe(key);
-  }
-  return stripeClient;
-}
+import { getStripeClient } from "@/lib/stripe-config";
 
 export async function cancelSubscription() {
   return withAuth(async ({ organizationId }) => {
@@ -25,7 +14,7 @@ export async function cancelSubscription() {
       throw new Error("No active subscription found");
     }
 
-    const stripe = getStripe();
+    const stripe = await getStripeClient();
 
     await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
       cancel_at_period_end: true,
@@ -50,7 +39,7 @@ export async function resumeSubscription() {
       throw new Error("No active subscription found");
     }
 
-    const stripe = getStripe();
+    const stripe = await getStripeClient();
 
     await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
       cancel_at_period_end: false,

+ 3 - 2
src/features/subscription/Components/subscription-settings.tsx

@@ -2,7 +2,7 @@
 
 import { useState } from "react";
 import { toast } from "sonner";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
 import { useRouter } from "next/navigation";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
@@ -64,6 +64,7 @@ export function SubscriptionSettings({
   features,
 }: Props) {
   const t = useTranslations("settings");
+  const locale = useLocale();
   const router = useRouter();
   const [checkoutLoading, setCheckoutLoading] = useState<"pro" | "enterprise" | null>(null);
   const [cancelLoading, setCancelLoading] = useState(false);
@@ -140,7 +141,7 @@ export function SubscriptionSettings({
 
   const formatDate = (iso: string | null) => {
     if (!iso) return "—";
-    return new Date(iso).toLocaleDateString(undefined, {
+    return new Date(iso).toLocaleDateString(locale, {
       year: "numeric",
       month: "long",
       day: "numeric",

+ 4 - 4
src/lib/delete-user-data.ts

@@ -2,7 +2,7 @@ import { db } from '@/lib/db'
 import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { unlink, rm } from 'fs/promises'
 import path from 'path'
-import Stripe from 'stripe'
+import { getStripeClient } from '@/lib/stripe-config'
 
 /**
  * Clean up a single organization where the user is the last member.
@@ -42,12 +42,12 @@ async function deleteOrganization(organizationId: string, userId: string) {
     select: { stripeSubscriptionId: true },
   })
 
-  if (subscription?.stripeSubscriptionId && process.env.STRIPE_SECRET_KEY) {
+  if (subscription?.stripeSubscriptionId) {
     try {
-      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
+      const stripe = await getStripeClient()
       await stripe.subscriptions.cancel(subscription.stripeSubscriptionId)
     } catch {
-      // Subscription may already be canceled on Stripe's side
+      // Subscription may already be canceled on Stripe's side, or Stripe not configured
     }
   }
 

+ 3 - 2
src/lib/features.ts

@@ -131,8 +131,9 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
       }
     }
 
-    const planName = subscription.plan.name.toLowerCase() as Plan
-    return PLAN_FEATURES[planName] ?? PLAN_FEATURES.free
+    const name = subscription.plan.name.toLowerCase()
+    const planName: Plan = name.includes('enterprise') ? 'enterprise' : name.includes('pro') ? 'pro' : 'free'
+    return PLAN_FEATURES[planName]
   }
 
   // Self-hosted mode — all features unlocked, license only controls branding

+ 62 - 0
src/lib/stripe-config.ts

@@ -0,0 +1,62 @@
+import Stripe from "stripe";
+import { db } from "./db";
+
+export type StripeConfig = {
+  secretKey: string;
+  webhookSecret: string;
+  proPriceId: string;
+  enterprisePriceId: string;
+};
+
+/**
+ * Read Stripe configuration from DB (system_settings), falling back to env vars.
+ * The active mode is determined by the `stripe.mode` setting (default: env
+ * STRIPE_MODE or "live").
+ */
+export async function getStripeConfig(): Promise<StripeConfig> {
+  const rows = await db.systemSetting.findMany({
+    where: { key: { startsWith: "stripe." } },
+  });
+
+  const map = new Map<string, string>();
+  for (const row of rows) {
+    if (row.value) map.set(row.key, row.value);
+  }
+
+  const mode =
+    map.get("stripe.mode") || process.env.STRIPE_MODE || "live";
+
+  const secretKey =
+    map.get(`stripe.${mode}.secretKey`) ||
+    process.env.STRIPE_SECRET_KEY ||
+    "";
+
+  const webhookSecret =
+    map.get(`stripe.${mode}.webhookSecret`) ||
+    process.env.STRIPE_WEBHOOK_SECRET ||
+    "";
+
+  const proPriceId =
+    map.get(`stripe.${mode}.proPriceId`) ||
+    process.env.STRIPE_PRO_PRICE_ID ||
+    "";
+
+  const enterprisePriceId =
+    map.get(`stripe.${mode}.enterprisePriceId`) ||
+    process.env.STRIPE_ENTERPRISE_PRICE_ID ||
+    "";
+
+  return { secretKey, webhookSecret, proPriceId, enterprisePriceId };
+}
+
+/**
+ * Create a Stripe client using DB-driven config.
+ * A new instance is created on every call so key changes take effect immediately.
+ */
+export async function getStripeClient(): Promise<Stripe> {
+  const config = await getStripeConfig();
+  if (!config.secretKey) {
+    throw new Error("Stripe secret key is not configured");
+  }
+  return new Stripe(config.secretKey);
+}