apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-billing-helpers.service.ts
StripePaymentBillingHelpersService
Shared platform API — Stripe Payment Billing Helpers (Stripe › Stripe Payment). Business Logic Service for RecallAssess.
constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
|
|||||||||
|
Parameters :
|
| addDaysSafe |
addDaysSafe(date: Date, days: number)
|
|
Returns :
Date
|
| calculateRenewalChargeCents | ||||||
calculateRenewalChargeCents(params: literal type)
|
||||||
|
Parameters :
Returns :
literal type
|
| Async clearCompanySubscriptionExpiryWhenRenewalIsAhead | ||||||
clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId: number)
|
||||||
|
Parameters :
Returns :
Promise<void>
|
| computeNextBillingAfterOnePaidPeriod | |||||||||
computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined)
|
|||||||||
|
Next period end after paying one full billing cycle from {@param previousAnchor}. Used by expired recovery preview and one-off invoice flows.
Parameters :
Returns :
Date
|
| extractPaymentIntentAndChargeIds | ||||||
extractPaymentIntentAndChargeIds(inv: Stripe.Invoice)
|
||||||
|
Parameters :
Returns :
literal type
|
| extractStripeCustomerId | ||||||
extractStripeCustomerId(ref: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined)
|
||||||
|
Parameters :
Returns :
string | null
|
| formatUsd | ||||||
formatUsd(amount: number)
|
||||||
|
Parameters :
Returns :
string
|
| getBillingCycleFixedDays | ||||||
getBillingCycleFixedDays(billingCycle: string | null | undefined)
|
||||||
|
Parameters :
Returns :
number
|
| Private Async getCompanyParticipantAdminGreetingName |
getCompanyParticipantAdminGreetingName(companyId: number, companyName: string)
|
|
First active PARTICIPANT_ADMIN for billing greetings; falls back to company name.
Returns :
Promise<string>
|
| Private getPortalSubscriptionBillingUrl |
getPortalSubscriptionBillingUrl()
|
|
Returns :
string
|
| isStripeFirstSubscriptionInvoiceBillingReason | ||||||
isStripeFirstSubscriptionInvoiceBillingReason(billingReason: string | null | undefined)
|
||||||
|
Stripe: first invoice for the subscription (not a recurring period renewal).
Parameters :
Returns :
boolean
|
| Private isUaeCountry | ||||||
isUaeCountry(country: string | null | undefined)
|
||||||
|
Parameters :
Returns :
boolean
|
| Async normalizeLocalSubscriptionTypeAfterFirstStripeInvoice | ||||||
normalizeLocalSubscriptionTypeAfterFirstStripeInvoice(params: literal type)
|
||||||
|
Recovery paths used to create paid placeholder rows as RENEWAL; first automatic Stripe charge is not a renewal. Corrects DB when isStripeFirstSubscriptionInvoiceBillingReason matches.
Parameters :
Returns :
Promise<SubscriptionType>
|
| renewalFeePercentagesForDb | ||||||
renewalFeePercentagesForDb(country: string | null | undefined)
|
||||||
|
Percent values for DB (e.g. 3.00 = 3%).
Parameters :
Returns :
literal type
|
| Async resolveCompanyIdFromStripeCustomer | ||||||
resolveCompanyIdFromStripeCustomer(customerId: string)
|
||||||
|
Parameters :
Returns :
Promise<number | null>
|
| resolveInvoiceTypeForStripeSubscriptionInvoice | ||||||
resolveInvoiceTypeForStripeSubscriptionInvoice(params: literal type)
|
||||||
|
Stripe Stripe.Invoice.billing_reason distinguishes subscription-cycle renewals from first/setup invoices. Only {@code subscription_cycle} is treated as a renewal invoice; other reasons follow the local subscription row type.
Parameters :
Returns :
InvoiceType
|
| resolveRecoveryAnchorDate | ||||||
resolveRecoveryAnchorDate(input?: string)
|
||||||
|
Parameters :
Returns :
Date
|
| Async sendCompanyTemplatedEmail | ||||||
sendCompanyTemplatedEmail(params: literal type)
|
||||||
|
Parameters :
Returns :
Promise<void>
|
| Async sendOneDayRenewalReminderEmails | ||||||||
sendOneDayRenewalReminderEmails(now: Date)
|
||||||||
|
Send one-day renewal reminders.
Parameters :
Returns :
Promise<literal type>
|
| Private subscriptionTypeToInvoiceType | ||||||
subscriptionTypeToInvoiceType(st: SubscriptionType)
|
||||||
|
Parameters :
Returns :
InvoiceType
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripePaymentBillingHelpersService.name)
|
| Private Readonly renewalProcessingFeeRate |
Type : number
|
Default value : 0.03
|
| Private Readonly uaeVatRate |
Type : number
|
Default value : 0.05
|
import { resolveFrontendUrl } from "@api/shared/email/resolve-frontend-url.util";
import { BNestEmailSenderService, BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import {
EmailStatus,
InvoiceType,
ParticipantRole,
SubscriptionStatus,
SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import { isUaeCompanyCountry } from "../../../../../config/billing.config";
/** API responses include fields not always present on generated Stripe typings. */
export type StripeInvoiceApi = Stripe.Invoice & {
payment_intent?: string | Stripe.PaymentIntent | null;
};
export type StripeSubscriptionApi = Stripe.Subscription & {
current_period_start?: number;
current_period_end?: number;
};
/**
* Shared billing helpers used by {@link StripePaymentService} and
* {@link StripePaymentInvoiceWebhookService} (invoice webhooks, emails, fee math).
*/
/**
* StripePaymentBillingHelpersService
*
* Shared platform API — Stripe Payment Billing Helpers (Stripe › Stripe Payment). Business Logic Service for RecallAssess.
*/
@Injectable()
export class StripePaymentBillingHelpersService {
private readonly logger = new Logger(StripePaymentBillingHelpersService.name);
private readonly renewalProcessingFeeRate = 0.03;
private readonly uaeVatRate = 0.05;
constructor(
private readonly prisma: BNestPrismaService,
private readonly emailSender: BNestEmailSenderService,
) {}
private getPortalSubscriptionBillingUrl(): string {
return `${resolveFrontendUrl()}/portal/settings/subscription?tab=subscription`;
}
formatUsd(amount: number): string {
try {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
} catch {
return `$${amount.toFixed(2)}`;
}
}
/** First active PARTICIPANT_ADMIN for billing greetings; falls back to company name. */
private async getCompanyParticipantAdminGreetingName(
companyId: number,
companyName: string,
): Promise<string> {
const admin = await this.prisma.client.participant.findFirst({
where: {
company_id: companyId,
role: ParticipantRole.PARTICIPANT_ADMIN,
is_active: true,
},
select: { first_name: true },
orderBy: { id: "asc" },
});
const firstName = admin?.first_name?.trim();
return firstName || companyName;
}
async sendCompanyTemplatedEmail(params: {
companyId: number;
templateKey: string;
variables: Record<string, string>;
dedupeKey: string;
/** When true, only skip if a prior attempt reached SENT (allows retry after FAILED). */
dedupeOnlySent?: boolean;
metadata?: Record<string, unknown>;
/** Default `stripe_webhook`; use e.g. `portal_schedule_cancel` when invoked from API after Stripe update */
triggeredBy?: string;
}): Promise<void> {
this.logger.log(
`[billing-email] prepare templateKey=${params.templateKey} companyId=${params.companyId} dedupeKey=${params.dedupeKey}`,
);
const existing = await this.prisma.client.emailLog.findFirst({
where: {
metadata: { path: ["dedupe_key"], equals: params.dedupeKey },
...(params.dedupeOnlySent ? { status: EmailStatus.SENT } : {}),
},
select: { id: true },
});
if (existing) {
this.logger.log(
`[billing-email] skip (dedupe hit) templateKey=${params.templateKey} emailLogId=${existing.id} dedupeKey=${params.dedupeKey}`,
);
return;
}
const company = await this.prisma.client.company.findUnique({
where: { id: params.companyId },
select: { id: true, name: true, email: true },
});
if (!company?.email) {
this.logger.warn(
`[billing-email] skip (missing company email) templateKey=${params.templateKey} companyId=${params.companyId}`,
);
return;
}
const template = await this.prisma.client.emailTemplate.findFirst({
where: { template_key: params.templateKey, is_active: true },
select: { id: true },
});
if (!template) {
this.logger.error(
`[billing-email] skip (template missing or inactive) templateKey=${params.templateKey}. Did you run the email template migration?`,
);
return;
}
const adminGreetingName = await this.getCompanyParticipantAdminGreetingName(
company.id,
company.name,
);
try {
await this.emailSender.sendTemplatedEmail({
to: company.email,
templateKey: params.templateKey,
variables: {
"user.name": adminGreetingName,
"company.name": company.name,
"system.portalBillingUrl": this.getPortalSubscriptionBillingUrl(),
...params.variables,
},
metadata: {
...(params.metadata ?? {}),
company_id: company.id,
dedupe_key: params.dedupeKey,
triggeredBy: params.triggeredBy ?? "stripe_webhook",
},
});
this.logger.log(
`[billing-email] queued templateKey=${params.templateKey} to=${company.email} (email log is created inside sendEmail)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.error(
`[billing-email] FAILED templateKey=${params.templateKey} to=${company.email} companyId=${params.companyId}: ${msg}`,
);
}
}
extractStripeCustomerId(
ref: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined,
): string | null {
if (!ref) return null;
if (typeof ref === "string") return ref;
if (typeof ref === "object" && "id" in ref && typeof (ref as { id: unknown }).id === "string") {
return (ref as { id: string }).id;
}
return null;
}
async resolveCompanyIdFromStripeCustomer(customerId: string): Promise<number | null> {
const co = await this.prisma.client.company.findFirst({
where: { stripe_customer_id: customerId },
select: { id: true },
});
return co?.id ?? null;
}
private isUaeCountry(country: string | null | undefined): boolean {
return isUaeCompanyCountry(country);
}
calculateRenewalChargeCents(params: { baseAmountCents: number; country: string | null | undefined }): {
totalCents: number;
processingFeeCents: number;
vatCents: number;
} {
const base = Math.max(0, Math.round(params.baseAmountCents));
const processingFeeCents = Math.round(base * this.renewalProcessingFeeRate);
const vatBaseCents = base + processingFeeCents;
const vatCents = this.isUaeCountry(params.country) ? Math.round(vatBaseCents * this.uaeVatRate) : 0;
return {
totalCents: base + processingFeeCents + vatCents,
processingFeeCents,
vatCents,
};
}
/** Percent values for DB (e.g. 3.00 = 3%). */
renewalFeePercentagesForDb(country: string | null | undefined): {
processingFeePct: number;
vatPct: number;
} {
return {
processingFeePct: this.renewalProcessingFeeRate * 100,
vatPct: this.isUaeCountry(country) ? this.uaeVatRate * 100 : 0,
};
}
extractPaymentIntentAndChargeIds(inv: Stripe.Invoice): {
paymentIntentId: string | null;
chargeId: string | null;
checkoutSessionId: string | null;
} {
const pi = (inv as StripeInvoiceApi).payment_intent;
if (!pi) {
return { paymentIntentId: null, chargeId: null, checkoutSessionId: null };
}
if (typeof pi === "string") {
return { paymentIntentId: pi, chargeId: null, checkoutSessionId: null };
}
if (pi.object === "payment_intent") {
const lc = pi.latest_charge;
const chargeId =
typeof lc === "string"
? lc
: lc && typeof lc === "object" && "id" in lc
? String((lc as Stripe.Charge).id)
: null;
const cs = (
pi as Stripe.PaymentIntent & {
checkout_session?: string | Stripe.Checkout.Session | null;
}
).checkout_session;
const checkoutSessionId =
typeof cs === "string" ? cs : cs && typeof cs === "object" && "id" in cs ? cs.id : null;
return { paymentIntentId: pi.id, chargeId, checkoutSessionId };
}
return { paymentIntentId: null, chargeId: null, checkoutSessionId: null };
}
/**
* Prefer line-item period (subscription invoices); fall back to invoice-level period_*.
*/
derivePeriodsFromStripeInvoice(inv: Stripe.Invoice): {
periodStart: Date | null;
periodEnd: Date | null;
} {
const lines = inv.lines;
if (lines && typeof lines === "object" && "data" in lines && Array.isArray(lines.data)) {
let bestStart: number | null = null;
let bestEnd: number | null = null;
let bestDuration = -1;
for (const line of lines.data) {
const period = (line as { period?: { start?: number; end?: number } }).period;
if (typeof period?.start === "number" && typeof period?.end === "number" && period.end > period.start) {
const duration = period.end - period.start;
if (
duration > bestDuration ||
(duration === bestDuration && (bestStart === null || period.start < bestStart))
) {
bestStart = period.start;
bestEnd = period.end;
bestDuration = duration;
}
}
}
if (bestStart !== null && bestEnd !== null) {
return {
periodStart: new Date(bestStart * 1000),
periodEnd: new Date(bestEnd * 1000),
};
}
}
if (typeof inv.period_start === "number" && typeof inv.period_end === "number") {
return {
periodStart: new Date(inv.period_start * 1000),
periodEnd: new Date(inv.period_end * 1000),
};
}
return { periodStart: null, periodEnd: null };
}
private subscriptionTypeToInvoiceType(st: SubscriptionType): InvoiceType {
switch (st) {
case SubscriptionType.RENEWAL:
return InvoiceType.RENEWAL;
case SubscriptionType.UPGRADE:
return "UPGRADE_PRORATION" as InvoiceType;
case SubscriptionType.DOWNGRADE:
return "DOWNGRADE_CREDIT" as InvoiceType;
case SubscriptionType.INITIAL:
default:
return InvoiceType.INITIAL_SUBSCRIPTION;
}
}
/** Stripe: first invoice for the subscription (not a recurring period renewal). */
isStripeFirstSubscriptionInvoiceBillingReason(billingReason: string | null | undefined): boolean {
const br = billingReason ?? "";
return br === "subscription_create" || br === "subscription";
}
/**
* Stripe {@link Stripe.Invoice.billing_reason} distinguishes subscription-cycle renewals from first/setup invoices.
* Only {@code subscription_cycle} is treated as a renewal invoice; other reasons follow the local subscription row type.
*/
resolveInvoiceTypeForStripeSubscriptionInvoice(params: {
billingReason: string | null | undefined;
localSubscriptionType: SubscriptionType;
}): InvoiceType {
if (params.billingReason === "subscription_cycle") {
return InvoiceType.RENEWAL;
}
return this.subscriptionTypeToInvoiceType(params.localSubscriptionType);
}
/**
* Recovery paths used to create paid placeholder rows as RENEWAL; first automatic Stripe charge is not a renewal.
* Corrects DB when {@link isStripeFirstSubscriptionInvoiceBillingReason} matches.
*/
async normalizeLocalSubscriptionTypeAfterFirstStripeInvoice(params: {
subscriptionRowId: number;
currentType: SubscriptionType;
previousSubscriptionId: number | null;
billingReason: string | null | undefined;
}): Promise<SubscriptionType> {
if (!this.isStripeFirstSubscriptionInvoiceBillingReason(params.billingReason)) {
return params.currentType;
}
if (params.currentType !== SubscriptionType.RENEWAL) {
return params.currentType;
}
const next = params.previousSubscriptionId != null ? SubscriptionType.UPGRADE : SubscriptionType.INITIAL;
await this.prisma.client.subscription.update({
where: { id: params.subscriptionRowId },
data: { subscription_type: next },
});
this.logger.log(
`Normalized subscription ${params.subscriptionRowId} subscription_type RENEWAL -> ${next} (first Stripe invoice, billing_reason=${params.billingReason ?? "null"})`,
);
return next;
}
getBillingCycleFixedDays(billingCycle: string | null | undefined): number {
const normalized = (billingCycle ?? "QUARTERLY").toUpperCase();
if (normalized === "ANNUAL") return 360;
if (normalized === "HALF_YEARLY") return 180;
return 90;
}
addDaysSafe(date: Date, days: number): Date {
const d = new Date(date.getTime());
d.setDate(d.getDate() + days);
return d;
}
/**
* Next period end after paying one full billing cycle from {@param previousAnchor}.
* Used by expired recovery preview and one-off invoice flows.
*/
computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined): Date {
return this.addDaysSafe(previousAnchor, this.getBillingCycleFixedDays(billingCycle));
}
resolveRecoveryAnchorDate(input?: string): Date {
if (!input || input.trim() === "") {
return new Date();
}
const parsed = new Date(input);
if (Number.isNaN(parsed.getTime())) {
return new Date();
}
return parsed;
}
/**
* After a successful paid flow, clear the admin/portal expiry flag when the company has a current
* ACTIVE subscription with `next_billing_date` set and still in the future.
*/
/**
* Send one-day renewal reminders.
* - Auto-renew ON (`stripe_cancel_at_period_end=false`): remind to ensure card is ready for tomorrow.
* - Auto-renew OFF (`stripe_cancel_at_period_end=true`): remind access expires tomorrow and invite reactivation.
*/
async sendOneDayRenewalReminderEmails(now: Date = new Date()): Promise<{
scanned: number;
remindedAutoRenewOn: number;
remindedAutoRenewOff: number;
}> {
const windowStart = new Date(now);
const windowEnd = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const subs = await this.prisma.client.subscription.findMany({
where: {
is_current: true,
status: SubscriptionStatus.ACTIVE,
next_billing_date: { gt: windowStart, lte: windowEnd },
},
select: {
id: true,
company_id: true,
next_billing_date: true,
stripe_cancel_at_period_end: true,
},
});
let remindedAutoRenewOn = 0;
let remindedAutoRenewOff = 0;
for (const sub of subs) {
if (!(sub.next_billing_date instanceof Date)) {
continue;
}
const nextDate = sub.next_billing_date.toISOString().split("T")[0];
if (sub.stripe_cancel_at_period_end) {
await this.sendCompanyTemplatedEmail({
companyId: sub.company_id,
templateKey: "billing.subscription.renewal.reminder.auto_off",
dedupeKey: `billing.subscription.renewal.reminder.auto_off:${sub.id}:${nextDate}`,
dedupeOnlySent: true,
triggeredBy: "subscription_renewal_reminder_d1",
variables: {
"subscription.next_renewal_date": nextDate,
},
metadata: {
subscription_id: sub.id,
next_billing_date: sub.next_billing_date.toISOString(),
auto_renewal: "off",
},
});
remindedAutoRenewOff += 1;
} else {
await this.sendCompanyTemplatedEmail({
companyId: sub.company_id,
templateKey: "billing.subscription.renewal.reminder.auto_on",
dedupeKey: `billing.subscription.renewal.reminder.auto_on:${sub.id}:${nextDate}`,
dedupeOnlySent: true,
triggeredBy: "subscription_renewal_reminder_d1",
variables: {
"subscription.next_renewal_date": nextDate,
},
metadata: {
subscription_id: sub.id,
next_billing_date: sub.next_billing_date.toISOString(),
auto_renewal: "on",
},
});
remindedAutoRenewOn += 1;
}
}
this.logger.log(
`One-day renewal reminders processed: scanned=${subs.length}, auto_renew_on=${remindedAutoRenewOn}, auto_renew_off=${remindedAutoRenewOff}`,
);
return {
scanned: subs.length,
remindedAutoRenewOn,
remindedAutoRenewOff,
};
}
async clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId: number): Promise<void> {
const now = new Date();
const viable = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: SubscriptionStatus.ACTIVE,
next_billing_date: { gte: now },
},
select: { id: true },
});
if (!viable) {
return;
}
await this.prisma.client.company.update({
where: { id: companyId },
data: { is_subscription_expiry: false },
});
}
}