Sfoglia il codice sorgente

move task to internal cronjob (#27)

Bernt Christian Egeland 7 mesi fa
parent
commit
9c5e4c6cb9

+ 0 - 33
src/app/api/internal/cleanup-portal-sessions/route.ts

@@ -1,33 +0,0 @@
-import { NextResponse } from "next/server";
-import { db } from "@/lib/db";
-
-export async function GET(request: Request) {
-  const authHeader = request.headers.get("authorization");
-  const cronSecret = process.env.CRON_SECRET;
-
-  if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-  }
-
-  const now = new Date();
-
-  const [deletedSessions, deletedLinks] = await Promise.all([
-    db.customerSession.deleteMany({
-      where: { expiresAt: { lt: now } },
-    }),
-    db.customerMagicLink.deleteMany({
-      where: {
-        OR: [
-          { expiresAt: { lt: now } },
-          { usedAt: { not: null } },
-        ],
-      },
-    }),
-  ]);
-
-  return NextResponse.json({
-    deletedSessions: deletedSessions.count,
-    deletedLinks: deletedLinks.count,
-    timestamp: now.toISOString(),
-  });
-}

+ 0 - 156
src/app/api/internal/cron/recurring-invoices/route.ts

@@ -1,156 +0,0 @@
-import { NextResponse } from "next/server";
-import { db } from "@/lib/db";
-import { resolveInvoicePrefix } from "@/lib/invoice-utils";
-
-export async function GET(request: Request) {
-  const authHeader = request.headers.get("authorization");
-  const cronSecret = process.env.CRON_SECRET;
-
-  if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-  }
-
-  const now = new Date();
-
-  const dueInvoices = await db.recurringInvoice.findMany({
-    where: {
-      isActive: true,
-      nextRunDate: { lte: now },
-    },
-    include: {
-      templateParts: true,
-      templateLabor: true,
-      vehicle: { select: { organizationId: true } },
-    },
-  });
-
-  const results: { recurringInvoiceId: string; serviceRecordId: string }[] = [];
-
-  for (const ri of dueInvoices) {
-    const organizationId = ri.vehicle.organizationId;
-    if (!organizationId) continue;
-
-    // Generate invoice number for this org
-    const settings = await db.appSetting.findMany({
-      where: {
-        organizationId,
-        key: { in: ["workshop.invoicePrefix", "workshop.invoiceStartNumber"] },
-      },
-    });
-
-    const settingsMap: Record<string, string> = {};
-    for (const s of settings) settingsMap[s.key] = s.value;
-
-    const prefix = resolveInvoicePrefix(settingsMap["workshop.invoicePrefix"] || "{year}-");
-    const startNumber = parseInt(settingsMap["workshop.invoiceStartNumber"] || "0", 10);
-
-    const lastRecord = await db.serviceRecord.findFirst({
-      where: { vehicle: { organizationId } },
-      orderBy: { createdAt: "desc" },
-      select: { invoiceNumber: true },
-    });
-
-    let nextNum = startNumber || 1001;
-    if (lastRecord?.invoiceNumber) {
-      const match = lastRecord.invoiceNumber.match(/(\d+)$/);
-      if (match) {
-        const lastNum = parseInt(match[1], 10) + 1;
-        nextNum = Math.max(nextNum, lastNum);
-      }
-    }
-    const invoiceNumber = `${prefix}${nextNum}`;
-
-    // Calculate totals
-    const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0);
-    const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0);
-    const subtotal = ri.cost + partsSubtotal + laborSubtotal;
-    const taxAmount = subtotal * (ri.taxRate / 100);
-    const totalAmount = subtotal + taxAmount;
-
-    const serviceRecord = await db.$transaction(async (tx) => {
-      const sr = await tx.serviceRecord.create({
-        data: {
-          title: ri.title,
-          description: ri.description,
-          type: ri.type,
-          status: "completed",
-          cost: ri.cost,
-          serviceDate: now,
-          invoiceNotes: ri.invoiceNotes,
-          subtotal,
-          taxRate: ri.taxRate,
-          taxAmount,
-          totalAmount,
-          invoiceNumber,
-          vehicleId: ri.vehicleId,
-          partItems: {
-            create: ri.templateParts.map((p) => ({
-              name: p.name,
-              partNumber: p.partNumber,
-              quantity: p.quantity,
-              unitPrice: p.unitPrice,
-              total: p.quantity * p.unitPrice,
-            })),
-          },
-          laborItems: {
-            create: ri.templateLabor.map((l) => ({
-              description: l.description,
-              hours: l.hours,
-              rate: l.rate,
-              total: l.hours * l.rate,
-            })),
-          },
-        },
-      });
-
-      // Update recurring invoice schedule
-      const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency);
-      const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate;
-
-      await tx.recurringInvoice.update({
-        where: { id: ri.id },
-        data: {
-          lastRunAt: now,
-          runCount: { increment: 1 },
-          nextRunDate,
-          ...(shouldDeactivate && { isActive: false }),
-        },
-      });
-
-      return sr;
-    });
-
-    results.push({
-      recurringInvoiceId: ri.id,
-      serviceRecordId: serviceRecord.id,
-    });
-  }
-
-  return NextResponse.json({
-    processed: results.length,
-    results,
-    timestamp: now.toISOString(),
-  });
-}
-
-function calculateNextRunDate(current: Date, frequency: string): Date {
-  const next = new Date(current);
-  switch (frequency) {
-    case "weekly":
-      next.setDate(next.getDate() + 7);
-      break;
-    case "biweekly":
-      next.setDate(next.getDate() + 14);
-      break;
-    case "monthly":
-      next.setMonth(next.getMonth() + 1);
-      break;
-    case "quarterly":
-      next.setMonth(next.getMonth() + 3);
-      break;
-    case "yearly":
-      next.setFullYear(next.getFullYear() + 1);
-      break;
-  }
-  return next;
-}

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

