apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-subscription-admin.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, stripeService: StripeService, billing: StripePaymentBillingHelpersService, invoiceWebhooks: StripePaymentInvoiceWebhookService, subscriptionWriter: StripePaymentSubscriptionWriterService)
|
||||||||||||||||||
|
Parameters :
|
| Private Async buildImmediateChargeSnapshot |
buildImmediateChargeSnapshot(companyId: number, recoveredLocalSubscriptionId: number)
|
|
Builds DB + Stripe status snapshot for the admin immediate-charge response. |
| Private computeNextBillingAfterOnePaidPeriod | |||||||||
computeNextBillingAfterOnePaidPeriod(previousAnchor: Date, billingCycle: string | null | undefined)
|
|||||||||
|
Next period end after paying one full billing cycle from {@param previousAnchor}.
Ensures the result is strictly in the future so Stripe
Parameters :
Returns :
Date
|
| Private Async mirrorPaidLatestInvoiceIfMissing | ||||||
mirrorPaidLatestInvoiceIfMissing(stripeSubscriptionId: string)
|
||||||
|
When the first invoice is paid automatically (default PM), Stripe may return the subscription as active so syncStripeSubscriptionForLocalSubscription never enters the incomplete + pay path. Align local Invoice + subscription state as invoice.paid would. Same for trialing: sync skips the incomplete/pay branch; mirror here so handleInvoicePaid runs (and {@code billing.payment.succeeded} sends) when webhooks are delayed.
Parameters :
Returns :
Promise<void>
|
| Async previewImmediateExpiredRecoveryCharge |
previewImmediateExpiredRecoveryCharge(companyId: number, fromDate?: string)
|
|
Returns :
Promise<ImmediateRecoveryQuote>
|
| Async refreshPaidSubscriptionLinkedPackageFromCatalog | ||||||
refreshPaidSubscriptionLinkedPackageFromCatalog(companyId: number)
|
||||||
|
Before portal recovery for expired companies: point the eligible paid subscription row at the active catalog Package for the same PackageType so Stripe sync uses current prices/Stripe price IDs. License count on the subscription row is unchanged.
Parameters :
Returns :
Promise<void>
|
| Private resolveRecoveryAnchorDate | ||||||
resolveRecoveryAnchorDate(input?: string)
|
||||||
|
Parameters :
Returns :
Date
|
| Async runAdminCreateStripeSubscription | ||||||
runAdminCreateStripeSubscription(companyId: number)
|
||||||
|
Admin: one-shot create/recreate Stripe subscription from local subscription rows. Behavior:
Parameters :
Returns :
Promise<literal type>
|
| Async runBatchStripeSubscriptionSync | ||||||
runBatchStripeSubscriptionSync(options?: literal type)
|
||||||
|
Parameters :
Returns :
Promise<literal type>
|
| Async runImmediateExpiredRecoveryCharge | |||||||||
runImmediateExpiredRecoveryCharge(companyId: number, options?: literal type)
|
|||||||||
|
Admin: one-shot expired recovery — same path as batch
Parameters :
Returns :
Promise<ImmediateChargeResult>
|
| Private Async shouldSkipStripeSubscriptionSync | ||||||
shouldSkipStripeSubscriptionSync(params: literal type)
|
||||||
|
Skip batch Stripe creation when the customer already has an active or trialing subscription that matches our DB (company or subscription-row Stripe id, or metadata.local_subscription_id). If Stripe has no such subscription (or only unrelated/canceled/past_due-only state), returns false so syncStripeSubscriptionForLocalSubscription can create a new one.
Parameters :
Returns :
Promise<boolean>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripePaymentSubscriptionAdminService.name)
|
import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
BillingCycle,
InvoiceStatus,
InvoiceType,
PackageType,
SubscriptionStatus,
SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import { getBillingCycleMultiplier } from "../../../../../config/billing-cycle";
import { ImmediateChargeResult } from "../../dto/immediate-charge-result.dto";
import { StripeService } from "../stripe.service";
import { ImmediateRecoveryQuote } from "./stripe-payment.types";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripePaymentInvoiceWebhookService } from "./stripe-payment-invoice-webhook.service";
import { StripePaymentSubscriptionWriterService } from "./stripe-payment-subscription-writer.service";
@Injectable()
export class StripePaymentSubscriptionAdminService {
private readonly logger = new Logger(StripePaymentSubscriptionAdminService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly stripeService: StripeService,
private readonly billing: StripePaymentBillingHelpersService,
private readonly invoiceWebhooks: StripePaymentInvoiceWebhookService,
private readonly subscriptionWriter: StripePaymentSubscriptionWriterService,
) {}
async runBatchStripeSubscriptionSync(options?: {
dryRun?: boolean;
recoverExpiredCompanies?: boolean;
}): Promise<{
activeSynced: number;
activeSkipped: number;
expiredAttempts: number;
errors: Array<{ companyId: number; message: string }>;
}> {
const dryRun = options?.dryRun ?? process.env["STRIPE_SYNC_DRY_RUN"] === "true";
const recoverExpired =
options?.recoverExpiredCompanies ?? process.env["STRIPE_SYNC_RECOVER_EXPIRED"] === "true";
const forceRecreate = process.env["STRIPE_SYNC_FORCE_RECREATE"] === "true";
const errors: Array<{ companyId: number; message: string }> = [];
let activeSynced = 0;
let activeSkipped = 0;
let expiredAttempts = 0;
if (!this.stripeService.isConfigured()) {
this.logger.warn(
"runBatchStripeSubscriptionSync: STRIPE_SECRET_KEY is missing — set it in recallassess-api docker/.env (or your shell).",
);
return {
activeSynced: 0,
activeSkipped: 0,
expiredAttempts: 0,
errors: [{ companyId: 0, message: "Stripe not configured" }],
};
}
const syncCompanyIdRaw = process.env["STRIPE_SYNC_COMPANY_ID"];
const syncCompanyId = syncCompanyIdRaw && syncCompanyIdRaw.length > 0 ? parseInt(syncCompanyIdRaw, 10) : NaN;
const companyScope = Number.isFinite(syncCompanyId) && syncCompanyId > 0 ? { company_id: syncCompanyId } : {};
// Eligible packages for creating/linking a Stripe subscription:
// - Exclude FREE_TRIAL (never billable)
// - Allow PRIVATE_VIP_TRIAL because it becomes billable after trial_end (VIP offer applied on first invoice)
const eligiblePackageFilter = {
package_type: { not: PackageType.FREE_TRIAL },
};
// "Paid" package filter for batch selection of already-billable subscriptions.
// (PRIVATE_VIP_TRIAL is excluded here because trial→paid is handled via trial_end logic.)
const paidPackageFilter = {
is_trial_package: false,
package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
};
const activeLocals = await this.prisma.client.subscription.findMany({
where: {
...companyScope,
is_current: true,
status: SubscriptionStatus.ACTIVE,
package: eligiblePackageFilter,
company: {
stripe_customer_id: { not: null },
},
},
include: {
company: { select: { id: true, stripe_customer_id: true, stripe_subscription_id: true } },
package: true,
},
});
this.logger.log(
`runBatchStripeSubscriptionSync: ${activeLocals.length} row(s) match (ACTIVE current, paid package, stripe_customer_id). ` +
`STRIPE_SYNC_COMPANY_ID=${Number.isFinite(syncCompanyId) ? String(syncCompanyId) : "unset"}`,
);
if (activeLocals.length === 0) {
this.logger.warn(
"No eligible local subscriptions. Need: is_current=true, status=ACTIVE, non-trial package (e.g. STARTUP), " +
"company.stripe_customer_id set. EXPIRED-only companies need STRIPE_SYNC_RECOVER_EXPIRED=true.",
);
}
for (const row of activeLocals) {
const companyId = row.company_id;
try {
const pricePerLicense = Number(row.package.price_per_licence ?? 0);
const cycleMultiplier = getBillingCycleMultiplier(row.billing_cycle || "QUARTERLY");
const cents = Math.round(pricePerLicense * row.license_count * cycleMultiplier * 100);
if (cents <= 0) {
activeSkipped += 1;
continue;
}
const stripeCustomerId = row.company.stripe_customer_id;
if (!stripeCustomerId) {
activeSkipped += 1;
continue;
}
if (
!forceRecreate &&
(await this.shouldSkipStripeSubscriptionSync({
stripeCustomerId,
companyStripeSubscriptionId: row.company.stripe_subscription_id,
rowStripeSubscriptionId: row.stripe_subscription_id,
localSubscriptionId: row.id,
}))
) {
activeSkipped += 1;
continue;
}
if (dryRun) {
this.logger.log(
`[dry-run] Would sync Stripe subscription for company ${companyId} (local sub ${row.id}, next_billing=${row.next_billing_date?.toISOString() ?? "null"})`,
);
activeSynced += 1;
continue;
}
const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, row.id, {
metadataSource: "batch_active_renewal",
});
if (created) {
activeSynced += 1;
} else {
activeSkipped += 1;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
errors.push({ companyId, message });
this.logger.error(`Batch Stripe sync failed for company ${companyId}: ${message}`);
}
}
if (recoverExpired) {
const expiredCompanies = await this.prisma.client.company.findMany({
where: {
...(Number.isFinite(syncCompanyId) && syncCompanyId > 0 ? { id: syncCompanyId } : {}),
is_subscription_expiry: true,
stripe_customer_id: { not: null },
},
select: { id: true, stripe_customer_id: true, stripe_subscription_id: true },
});
let startupPackageCache:
| {
id: number;
minimum_license_required: number;
}
| null
| undefined;
for (const c of expiredCompanies) {
const hasCurrentActive = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
is_current: true,
status: SubscriptionStatus.ACTIVE,
},
select: { id: true },
});
if (hasCurrentActive) {
continue;
}
let lastPaid = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
status: SubscriptionStatus.EXPIRED,
package: paidPackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
});
if (!lastPaid?.package) {
const lastExpiredAny = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
status: SubscriptionStatus.EXPIRED,
},
orderBy: { id: "desc" },
include: { package: true },
});
const isTrialExpired =
!!lastExpiredAny?.package &&
(lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);
if (!isTrialExpired) {
continue;
}
if (startupPackageCache === undefined) {
startupPackageCache = await this.prisma.client.package.findFirst({
where: {
package_type: PackageType.STARTUP,
is_active: true,
},
select: {
id: true,
minimum_license_required: true,
},
});
}
if (!startupPackageCache) {
this.logger.warn(
`Expired recovery skipped for company ${c.id}: STARTUP package not found/active for trial-to-paid fallback`,
);
continue;
}
const migratedLicenseCount = Math.max(
lastExpiredAny?.license_count ?? 0,
startupPackageCache.minimum_license_required ?? 1,
1,
);
const migrated = await this.prisma.client.subscription.create({
data: {
company_id: c.id,
package_id: startupPackageCache.id,
previous_subscription_id: lastExpiredAny?.id ?? null,
license_count: migratedLicenseCount,
status: SubscriptionStatus.EXPIRED,
is_current: false,
billing_cycle: BillingCycle.QUARTERLY,
start_date: new Date(),
end_date: new Date(),
next_billing_date: new Date(),
// First paid term after trial: upgrade from trial, not a billing-cycle renewal.
subscription_type: SubscriptionType.UPGRADE,
},
include: { package: true },
});
this.logger.log(
`Expired trial fallback: created STARTUP quarterly row ${migrated.id} for company ${c.id} (period start anchor=now)`,
);
lastPaid = migrated;
}
const pricePerLicense = Number(lastPaid.package.price_per_licence ?? 0);
const cycleMultiplier = getBillingCycleMultiplier(lastPaid.billing_cycle || "QUARTERLY");
if (Math.round(pricePerLicense * lastPaid.license_count * cycleMultiplier * 100) <= 0) {
continue;
}
const expiredStripeCustomerId = c.stripe_customer_id;
if (!expiredStripeCustomerId) {
continue;
}
try {
if (
!forceRecreate &&
(await this.shouldSkipStripeSubscriptionSync({
stripeCustomerId: expiredStripeCustomerId,
companyStripeSubscriptionId: c.stripe_subscription_id,
rowStripeSubscriptionId: lastPaid.stripe_subscription_id,
localSubscriptionId: lastPaid.id,
}))
) {
continue;
}
if (dryRun) {
this.logger.log(
`[dry-run] Would recover expired company ${c.id} via one-off invoice + trialing subscription (local sub ${lastPaid.id})`,
);
expiredAttempts += 1;
continue;
}
if (!lastPaid) {
continue;
}
const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(c.id, lastPaid.id, {
metadataSource: "batch_expired_recovery",
forceImmediateFirstInvoice: true,
});
if (created) {
expiredAttempts += 1;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
errors.push({ companyId: c.id, message });
this.logger.error(`Expired recovery Stripe sync failed for company ${c.id}: ${message}`);
}
}
}
this.logger.log(
`runBatchStripeSubscriptionSync complete: activeSynced=${activeSynced}, activeSkipped=${activeSkipped}, expiredAttempts=${expiredAttempts}, errors=${errors.length}, dryRun=${dryRun}, forceRecreate=${forceRecreate}`,
);
return { activeSynced, activeSkipped, expiredAttempts, errors };
}
/**
* Before portal recovery for expired companies: point the eligible paid subscription row at the
* active catalog {@link Package} for the same {@link PackageType} so Stripe sync uses current
* prices/Stripe price IDs. License count on the subscription row is unchanged.
*/
async refreshPaidSubscriptionLinkedPackageFromCatalog(companyId: number): Promise<void> {
const paidPackageFilter = {
is_trial_package: false,
package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
};
const currentActive = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: SubscriptionStatus.ACTIVE,
package: paidPackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
});
const latestExpired = !currentActive
? await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
status: SubscriptionStatus.EXPIRED,
package: paidPackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
})
: null;
const localSub = currentActive ?? latestExpired;
if (!localSub?.package) {
return;
}
const canonicalPackage = await this.prisma.client.package.findFirst({
where: {
package_type: localSub.package.package_type,
is_active: true,
OR: [{ special_slug: null }, { special_slug: "" }],
},
});
if (!canonicalPackage || canonicalPackage.id === localSub.package_id) {
return;
}
await this.prisma.client.subscription.update({
where: { id: localSub.id },
data: { package_id: canonicalPackage.id },
});
this.logger.log(
`refreshPaidSubscriptionLinkedPackageFromCatalog: company ${companyId} subscription ${localSub.id} package_id ${localSub.package_id} -> ${canonicalPackage.id} (${localSub.package.package_type})`,
);
}
/**
* Admin: one-shot create/recreate Stripe subscription from local subscription rows.
*
* Behavior:
* - Prefers local `is_current=true` + `status=ACTIVE` paid subscription.
* - If none, uses the latest local `status=EXPIRED` paid subscription.
* - Even if Stripe already has an active/trialing subscription for the customer,
* this will cancel billable Stripe subscriptions first and create a new one.
*/
async runAdminCreateStripeSubscription(companyId: number): Promise<{
success: boolean;
message: string;
next_period_end_iso?: string | null;
}> {
if (!this.stripeService.isConfigured()) {
throw new BadRequestException("Stripe is not configured");
}
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { id: true, stripe_customer_id: true },
});
if (!company) {
throw new NotFoundException(`Company ${companyId} not found`);
}
if (!company.stripe_customer_id) {
throw new BadRequestException("Company has no Stripe customer ID.");
}
// Eligible packages for creating/linking a Stripe subscription:
// - Exclude FREE_TRIAL (never billable)
// - Allow PRIVATE_VIP_TRIAL because it becomes billable after trial_end (VIP offer applied on first invoice)
const eligiblePackageFilter = {
package_type: { not: PackageType.FREE_TRIAL },
};
// Prefer current ACTIVE eligible subscription (mirrors batch "active renewals" selection).
const currentActive = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: SubscriptionStatus.ACTIVE,
package: eligiblePackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
});
// Some older data may not keep `is_current` perfectly in sync. If no current row is found,
// fall back to the latest ACTIVE eligible subscription for the company.
const latestActiveAny = !currentActive
? await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
status: SubscriptionStatus.ACTIVE,
package: eligiblePackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
})
: null;
// Fallback for ended companies: latest eligible expired subscription.
const latestExpired = !(currentActive ?? latestActiveAny)
? await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
status: SubscriptionStatus.EXPIRED,
package: eligiblePackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
})
: null;
const localSub = currentActive ?? latestActiveAny ?? latestExpired;
if (!localSub?.package) {
throw new BadRequestException(
"No eligible local paid subscription row found. Subscribe in the app first (Settings → Subscription billing).",
);
}
let created: boolean;
try {
created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, localSub.id, {
metadataSource: "admin_manual_create_subscription",
// For expired recovery we prefer immediate first invoice alignment.
forceImmediateFirstInvoice: localSub.status === SubscriptionStatus.EXPIRED,
});
} catch (e) {
const stripeErr = e as Stripe.errors.StripeError | undefined;
if (
stripeErr?.code === "resource_missing" &&
typeof stripeErr.message === "string" &&
/customer/i.test(stripeErr.message)
) {
const mode = this.stripeService.getDashboardAccountMode();
throw new BadRequestException(
`Stripe could not find customer ${company.stripe_customer_id} with this server's ${mode} API key (no such customer). ` +
`This usually means the Stripe Dashboard is in a different mode than STRIPE_SECRET_KEY (test vs live), ` +
`or the customer belongs to another Stripe account. ` +
`Open the customer in Dashboard with the same test/live toggle and account as UAT, or fix company.stripe_customer_id if the DB was copied from another environment.`,
);
}
throw e;
}
if (!created) {
return {
success: false,
message: "Stripe sync did not create a subscription (see server logs).",
next_period_end_iso: null,
};
}
let nextPeriodEndIso: string | null = null;
const companyAfter = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { stripe_subscription_id: true },
});
if (companyAfter?.stripe_subscription_id) {
try {
const stripeSub = await this.stripeService.retrieveSubscription(companyAfter.stripe_subscription_id);
const end = (stripeSub as Stripe.Subscription & { current_period_end?: number }).current_period_end;
if (typeof end === "number") {
nextPeriodEndIso = new Date(end * 1000).toISOString();
}
} catch (e) {
this.logger.warn(
`runAdminCreateStripeSubscription: could not read current_period_end for company ${companyId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
const endLabel = nextPeriodEndIso
? new Date(nextPeriodEndIso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
: null;
const message =
localSub.status === SubscriptionStatus.EXPIRED
? endLabel
? `Stripe subscription recovery is set up. Confirm payment in Stripe if needed. Next billing period ends ${endLabel} (UTC).`
: "Stripe subscription recovery created/replaced for this company. Confirm payment in Stripe if needed."
: endLabel
? `Stripe billing is linked. Your current period ends ${endLabel} (UTC); renewals follow this billing cycle.`
: "Stripe subscription created for this company. Confirm payment in Stripe if needed.";
return {
success: true,
message,
next_period_end_iso: nextPeriodEndIso,
};
}
/**
* Builds DB + Stripe status snapshot for the admin immediate-charge response.
*/
private async buildImmediateChargeSnapshot(
companyId: number,
recoveredLocalSubscriptionId: number,
): Promise<Pick<ImmediateChargeResult, "stripe_subscription_status" | "company" | "subscription" | "invoice">> {
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { is_subscription_expiry: true, stripe_subscription_id: true },
});
if (!company) {
return {
stripe_subscription_status: null,
company: undefined,
subscription: null,
invoice: null,
};
}
const subscription = await this.prisma.client.subscription.findFirst({
where: { id: recoveredLocalSubscriptionId, company_id: companyId },
select: { id: true, status: true, is_current: true, stripe_subscription_id: true },
});
const invoiceRow = await this.prisma.client.invoice.findFirst({
where: { company_id: companyId, subscription_id: recoveredLocalSubscriptionId },
orderBy: { id: "desc" },
select: {
id: true,
stripe_invoice_id: true,
stripe_payment_intent_id: true,
status: true,
total_amount: true,
paid_date: true,
},
});
let stripe_subscription_status: string | null = null;
if (company.stripe_subscription_id) {
try {
const sub = await this.stripeService.retrieveSubscription(company.stripe_subscription_id);
stripe_subscription_status = sub.status;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(
`Immediate charge snapshot: could not retrieve Stripe subscription ${company.stripe_subscription_id}: ${msg}`,
);
}
}
return {
stripe_subscription_status,
company: {
is_subscription_expiry: company.is_subscription_expiry,
stripe_subscription_id: company.stripe_subscription_id,
},
subscription: subscription
? {
id: subscription.id,
status: String(subscription.status),
is_current: subscription.is_current,
stripe_subscription_id: subscription.stripe_subscription_id,
}
: null,
invoice: invoiceRow
? {
id: invoiceRow.id,
stripe_invoice_id: invoiceRow.stripe_invoice_id,
stripe_payment_intent_id: invoiceRow.stripe_payment_intent_id,
status: String(invoiceRow.status),
total_amount: Number(invoiceRow.total_amount),
paid_date: invoiceRow.paid_date?.toISOString() ?? null,
}
: null,
};
}
/**
* Admin: one-shot expired recovery — same path as batch `STRIPE_SYNC_RECOVER_EXPIRED`.
* Charges one billing period via a standalone Stripe invoice (card / PaymentIntent), then creates a trialing subscription for future renewals.
*/
async runImmediateExpiredRecoveryCharge(
companyId: number,
options?: { forceRecreateStripeSubscription?: boolean; recoveryFromDate?: string },
): Promise<ImmediateChargeResult> {
if (!this.stripeService.isConfigured()) {
throw new BadRequestException("Stripe is not configured");
}
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: {
id: true,
is_subscription_expiry: true,
stripe_customer_id: true,
stripe_subscription_id: true,
},
});
if (!company) {
throw new NotFoundException(`Company ${companyId} not found`);
}
if (!company.is_subscription_expiry) {
throw new BadRequestException(
"Subscription expiry is not set for this company. This action is only for expired subscription recovery.",
);
}
if (!company.stripe_customer_id) {
throw new BadRequestException("Company has no Stripe customer ID.");
}
const paidPackageFilter = {
is_trial_package: false,
package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
};
const c = company;
const hasCurrentActive = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
is_current: true,
status: SubscriptionStatus.ACTIVE,
},
select: { id: true },
});
if (hasCurrentActive) {
throw new BadRequestException(
"This company still has a current ACTIVE subscription. Resolve that before running expired recovery.",
);
}
let lastPaid = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
status: SubscriptionStatus.EXPIRED,
package: paidPackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
});
// VIP-only offer: if the last expired row was PRIVATE_VIP_TRIAL, apply discount on the immediate charge.
let vipOfferAppliesToImmediateRecovery = false;
let startupPackageCache:
| {
id: number;
minimum_license_required: number;
}
| null
| undefined;
if (!lastPaid?.package) {
const lastExpiredAny = await this.prisma.client.subscription.findFirst({
where: {
company_id: c.id,
status: SubscriptionStatus.EXPIRED,
},
orderBy: { id: "desc" },
include: { package: true },
});
const isTrialExpired =
!!lastExpiredAny?.package &&
(lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);
if (!isTrialExpired) {
throw new BadRequestException(
"No paid expired subscription row found to recover. Check package, or use the customer app to subscribe.",
);
}
vipOfferAppliesToImmediateRecovery = lastExpiredAny?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;
if (startupPackageCache === undefined) {
startupPackageCache = await this.prisma.client.package.findFirst({
where: {
package_type: PackageType.STARTUP,
is_active: true,
},
select: {
id: true,
minimum_license_required: true,
},
});
}
if (!startupPackageCache) {
throw new BadRequestException(
"STARTUP package is not available for trial-to-paid fallback. Configure an active STARTUP package.",
);
}
const migratedLicenseCount = Math.max(
lastExpiredAny?.license_count ?? 0,
startupPackageCache.minimum_license_required ?? 1,
1,
);
const migrated = await this.prisma.client.subscription.create({
data: {
company_id: c.id,
package_id: startupPackageCache.id,
previous_subscription_id: lastExpiredAny?.id ?? null,
license_count: migratedLicenseCount,
status: SubscriptionStatus.EXPIRED,
is_current: false,
billing_cycle: BillingCycle.QUARTERLY,
start_date: new Date(),
end_date: new Date(),
next_billing_date: new Date(),
subscription_type: SubscriptionType.UPGRADE,
} as any,
include: { package: true },
});
this.logger.log(
`Immediate expired recovery: created STARTUP quarterly row ${migrated.id} for company ${c.id}`,
);
lastPaid = migrated;
}
if (!lastPaid?.package) {
throw new BadRequestException("Could not resolve an expired subscription row to recover.");
}
const pricePerLicense = Number(lastPaid.package.price_per_licence ?? 0);
const cycleMultiplier = getBillingCycleMultiplier(lastPaid.billing_cycle || "QUARTERLY");
if (Math.round(pricePerLicense * lastPaid.license_count * cycleMultiplier * 100) <= 0) {
throw new BadRequestException(
"Renewal amount is zero (check package price and license count). Cannot charge.",
);
}
const hasPmForImmediate = await this.stripeService.ensureDefaultPaymentMethodFromSavedCards(
c.stripe_customer_id!,
);
if (!hasPmForImmediate) {
throw new BadRequestException(
"Immediate charge requires a saved card on the Stripe customer with invoice default set. " +
"In Stripe Dashboard open the customer, add a card payment method, and set it as the default for invoices.",
);
}
const pmIdForImmediate = await this.stripeService.getInvoiceDefaultPaymentMethodId(c.stripe_customer_id!);
if (!pmIdForImmediate) {
throw new BadRequestException(
"Could not resolve invoice default payment method id after saving card default. Retry or set default in Stripe Dashboard.",
);
}
const forceRecreate =
options?.forceRecreateStripeSubscription === true || process.env["STRIPE_SYNC_FORCE_RECREATE"] === "true";
if (
!forceRecreate &&
(await this.shouldSkipStripeSubscriptionSync({
stripeCustomerId: c.stripe_customer_id!,
companyStripeSubscriptionId: c.stripe_subscription_id,
rowStripeSubscriptionId: lastPaid.stripe_subscription_id,
localSubscriptionId: lastPaid.id,
}))
) {
throw new BadRequestException(
"Stripe subscription already linked and up to date; nothing to do. Set STRIPE_SYNC_FORCE_RECREATE=true on the server to force recreate if needed.",
);
}
if (forceRecreate) {
await this.stripeService.cancelCustomerBillableSubscriptions(c.stripe_customer_id!);
}
const recoveredSubId = lastPaid.id;
const immediateDiscountMultiplier =
lastPaid.billing_cycle === BillingCycle.ANNUAL
? vipOfferAppliesToImmediateRecovery
? 2 / 3
: 5 / 6
: vipOfferAppliesToImmediateRecovery
? 2 / 3
: undefined;
const created = await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(c.id, recoveredSubId, {
metadataSource: "admin_immediate_expired_recovery",
forceImmediateFirstInvoice: true,
recoveryAnchorDate: options?.recoveryFromDate,
// Annual offer: charge 300/360 days (2 months free). VIP annual uses 2/3 (2+1 free).
immediateFirstInvoiceDiscountMultiplier: immediateDiscountMultiplier,
});
const snapshot = await this.buildImmediateChargeSnapshot(c.id, recoveredSubId);
if (!created) {
return {
success: false,
message: "Stripe sync did not create a subscription (see server logs).",
...snapshot,
};
}
return {
success: true,
message: vipOfferAppliesToImmediateRecovery
? "Recovery complete: VIP offer applied on the immediate charge; a trialing subscription is set for the next renewal. See snapshot below."
: "Recovery complete: one billing period was charged on a Stripe invoice; a trialing subscription is set for the next renewal. See snapshot below.",
...snapshot,
};
}
/**
* Stripe subscription invoices: reactivate expired local rows after payment, and keep next_billing_date aligned.
*/
/**
* Alias handler for some Stripe/local test flows:
* Stripe may emit `invoice_payment.paid` instead of/alongside `invoice.paid`.
*
* We resolve the actual Stripe invoice from the payload (best-effort) and then
* reuse {@link handleInvoicePaid} so local invoice/subscription rotation stays consistent.
*/
/**
* When a customer adds a payment method, try paying their latest subscription invoice now.
* This fixes the "no payment method -> invoice pending" case without manual admin action.
*/
/**
* When a SetupIntent succeeds (payment method saved), try paying latest subscription invoice.
*/
/**
* Skip batch Stripe creation when the customer already has an active or trialing subscription
* that matches our DB (company or subscription-row Stripe id, or metadata.local_subscription_id).
* If Stripe has no such subscription (or only unrelated/canceled/past_due-only state), returns false
* so {@link syncStripeSubscriptionForLocalSubscription} can create a new one.
*/
private async shouldSkipStripeSubscriptionSync(params: {
stripeCustomerId: string;
companyStripeSubscriptionId: string | null;
rowStripeSubscriptionId: string | null;
localSubscriptionId: number;
}): Promise<boolean> {
const activeLike = await this.stripeService.listSubscriptionsForCustomer(params.stripeCustomerId, [
"active",
"trialing",
]);
if (activeLike.length === 0) {
return false;
}
const companyId = params.companyStripeSubscriptionId?.trim() || null;
const rowId = params.rowStripeSubscriptionId?.trim() || null;
const localSubStr = String(params.localSubscriptionId);
for (const sub of activeLike) {
if (companyId && sub.id === companyId) {
return true;
}
if (rowId && sub.id === rowId) {
return true;
}
const metaLocal = sub.metadata?.["local_subscription_id"];
if (metaLocal === localSubStr) {
return true;
}
}
return false;
}
/**
* When the first invoice is paid automatically (default PM), Stripe may return the subscription as
* {@link Stripe.Subscription.Status active} so {@link syncStripeSubscriptionForLocalSubscription} never
* enters the incomplete + pay path. Align local Invoice + subscription state as invoice.paid would.
* Same for {@link Stripe.Subscription.Status trialing}: sync skips the incomplete/pay branch; mirror here
* so {@link handleInvoicePaid} runs (and {@code billing.payment.succeeded} sends) when webhooks are delayed.
*/
private async mirrorPaidLatestInvoiceIfMissing(stripeSubscriptionId: string): Promise<void> {
const sub = await this.stripeService.retrieveSubscriptionWithLatestInvoice(stripeSubscriptionId);
if (sub.status !== "active" && sub.status !== "trialing") {
return;
}
const latest = sub.latest_invoice;
const inv =
typeof latest === "object" && latest && latest.object === "invoice" ? (latest as Stripe.Invoice) : null;
if (!inv || inv.status !== "paid") {
return;
}
// Zero-amount Stripe invoices can still be status=paid (e.g. discounts/trials).
// We still need the normal invoice.paid side effects, including billing email.
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: inv.id },
select: { id: true },
});
if (existing) {
return;
}
await this.invoiceWebhooks.handleInvoicePaid(inv);
this.logger.log(
`mirrorPaidLatestInvoiceIfMissing: applied handleInvoicePaid for stripe_invoice_id=${inv.id} (subscription=${stripeSubscriptionId}).`,
);
}
/**
* Next period end after paying one full billing cycle from {@param previousAnchor}.
* Ensures the result is strictly in the future so Stripe `trial_end` is valid.
*/
private computeNextBillingAfterOnePaidPeriod(
previousAnchor: Date,
billingCycle: string | null | undefined,
): Date {
return this.billing.addDaysSafe(previousAnchor, this.billing.getBillingCycleFixedDays(billingCycle));
}
private 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;
}
async previewImmediateExpiredRecoveryCharge(
companyId: number,
fromDate?: string,
): Promise<ImmediateRecoveryQuote> {
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: {
id: true,
is_subscription_expiry: true,
country: true,
},
});
if (!company) {
throw new NotFoundException(`Company ${companyId} not found`);
}
if (!company.is_subscription_expiry) {
throw new BadRequestException("Immediate re-activation is only available for expired subscriptions.");
}
const paidPackageFilter = {
is_trial_package: false,
package_type: { notIn: [PackageType.FREE_TRIAL, PackageType.PRIVATE_VIP_TRIAL] },
};
const hasCurrentActive = await this.prisma.client.subscription.findFirst({
where: {
company_id: company.id,
is_current: true,
status: SubscriptionStatus.ACTIVE,
},
select: { id: true },
});
if (hasCurrentActive) {
throw new BadRequestException("Company already has an active subscription.");
}
let target = await this.prisma.client.subscription.findFirst({
where: {
company_id: company.id,
status: SubscriptionStatus.EXPIRED,
package: paidPackageFilter,
},
orderBy: { id: "desc" },
include: { package: true },
});
let vipOfferAppliesToImmediateRecovery = false;
if (!target?.package) {
const lastExpiredAny = await this.prisma.client.subscription.findFirst({
where: {
company_id: company.id,
status: SubscriptionStatus.EXPIRED,
},
orderBy: { id: "desc" },
include: { package: true },
});
const isTrialExpired =
!!lastExpiredAny?.package &&
(lastExpiredAny.package.package_type === PackageType.FREE_TRIAL ||
lastExpiredAny.package.package_type === PackageType.PRIVATE_VIP_TRIAL);
if (!isTrialExpired) {
throw new BadRequestException("No expired paid subscription available for re-activation.");
}
vipOfferAppliesToImmediateRecovery = lastExpiredAny?.package?.package_type === PackageType.PRIVATE_VIP_TRIAL;
const startupPackage = await this.prisma.client.package.findFirst({
where: { package_type: PackageType.STARTUP, is_active: true },
select: { id: true, price_per_licence: true, minimum_license_required: true },
});
if (!startupPackage) {
throw new BadRequestException("STARTUP package is required for trial-to-paid re-activation preview.");
}
const licenseCount = Math.max(
lastExpiredAny?.license_count ?? 0,
startupPackage.minimum_license_required ?? 1,
1,
);
if (!lastExpiredAny || !lastExpiredAny.package) {
throw new BadRequestException("Could not resolve expired trial subscription for preview.");
}
target = {
...lastExpiredAny,
license_count: licenseCount,
billing_cycle: BillingCycle.QUARTERLY,
package: {
...lastExpiredAny.package,
price_per_licence: startupPackage.price_per_licence,
package_type: PackageType.STARTUP,
},
} as typeof target;
}
if (!target?.package) {
throw new BadRequestException("Could not calculate re-activation preview.");
}
const cycle = (target.billing_cycle || "QUARTERLY") as "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
const cycleMultiplier = getBillingCycleMultiplier(cycle);
const pricePerLicense = Number(target.package.price_per_licence ?? 0);
const fullBaseAmountCents = Math.round(pricePerLicense * target.license_count * cycleMultiplier * 100);
let baseAmountCents = fullBaseAmountCents;
let immediateDiscountMultiplier: number | null = null;
if (cycle === "ANNUAL") {
immediateDiscountMultiplier = vipOfferAppliesToImmediateRecovery ? 2 / 3 : 5 / 6;
} else if (vipOfferAppliesToImmediateRecovery) {
immediateDiscountMultiplier = 2 / 3;
}
if (typeof immediateDiscountMultiplier === "number") {
baseAmountCents = Math.max(1, Math.round(baseAmountCents * immediateDiscountMultiplier));
}
if (baseAmountCents <= 0) {
throw new BadRequestException("Re-activation amount is zero. Check package price and license count.");
}
const charge = this.billing.calculateRenewalChargeCents({
baseAmountCents,
country: company.country,
});
const nextRecurringCharge = this.billing.calculateRenewalChargeCents({
baseAmountCents: fullBaseAmountCents,
country: company.country,
});
const anchor = this.billing.resolveRecoveryAnchorDate(fromDate);
const endDate = this.billing.computeNextBillingAfterOnePaidPeriod(anchor, cycle);
const toDateOnly = (d: Date): string => d.toISOString().slice(0, 10);
return {
currency: "USD",
base_amount: baseAmountCents / 100,
processing_fee: charge.processingFeeCents / 100,
vat_fee: charge.vatCents / 100,
immediate_charge_amount: charge.totalCents / 100,
next_recurring_charge_amount: nextRecurringCharge.totalCents / 100,
vip_offer_applied: vipOfferAppliesToImmediateRecovery,
vip_offer_summary: vipOfferAppliesToImmediateRecovery
? cycle === "ANNUAL"
? "VIP offer applied: annual immediate charge uses 2/3 of one full cycle (2+1 free)."
: "VIP offer applied: immediate charge uses 2/3 of one full cycle."
: cycle === "ANNUAL"
? "Annual offer applied: immediate charge uses 300/360 days (2 months free)."
: null,
billing_cycle: cycle,
from_date: toDateOnly(anchor),
end_date: toDateOnly(endDate),
next_stripe_billing_date: toDateOnly(endDate),
};
}
}