/* * This file is part of a proprietary work. * * Copyright (c) 2025 Fossorial, Inc. * All rights reserved. * * This file is licensed under the Fossorial Commercial License. * You may not use this file except in compliance with the License. * Unauthorized use, copying, modification, or distribution is strictly prohibited. * * This file is not licensed under the AGPLv3. */ import Stripe from "stripe"; import { subscriptions, db, subscriptionItems, usage, sites, customers, orgs } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { getFeatureIdByMetricId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, previousAttributes: Partial | undefined ): Promise { try { // Fetch the subscription from Stripe with expanded price.tiers const fullSubscription = await stripe!.subscriptions.retrieve( subscription.id, { expand: ["items.data.price.tiers"] } ); logger.info(JSON.stringify(fullSubscription, null, 2)); const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)) .limit(1); if (!existingSubscription) { logger.info( `Subscription with ID ${subscription.id} does not exist.` ); return; } // get the customer const [existingCustomer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) .limit(1); await db .update(subscriptions) .set({ status: subscription.status, canceledAt: subscription.canceled_at ? subscription.canceled_at : null, updatedAt: Math.floor(Date.now() / 1000), billingCycleAnchor: subscription.billing_cycle_anchor }) .where(eq(subscriptions.subscriptionId, subscription.id)); await handleSubscriptionLifesycle( existingCustomer.orgId, subscription.status ); // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { const itemsToUpsert = fullSubscription.items.data.map((item) => ({ subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, meterId: item.plan.meter, unitAmount: item.price.unit_amount || 0, currentPeriodStart: item.current_period_start, currentPeriodEnd: item.current_period_end, tiers: item.price.tiers ? JSON.stringify(item.price.tiers) : null, interval: item.plan.interval })); if (itemsToUpsert.length > 0) { await db.transaction(async (trx) => { await trx .delete(subscriptionItems) .where( eq( subscriptionItems.subscriptionId, subscription.id ) ); await trx.insert(subscriptionItems).values(itemsToUpsert); }); logger.info( `Updated ${itemsToUpsert.length} subscription items for subscription ${subscription.id}.` ); } // --- Detect cycled items and update usage --- if (previousAttributes) { // Only proceed if latest_invoice changed (per Stripe docs) if ("latest_invoice" in previousAttributes) { // If items array present in previous_attributes, check each item if (Array.isArray(previousAttributes.items?.data)) { for (const item of subscription.items.data) { const prevItem = previousAttributes.items.data.find( (pi: any) => pi.id === item.id ); if ( prevItem && prevItem.current_period_end && item.current_period_start && prevItem.current_period_end === item.current_period_start && item.current_period_start > prevItem.current_period_start ) { logger.info( `Subscription item ${item.id} has cycled. Resetting usage.` ); } else { continue; } // This item has cycled const meterId = item.plan.meter; if (!meterId) { logger.warn( `No meterId found for subscription item ${item.id}. Skipping usage reset.` ); continue; } const featureId = getFeatureIdByMetricId(meterId); if (!featureId) { logger.warn( `No featureId found for meterId ${meterId}. Skipping usage reset.` ); continue; } const orgId = existingCustomer.orgId; if (!orgId) { logger.warn( `No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.` ); continue; } await db.transaction(async (trx) => { const [usageRow] = await trx .select() .from(usage) .where( eq( usage.usageId, `${orgId}-${featureId}` ) ) .limit(1); if (usageRow) { // get the next rollover date const [org] = await trx .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); const lastRollover = usageRow.rolledOverAt ? new Date(usageRow.rolledOverAt * 1000) : new Date(); const anchorDate = org.createdAt ? new Date(org.createdAt) : new Date(); const nextRollover = calculateNextRollOverDate( lastRollover, anchorDate ); await trx .update(usage) .set({ previousValue: usageRow.latestValue, latestValue: usageRow.instantaneousValue || 0, updatedAt: Math.floor( Date.now() / 1000 ), rolledOverAt: Math.floor( Date.now() / 1000 ), nextRolloverAt: Math.floor( nextRollover.getTime() / 1000 ) }) .where( eq(usage.usageId, usageRow.usageId) ); logger.info( `Usage reset for org ${orgId}, meter ${featureId} on subscription item cycle.` ); } // Also reset the sites to 0 await trx .update(sites) .set({ megabytesIn: 0, megabytesOut: 0 }) .where(eq(sites.orgId, orgId)); }); } } } } // --- end usage update --- } } catch (error) { logger.error( `Error handling subscription updated event for ID ${subscription.id}:`, error ); } return; } /** * Calculate the next billing date based on monthly billing cycle * Handles end-of-month scenarios as described in the requirements * Made public for testing */ function calculateNextRollOverDate(lastRollover: Date, anchorDate: Date): Date { const rolloverDate = new Date(lastRollover); const anchor = new Date(anchorDate); // Get components from rollover date const rolloverYear = rolloverDate.getUTCFullYear(); const rolloverMonth = rolloverDate.getUTCMonth(); // Calculate target month and year (next month) let targetMonth = rolloverMonth + 1; let targetYear = rolloverYear; if (targetMonth > 11) { targetMonth = 0; targetYear++; } // Get anchor day for billing const anchorDay = anchor.getUTCDate(); // Get the last day of the target month const lastDayOfMonth = new Date( Date.UTC(targetYear, targetMonth + 1, 0) ).getUTCDate(); // Use the anchor day or the last day of the month, whichever is smaller const targetDay = Math.min(anchorDay, lastDayOfMonth); // Create the next billing date using UTC const nextBilling = new Date( Date.UTC( targetYear, targetMonth, targetDay, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds() ) ); return nextBilling; }