@@ -1,243 +0,0 @@
-import { NextResponse } from "next/server";
-import Stripe from "stripe";
-import { db } from "@/lib/db";
-import { isCloudMode } from "@/lib/features";
-import { getStripeClient, getStripeConfig } from "@/lib/stripe-config";
-
-/**
- * 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 ---
-  const config = await getStripeConfig();
-  if (!config.secretKey) {
-    return NextResponse.json(
-      { error: "Stripe secret key is not configured" },
-      { status: 500 },
-    );
-  }
-
-  const stripe = await getStripeClient();
-  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(),
-  });
-}

+ 4 - 269
src/cronTasks.ts

@@ -1,269 +1,4 @@
-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'
-
-// ---------------------------------------------------------------------------
-// Cloud mode: Stripe subscription validation
-// Runs daily at 01:00 UTC (offset from license check at 00:00 to spread load)
-// ---------------------------------------------------------------------------
-
-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) return
-
-  const job = new CronJob('0 1 * * *', async () => {
-    try {
-      const config = await getStripeConfig()
-      if (!config.secretKey) return
-
-      const stripe = await getStripeClient()
-
-      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 {
-      // Find all organizations that have a license key stored
-      const licenseSettings = await db.appSetting.findMany({
-        where: { key: 'license.key' },
-        select: { organizationId: true, value: true },
-      })
-
-      for (const setting of licenseSettings) {
-        if (!setting.organizationId) continue
-
-        try {
-          await revalidateOrganizationLicense(setting.organizationId, setting.value)
-        } catch (error) {
-          console.error(
-            `[cron] Failed to revalidate license for org ${setting.organizationId}:`,
-            error
-          )
-        }
-      }
-    } catch (error) {
-      console.error('[cron] License revalidation failed:', error)
-    }
-  })
-
-  job.start()
-}
-
-async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
-  let valid = false
-  let plan = 'free'
-  let expiresAt = ''
-
-  const response = await fetch(`${TORQVOICE_COM_URL}/api/license/validate`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ key: licenseKey, organizationId }),
-    signal: AbortSignal.timeout(10000),
-  })
-
-  if (response.ok) {
-    const data = await response.json()
-    valid = data.valid === true
-    if (valid && data.plan) {
-      plan = data.plan
-    }
-    if (data.expiresAt) {
-      expiresAt = data.expiresAt
-    }
-  }
-
-  const now = new Date().toISOString()
-
-  // Find any user in this org to use as the setting owner
-  const orgMember = await db.organizationMember.findFirst({
-    where: { organizationId },
-    select: { userId: true },
-  })
-
-  if (!orgMember) return
-
-  const upserts = [
-    db.appSetting.upsert({
-      where: { organizationId_key: { organizationId, key: 'license.valid' } },
-      update: { value: String(valid) },
-      create: {
-        userId: orgMember.userId,
-        organizationId,
-        key: 'license.valid',
-        value: String(valid),
-      },
-    }),
-    db.appSetting.upsert({
-      where: {
-        organizationId_key: { organizationId, key: 'license.checkedAt' },
-      },
-      update: { value: now },
-      create: {
-        userId: orgMember.userId,
-        organizationId,
-        key: 'license.checkedAt',
-        value: now,
-      },
-    }),
-    db.appSetting.upsert({
-      where: { organizationId_key: { organizationId, key: 'license.plan' } },
-      update: { value: plan },
-      create: {
-        userId: orgMember.userId,
-        organizationId,
-        key: 'license.plan',
-        value: plan,
-      },
-    }),
-  ]
-
-  if (expiresAt) {
-    upserts.push(
-      db.appSetting.upsert({
-        where: {
-          organizationId_key: { organizationId, key: 'license.expiresAt' },
-        },
-        update: { value: expiresAt },
-        create: {
-          userId: orgMember.userId,
-          organizationId,
-          key: 'license.expiresAt',
-          value: expiresAt,
-        },
-      })
-    )
-  }
-
-  await db.$transaction(upserts)
-}
+export { checkSubscriptions } from './lib/cron/check-subscriptions'
+export { checkLicenses } from './lib/cron/check-licenses'
+export { processRecurringInvoices } from './lib/cron/recurring-invoices'
+export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'

+ 3 - 1
src/instrumentation.ts

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

+ 87 - 0
src/lib/cron/check-licenses.ts

@@ -0,0 +1,87 @@
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
+
+const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
+
+async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
+  let valid = false
+  let plan = 'free'
+  let expiresAt = ''
+
+  const response = await fetch(`${TORQVOICE_COM_URL}/api/license/validate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ key: licenseKey, organizationId }),
+    signal: AbortSignal.timeout(10000),
+  })
+
+  if (response.ok) {
+    const data = await response.json()
+    valid = data.valid === true
+    if (valid && data.plan) plan = data.plan
+    if (data.expiresAt) expiresAt = data.expiresAt
+  }
+
+  const now = new Date().toISOString()
+  const orgMember = await db.organizationMember.findFirst({
+    where: { organizationId },
+    select: { userId: true },
+  })
+
+  if (!orgMember) return
+
+  const upserts = [
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.valid' } },
+      update: { value: String(valid) },
+      create: { userId: orgMember.userId, organizationId, key: 'license.valid', value: String(valid) },
+    }),
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.checkedAt' } },
+      update: { value: now },
+      create: { userId: orgMember.userId, organizationId, key: 'license.checkedAt', value: now },
+    }),
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.plan' } },
+      update: { value: plan },
+      create: { userId: orgMember.userId, organizationId, key: 'license.plan', value: plan },
+    }),
+  ]
+
+  if (expiresAt) {
+    upserts.push(
+      db.appSetting.upsert({
+        where: { organizationId_key: { organizationId, key: 'license.expiresAt' } },
+        update: { value: expiresAt },
+        create: { userId: orgMember.userId, organizationId, key: 'license.expiresAt', value: expiresAt },
+      })
+    )
+  }
+
+  await db.$transaction(upserts)
+}
+
+/** Revalidates all license keys against torqvoice.com daily at 00:00 UTC */
+export function checkLicenses() {
+  const job = new CronJob('0 0 * * *', async () => {
+    try {
+      const licenseSettings = await db.appSetting.findMany({
+        where: { key: 'license.key' },
+        select: { organizationId: true, value: true },
+      })
+
+      for (const setting of licenseSettings) {
+        if (!setting.organizationId) continue
+        try {
+          await revalidateOrganizationLicense(setting.organizationId, setting.value)
+        } catch (error) {
+          console.error(`[cron] Failed to revalidate license for org ${setting.organizationId}:`, error)
+        }
+      }
+    } catch (error) {
+      console.error('[cron] License revalidation failed:', error)
+    }
+  })
+
+  job.start()
+}

