Przeglądaj źródła

check subscriptions in cloud mode

Bernt Christian Egeland 7 miesięcy temu
rodzic
commit
2443c703c1

+ 385 - 80
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -1,17 +1,13 @@
 'use client'
 
 import { useRef, useState } from 'react'
+import Image from 'next/image'
 import { Button } from '@/components/ui/button'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Checkbox } from '@/components/ui/checkbox'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { useGlassModal } from '@/components/glass-modal'
-import {
-  AlertTriangle,
-  Download,
-  FileArchive,
-  Loader2,
-  Upload,
-} from 'lucide-react'
+import { AlertTriangle, ArrowRight, Download, FileArchive, Loader2, Upload } from 'lucide-react'
 import { ReadOnlyBanner, ReadOnlyWrapper } from '../read-only-guard'
 
 interface ExportOptions {
@@ -25,12 +21,24 @@ interface ExportOptions {
 }
 
 const OPTION_LABELS: { key: keyof ExportOptions; label: string; description: string }[] = [
-  { key: 'settings', label: 'Settings', description: 'Workshop info, invoice config, payment providers' },
+  {
+    key: 'settings',
+    label: 'Settings',
+    description: 'Workshop info, invoice config, payment providers',
+  },
   { key: 'customers', label: 'Customers', description: 'Customer records and contact info' },
-  { key: 'vehicles', label: 'Vehicles & Service Records', description: 'Vehicles, notes, fuel logs, reminders, service records' },
+  {
+    key: 'vehicles',
+    label: 'Vehicles & Service Records',
+    description: 'Vehicles, notes, fuel logs, reminders, service records',
+  },
   { key: 'quotes', label: 'Quotes', description: 'Quotes with parts and labor' },
   { key: 'inventory', label: 'Inventory', description: 'Inventory parts and stock levels' },
-  { key: 'customFields', label: 'Custom Fields', description: 'Custom field definitions and values' },
+  {
+    key: 'customFields',
+    label: 'Custom Fields',
+    description: 'Custom field definitions and values',
+  },
   { key: 'files', label: 'Uploaded Files', description: 'Images, service attachments, logo' },
 ]
 
@@ -145,6 +153,84 @@ export function DataSettings() {
     setImporting(false)
   }
 
+  // ── LubeLog import dialog ──────────────────────────────────────────────
+  const lubelogInputRef = useRef<HTMLInputElement>(null)
+  const [lubelogOpen, setLubelogOpen] = useState(false)
+  const [lubelogFile, setLubelogFile] = useState<File | null>(null)
+  const [importingLubelog, setImportingLubelog] = useState(false)
+
+  const handleLubeLogImport = async () => {
+    if (!lubelogFile) return
+
+    setImportingLubelog(true)
+    try {
+      const buffer = await lubelogFile.arrayBuffer()
+      const res = await fetch('/api/backup/import-lubelog', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/zip' },
+        body: buffer,
+      })
+
+      if (!res.ok) {
+        const body = await res.json()
+        throw new Error(body.error || 'Import failed')
+      }
+
+      const result = await res.json()
+      setLubelogFile(null)
+      if (lubelogInputRef.current) lubelogInputRef.current.value = ''
+      setLubelogOpen(false)
+      modal.open(
+        'success',
+        'LubeLog Import Complete',
+        `Imported ${result.imported.vehicles} vehicles, ${result.imported.serviceRecords} service records, and ${result.imported.notes} notes.`
+      )
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Import failed'
+      modal.open('error', 'Import Failed', message)
+    }
+    setImportingLubelog(false)
+  }
+
+  // ── Invoice Ninja import dialog ──────────────────────────────────────────
+  const invoiceNinjaInputRef = useRef<HTMLInputElement>(null)
+  const [invoiceNinjaOpen, setInvoiceNinjaOpen] = useState(false)
+  const [invoiceNinjaFile, setInvoiceNinjaFile] = useState<File | null>(null)
+  const [importingInvoiceNinja, setImportingInvoiceNinja] = useState(false)
+
+  const handleInvoiceNinjaImport = async () => {
+    if (!invoiceNinjaFile) return
+
+    setImportingInvoiceNinja(true)
+    try {
+      const buffer = await invoiceNinjaFile.arrayBuffer()
+      const res = await fetch('/api/backup/import-invoice-ninja', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/zip' },
+        body: buffer,
+      })
+
+      if (!res.ok) {
+        const body = await res.json()
+        throw new Error(body.error || 'Import failed')
+      }
+
+      const result = await res.json()
+      setInvoiceNinjaFile(null)
+      if (invoiceNinjaInputRef.current) invoiceNinjaInputRef.current.value = ''
+      setInvoiceNinjaOpen(false)
+      modal.open(
+        'success',
+        'Invoice Ninja Import Complete',
+        `Imported ${result.imported.customers} customers, ${result.imported.products} products, ${result.imported.invoices} invoices, ${result.imported.documents} documents, and ${result.imported.payments} payments.`
+      )
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Import failed'
+      modal.open('error', 'Import Failed', message)
+    }
+    setImportingInvoiceNinja(false)
+  }
+
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
@@ -156,104 +242,323 @@ export function DataSettings() {
       </div>
 
       <ReadOnlyWrapper>
-      <div className="grid gap-6 lg:grid-cols-2">
-        {/* Export Card */}
+        <div className="grid gap-6 lg:grid-cols-2">
+          {/* Export Card */}
+          <Card className="border-0 shadow-sm">
+            <CardHeader>
+              <CardTitle className="flex items-center gap-2 text-base">
+                <Download className="h-4 w-4" /> Export Data
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-muted-foreground">
+                Download your data as a zip file. Select which categories to include in the backup.
+              </p>
+
+              <div className="space-y-3">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm font-medium">Include in backup:</span>
+                  <button
+                    type="button"
+                    onClick={toggleAll}
+                    className="text-xs text-primary hover:underline"
+                  >
+                    {allChecked ? 'Deselect All' : 'Select All'}
+                  </button>
+                </div>
+
+                <div className="space-y-2">
+                  {OPTION_LABELS.map(({ key, label, description }) => (
+                    <label
+                      key={key}
+                      className="flex items-start gap-3 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
+                    >
+                      <Checkbox
+                        checked={options[key]}
+                        onCheckedChange={() => toggleOption(key)}
+                        className="mt-0.5"
+                      />
+                      <div className="space-y-0.5">
+                        <div className="text-sm font-medium leading-none">{label}</div>
+                        <div className="text-xs text-muted-foreground">{description}</div>
+                      </div>
+                    </label>
+                  ))}
+                </div>
+
+                {options.files && (
+                  <p className="text-xs text-muted-foreground flex items-center gap-1.5">
+                    <FileArchive className="h-3.5 w-3.5 shrink-0" />
+                    Includes uploaded files — backup may be larger
+                  </p>
+                )}
+              </div>
+
+              <Button onClick={handleExport} disabled={exporting || noneChecked}>
+                {exporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                Download Backup
+              </Button>
+            </CardContent>
+          </Card>
+
+          {/* Import Card */}
+          <Card className="border-0 shadow-sm">
+            <CardHeader>
+              <CardTitle className="flex items-center gap-2 text-base">
+                <Upload className="h-4 w-4" /> Import Data
+              </CardTitle>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
+                <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
+                <span>
+                  Importing will <strong>replace all existing data</strong> with the contents of the
+                  backup file. This action cannot be undone.
+                </span>
+              </div>
+              <div className="space-y-3">
+                <input
+                  ref={fileInputRef}
+                  type="file"
+                  accept=".json,.zip"
+                  onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
+                  className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Supports both .zip (v2) and .json (v1) backup files.
+                </p>
+                <Button
+                  onClick={handleImport}
+                  disabled={!selectedFile || importing}
+                  variant="outline"
+                >
+                  {importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                  Upload &amp; Restore
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+
+        {/* Import from Other Services */}
         <Card className="border-0 shadow-sm">
           <CardHeader>
             <CardTitle className="flex items-center gap-2 text-base">
-              <Download className="h-4 w-4" /> Export Data
+              <ArrowRight className="h-4 w-4" /> Import from Other Services
             </CardTitle>
           </CardHeader>
           <CardContent className="space-y-4">
             <p className="text-sm text-muted-foreground">
-              Download your data as a zip file. Select which categories to include in the backup.
+              Migrate your data from another platform. Select a service to get started.
             </p>
+            <div className="flex flex-wrap gap-4">
+              {/* LubeLog */}
+              <button
+                type="button"
+                onClick={() => setLubelogOpen(true)}
+                className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:border-primary/50 hover:shadow-md"
+              >
+                <Image
+                  src="/images/import/lubelog.png"
+                  alt="LubeLog"
+                  width={120}
+                  height={30}
+                  className="object-contain"
+                  unoptimized
+                />
+              </button>
 
+              {/* Invoice Ninja */}
+              <button
+                type="button"
+                onClick={() => setInvoiceNinjaOpen(true)}
+                className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-all hover:border-primary/50 hover:shadow-md"
+              >
+                <Image
+                  src="/images/import/invoice_ninja.png"
+                  alt="Invoice Ninja"
+                  width={140}
+                  height={30}
+                  className="object-contain"
+                  unoptimized
+                />
+              </button>
+            </div>
+          </CardContent>
+        </Card>
+      </ReadOnlyWrapper>
+
+      {/* LubeLog Import Dialog */}
+      <Dialog
+        open={lubelogOpen}
+        onOpenChange={(open) => {
+          setLubelogOpen(open)
+          if (!open) {
+            setLubelogFile(null)
+            if (lubelogInputRef.current) lubelogInputRef.current.value = ''
+          }
+        }}
+      >
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-3">
+              <Image
+                src="/images/import/lubelog.png"
+                alt="LubeLog"
+                width={24}
+                height={24}
+                className="object-contain"
+                unoptimized
+              />
+              Import from LubeLog
+            </DialogTitle>
+          </DialogHeader>
+
+          <div className="space-y-4">
             <div className="space-y-3">
-              <div className="flex items-center justify-between">
-                <span className="text-sm font-medium">Include in backup:</span>
-                <button
-                  type="button"
-                  onClick={toggleAll}
-                  className="text-xs text-primary hover:underline"
-                >
-                  {allChecked ? 'Deselect All' : 'Select All'}
-                </button>
-              </div>
+              <p className="text-sm font-medium">How to export your LubeLog data:</p>
+              <ol className="list-inside list-decimal space-y-1.5 text-sm text-muted-foreground">
+                <li>Open your LubeLog instance</li>
+                <li>
+                  Go to <strong className="text-foreground">Settings</strong>
+                </li>
+                <li>
+                  Click <strong className="text-foreground">Make Backup</strong> to download a zip
+                  file
+                </li>
+                <li>Upload the downloaded zip file below</li>
+              </ol>
+            </div>
 
-              <div className="space-y-2">
-                {OPTION_LABELS.map(({ key, label, description }) => (
-                  <label
-                    key={key}
-                    className="flex items-start gap-3 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
-                  >
-                    <Checkbox
-                      checked={options[key]}
-                      onCheckedChange={() => toggleOption(key)}
-                      className="mt-0.5"
-                    />
-                    <div className="space-y-0.5">
-                      <div className="text-sm font-medium leading-none">{label}</div>
-                      <div className="text-xs text-muted-foreground">{description}</div>
-                    </div>
-                  </label>
-                ))}
-              </div>
+            <div className="flex items-start gap-2 rounded-md bg-blue-500/10 p-3 text-sm text-blue-700 dark:text-blue-400">
+              <FileArchive className="mt-0.5 h-4 w-4 shrink-0" />
+              <span>
+                This will <strong>add data alongside</strong> your existing records — nothing will
+                be deleted.
+              </span>
+            </div>
 
-              {options.files && (
-                <p className="text-xs text-muted-foreground flex items-center gap-1.5">
-                  <FileArchive className="h-3.5 w-3.5 shrink-0" />
-                  Includes uploaded files — backup may be larger
-                </p>
-              )}
+            <div className="space-y-2">
+              <p className="text-sm font-medium">What will be imported:</p>
+              <ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
+                <li>Vehicles</li>
+                <li>Service records with attachments</li>
+                <li>Notes</li>
+              </ul>
             </div>
 
-            <Button onClick={handleExport} disabled={exporting || noneChecked}>
-              {exporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              Download Backup
-            </Button>
-          </CardContent>
-        </Card>
+            <div className="space-y-3 border-t pt-4">
+              <input
+                ref={lubelogInputRef}
+                type="file"
+                accept=".zip"
+                onChange={(e) => setLubelogFile(e.target.files?.[0] ?? null)}
+                className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
+              />
+              <p className="text-xs text-muted-foreground">Files up to 200MB supported.</p>
+            </div>
 
-        {/* Import Card */}
-        <Card className="border-0 shadow-sm">
-          <CardHeader>
-            <CardTitle className="flex items-center gap-2 text-base">
-              <Upload className="h-4 w-4" /> Import Data
-            </CardTitle>
-          </CardHeader>
-          <CardContent className="space-y-4">
-            <div className="flex items-start gap-2 rounded-md bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
-              <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
+            <div className="flex justify-end gap-2 pt-2">
+              <Button variant="ghost" onClick={() => setLubelogOpen(false)}>
+                Cancel
+              </Button>
+              <Button onClick={handleLubeLogImport} disabled={!lubelogFile || importingLubelog}>
+                {importingLubelog && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                Import
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+
+      {/* Invoice Ninja Import Dialog */}
+      <Dialog
+        open={invoiceNinjaOpen}
+        onOpenChange={(open) => {
+          setInvoiceNinjaOpen(open)
+          if (!open) {
+            setInvoiceNinjaFile(null)
+            if (invoiceNinjaInputRef.current) invoiceNinjaInputRef.current.value = ''
+          }
+        }}
+      >
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-3">
+              <Image
+                src="/images/import/invoice_ninja.png"
+                alt="Invoice Ninja"
+                width={28}
+                height={28}
+                className="object-contain"
+                unoptimized
+              />
+              Import from Invoice Ninja
+            </DialogTitle>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            <div className="space-y-3">
+              <p className="text-sm font-medium">How to export your Invoice Ninja data:</p>
+              <ol className="list-inside list-decimal space-y-1.5 text-sm text-muted-foreground">
+                <li>Open your Invoice Ninja instance</li>
+                <li>
+                  Go to <strong className="text-foreground">Settings &gt; Import | Export</strong>
+                </li>
+                <li>
+                  Click <strong className="text-foreground">Export</strong> to download a zip file
+                </li>
+                <li>Upload the downloaded zip file below</li>
+              </ol>
+            </div>
+
+            <div className="flex items-start gap-2 rounded-md bg-blue-500/10 p-3 text-sm text-blue-700 dark:text-blue-400">
+              <FileArchive className="mt-0.5 h-4 w-4 shrink-0" />
               <span>
-                Importing will <strong>replace all existing data</strong> with the contents of the
-                backup file. This action cannot be undone.
+                This will <strong>add data alongside</strong> your existing records — nothing will
+                be deleted.
               </span>
             </div>
-            <div className="space-y-3">
+
+            <div className="space-y-2">
+              <p className="text-sm font-medium">What will be imported:</p>
+              <ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
+                <li>Customers (clients)</li>
+                <li>Products (inventory)</li>
+                <li>Invoices as service records (parts &amp; labor)</li>
+                <li>Documents &amp; attachments</li>
+                <li>Payments</li>
+              </ul>
+            </div>
+
+            <div className="space-y-3 border-t pt-4">
               <input
-                ref={fileInputRef}
+                ref={invoiceNinjaInputRef}
                 type="file"
-                accept=".json,.zip"
-                onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
+                accept=".zip"
+                onChange={(e) => setInvoiceNinjaFile(e.target.files?.[0] ?? null)}
                 className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20"
               />
               <p className="text-xs text-muted-foreground">
-                Supports both .zip (v2) and .json (v1) backup files.
+                Upload an Invoice Ninja zip export file. Files up to 200MB supported.
               </p>
+            </div>
+
+            <div className="flex justify-end gap-2 pt-2">
+              <Button variant="ghost" onClick={() => setInvoiceNinjaOpen(false)}>
+                Cancel
+              </Button>
               <Button
-                onClick={handleImport}
-                disabled={!selectedFile || importing}
-                variant="outline"
+                onClick={handleInvoiceNinjaImport}
+                disabled={!invoiceNinjaFile || importingInvoiceNinja}
               >
-                {importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                Upload &amp; Restore
+                {importingInvoiceNinja && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                Import
               </Button>
             </div>
-          </CardContent>
-        </Card>
-      </div>
-      </ReadOnlyWrapper>
+          </div>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 252 - 0
src/app/api/cron/validate-subscriptions/route.ts

@@ -0,0 +1,252 @@
+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;
+}
+
+/**
+ * Map Stripe subscription status to our internal status.
+ * Stripe statuses: active, past_due, unpaid, canceled, incomplete,
+ *                  incomplete_expired, trialing, paused
+ * Our statuses:    active, past_due, canceled, trialing
+ */
+function mapStripeStatus(stripeStatus: string): string {
+  switch (stripeStatus) {
+    case "active":
+      return "active";
+    case "trialing":
+      return "trialing";
+    case "past_due":
+      return "past_due";
+    // All terminal / non-active states map to canceled
+    case "canceled":
+    case "unpaid":
+    case "incomplete_expired":
+    case "paused":
+    case "incomplete":
+      return "canceled";
+    default:
+      return "canceled";
+  }
+}
+
+/**
+ * Determine the license plan name from the subscription state.
+ * Returns "free" if the subscription is not in a paying state.
+ */
+function resolveLicensePlan(
+  internalStatus: string,
+  planName: string,
+): string {
+  if (internalStatus !== "active" && internalStatus !== "trialing") {
+    return "free";
+  }
+  const lower = planName.toLowerCase();
+  if (lower.includes("enterprise")) return "enterprise";
+  if (lower.includes("pro")) return "pro";
+  return "free";
+}
+
+type ValidationResult = {
+  subscriptionId: string;
+  organizationId: string;
+  stripeSubscriptionId: string;
+  previousStatus: string;
+  newStatus: string;
+  action: "synced" | "unchanged" | "stripe_missing" | "error";
+  error?: string;
+};
+
+export async function GET(request: Request) {
+  // --- Auth ---
+  const authHeader = request.headers.get("authorization");
+  const cronSecret = process.env.CRON_SECRET;
+
+  if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  // --- Guard: only run in cloud mode ---
+  if (!isCloudMode()) {
+    return NextResponse.json({
+      skipped: true,
+      reason: "Not in cloud mode",
+      timestamp: new Date().toISOString(),
+    });
+  }
+
+  // --- Guard: Stripe must be configured ---
+  if (!process.env.STRIPE_SECRET_KEY) {
+    return NextResponse.json(
+      { error: "STRIPE_SECRET_KEY is not configured" },
+      { status: 500 },
+    );
+  }
+
+  const stripe = getStripe();
+  const results: ValidationResult[] = [];
+
+  // Fetch all subscriptions that are not already in a terminal state.
+  // We validate: active (may have expired), past_due (may have been paid or
+  // canceled), and trialing (may have converted or expired).
+  const subscriptions = await db.subscription.findMany({
+    where: {
+      status: { in: ["active", "past_due", "trialing"] },
+      stripeSubscriptionId: { not: null },
+    },
+    include: { plan: true },
+  });
+
+  for (const sub of subscriptions) {
+    const result: ValidationResult = {
+      subscriptionId: sub.id,
+      organizationId: sub.organizationId,
+      stripeSubscriptionId: sub.stripeSubscriptionId!,
+      previousStatus: sub.status,
+      newStatus: sub.status,
+      action: "unchanged",
+    };
+
+    try {
+      // Fetch the authoritative state from Stripe
+      const stripeSub = await stripe.subscriptions.retrieve(
+        sub.stripeSubscriptionId!,
+      );
+
+      const newStatus = mapStripeStatus(stripeSub.status);
+      const currentItem = stripeSub.items.data[0];
+      const periodStart = currentItem?.current_period_start
+        ? new Date(currentItem.current_period_start * 1000)
+        : null;
+      const periodEnd = currentItem?.current_period_end
+        ? new Date(currentItem.current_period_end * 1000)
+        : null;
+      const cancelAtPeriodEnd = stripeSub.cancel_at_period_end;
+
+      // Detect if anything has drifted
+      const statusChanged = sub.status !== newStatus;
+      const periodEndChanged =
+        periodEnd?.getTime() !== sub.currentPeriodEnd?.getTime();
+      const periodStartChanged =
+        periodStart?.getTime() !== sub.currentPeriodStart?.getTime();
+      const cancelChanged = sub.cancelAtPeriodEnd !== cancelAtPeriodEnd;
+
+      if (
+        statusChanged ||
+        periodEndChanged ||
+        periodStartChanged ||
+        cancelChanged
+      ) {
+        const licensePlan = resolveLicensePlan(newStatus, sub.plan.name);
+
+        // Update subscription record and license.plan in a transaction
+        await db.$transaction([
+          db.subscription.update({
+            where: { id: sub.id },
+            data: {
+              status: newStatus,
+              currentPeriodStart: periodStart,
+              currentPeriodEnd: periodEnd,
+              cancelAtPeriodEnd,
+            },
+          }),
+          db.appSetting.upsert({
+            where: {
+              organizationId_key: {
+                organizationId: sub.organizationId,
+                key: "license.plan",
+              },
+            },
+            create: {
+              organizationId: sub.organizationId,
+              key: "license.plan",
+              value: licensePlan,
+              userId: "",
+            },
+            update: { value: licensePlan },
+          }),
+        ]);
+
+        result.newStatus = newStatus;
+        result.action = "synced";
+      }
+    } catch (error) {
+      // Stripe resource_missing means the subscription was deleted on Stripe's
+      // side but we never got the webhook. Downgrade it.
+      if (
+        error instanceof Stripe.errors.StripeError &&
+        error.code === "resource_missing"
+      ) {
+        try {
+          await db.$transaction([
+            db.subscription.update({
+              where: { id: sub.id },
+              data: { status: "canceled" },
+            }),
+            db.appSetting.upsert({
+              where: {
+                organizationId_key: {
+                  organizationId: sub.organizationId,
+                  key: "license.plan",
+                },
+              },
+              create: {
+                organizationId: sub.organizationId,
+                key: "license.plan",
+                value: "free",
+                userId: "",
+              },
+              update: { value: "free" },
+            }),
+          ]);
+
+          result.newStatus = "canceled";
+          result.action = "stripe_missing";
+        } catch (dbError) {
+          result.action = "error";
+          result.error =
+            dbError instanceof Error
+              ? dbError.message
+              : "Failed to cancel orphaned subscription";
+          console.error(
+            `[cron:validate-subscriptions] DB error for orphaned sub ${sub.id}:`,
+            dbError,
+          );
+        }
+      } else {
+        result.action = "error";
+        result.error =
+          error instanceof Error ? error.message : "Stripe API error";
+        console.error(
+          `[cron:validate-subscriptions] Stripe error for sub ${sub.id}:`,
+          error,
+        );
+      }
+    }
+
+    results.push(result);
+  }
+
+  const synced = results.filter((r) => r.action === "synced").length;
+  const errors = results.filter((r) => r.action === "error").length;
+  const missing = results.filter((r) => r.action === "stripe_missing").length;
+
+  return NextResponse.json({
+    processed: results.length,
+    synced,
+    errors,
+    stripeMissing: missing,
+    results,
+    timestamp: new Date().toISOString(),
+  });
+}

+ 155 - 0
src/cronTasks.ts

@@ -1,8 +1,163 @@
 import { CronJob } from 'cron'
+import Stripe from 'stripe'
 import { db } from './lib/db'
 
 const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
 
+// ---------------------------------------------------------------------------
+// Cloud mode: Stripe subscription validation
+// 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':
+      return 'active'
+    case 'trialing':
+      return 'trialing'
+    case 'past_due':
+      return 'past_due'
+    default:
+      return 'canceled'
+  }
+}
+
+function resolveLicensePlan(internalStatus: string, planName: string): string {
+  if (internalStatus !== 'active' && internalStatus !== 'trialing') {
+    return 'free'
+  }
+  const lower = planName.toLowerCase()
+  if (lower.includes('enterprise')) return 'enterprise'
+  if (lower.includes('pro')) return 'pro'
+  return 'free'
+}
+
+export function checkSubscriptions() {
+  const isCloud = process.env.TORQVOICE_MODE === 'cloud'
+  if (!isCloud || !process.env.STRIPE_SECRET_KEY) return
+
+  const job = new CronJob('0 1 * * *', async () => {
+    try {
+      const stripe = getStripe()
+
+      const subscriptions = await db.subscription.findMany({
+        where: {
+          status: { in: ['active', 'past_due', 'trialing'] },
+          stripeSubscriptionId: { not: null },
+        },
+        include: { plan: true },
+      })
+
+      let synced = 0
+      let errors = 0
+
+      for (const sub of subscriptions) {
+        try {
+          const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId!)
+
+          const newStatus = mapStripeStatus(stripeSub.status)
+          const currentItem = stripeSub.items.data[0]
+          const periodStart = currentItem?.current_period_start
+            ? new Date(currentItem.current_period_start * 1000)
+            : null
+          const periodEnd = currentItem?.current_period_end
+            ? new Date(currentItem.current_period_end * 1000)
+            : null
+          const cancelAtPeriodEnd = stripeSub.cancel_at_period_end
+
+          const statusChanged = sub.status !== newStatus
+          const periodEndChanged = periodEnd?.getTime() !== sub.currentPeriodEnd?.getTime()
+          const periodStartChanged = periodStart?.getTime() !== sub.currentPeriodStart?.getTime()
+          const cancelChanged = sub.cancelAtPeriodEnd !== cancelAtPeriodEnd
+
+          if (statusChanged || periodEndChanged || periodStartChanged || cancelChanged) {
+            const licensePlan = resolveLicensePlan(newStatus, sub.plan.name)
+
+            await db.$transaction([
+              db.subscription.update({
+                where: { id: sub.id },
+                data: {
+                  status: newStatus,
+                  currentPeriodStart: periodStart,
+                  currentPeriodEnd: periodEnd,
+                  cancelAtPeriodEnd,
+                },
+              }),
+              db.appSetting.upsert({
+                where: {
+                  organizationId_key: {
+                    organizationId: sub.organizationId,
+                    key: 'license.plan',
+                  },
+                },
+                create: {
+                  organizationId: sub.organizationId,
+                  key: 'license.plan',
+                  value: licensePlan,
+                  userId: '',
+                },
+                update: { value: licensePlan },
+              }),
+            ])
+
+            synced++
+          }
+        } catch (error) {
+          // Subscription deleted on Stripe side — we never got the webhook
+          if (error instanceof Stripe.errors.StripeError && error.code === 'resource_missing') {
+            try {
+              await db.$transaction([
+                db.subscription.update({
+                  where: { id: sub.id },
+                  data: { status: 'canceled' },
+                }),
+                db.appSetting.upsert({
+                  where: {
+                    organizationId_key: {
+                      organizationId: sub.organizationId,
+                      key: 'license.plan',
+                    },
+                  },
+                  create: {
+                    organizationId: sub.organizationId,
+                    key: 'license.plan',
+                    value: 'free',
+                    userId: '',
+                  },
+                  update: { value: 'free' },
+                }),
+              ])
+
+              synced++
+            } catch (dbError) {
+              errors++
+              console.error(`[cron] Failed to cancel orphaned subscription ${sub.id}:`, dbError)
+            }
+          } else {
+            errors++
+            console.error(`[cron] Failed to validate subscription ${sub.id}:`, error)
+          }
+        }
+      }
+    } catch (error) {
+      console.error('[cron] Subscription validation failed:', error)
+    }
+  })
+
+  job.start()
+}
+
 export function checkLicenses() {
   const job = new CronJob('0 0 * * *', async () => {
     try {

+ 2 - 1
src/instrumentation.ts

@@ -1,6 +1,7 @@
 export async function register() {
   if (process.env.NEXT_RUNTIME === 'nodejs') {
-    const { checkLicenses } = await import('./cronTasks')
+    const { checkLicenses, checkSubscriptions } = await import('./cronTasks')
     checkLicenses()
+    checkSubscriptions()
   }
 }

+ 20 - 1
src/lib/features.ts

@@ -76,6 +76,11 @@ export function isCloudMode(): boolean {
   return process.env.TORQVOICE_MODE === 'cloud'
 }
 
+// Grace period (in ms) after currentPeriodEnd before we cut off features.
+// Gives Stripe time to process renewals and deliver webhooks, and the daily
+// cron time to sync. 3 days covers Stripe's initial retry window.
+const SUBSCRIPTION_GRACE_MS = 3 * 24 * 60 * 60 * 1000
+
 export const getFeatures = cache(async (organizationId: string): Promise<PlanFeatures> => {
   if (isCloudMode()) {
     const subscription = await db.subscription.findUnique({
@@ -83,10 +88,24 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
       include: { plan: true },
     })
 
-    if (!subscription || subscription.status !== 'active') {
+    if (!subscription) {
+      return PLAN_FEATURES.free
+    }
+
+    // Only active and trialing subscriptions grant premium features
+    if (subscription.status !== 'active' && subscription.status !== 'trialing') {
       return PLAN_FEATURES.free
     }
 
+    // Defense-in-depth: if the billing period has ended and grace has elapsed,
+    // treat as expired even if status hasn't been updated yet (missed webhook).
+    if (subscription.currentPeriodEnd) {
+      const graceDeadline = new Date(subscription.currentPeriodEnd.getTime() + SUBSCRIPTION_GRACE_MS)
+      if (new Date() > graceDeadline) {
+        return PLAN_FEATURES.free
+      }
+    }
+
     const planName = subscription.plan.name.toLowerCase() as Plan
     return PLAN_FEATURES[planName] ?? PLAN_FEATURES.free
   }