apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-customer-subscription.service.ts
Properties |
|
Methods |
constructor(prisma: BNestPrismaService, billing: StripePaymentBillingHelpersService)
|
|||||||||
|
Parameters :
|
| Async handleCustomerSubscriptionUpdated | ||||||
handleCustomerSubscriptionUpdated(subscription: Stripe.Subscription)
|
||||||
|
Keeps Subscription.stripe_cancel_at_period_end in sync when the customer changes cancellation
settings in Stripe (Billing Portal, Dashboard, API). Stripe does not send
Parameters :
Returns :
Promise<void>
|
| Async sendBillingSubscriptionResumeEmail | ||||||||||||
sendBillingSubscriptionResumeEmail(companyId: number, subscription: Stripe.Subscription, options?: literal type)
|
||||||||||||
|
Send “subscription resumed” email when
Parameters :
Returns :
Promise<void>
|
| Async sendBillingSubscriptionScheduledCancelEmail | ||||||||||||
sendBillingSubscriptionScheduledCancelEmail(companyId: number, subscription: Stripe.Subscription, options?: literal type)
|
||||||||||||
|
Parameters :
Returns :
Promise<void>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripePaymentCustomerSubscriptionService.name)
|
import { BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import { SubscriptionStatus } from "@prisma/client";
import Stripe from "stripe";
import { localStripeCancelAtPeriodEndFromSubscription } from "../stripe.service";
import { StripeSubscriptionApi } from "./stripe-payment.types";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
@Injectable()
export class StripePaymentCustomerSubscriptionService {
private readonly logger = new Logger(StripePaymentCustomerSubscriptionService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly billing: StripePaymentBillingHelpersService,
) {}
async sendBillingSubscriptionScheduledCancelEmail(
companyId: number,
subscription: Stripe.Subscription,
options?: { triggeredBy?: string; subscriptionRowId?: number },
): Promise<void> {
if (subscription.cancel_at_period_end !== true) {
return;
}
const subId = subscription.id;
const cancelAtUnix = subscription.cancel_at;
const periodEndUnix =
(subscription as StripeSubscriptionApi).current_period_end ??
// Some Stripe responses/events may omit current_period_end; cancel_at often equals period end when scheduled.
(typeof cancelAtUnix === "number" ? cancelAtUnix : undefined);
const endsOn = typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : null;
const dedupeSegment =
typeof cancelAtUnix === "number"
? String(cancelAtUnix)
: typeof periodEndUnix === "number"
? String(periodEndUnix)
: "unknown";
const dedupeKey = `billing.subscription.cancelled:${subId}:${dedupeSegment}`;
await this.billing.sendCompanyTemplatedEmail({
companyId,
templateKey: "billing.subscription.cancelled",
dedupeKey,
triggeredBy: options?.triggeredBy ?? "stripe_webhook",
variables: {
"subscription.ends_on": endsOn ? endsOn.toISOString().split("T")[0] : "",
"subscription.cancel_type": "scheduled",
},
metadata: {
stripe_subscription_id: subId,
...(options?.subscriptionRowId != null ? { subscription_id: options.subscriptionRowId } : {}),
},
});
}
/**
* Send “subscription resumed” email when `cancel_at_period_end` is cleared (portal, Billing Portal, or webhook).
* Dedupe per Stripe billing period (`current_period_start`) so duplicate events do not spam.
*/
async sendBillingSubscriptionResumeEmail(
companyId: number,
subscription: Stripe.Subscription,
options?: { triggeredBy?: string; subscriptionRowId?: number },
): Promise<void> {
if (subscription.cancel_at_period_end === true) {
return;
}
const status = subscription.status;
if (status === "canceled" || status === "incomplete_expired") {
return;
}
const subId = subscription.id;
const periodEndUnix = (subscription as StripeSubscriptionApi).current_period_end;
const periodStartUnix = (subscription as StripeSubscriptionApi).current_period_start;
let endsOn = typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : null;
if (!endsOn && options?.subscriptionRowId != null) {
// Fallback to local DB row when Stripe event omitted current_period_end.
const local = await this.prisma.client.subscription.findUnique({
where: { id: options.subscriptionRowId },
select: { next_billing_date: true },
});
if (local?.next_billing_date instanceof Date) {
endsOn = local.next_billing_date;
}
}
const dedupeSegment = typeof periodStartUnix === "number" ? String(periodStartUnix) : "unknown";
const dedupeKey = `billing.subscription.reactivated:${subId}:${dedupeSegment}`;
await this.billing.sendCompanyTemplatedEmail({
companyId,
templateKey: "billing.subscription.reactivated",
dedupeKey,
triggeredBy: options?.triggeredBy ?? "stripe_webhook",
variables: {
"subscription.next_renewal_date": endsOn ? endsOn.toISOString().split("T")[0] : "",
},
metadata: {
stripe_subscription_id: subId,
...(options?.subscriptionRowId != null ? { subscription_id: options.subscriptionRowId } : {}),
},
});
}
async handleCustomerSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
const subId = subscription.id;
const companyIdStr = subscription.metadata?.["company_id"];
const localSubIdStr = subscription.metadata?.["local_subscription_id"];
if (!companyIdStr || !localSubIdStr) {
this.logger.debug(
`customer.subscription.created ${subId}: skip (missing company_id or local_subscription_id in metadata)`,
);
return;
}
const companyId = parseInt(companyIdStr, 10);
const localSubId = parseInt(localSubIdStr, 10);
if (Number.isNaN(companyId) || Number.isNaN(localSubId)) {
return;
}
const row = await this.prisma.client.subscription.findFirst({
where: { id: localSubId, company_id: companyId },
select: { id: true },
});
if (!row) {
this.logger.warn(
`customer.subscription.created: no local subscription id=${localSubId} for company ${companyId}`,
);
return;
}
await this.prisma.client.company.update({
where: { id: companyId },
data: { stripe_subscription_id: subId },
});
await this.prisma.client.subscription.update({
where: { id: localSubId },
data: {
stripe_subscription_id: subId,
stripe_cancel_at_period_end: localStripeCancelAtPeriodEndFromSubscription(subscription),
},
});
this.logger.log(
`customer.subscription.created: linked Stripe ${subId} to local subscription ${localSubId} (company ${companyId})`,
);
}
/**
* Keeps {@link Subscription.stripe_cancel_at_period_end} in sync when the customer changes cancellation
* settings in Stripe (Billing Portal, Dashboard, API). Stripe does not send `invoice.paid` for that.
*/
async handleCustomerSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
const subId = subscription.id;
const cancelAtEnd = localStripeCancelAtPeriodEndFromSubscription(subscription);
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer && typeof subscription.customer === "object" && "id" in subscription.customer
? (subscription.customer.id ?? null)
: null;
this.logger.warn(
`customer.subscription.updated/deleted: received stripe_sub=${subId} customer=${customerId ?? "unknown"} status=${
subscription.status
} cancel_at_period_end=${subscription.cancel_at_period_end === true} cancel_at=${
typeof subscription.cancel_at === "number" ? subscription.cancel_at : "null"
} mapped_cancel_at_period_end=${cancelAtEnd}`,
);
const subSelect = {
id: true,
company_id: true,
is_current: true,
status: true,
stripe_cancel_at_period_end: true,
stripe_subscription_id: true,
} as const;
let localSub = await this.prisma.client.subscription.findFirst({
where: { stripe_subscription_id: subId, is_current: true },
select: subSelect,
});
if (!localSub) {
localSub = await this.prisma.client.subscription.findFirst({
where: { stripe_subscription_id: subId },
orderBy: { id: "desc" },
select: subSelect,
});
}
if (!localSub) {
const companyIdStr = subscription.metadata?.["company_id"];
const localSubIdStr = subscription.metadata?.["local_subscription_id"];
if (companyIdStr && localSubIdStr) {
const companyId = parseInt(companyIdStr, 10);
const localSubId = parseInt(localSubIdStr, 10);
if (!Number.isNaN(companyId) && !Number.isNaN(localSubId)) {
const row = await this.prisma.client.subscription.findFirst({
where: { id: localSubId, company_id: companyId },
select: subSelect,
});
if (row) {
localSub = row;
}
}
}
}
/** Billing Portal sometimes leaves subscription rows without `stripe_subscription_id` while company has it. */
let resolveStripeSubIdViaCompany = false;
if (!localSub) {
const company = await this.prisma.client.company.findFirst({
where: { stripe_subscription_id: subId },
select: { id: true, stripe_customer_id: true },
});
if (company) {
let row = await this.prisma.client.subscription.findFirst({
where: { company_id: company.id, is_current: true },
select: subSelect,
});
if (!row) {
row = await this.prisma.client.subscription.findFirst({
where: { company_id: company.id },
orderBy: { id: "desc" },
select: subSelect,
});
}
if (row) {
localSub = row;
resolveStripeSubIdViaCompany = true;
this.logger.log(
`customer.subscription.updated/deleted ${subId}: resolved subscription row ${row.id} via company.stripe_subscription_id (company ${company.id})`,
);
}
}
}
// Final fallback: resolve company via Stripe customer id, then map to current/latest subscription row.
if (!localSub && customerId) {
const company = await this.prisma.client.company.findFirst({
where: { stripe_customer_id: customerId },
select: { id: true, stripe_subscription_id: true },
});
if (company) {
let row = await this.prisma.client.subscription.findFirst({
where: { company_id: company.id, is_current: true },
select: subSelect,
});
if (!row) {
row = await this.prisma.client.subscription.findFirst({
where: { company_id: company.id },
orderBy: { id: "desc" },
select: subSelect,
});
}
if (row) {
localSub = row;
resolveStripeSubIdViaCompany = true;
this.logger.log(
`customer.subscription.updated/deleted ${subId}: resolved subscription row ${row.id} via company.stripe_customer_id=${customerId} (company ${company.id})`,
);
}
}
}
if (!localSub) {
this.logger.warn(
`customer.subscription.updated/deleted ${subId}: no local subscription (stripe_subscription_id, metadata company_id/local_subscription_id, company.stripe_subscription_id, or company.stripe_customer_id=${customerId ?? "unknown"})`,
);
return;
}
const wasScheduledCancel = localSub.stripe_cancel_at_period_end === true;
const stripeStatus = subscription.status;
const isTerminalCancellation = stripeStatus === "canceled" || stripeStatus === "incomplete_expired";
const nextLocalStatus = isTerminalCancellation ? SubscriptionStatus.CANCELLED : localSub.status;
const shouldBackfillSubscriptionStripeId =
resolveStripeSubIdViaCompany || localSub.stripe_subscription_id !== subId;
await this.prisma.client.$transaction([
this.prisma.client.subscription.update({
where: { id: localSub.id },
data: {
stripe_cancel_at_period_end: cancelAtEnd,
...(nextLocalStatus !== localSub.status ? { status: nextLocalStatus } : {}),
...(shouldBackfillSubscriptionStripeId ? { stripe_subscription_id: subId } : {}),
},
}),
this.prisma.client.company.update({
where: { id: localSub.company_id },
data: {
stripe_subscription_id: subId,
...(isTerminalCancellation && localSub.is_current ? { is_subscription_expiry: true } : {}),
},
}),
]);
this.logger.log(
`customer.subscription.updated/deleted: subscription row ${localSub.id} stripe_cancel_at_period_end=${cancelAtEnd} local_status=${nextLocalStatus} (Stripe ${subId}, status=${subscription.status}, backfilled_subscription_stripe_id=${shouldBackfillSubscriptionStripeId}, set_company_subscription_expiry=${isTerminalCancellation && localSub.is_current})`,
);
// Scheduled cancellation email: do not require a local DB transition (portal may set the flag before this webhook).
if (subscription.cancel_at_period_end === true) {
await this.sendBillingSubscriptionScheduledCancelEmail(localSub.company_id, subscription, {
triggeredBy: "stripe_webhook_customer_subscription",
subscriptionRowId: localSub.id,
});
} else if (
wasScheduledCancel &&
subscription.cancel_at_period_end === false &&
subscription.status !== "canceled" &&
subscription.status !== "incomplete_expired"
) {
await this.sendBillingSubscriptionResumeEmail(localSub.company_id, subscription, {
triggeredBy: "stripe_webhook_customer_subscription",
subscriptionRowId: localSub.id,
});
}
}
}