+ 130 - 0
src/lib/cron/check-subscriptions.ts

@@ -0,0 +1,130 @@
+import { CronJob } from 'cron'
+import Stripe from 'stripe'
+import { db } from '@/lib/db'
+import { getStripeClient, getStripeConfig } from '@/lib/stripe-config'
+
+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'
+}
+
+async function syncSubscription(
+  stripe: Stripe,
+  sub: { id: string; organizationId: string; stripeSubscriptionId: string | null; status: string; currentPeriodStart: Date | null; currentPeriodEnd: Date | null; cancelAtPeriodEnd: boolean; plan: { name: string } },
+) {
+  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) {
+    return false
+  }
+
+  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 },
+    }),
+  ])
+
+  return true
+}
+
+async function cancelOrphanedSubscription(subId: string, organizationId: string) {
+  await db.$transaction([
+    db.subscription.update({ where: { id: subId }, data: { status: 'canceled' } }),
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.plan' } },
+      create: { organizationId, key: 'license.plan', value: 'free', userId: '' },
+      update: { value: 'free' },
+    }),
+  ])
+}
+
+/** Cloud mode: validates subscriptions against Stripe daily at 01:00 UTC */
+export function checkSubscriptions() {
+  const isCloud = process.env.TORQVOICE_MODE === 'cloud'
+  if (!isCloud) return
+
+  const job = new CronJob('0 1 * * *', async () => {
+    try {
+      const config = await getStripeConfig()
+      if (!config.secretKey) return
+
+      const stripe = await getStripeClient()
+      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 changed = await syncSubscription(stripe, sub)
+          if (changed) synced++
+        } catch (error) {
+          if (error instanceof Stripe.errors.StripeError && error.code === 'resource_missing') {
+            try {
+              await cancelOrphanedSubscription(sub.id, sub.organizationId)
+              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)
+          }
+        }
+      }
+
+      if (synced > 0 || errors > 0) {
+        console.warn(`[cron] Subscription validation: ${synced} synced, ${errors} errors`)
+      }
+    } catch (error) {
+      console.error('[cron] Subscription validation failed:', error)
+    }
+  })
+
+  job.start()
+}

