apps/recallassess/recallassess-api/src/api/admin/subscription/subscription.service.ts
BNestBaseModuleService
Methods |
|
constructor(prisma: BNestPrismaService, systemLogService: SystemLogService, stripeService: StripeService)
|
||||||||||||
|
Parameters :
|
| Async add | ||||||
add(data: any)
|
||||||
|
Override add method to log creation
Parameters :
Returns :
Promise<any>
|
| Async delete | ||||||
delete(id: number)
|
||||||
|
Override delete method to log deletion
Parameters :
Returns :
Promise<void>
|
| Async getDetail | ||||||
getDetail(id: number)
|
||||||
|
Override getDetail to prevent recursive loading of previousSubscription which causes timeout issues
Parameters :
|
| Async save |
save(id: number, data: any)
|
|
Override save method to log update
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 :
|
| Async updateAutoRenewFromAdmin |
updateAutoRenewFromAdmin(id: number, enabled: boolean)
|
|
|
| Async updateNextBillingDateFromAdmin |
updateNextBillingDateFromAdmin(id: number, nextBillingDate: string)
|
|
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);
}
}