apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-billing-helpers.service.ts
Shared billing helpers used by StripePaymentService and StripePaymentInvoiceWebhookService (invoice webhooks, emails, fee math).
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)
|
||||||
|
After a successful paid flow, clear the admin/portal expiry flag when the company has a current
ACTIVE subscription with
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 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>
|
| 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 { BNestEmailSenderService, BNestPrismaService } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
import { InvoiceType, 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).
*/
@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 {
const base = (process.env["FRONTEND_URL"] ||
process.env["PWA_URL"] ||
"https://recallassess.localdev:8443") as string;
return `${base}/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)}`;
}
}
async sendCompanyTemplatedEmail(params: {
companyId: number;
templateKey: string;
variables: Record<string, string>;
dedupeKey: string;
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 },
},
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;
}
try {
await this.emailSender.sendTemplatedEmail({
to: company.email,
templateKey: params.templateKey,
variables: {
"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.
*/
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 },
});
}
}