+ 35 - 0
src/lib/cron/cleanup-portal-sessions.ts

@@ -0,0 +1,35 @@
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
+
+/** Deletes expired portal sessions and used magic links daily at 02:00 UTC */
+export function cleanupPortalSessions() {
+  const job = new CronJob('0 2 * * *', async () => {
+    try {
+      const now = new Date()
+
+      const [deletedSessions, deletedLinks] = await Promise.all([
+        db.customerSession.deleteMany({
+          where: { expiresAt: { lt: now } },
+        }),
+        db.customerMagicLink.deleteMany({
+          where: {
+            OR: [
+              { expiresAt: { lt: now } },
+              { usedAt: { not: null } },
+            ],
+          },
+        }),
+      ])
+
+      if (deletedSessions.count > 0 || deletedLinks.count > 0) {
+        console.warn(
+          `[cron] Cleanup: ${deletedSessions.count} sessions, ${deletedLinks.count} magic links`
+        )
+      }
+    } catch (error) {
+      console.error('[cron] Portal session cleanup failed:', error)
+    }
+  })
+
+  job.start()
+}

+ 145 - 0
src/lib/cron/recurring-invoices.ts

@@ -0,0 +1,145 @@
+import { CronJob } from 'cron'
+import { db } from '@/lib/db'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+
+function calculateNextRunDate(current: Date, frequency: string): Date {
+  const next = new Date(current)
+  switch (frequency) {
+    case 'weekly':
+      next.setDate(next.getDate() + 7)
+      break
+    case 'biweekly':
+      next.setDate(next.getDate() + 14)
+      break
+    case 'monthly':
+      next.setMonth(next.getMonth() + 1)
+      break
+    case 'quarterly':
+      next.setMonth(next.getMonth() + 3)
+      break
+    case 'yearly':
+      next.setFullYear(next.getFullYear() + 1)
+      break
+  }
+  return next
+}
+
+async function generateInvoiceNumber(organizationId: string): Promise<string> {
+  const settings = await db.appSetting.findMany({
+    where: { organizationId, key: { in: ['workshop.invoicePrefix', 'workshop.invoiceStartNumber'] } },
+  })
+
+  const settingsMap: Record<string, string> = {}
+  for (const s of settings) settingsMap[s.key] = s.value
+
+  const prefix = resolveInvoicePrefix(settingsMap['workshop.invoicePrefix'] || '{year}-')
+  const startNumber = parseInt(settingsMap['workshop.invoiceStartNumber'] || '0', 10)
+
+  const lastRecord = await db.serviceRecord.findFirst({
+    where: { vehicle: { organizationId } },
+    orderBy: { createdAt: 'desc' },
+    select: { invoiceNumber: true },
+  })
+
+  let nextNum = startNumber || 1001
+  if (lastRecord?.invoiceNumber) {
+    const match = lastRecord.invoiceNumber.match(/(\d+)$/)
+    if (match) {
+      const lastNum = parseInt(match[1], 10) + 1
+      nextNum = Math.max(nextNum, lastNum)
+    }
+  }
+
+  return `${prefix}${nextNum}`
+}
+
+/** Generates invoices from recurring templates every hour */
+export function processRecurringInvoices() {
+  const job = new CronJob('0 * * * *', async () => {
+    try {
+      const now = new Date()
+
+      const dueInvoices = await db.recurringInvoice.findMany({
+        where: { isActive: true, nextRunDate: { lte: now } },
+        include: {
+          templateParts: true,
+          templateLabor: true,
+          vehicle: { select: { organizationId: true } },
+        },
+      })
+
+      let processed = 0
+
+      for (const ri of dueInvoices) {
+        const organizationId = ri.vehicle.organizationId
+        if (!organizationId) continue
+
+        try {
+          const invoiceNumber = await generateInvoiceNumber(organizationId)
+
+          const partsSubtotal = ri.templateParts.reduce((s, p) => s + p.quantity * p.unitPrice, 0)
+          const laborSubtotal = ri.templateLabor.reduce((s, l) => s + l.hours * l.rate, 0)
+          const subtotal = ri.cost + partsSubtotal + laborSubtotal
+          const taxAmount = subtotal * (ri.taxRate / 100)
+          const totalAmount = subtotal + taxAmount
+
+          await db.$transaction(async (tx) => {
+            await tx.serviceRecord.create({
+              data: {
+                title: ri.title,
+                description: ri.description,
+                type: ri.type,
+                status: 'completed',
+                cost: ri.cost,
+                serviceDate: now,
+                invoiceNotes: ri.invoiceNotes,
+                subtotal,
+                taxRate: ri.taxRate,
+                taxAmount,
+                totalAmount,
+                invoiceNumber,
+                vehicleId: ri.vehicleId,
+                partItems: {
+                  create: ri.templateParts.map((p) => ({
+                    name: p.name, partNumber: p.partNumber,
+                    quantity: p.quantity, unitPrice: p.unitPrice, total: p.quantity * p.unitPrice,
+                  })),
+                },
+                laborItems: {
+                  create: ri.templateLabor.map((l) => ({
+                    description: l.description, hours: l.hours, rate: l.rate, total: l.hours * l.rate,
+                  })),
+                },
+              },
+            })
+
+            const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency)
+            const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate
+
+            await tx.recurringInvoice.update({
+              where: { id: ri.id },
+              data: {
+                lastRunAt: now,
+                runCount: { increment: 1 },
+                nextRunDate,
+                ...(shouldDeactivate && { isActive: false }),
+              },
+            })
+          })
+
+          processed++
+        } catch (error) {
+          console.error(`[cron] Failed to process recurring invoice ${ri.id}:`, error)
+        }
+      }
+
+      if (processed > 0) {
+        console.warn(`[cron] Processed ${processed} recurring invoices`)
+      }
+    } catch (error) {
+      console.error('[cron] Recurring invoice processing failed:', error)
+    }
+  })
+
+  job.start()
+}