File

apps/recallassess/recallassess-api/src/api/admin/subscription/subscription.service.ts

Extends

BNestBaseModuleService

Index

Methods

Constructor

constructor(prisma: BNestPrismaService, systemLogService: SystemLogService, stripeService: StripeService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
systemLogService SystemLogService No
stripeService StripeService No

Methods

Async add
add(data: any)

Override add method to log creation

Parameters :
Name Type Optional
data any No
Returns : Promise<any>
Async delete
delete(id: number)

Override delete method to log deletion

Parameters :
Name Type Optional
id number No
Returns : Promise<void>
Async getDetail
getDetail(id: number)

Override getDetail to prevent recursive loading of previousSubscription which causes timeout issues

Parameters :
Name Type Optional
id number No
Async getList
getList(paginationOptions: PaginationOptions)

Override getList method to transform raw data before plainToInstance processes it This prevents Decimal errors when plainToInstance processes nested relation objects with Decimal fields

Parameters :
Name Type Optional
paginationOptions PaginationOptions No
Async save
save(id: number, data: any)

Override save method to log update

Parameters :
Name Type Optional
id number No
data any No
Returns : Promise<any>
Async syncStripeSubscriptionFieldsFromLive
syncStripeSubscriptionFieldsFromLive(id: number)

Admin: load this subscription row’s Stripe object and persist stripe_cancel_at_period_end (and stripe_subscription_id if Stripe returns a different id).

Parameters :
Name Type Optional
id number No
Async updateAutoRenewFromAdmin
updateAutoRenewFromAdmin(id: number, enabled: boolean)
Parameters :
Name Type Optional
id number No
enabled boolean No
Async updateNextBillingDateFromAdmin
updateNextBillingDateFromAdmin(id: number, nextBillingDate: string)
Parameters :
Name Type Optional
id number No
nextBillingDate string No
Returns : Promise<literal type>
import { SystemLogService } from "@api/shared/services";
import {
  localStripeCancelAtPeriodEndFromSubscription,
  StripeService,
} from "@api/shared/stripe/services/stripe.service";
import { buildListResponseData } from "@bish-nest/core";
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { PaginationOptions } from "@bish-nest/core/data/pagination/pagination-options.interface";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { ListResponseDataInterface } from "@bish-nest/core/interfaces/list-response-data.interface";
import { SaveResponseDataInterface } from "@bish-nest/core/interfaces/save-response-data.interface";
import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";

@Injectable()
export class SubscriptionService extends BNestBaseModuleService {
  constructor(
    protected prisma: BNestPrismaService,
    private readonly systemLogService: SystemLogService,
    private readonly stripeService: StripeService,
  ) {
    super();
  }

  /**
   * Admin: load this subscription row’s Stripe object and persist
   * {@link stripe_cancel_at_period_end} (and {@link stripe_subscription_id} if Stripe returns a different id).
   */
  async syncStripeSubscriptionFieldsFromLive(id: number): Promise<SaveResponseDataInterface<unknown>> {
    const sub = await this.prisma.client.subscription.findUnique({
      where: { id },
      select: {
        id: true,
        is_current: true,
        stripe_subscription_id: true,
        company: { select: { stripe_customer_id: true } },
      },
    });
    if (!sub) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }
    // For non-current subscriptions, skip Stripe sync and keep local auto-renew disabled.
    if (!sub.is_current) {
      return this.save(id, { stripe_cancel_at_period_end: false });
    }
    // If Stripe subscription id is missing, keep behavior deterministic:
    // do not try to infer from customer history; persist local no-auto-renew.
    if (!sub.stripe_subscription_id) {
      return this.save(id, { stripe_cancel_at_period_end: false });
    }
    if (!this.stripeService.isConfigured()) {
      throw new UnprocessableEntityException("Stripe is not configured on the server.");
    }

    let stripeSub;
    try {
      stripeSub = await this.stripeService.retrieveSubscription(sub.stripe_subscription_id);
    } catch (e) {
      const stripeLike = e as { code?: string; message?: string };
      const msg = e instanceof Error ? e.message : String(e);
      const subscriptionGone =
        stripeLike.code === "resource_missing" ||
        /No such subscription/i.test(msg) ||
        /No such Subscription/i.test(msg);
      if (subscriptionGone) {
        return this.save(id, { stripe_cancel_at_period_end: true });
      }
      throw new UnprocessableEntityException(`Could not load subscription from Stripe: ${msg}`);
    }

    const stripeStatus = (stripeSub as { status?: string }).status;
    const endedStatuses = ["canceled", "incomplete_expired", "incomplete"];
    const cancelAtPeriodEnd = endedStatuses.includes(stripeStatus ?? "")
      ? false
      : localStripeCancelAtPeriodEndFromSubscription(stripeSub);
    const payload: Record<string, unknown> = { stripe_cancel_at_period_end: cancelAtPeriodEnd };
    if (stripeSub.id && stripeSub.id !== sub.stripe_subscription_id) {
      payload["stripe_subscription_id"] = stripeSub.id;
    }

    return this.save(id, payload);
  }

  async updateAutoRenewFromAdmin(id: number, enabled: boolean): Promise<SaveResponseDataInterface<unknown>> {
    const sub = await this.prisma.client.subscription.findUnique({
      where: { id },
      select: {
        id: true,
        is_current: true,
        stripe_subscription_id: true,
      },
    });
    if (!sub) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }

    // Non-current subscriptions are always treated as non-auto-renewing in local state.
    if (!sub.is_current) {
      return this.save(id, { stripe_cancel_at_period_end: false });
    }

    if (!sub.stripe_subscription_id) {
      if (!enabled) {
        return this.save(id, { stripe_cancel_at_period_end: false });
      }
      throw new UnprocessableEntityException("Cannot enable auto-renew without a Stripe subscription ID.");
    }

    if (!this.stripeService.isConfigured()) {
      throw new UnprocessableEntityException("Stripe is not configured on the server.");
    }

    const updatedStripeSub = await this.stripeService.setSubscriptionCancelAtPeriodEnd(
      sub.stripe_subscription_id,
      !enabled,
    );
    const cancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(updatedStripeSub);
    // Stripe is source of truth here. Local DB is reconciled by `customer.subscription.updated` webhook.
    // Return a projected row so admin UI updates immediately while webhook persists local state.
    const latestLocal = await this.prisma.client.subscription.findUnique({ where: { id } });
    if (!latestLocal) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }
    const projected = {
      ...latestLocal,
      stripe_cancel_at_period_end: cancelAtPeriodEnd,
      stripe_subscription_id: updatedStripeSub.id ?? sub.stripe_subscription_id,
    };
    return { data: [projected] };
  }

  async updateNextBillingDateFromAdmin(
    id: number,
    nextBillingDate: string,
  ): Promise<{ success: boolean; message: string; next_billing_date: string }> {
    if (!this.stripeService.isConfigured()) {
      throw new UnprocessableEntityException("Stripe is not configured on the server.");
    }

    const sub = await this.prisma.client.subscription.findUnique({
      where: { id },
      include: {
        company: { select: { stripe_subscription_id: true } },
      },
    });
    if (!sub) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }

    const stripeSubId = sub.stripe_subscription_id || sub.company?.stripe_subscription_id || null;
    if (!stripeSubId) {
      throw new UnprocessableEntityException("No Stripe subscription ID found for this subscription.");
    }

    const parsed = new Date(nextBillingDate);
    if (Number.isNaN(parsed.getTime())) {
      throw new UnprocessableEntityException("Invalid next billing date.");
    }
    const anchorUnix = Math.floor(parsed.getTime() / 1000);
    const nowUnix = Math.floor(Date.now() / 1000);
    if (anchorUnix <= nowUnix) {
      throw new UnprocessableEntityException("next_billing_date must be in the future.");
    }

    await this.stripeService.updateSubscriptionBillingCycleAnchor(stripeSubId, anchorUnix);
    const updatedStripeSub = await this.stripeService.retrieveSubscription(stripeSubId);
    const stripePeriodEndUnix = (updatedStripeSub as { current_period_end?: number }).current_period_end;
    const normalizedNext = typeof stripePeriodEndUnix === "number" ? new Date(stripePeriodEndUnix * 1000) : parsed;

    const oldData = sub as unknown as Record<string, unknown>;
    await this.prisma.client.subscription.update({
      where: { id },
      data: {
        next_billing_date: normalizedNext,
        end_date: normalizedNext,
        stripe_subscription_id: stripeSubId,
      },
    });
    const updated = await this.prisma.client.subscription.findUnique({ where: { id } });
    if (!updated) {
      throw new NotFoundException(`Subscription with ID ${id} not found after update`);
    }

    const changedFields = SystemLogService.calculateChangedFields(
      oldData,
      updated as unknown as Record<string, unknown>,
    );
    await this.systemLogService.logUpdate(
      SystemLogEntityType.SUBSCRIPTION,
      id,
      oldData,
      updated as unknown as Record<string, unknown>,
      changedFields,
      { company_id: sub.company_id },
    );

    return {
      success: true,
      message: `Next billing date updated to ${normalizedNext.toISOString()}.`,
      next_billing_date: normalizedNext.toISOString(),
    };
  }

  /**
   * Override add method to log creation
   */
  async add(data: any): Promise<any> {
    const addResponse = await super.add(data);
    const subscription = addResponse.data;

    // Log the creation
    await this.systemLogService.logInsert(
      SystemLogEntityType.SUBSCRIPTION,
      subscription.id,
      subscription as Record<string, unknown>,
      { company_id: subscription.company_id },
    );

    return addResponse;
  }

  /**
   * Override save method to log update
   */
  async save(id: number, data: any): Promise<any> {
    // Get old data before update
    const oldSubscription = await this.prisma.client.subscription.findUnique({
      where: { id },
    });

    if (!oldSubscription) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }

    const saveResponse = await super.save(id, data);
    const updatedSubscription = saveResponse.data;

    // Calculate changed fields and log the update
    const changedFields = SystemLogService.calculateChangedFields(
      oldSubscription as Record<string, unknown>,
      updatedSubscription as Record<string, unknown>,
    );

    await this.systemLogService.logUpdate(
      SystemLogEntityType.SUBSCRIPTION,
      id,
      oldSubscription as Record<string, unknown>,
      updatedSubscription as Record<string, unknown>,
      changedFields,
      { company_id: updatedSubscription.company_id ?? oldSubscription.company_id },
    );

    return saveResponse;
  }

  /**
   * Override delete method to log deletion
   */
  async delete(id: number): Promise<void> {
    // Get subscription data before deletion
    const subscription = await this.prisma.client.subscription.findUnique({
      where: { id },
    });

    if (!subscription) {
      throw new NotFoundException(`Subscription with ID ${id} not found`);
    }

    // Call parent delete method
    await super.delete(id);

    // Log the deletion
    await this.systemLogService.logDelete(
      SystemLogEntityType.SUBSCRIPTION,
      id,
      subscription as Record<string, unknown>,
      { company_id: subscription.company_id },
    );
  }
  /**
   * Override getDetail to prevent recursive loading of previousSubscription
   * which causes timeout issues
   */
  async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
    const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
    const repoName = moduleCurrentCfg.repoName;
    const repo = this.commonMethods.getRepo(repoName);

    // Build include with limited previousSubscription to prevent recursion
    const include: Record<string, unknown> = {
      company: {
        select: {
          id: true,
          name: true,
        },
      },
      package: {
        select: {
          id: true,
          name: true,
        },
      },
      previousSubscription: {
        select: {
          id: true,
          company_id: true,
          package_id: true,
          status: true,
          billing_cycle: true,
          subscription_type: true,
          is_current: true,
          license_count: true,
          created_at: true,
          // Do NOT include nested previousSubscription to prevent recursion
        },
      },
      userCreatedBy: {
        select: {
          id: true,
          first_name: true,
          last_name: true,
        },
      },
      userUpdatedBy: {
        select: {
          id: true,
          first_name: true,
          last_name: true,
        },
      },
    };

    const findParams = {
      where: { id },
      include,
    };

    // Get the data
    let data: any = await repo.findUnique(findParams);
    if (!data) {
      const msg = "The record you are looking for is not found.";
      throw new UnprocessableEntityException(msg);
    }

    // Sanitize data to remove Decimal fields and set display names
    const sanitizedData: Record<string, unknown> = { ...data };

    // Extract company fields
    const company = data["company"] as { id?: number; name?: string } | null | undefined;
    if (company) {
      sanitizedData["company"] = {
        id: company.id,
        name: company.name || "",
      };
      sanitizedData["company_name"] = company.name || "";
    } else {
      sanitizedData["company_name"] = "";
    }

    // Extract package fields
    const packageObj = data["package"] as { id?: number; name?: string } | null | undefined;
    if (packageObj) {
      sanitizedData["package"] = {
        id: packageObj.id,
        name: packageObj.name || "",
      };
      sanitizedData["package_name"] = packageObj.name || "";
    } else {
      sanitizedData["package_name"] = "";
    }

    // Transform to DTO
    data = plainToInstance(moduleCurrentCfg.detailDto, sanitizedData);

    return this.moduleMethods.getReturnDataForDetail(data);
  }

  /**
   * Override getList method to transform raw data before plainToInstance processes it
   * This prevents Decimal errors when plainToInstance processes nested relation objects with Decimal fields
   */
  async getList(paginationOptions: PaginationOptions): Promise<ListResponseDataInterface<unknown>> {
    const { page, limit } = paginationOptions;

    // Get raw data from parent method's moduleMethods
    const [rawData, totalCount] = await this.moduleMethods.getListRows(paginationOptions);

    const moduleCurrentCfg = this.gVars.moduleCurrentCfg;

    // Transform raw data to remove Decimal fields from nested relations before plainToInstance
    const sanitizedData = rawData.map((row: Record<string, unknown>) => {
      const sanitizedRow: Record<string, unknown> = { ...row };

      // Extract only needed fields from company relation, remove any Decimal fields
      const company = row["company"] as { id?: number; name?: string } | null | undefined;
      if (company) {
        sanitizedRow["company"] = {
          id: company.id,
          name: company.name || "",
        };
        // Set company_name directly for the DTO
        sanitizedRow["company_name"] = company.name || "";
      } else {
        sanitizedRow["company_name"] = "";
      }

      // Extract only needed fields from package relation, remove Decimal fields like price_per_licence
      const packageObj = row["package"] as { id?: number; name?: string } | null | undefined;
      if (packageObj) {
        sanitizedRow["package"] = {
          id: packageObj.id,
          name: packageObj.name || "",
        };
        // Set package_name directly for the DTO
        sanitizedRow["package_name"] = packageObj.name || "";
      } else {
        sanitizedRow["package_name"] = "";
      }

      return sanitizedRow;
    });

    // Convert the sanitized data to appropriate DTOs
    // biome-ignore lint/suspicious/noExplicitAny: Base service uses any for DTO transformation
    let data: any[] = sanitizedData;

    if (paginationOptions.vl) {
      if (moduleCurrentCfg.simpleDto) {
        // biome-ignore lint/suspicious/noExplicitAny: plainToInstance requires any for dynamic DTOs
        data = sanitizedData.map((row: any) => plainToInstance(moduleCurrentCfg.simpleDto, row));
      }
    } else {
      if (moduleCurrentCfg.listDto) {
        // biome-ignore lint/suspicious/noExplicitAny: plainToInstance requires any for dynamic DTOs
        data = sanitizedData.map((row: any) => plainToInstance(moduleCurrentCfg.listDto, row));
      }
    }

    return buildListResponseData(data, page || 1, limit || 10, totalCount);
  }
}

results matching ""

    No results matching ""