apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-payment/stripe-payment-checkout.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, stripeService: StripeService, billing: StripePaymentBillingHelpersService, invoiceWebhooks: StripePaymentInvoiceWebhookService, subscriptionWriter: StripePaymentSubscriptionWriterService, invoicePdfService: InvoicePdfService)
|
|||||||||||||||||||||
|
Parameters :
|
| Private addMonthsSafe |
addMonthsSafe(date: Date, months: number)
|
|
Returns :
Date
|
| Async createCheckoutSession | ||||||
createCheckoutSession(dto: CreateCheckoutSessionDto)
|
||||||
|
Create checkout session for participant payment
Parameters :
Returns :
unknown
|
| Async createPaymentIntent | ||||||
createPaymentIntent(dto: CreatePaymentIntentDto)
|
||||||
|
Create Payment Intent or Setup Intent (for embedded card form) Called BEFORE account creation during sign-up flow For $0 amounts (trial packages), uses Setup Intent to collect payment method without charging For paid amounts, uses Payment Intent to charge immediately
Parameters :
Returns :
Promise<PaymentIntentResponseDto>
|
| Private getBillingCycleMonths | ||||||
getBillingCycleMonths(billingCycle: string | null | undefined)
|
||||||
|
Parameters :
Returns :
number
|
| Async getInvoiceStatus | ||||||
getInvoiceStatus(invoiceId: number)
|
||||||
|
Get invoice payment status
Parameters :
Returns :
unknown
|
| Async handleCheckoutSessionCompleted | ||||||
handleCheckoutSessionCompleted(session: Stripe.Checkout.Session)
|
||||||
|
Handle successful checkout session
Parameters :
Returns :
any
|
| Async handlePaymentFailed | ||||||
handlePaymentFailed(paymentIntent: Stripe.PaymentIntent)
|
||||||
|
Handle failed payment
Parameters :
Returns :
any
|
| Async handlePaymentSucceeded | ||||||
handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent)
|
||||||
|
Handle successful payment
Parameters :
Returns :
any
|
| Private isTrialLikeLocalPackage | ||||||
isTrialLikeLocalPackage(pkg: literal type)
|
||||||
|
Parameters :
Returns :
boolean
|
| Private Async revertProvisionalSubscriptionAfterCheckoutFailed | |||||||||
revertProvisionalSubscriptionAfterCheckoutFailed(invoice: literal type, logContext: string)
|
|||||||||
|
Portal first-time paid checkout creates a provisional local row (ACTIVE, is_current) with no Subscription.next_billing_date until payment succeeds. If checkout fails, is abandoned, or times out, the invoice is marked FAILED — cancel that placeholder so the UI does not show an “active” plan with an empty renewal date.
Parameters :
Returns :
Promise<void>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripePaymentCheckoutService.name)
|
import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
BillingCycle,
InvoiceStatus,
InvoiceType,
PackageType,
SubscriptionPlan,
SubscriptionStatus,
SubscriptionType,
} from "@prisma/client";
import Stripe from "stripe";
import {
calculateNextBillingDateFromAnchor,
calculateTrialNextBillingDate,
getBillingCycleMultiplier,
} from "../../../../../config/billing-cycle";
import { InvoicePdfService } from "../../../invoice/invoice-pdf.service";
import { CreateCheckoutSessionDto, CreatePaymentIntentDto, PaymentIntentResponseDto } from "../../dto/payment.dto";
import { localStripeCancelAtPeriodEndFromSubscription, StripeService } from "../stripe.service";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripePaymentInvoiceWebhookService } from "./stripe-payment-invoice-webhook.service";
import { StripePaymentSubscriptionWriterService } from "./stripe-payment-subscription-writer.service";
type StripeSubscriptionApi = Stripe.Subscription & {
current_period_start?: number;
current_period_end?: number;
};
@Injectable()
export class StripePaymentCheckoutService {
private readonly logger = new Logger(StripePaymentCheckoutService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly stripeService: StripeService,
private readonly billing: StripePaymentBillingHelpersService,
private readonly invoiceWebhooks: StripePaymentInvoiceWebhookService,
private readonly subscriptionWriter: StripePaymentSubscriptionWriterService,
private readonly invoicePdfService: InvoicePdfService,
) {}
private dataForSubscriptionRowSupersededByPaidChange() {
return {
is_current: false,
status: SubscriptionStatus.CANCELLED,
end_date: new Date(),
stripe_subscription_id: null,
stripe_cancel_at_period_end: false,
};
}
/**
* Portal first-time paid checkout creates a provisional local row (ACTIVE, is_current) with no
* {@link Subscription.next_billing_date} until payment succeeds. If checkout fails, is abandoned, or
* times out, the invoice is marked FAILED — cancel that placeholder so the UI does not show an
* “active” plan with an empty renewal date.
*/
private async revertProvisionalSubscriptionAfterCheckoutFailed(
invoice: { id: number; invoice_type: InvoiceType; subscription_id: number },
logContext: string,
): Promise<void> {
if (invoice.invoice_type !== InvoiceType.INITIAL_SUBSCRIPTION) {
return;
}
const sub = await this.prisma.client.subscription.findUnique({
where: { id: invoice.subscription_id },
});
if (!sub?.is_current || sub.status !== SubscriptionStatus.ACTIVE) {
return;
}
if (sub.next_billing_date != null) {
return;
}
if (sub.stripe_subscription_id) {
return;
}
await this.prisma.client.subscription.update({
where: { id: sub.id },
data: this.dataForSubscriptionRowSupersededByPaidChange(),
});
this.logger.log(
`Reverted provisional subscription ${sub.id} after failed/abandoned checkout (invoice ${invoice.id}): ${logContext}`,
);
}
/**
* Create checkout session for participant payment
*/
async createCheckoutSession(dto: CreateCheckoutSessionDto) {
this.logger.log(`Creating checkout session for company ${dto.company_id}, invoice ${dto.invoice_id}`);
// Verify company exists
const company = await this.prisma.client.company.findUnique({
where: { id: dto.company_id },
});
if (!company) {
throw new NotFoundException("Company not found");
}
// Verify invoice exists
const invoice = await this.prisma.client.invoice.findUnique({
where: { id: dto.invoice_id },
});
if (!invoice) {
throw new NotFoundException("Invoice not found");
}
if (invoice.status === InvoiceStatus.PAID) {
throw new BadRequestException("Invoice is already paid");
}
// Generate success and cancel URLs
const FRONTEND_URL_ENV_KEY = "FRONTEND_URL" as const;
const baseUrl = dto.success_url || process.env[FRONTEND_URL_ENV_KEY];
const successUrl = `${baseUrl}/payment/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/payment/cancelled`;
// Create checkout session
const session = await this.stripeService.createCheckoutSession({
amount: dto.amount,
currency: dto.currency || "usd",
successUrl,
cancelUrl,
customerEmail: dto.customer_email,
metadata: {
companyId: dto.company_id.toString(),
invoiceId: dto.invoice_id.toString(),
participantId: dto.participant_id || "",
},
});
if (!session.url) {
throw new Error("Stripe did not return a checkout session URL");
}
// Update invoice with session ID
await this.prisma.client.invoice.update({
where: { id: dto.invoice_id },
data: {
stripe_checkout_session_id: session.id,
},
});
return {
session_id: session.id,
session_url: session.url,
publishable_key: this.stripeService.getPublishableKey(),
};
}
/**
* Create Payment Intent or Setup Intent (for embedded card form)
* Called BEFORE account creation during sign-up flow
* For $0 amounts (trial packages), uses Setup Intent to collect payment method without charging
* For paid amounts, uses Payment Intent to charge immediately
*/
async createPaymentIntent(dto: CreatePaymentIntentDto): Promise<PaymentIntentResponseDto> {
// Convert amount to number and round to integer (cents)
// Allow 0 for trial packages
const amount = Math.round(Number(dto.amount ?? 0));
// Log payment intent creation details
this.logger.log(
`Creating payment intent - amount: ${amount} cents (${(amount / 100).toFixed(2)} USD), customer: ${dto.customer_email}`,
);
// Only reject clearly invalid amounts (negative or NaN after conversion)
if (amount < 0 || Number.isNaN(amount)) {
this.logger.error(`Invalid amount received: ${dto.amount} (converted to: ${amount})`);
throw new BadRequestException("amount must be a non-negative number (>= 0)");
}
// Create customer with Stripe
// Note: company and invoice don't exist yet - they're created AFTER payment/setup succeeds
const customer = await this.stripeService.createCustomer({
email: dto.customer_email,
name: dto.customer_name,
metadata: {
context: "signup",
pending_company_email: dto.customer_email || "",
},
});
const metadata = {
customer_email: dto.customer_email || "",
customer_name: dto.customer_name || "",
signup_flow: "recallassess",
environment: process.env["NODE_ENV"] || "development",
timestamp: new Date().toISOString(),
};
// For $0 amounts (trial packages), use Setup Intent to collect payment method
if (amount === 0) {
const setupIntent = await this.stripeService.createSetupIntent({
customerId: customer.id,
metadata,
});
this.logger.log(`Setup intent created: ${setupIntent.id} for ${dto.customer_email} (trial package)`);
if (!setupIntent.client_secret) {
throw new Error("Stripe did not return a client secret for the setup intent");
}
return {
client_secret: setupIntent.client_secret,
setup_intent_id: setupIntent.id,
publishable_key: this.stripeService.getPublishableKey(),
stripe_customer_id: customer.id,
is_setup_intent: true,
};
}
// For paid amounts, use Payment Intent to charge immediately
const paymentIntent = await this.stripeService.createPaymentIntent({
amount: amount,
currency: dto.currency || "usd",
metadata,
customerId: customer.id,
automaticPaymentMethods: {
enabled: true,
},
});
this.logger.log(`Payment intent created: ${paymentIntent.id} for ${dto.customer_email}`);
if (!paymentIntent.client_secret) {
throw new Error("Stripe did not return a client secret for the payment intent");
}
return {
client_secret: paymentIntent.client_secret,
payment_intent_id: paymentIntent.id,
publishable_key: this.stripeService.getPublishableKey(),
stripe_customer_id: customer.id,
is_setup_intent: false,
};
}
/**
* Get invoice payment status
*/
async getInvoiceStatus(invoiceId: number) {
const invoice = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
});
if (!invoice) {
throw new NotFoundException("Invoice not found");
}
// If invoice is pending, check various Stripe statuses
if (invoice.status === "PENDING") {
// First, check if there's a payment intent and if it still exists in Stripe
if (invoice.stripe_payment_intent_id) {
try {
this.logger.log(`Checking payment intent ${invoice.stripe_payment_intent_id} for invoice ${invoiceId}`);
const paymentIntent = await this.stripeService.retrievePaymentIntent(invoice.stripe_payment_intent_id);
this.logger.log(`Payment intent status: ${paymentIntent.status}`);
// If payment intent is succeeded, it should have been processed by webhook
// If it's failed or canceled, mark invoice as failed
if (paymentIntent.status === "succeeded") {
// Payment succeeded - this should have been caught by webhook
// But if invoice is still pending, something went wrong
this.logger.warn(`Payment intent succeeded but invoice ${invoiceId} still pending - forcing update`);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: "PAID",
paid_date: new Date(),
},
});
const total =
typeof invoice.total_amount === "number" ? invoice.total_amount : Number(invoice.total_amount);
const invNo = invoice.invoice_number ?? String(invoiceId);
await this.billing.sendCompanyTemplatedEmail({
companyId: invoice.company_id,
templateKey: "billing.payment.succeeded",
dedupeKey: `billing.payment.succeeded:local_pi_repair:${invoice.stripe_payment_intent_id ?? invoiceId}`,
triggeredBy: "getInvoiceStatus_payment_intent_repair",
variables: {
"invoice.number": invNo,
"invoice.amount": this.billing.formatUsd(Number.isFinite(total) ? total : 0),
"subscription.next_billing_date": "",
},
metadata: {
invoice_id: invoice.id,
stripe_payment_intent_id: invoice.stripe_payment_intent_id ?? undefined,
},
});
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: "Paid",
amount: invoice.total_amount,
paid_date: new Date(),
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: invoice.failure_code,
failure_message: invoice.failure_message,
synced_from_stripe: true,
};
} else if (paymentIntent.status === "canceled" || paymentIntent.status === "requires_payment_method") {
// Payment was canceled or failed - mark as failed
this.logger.log(`Payment intent ${paymentIntent.status} - marking invoice as failed`);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: "FAILED",
failure_code: paymentIntent.last_payment_error?.code || "payment_cancelled",
failure_message: paymentIntent.last_payment_error?.message || `Payment ${paymentIntent.status}`,
},
});
await this.revertProvisionalSubscriptionAfterCheckoutFailed(
{
id: invoice.id,
invoice_type: invoice.invoice_type,
subscription_id: invoice.subscription_id,
},
`payment_intent ${paymentIntent.status}`,
);
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: "Failed",
amount: invoice.total_amount,
paid_date: invoice.paid_date,
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: paymentIntent.last_payment_error?.code || "payment_cancelled",
failure_message: paymentIntent.last_payment_error?.message || `Payment ${paymentIntent.status}`,
synced_from_stripe: true,
};
}
} catch (error) {
// Payment intent doesn't exist in Stripe - mark as failed
this.logger.warn(
`Payment intent ${invoice.stripe_payment_intent_id} not found in Stripe for invoice ${invoiceId}: ${error instanceof Error ? error.message : String(error)}`,
);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: "FAILED",
failure_code: "payment_intent_not_found",
failure_message: "Payment intent not found in Stripe - payment may have failed",
},
});
await this.revertProvisionalSubscriptionAfterCheckoutFailed(
{
id: invoice.id,
invoice_type: invoice.invoice_type,
subscription_id: invoice.subscription_id,
},
"payment_intent_not_found",
);
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: "Failed",
amount: invoice.total_amount,
paid_date: invoice.paid_date,
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: "payment_intent_not_found",
failure_message: "Payment intent not found in Stripe - payment may have failed",
synced_from_stripe: true,
};
}
}
// If no payment intent ID, check checkout session
if (invoice.stripe_checkout_session_id) {
try {
this.logger.log(
`Checking Stripe payment status for pending invoice ${invoiceId}, session: ${invoice.stripe_checkout_session_id}`,
);
const session = await this.stripeService.retrieveCheckoutSession(invoice.stripe_checkout_session_id);
// If payment is completed in Stripe but invoice is still pending, process it
if (session.payment_status === "paid" && invoice.status === "PENDING") {
this.logger.log(`Payment completed in Stripe but invoice is pending. Processing checkout session...`);
await this.handleCheckoutSessionCompleted(session);
// Reload invoice to get updated status
const updatedInvoice = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
});
if (updatedInvoice) {
return {
invoice_id: updatedInvoice.id,
invoice_number: updatedInvoice.invoice_number,
status: updatedInvoice.status,
amount: updatedInvoice.total_amount,
paid_date: updatedInvoice.paid_date,
stripe_payment_intent_id: updatedInvoice.stripe_payment_intent_id,
stripe_charge_id: updatedInvoice.stripe_charge_id,
failure_code: updatedInvoice.failure_code,
failure_message: updatedInvoice.failure_message,
synced_from_stripe: true,
};
}
} else if (session.payment_status === "unpaid") {
// Checkout session exists but payment not completed - mark as failed
this.logger.log(`Checkout session unpaid - marking invoice as failed`);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: "FAILED",
failure_code: "checkout_unpaid",
failure_message: "Checkout session was not completed",
},
});
await this.revertProvisionalSubscriptionAfterCheckoutFailed(
{
id: invoice.id,
invoice_type: invoice.invoice_type,
subscription_id: invoice.subscription_id,
},
"checkout session unpaid",
);
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: "Failed",
amount: invoice.total_amount,
paid_date: invoice.paid_date,
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: "checkout_unpaid",
failure_message: "Checkout session was not completed",
synced_from_stripe: true,
};
}
} catch (error) {
this.logger.warn(
`Failed to check Stripe payment status for invoice ${invoiceId}: ${error instanceof Error ? error.message : String(error)}`,
);
// Continue to return database status if Stripe check fails
}
}
// If invoice has been pending for more than 24 hours, mark as failed
const createdAt = invoice.created_at;
const hoursPending = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60);
if (hoursPending > 24) {
this.logger.log(
`Invoice ${invoiceId} has been pending for ${hoursPending.toFixed(1)} hours - marking as failed`,
);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: "FAILED",
failure_code: "payment_timeout",
failure_message: `Payment not completed within ${hoursPending.toFixed(1)} hours`,
},
});
await this.revertProvisionalSubscriptionAfterCheckoutFailed(
{
id: invoice.id,
invoice_type: invoice.invoice_type,
subscription_id: invoice.subscription_id,
},
"payment_timeout",
);
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: "Failed",
amount: invoice.total_amount,
paid_date: invoice.paid_date,
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: "payment_timeout",
failure_message: `Payment not completed within ${hoursPending.toFixed(1)} hours`,
synced_from_stripe: true,
};
}
}
// Normalize status for frontend (PAID -> Paid, FAILED -> Failed, PENDING -> Pending)
let normalizedStatus: string;
switch (invoice.status) {
case "PAID":
normalizedStatus = "Paid";
break;
case "FAILED":
normalizedStatus = "Failed";
break;
case "PENDING":
case "CANCELLED":
default:
normalizedStatus = "Pending";
break;
}
return {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
status: normalizedStatus,
amount: invoice.total_amount,
paid_date: invoice.paid_date,
stripe_payment_intent_id: invoice.stripe_payment_intent_id,
stripe_charge_id: invoice.stripe_charge_id,
failure_code: invoice.failure_code,
failure_message: invoice.failure_message,
};
}
/**
* Handle successful checkout session
*/
async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
this.logger.log(`=== CHECKOUT SESSION COMPLETED ===`);
this.logger.log(`Session ID: ${session.id}`);
this.logger.log(`Payment Status: ${session.payment_status}`);
this.logger.log(
`Payment Intent: ${typeof session.payment_intent === "string" ? session.payment_intent : (session.payment_intent?.id ?? "N/A")}`,
);
this.logger.log(`Session metadata: ${JSON.stringify(session.metadata)}`);
this.logger.log(`Session mode: ${session.mode}`);
this.logger.log(`Customer ID: ${session.customer}`);
const metadata = (session.metadata ?? {}) as Stripe.Metadata & {
invoiceId?: string;
invoice_id?: string;
company_id?: string;
package_id?: string;
license_count?: string;
invoice_type?: string;
billing_cycle?: string;
should_update_existing?: string; // "true" = update existing subscription, "false" = create new
is_billing_cycle_change?: string; // "true" = billing cycle changed
};
const invoiceIdRaw = metadata.invoiceId ?? metadata.invoice_id;
const invoiceId = invoiceIdRaw ? parseInt(invoiceIdRaw, 10) : NaN;
this.logger.log(`Invoice ID from metadata: ${invoiceIdRaw} -> parsed: ${invoiceId}`);
if (!invoiceId || isNaN(invoiceId)) {
this.logger.error(`No valid invoice ID in session metadata. Raw value: ${invoiceIdRaw}`);
this.logger.error(`Full metadata: ${JSON.stringify(metadata)}`);
return;
}
this.logger.log(`Processing invoice ${invoiceId} from checkout session`);
const invoice = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
include: {
company: true,
subscription: {
include: {
package: true,
},
},
},
});
if (!invoice) {
this.logger.error(`Invoice ${invoiceId} not found in database`);
return;
}
this.logger.log(
`Invoice found: ID=${invoice.id}, Status=${invoice.status}, SubscriptionID=${invoice.subscription_id}`,
);
const paymentOk = session.payment_status === "paid" || session.payment_status === "no_payment_required";
if (!paymentOk) {
this.logger.warn(
`Checkout session ${session.id} not paid (payment_status=${session.payment_status}); skipping subscription/invoice paid updates`,
);
if (invoice.status === InvoiceStatus.PENDING) {
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: InvoiceStatus.FAILED,
failure_code: `checkout_${session.payment_status ?? "unpaid"}`,
failure_message: `Checkout completed without successful payment (payment_status=${session.payment_status ?? "unknown"})`,
},
});
await this.revertProvisionalSubscriptionAfterCheckoutFailed(
{
id: invoice.id,
invoice_type: invoice.invoice_type,
subscription_id: invoice.subscription_id,
},
`checkout.session.completed payment_status=${session.payment_status}`,
);
}
return;
}
if (session.customer && session.payment_intent) {
try {
const customerId = typeof session.customer === "string" ? session.customer : session.customer.id;
const paymentIntentIdForPm =
typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent.id;
const paymentIntent = await this.stripeService.retrievePaymentIntent(paymentIntentIdForPm, [
"payment_method",
]);
if (paymentIntent.payment_method) {
const paymentMethodId =
typeof paymentIntent.payment_method === "string"
? paymentIntent.payment_method
: paymentIntent.payment_method.id;
await this.stripeService.setCustomerDefaultPaymentMethod(customerId, paymentMethodId);
this.logger.log(`Payment method ${paymentMethodId} set as default for customer ${customerId}`);
}
} catch (pmError) {
this.logger.warn(
`Failed to set payment method as default: ${pmError instanceof Error ? pmError.message : String(pmError)}`,
);
}
}
// Update invoice status
const paymentIntentId =
typeof session.payment_intent === "string" ? session.payment_intent : (session.payment_intent?.id ?? null);
this.logger.log(`Payment Intent ID: ${paymentIntentId}`);
let checkoutStripeChargeId: string | null = null;
if (paymentIntentId && this.stripeService.isConfigured()) {
try {
const piForCharge = await this.stripeService.retrievePaymentIntent(paymentIntentId, []);
const lc = piForCharge.latest_charge;
checkoutStripeChargeId = typeof lc === "string" ? lc : (lc?.id ?? null);
} catch (e) {
this.logger.warn(
`Could not resolve Stripe charge id for checkout payment intent ${paymentIntentId}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
const checkoutPaidInvoiceExtras =
checkoutStripeChargeId !== null ? { stripe_charge_id: checkoutStripeChargeId } : {};
// Check if this is a subscription change (has package_id and license_count in metadata)
const isSubscriptionChange = !!(metadata.package_id && metadata.license_count);
this.logger.log(`=== SUBSCRIPTION CHANGE CHECK ===`);
this.logger.log(`Is subscription change: ${isSubscriptionChange}`);
this.logger.log(`Package ID from metadata: ${metadata.package_id}`);
this.logger.log(`License count from metadata: ${metadata.license_count}`);
this.logger.log(`Company ID from metadata: ${metadata.company_id}`);
if (isSubscriptionChange) {
// Process subscription change - create new subscription and update old one
const companyId = parseInt(metadata.company_id ?? invoice.company_id.toString(), 10);
const packageIdRaw = metadata.package_id;
const licenseCountRaw = metadata.license_count;
if (!packageIdRaw || !licenseCountRaw) {
this.logger.error("Missing package_id or license_count in subscription change metadata");
// Fall through to regular invoice update
} else {
const packageId = parseInt(packageIdRaw, 10);
const licenseCount = parseInt(licenseCountRaw, 10);
if (isNaN(packageId) || isNaN(licenseCount)) {
this.logger.error(
`Invalid package_id or license_count: package_id=${packageIdRaw}, license_count=${licenseCountRaw}`,
);
// Fall through to regular invoice update
} else {
// Get current subscription
const currentSubscription = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
},
include: {
package: true,
},
});
const licensesConsumed = currentSubscription?.licenses_consumed ?? 0;
const licensesAvailable = Math.max(0, licenseCount - licensesConsumed);
// Get the invoice's current subscription
const invoiceSubscription = invoice.subscription;
// Check invoice_type from metadata to determine if this is an upgrade/change
// If invoice_type is UPGRADE or DOWNGRADE, always create new subscription
const invoiceType = metadata.invoice_type;
const isUpgradeOrDowngrade = invoiceType === "UPGRADE" || invoiceType === "DOWNGRADE";
// Check if this is an initial subscription
// It's initial if: no current subscription exists AND it's not explicitly an upgrade/downgrade
const isInitialSubscription = !currentSubscription && !isUpgradeOrDowngrade;
this.logger.log(
`Invoice type: ${invoiceType}, isUpgradeOrDowngrade: ${isUpgradeOrDowngrade}, isInitialSubscription: ${isInitialSubscription}`,
);
this.logger.log(
`Current subscription exists: ${!!currentSubscription}, Current subscription ID: ${currentSubscription?.id}, Invoice subscription ID: ${invoiceSubscription?.id}`,
);
if (isInitialSubscription) {
// Get package to check if it's a trial package
const packageData = await this.prisma.client.package.findUnique({
where: { id: packageId },
});
if (!packageData) {
this.logger.error(`Package ${packageId} not found for initial subscription`);
// Fall through to regular invoice update
} else {
// Calculate next billing date
// Trial: trial end from package; billing_cycle = quarterly (post-trial cadence).
let nextBillingDate: Date;
let subscriptionBillingCycle: BillingCycle | null = null;
const isTrialPackage =
packageData.is_trial_package ||
packageData.package_type === "FREE_TRIAL" ||
packageData.package_type === "PRIVATE_VIP_TRIAL";
if (isTrialPackage) {
nextBillingDate = calculateTrialNextBillingDate(packageData.trial_duration_days);
subscriptionBillingCycle = BillingCycle.QUARTERLY;
this.logger.log(
`Initial trial subscription: package_type=${packageData.package_type}, trial_duration_days=${packageData.trial_duration_days}, next_billing_date=${nextBillingDate.toISOString()}, billing_cycle=${subscriptionBillingCycle}`,
);
} else {
// For non-trial packages, use billing cycle
// Handle empty string from metadata (converted from null)
const billingCycleFromMetadata =
metadata.billing_cycle && metadata.billing_cycle !== "" ? metadata.billing_cycle : null;
const billingCycle = billingCycleFromMetadata || invoiceSubscription.billing_cycle || "QUARTERLY";
nextBillingDate = calculateNextBillingDateFromAnchor(billingCycle);
subscriptionBillingCycle = billingCycle as any;
this.logger.log(
`Initial subscription: using billing cycle ${billingCycle}, next_billing_date=${nextBillingDate.toISOString()}`,
);
}
// Build update data
const updateData: any = {
package_id: packageId,
license_count: licenseCount,
licenses_available: licensesAvailable,
licenses_consumed: licensesConsumed,
status: "ACTIVE",
is_current: true,
subscription_type: "INITIAL",
next_billing_date: nextBillingDate,
};
if (subscriptionBillingCycle) {
updateData.billing_cycle = subscriptionBillingCycle;
}
// For initial subscriptions, update the existing subscription (created before payment)
await this.prisma.client.subscription.update({
where: { id: invoiceSubscription.id },
data: updateData,
});
// Update invoice with payment info
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: InvoiceStatus.PAID,
stripe_payment_intent_id: paymentIntentId,
stripe_checkout_session_id: session.id,
paid_date: new Date(),
...checkoutPaidInvoiceExtras,
},
});
this.logger.log(
`Initial subscription completed: companyId=${companyId}, invoiceId=${invoiceId}, subscriptionId=${invoiceSubscription.id}, next_billing_date=${nextBillingDate.toISOString()}`,
);
await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, invoiceSubscription.id, {
metadataSource: "webhook_checkout",
});
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
return; // Exit early after processing initial subscription
}
} else {
// For subscription changes (UPGRADE/DOWNGRADE), we must have a current subscription
if (!currentSubscription) {
this.logger.error(
`Cannot process upgrade/downgrade without existing subscription. Company ID: ${companyId}`,
);
// Fall through to regular invoice update
} else {
// Portal confirm flow always sends should_update_existing "false" so paid changes get a new local row
// and syncStripeSubscriptionForLocalSubscription replaces Stripe subscription(s) at the new amount.
const shouldUpdateExisting = metadata.should_update_existing === "true";
const isBillingCycleChange = metadata.is_billing_cycle_change === "true";
const billingCycle = metadata.billing_cycle || currentSubscription?.billing_cycle || "QUARTERLY";
const isLicenseIncreaseOnly =
currentSubscription.package_id === packageId &&
(currentSubscription.billing_cycle || "QUARTERLY") === (billingCycle || "QUARTERLY") &&
currentSubscription.license_count < licenseCount;
// Calculate next billing date based on billing cycle
// For billing cycle changes, calculate from existing next_billing_date, not payment date
const baseDateForNextBilling =
isBillingCycleChange && currentSubscription?.next_billing_date
? currentSubscription.next_billing_date
: undefined; // Use now for plan changes
const nextBillingDate = calculateNextBillingDateFromAnchor(billingCycle, baseDateForNextBilling);
if (shouldUpdateExisting) {
// Legacy / manual metadata only: update one local row in place (portal does not use this).
// License increase only (same plan + same billing cycle): UPDATE existing subscription
// This is an amendment to the current subscription period
await this.prisma.client.subscription.update({
where: { id: currentSubscription.id },
data: {
license_count: licenseCount,
licenses_available: licensesAvailable,
next_billing_date: nextBillingDate,
// billing_cycle stays the same (not changing)
},
});
// Update invoice with payment info
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: InvoiceStatus.PAID,
stripe_payment_intent_id: paymentIntentId,
stripe_checkout_session_id: session.id,
paid_date: new Date(),
subscription_id: currentSubscription.id,
...checkoutPaidInvoiceExtras,
},
});
this.logger.log(`=== LICENSE INCREASE UPDATE COMPLETED ===`);
this.logger.log(`Company ID: ${companyId}`);
this.logger.log(`Invoice ID: ${invoiceId}`);
this.logger.log(`Subscription ID: ${currentSubscription.id} (updated)`);
this.logger.log(`License count updated: ${currentSubscription.license_count} → ${licenseCount}`);
this.logger.log(`Next billing date: ${nextBillingDate.toISOString()}`);
await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, currentSubscription.id, {
metadataSource: "webhook_checkout",
});
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
return; // Exit early after processing subscription update
} else {
// Same-plan license bump, plan change, or billing-cycle change: new local row + Stripe replace via sync.
await this.prisma.client.subscription.update({
where: { id: currentSubscription.id },
data: this.dataForSubscriptionRowSupersededByPaidChange(),
});
const subscriptionType = invoiceType === "DOWNGRADE" ? "DOWNGRADE" : "UPGRADE";
const newSubscription = await this.prisma.client.subscription.create({
data: {
company_id: companyId,
package_id: packageId,
license_count: licenseCount,
licenses_available: licensesAvailable,
licenses_consumed: licensesConsumed,
status: "ACTIVE",
is_current: true,
subscription_type: subscriptionType,
billing_cycle: billingCycle as any,
start_date: new Date(),
end_date: nextBillingDate,
next_billing_date: nextBillingDate,
previous_subscription_id: currentSubscription.id,
},
});
// Update invoice with payment info and new subscription
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: InvoiceStatus.PAID,
stripe_payment_intent_id: paymentIntentId,
stripe_checkout_session_id: session.id,
paid_date: new Date(),
subscription_id: newSubscription.id,
...checkoutPaidInvoiceExtras,
},
});
this.logger.log(`=== NEW SUBSCRIPTION CREATED ===`);
this.logger.log(`Company ID: ${companyId}`);
this.logger.log(`Invoice ID: ${invoiceId}`);
this.logger.log(`New Subscription ID: ${newSubscription.id}`);
this.logger.log(`Old Subscription ID: ${currentSubscription.id}`);
const changeTypeLabel = isLicenseIncreaseOnly
? "License increase (same plan)"
: isBillingCycleChange
? "Billing cycle change"
: "Plan / package change";
this.logger.log(`Change type: ${changeTypeLabel}`);
this.logger.log(`Old subscription is_current set to: false`);
this.logger.log(`New subscription is_current set to: true`);
this.logger.log(`Billing cycle: ${billingCycle}`);
this.logger.log(`Next billing date: ${nextBillingDate.toISOString()}`);
await this.subscriptionWriter.syncStripeSubscriptionForLocalSubscription(companyId, newSubscription.id, {
metadataSource: "webhook_checkout",
});
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(companyId);
return; // Exit early after processing subscription change
}
}
}
// If subscription change processing failed, fall through to regular invoice update
}
}
}
// Regular invoice payment (not subscription change or subscription change processing failed)
this.logger.log(`=== PROCESSING REGULAR INVOICE PAYMENT ===`);
this.logger.log(`Invoice ID: ${invoiceId}`);
await this.prisma.client.invoice.update({
where: { id: invoiceId },
data: {
status: InvoiceStatus.PAID,
stripe_payment_intent_id: paymentIntentId,
stripe_checkout_session_id: session.id,
paid_date: new Date(),
...checkoutPaidInvoiceExtras,
},
});
this.logger.log(`=== INVOICE ${invoiceId} MARKED AS PAID ===`);
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(invoice.company_id);
}
/**
* Handle successful payment
*/
async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
this.logger.log(`=== PAYMENT SUCCEEDED PROCESSING ===`);
this.logger.log(`Payment Intent ID: ${paymentIntent.id}`);
this.logger.log(`Amount: ${paymentIntent.amount} cents ($${(paymentIntent.amount / 100).toFixed(2)})`);
this.logger.log(`Status: ${paymentIntent.status}`);
this.logger.log(`Metadata: ${JSON.stringify(paymentIntent.metadata)}`);
this.logger.log(
`[stripe-link-debug] handlePaymentSucceeded stripeConfigured=${this.stripeService.isConfigured()} invoiceField=${
(paymentIntent as any)?.invoice ? "present" : "missing"
}`,
);
// Check if this payment intent is from a checkout session
let checkoutSessionMetadata = null;
if ((paymentIntent as any).checkout_session) {
try {
this.logger.log(`Payment intent has checkout_session: ${(paymentIntent as any).checkout_session}`);
const checkoutSession = await this.stripeService.retrieveCheckoutSession(
(paymentIntent as any).checkout_session,
);
checkoutSessionMetadata = checkoutSession.metadata;
this.logger.log(`Retrieved checkout session metadata: ${JSON.stringify(checkoutSessionMetadata)}`);
} catch (error) {
this.logger.warn(
`Failed to retrieve checkout session: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Find invoice by payment intent ID
let invoice = await this.prisma.client.invoice.findFirst({
where: { stripe_payment_intent_id: paymentIntent.id },
include: { company: true },
});
this.logger.log(
`Invoice lookup by payment_intent_id: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
);
// If not found by payment_intent_id, try to find by metadata (for subscription changes)
if (!invoice && paymentIntent.metadata) {
const metadata = paymentIntent.metadata as Stripe.Metadata & { invoice_id?: string };
this.logger.log(`Checking payment intent metadata for invoice_id: ${metadata.invoice_id}`);
if (metadata.invoice_id) {
const invoiceId = parseInt(metadata.invoice_id, 10);
this.logger.log(`Parsed invoice ID from payment intent: ${invoiceId}`);
if (!isNaN(invoiceId)) {
invoice = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
include: { company: true },
});
this.logger.log(
`Invoice lookup by payment intent metadata: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
);
}
}
}
// If still not found, try checkout session metadata
if (!invoice && checkoutSessionMetadata) {
const metadata = checkoutSessionMetadata as { invoice_id?: string };
this.logger.log(`Checking checkout session metadata for invoice_id: ${metadata.invoice_id}`);
if (metadata.invoice_id) {
const invoiceId = parseInt(metadata.invoice_id, 10);
this.logger.log(`Parsed invoice ID from checkout session: ${invoiceId}`);
if (!isNaN(invoiceId)) {
invoice = await this.prisma.client.invoice.findUnique({
where: { id: invoiceId },
include: { company: true },
});
this.logger.log(
`Invoice lookup by checkout session metadata: ${invoice ? `Found invoice ${invoice.id}` : "Not found"}`,
);
}
}
}
if (!invoice) {
this.logger.warn(`No invoice found for payment intent ${paymentIntent.id} - checking for recent invoices`);
// If Stripe associates the payment intent with an invoice (common for subscription renewals),
// fetch that Stripe invoice and run the normal invoice.paid reconciliation.
// This avoids relying on our DB linking/pending-invoice heuristics.
const piAny = paymentIntent as any;
const piInvoiceRef = piAny.invoice ?? piAny.invoice_id ?? null;
const stripeInvoiceId =
typeof piInvoiceRef === "string"
? piInvoiceRef
: piInvoiceRef && typeof piInvoiceRef === "object" && typeof piInvoiceRef.id === "string"
? piInvoiceRef.id
: null;
// Some webhook payloads do not include `invoice` on the PI object.
// If we have Stripe credentials, re-fetch the PI with `invoice` expanded so we can reconcile.
let stripeInvoiceIdResolved: string | null = stripeInvoiceId;
if (!stripeInvoiceIdResolved && this.stripeService.isConfigured()) {
try {
const piExpanded = await this.stripeService.retrievePaymentIntent(paymentIntent.id, ["invoice"]);
const piExpAny = piExpanded as any;
const invRef = piExpAny.invoice ?? piExpAny.invoice_id ?? null;
stripeInvoiceIdResolved =
typeof invRef === "string"
? invRef
: invRef && typeof invRef === "object" && typeof invRef.id === "string"
? invRef.id
: null;
if (stripeInvoiceIdResolved) {
this.logger.warn(
`[stripe-renewal-debug] handlePaymentSucceeded: resolved invoice by re-fetching PI ${paymentIntent.id} -> ${stripeInvoiceIdResolved}`,
);
} else {
this.logger.warn(
`[stripe-link-debug] handlePaymentSucceeded: re-fetched PI ${paymentIntent.id} but PI still has no invoice reference`,
);
}
} catch (e) {
this.logger.warn(
`[stripe-renewal-debug] handlePaymentSucceeded: failed re-fetching PI ${paymentIntent.id} for invoice expansion: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
if (!stripeInvoiceIdResolved && !this.stripeService.isConfigured()) {
this.logger.warn(
`[stripe-link-debug] handlePaymentSucceeded: Stripe not configured, cannot resolve invoice from payment_intent ${paymentIntent.id}`,
);
}
if (stripeInvoiceIdResolved && this.stripeService.isConfigured()) {
try {
this.logger.warn(
`[stripe-renewal-debug] handlePaymentSucceeded: resolving invoice from payment_intent.invoice ${stripeInvoiceIdResolved}`,
);
const stripeInv = await this.stripeService.getInvoiceExpanded(stripeInvoiceIdResolved);
await this.invoiceWebhooks.handleInvoicePaid(stripeInv);
return;
} catch (e) {
this.logger.warn(
`[stripe-renewal-debug] handlePaymentSucceeded: failed resolving invoice from payment_intent.invoice ${stripeInvoiceIdResolved}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
// For signup payments, the invoice might not be linked yet due to race condition
// Try to find invoices created recently for the same customer
const metadata = paymentIntent.metadata as Stripe.Metadata & {
customer_email?: string;
signup_flow?: string;
};
if (metadata.customer_email && metadata.signup_flow === "recallassess") {
const recentInvoices = await this.prisma.client.invoice.findMany({
where: {
company: {
email: metadata.customer_email,
},
status: "PENDING",
created_at: {
gte: new Date(Date.now() - 5 * 60 * 1000), // Last 5 minutes
},
},
include: { company: true },
orderBy: { created_at: "desc" },
take: 5,
});
this.logger.log(
`Found ${recentInvoices.length} recent pending invoices for customer ${metadata.customer_email}`,
);
if (recentInvoices.length > 0) {
// Use the most recent invoice
invoice = recentInvoices[0];
this.logger.log(`Using recent invoice ${invoice.id} (created ${invoice.created_at})`);
// Update the invoice to link it to this payment intent
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: { stripe_payment_intent_id: paymentIntent.id },
});
this.logger.log(`Linked payment intent ${paymentIntent.id} to invoice ${invoice.id}`);
}
}
if (!invoice) {
// For subscription renewals, Stripe's PI payload often has no invoice reference and no metadata.
// In that case `invoice.paid` is the authoritative event and will reconcile correctly.
// Keep as WARN to avoid noisy false-positive errors.
this.logger.warn(`Still no invoice found for payment intent ${paymentIntent.id} (will rely on invoice.paid)`);
this.logger.warn(`Payment metadata: ${JSON.stringify(paymentIntent.metadata)}`);
this.logger.warn(
`[stripe-link-debug] handlePaymentSucceeded: mapping failed (no local invoice row found via stripe_payment_intent_id/metadata/checkout).`,
);
return;
}
}
if (invoice) {
this.logger.log(`Found invoice ${invoice.id} with status ${invoice.status}`);
this.logger.log(`Company: ${invoice.company?.name} (${invoice.company_id})`);
const latestChargeId =
typeof paymentIntent.latest_charge === "string"
? paymentIntent.latest_charge
: (paymentIntent.latest_charge?.id ?? null);
this.logger.log(`Updating invoice ${invoice.id} to PAID status`);
this.logger.log(`Charge ID: ${latestChargeId}`);
try {
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: {
status: InvoiceStatus.PAID,
stripe_payment_intent_id: paymentIntent.id,
stripe_charge_id: latestChargeId,
paid_date: new Date(),
},
});
this.logger.log(`✅ Invoice ${invoice.id} successfully updated to PAID`);
this.invoicePdfService.scheduleWarmInvoicePdf(invoice.id);
} catch (updateError) {
this.logger.error(
`❌ Failed to update invoice ${invoice.id}: ${updateError instanceof Error ? updateError.message : String(updateError)}`,
);
throw updateError;
}
// Check if this is a subscription change that needs processing
const paymentMetadata = paymentIntent.metadata as Stripe.Metadata & {
invoice_id?: string;
company_id?: string;
package_id?: string;
license_count?: string;
is_new_signup?: string;
};
const isSubscriptionChange = !!(paymentMetadata.package_id && paymentMetadata.license_count);
// Portal plan changes are reconciled in checkout.session.completed; avoid duplicate local rows here.
if (isSubscriptionChange && checkoutSessionMetadata) {
this.logger.log(
`Skipping subscription change in payment_intent.succeeded for invoice ${invoice.id}; checkout.session.completed owns portal upgrades.`,
);
} else if (isSubscriptionChange && invoice.status === InvoiceStatus.PENDING) {
// This is a subscription change - process it
const companyId = parseInt(paymentMetadata.company_id ?? invoice.company_id.toString(), 10);
const packageIdRaw = paymentMetadata.package_id;
const licenseCountRaw = paymentMetadata.license_count;
if (!packageIdRaw || !licenseCountRaw) {
this.logger.error("Missing package_id or license_count in payment intent metadata");
} else {
const packageId = parseInt(packageIdRaw, 10);
const licenseCount = parseInt(licenseCountRaw, 10);
if (!isNaN(packageId) && !isNaN(licenseCount)) {
// Get current subscription
const currentSubscription = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
},
});
if (currentSubscription && currentSubscription.id !== invoice.subscription_id) {
// Mark old subscription as not current
await this.prisma.client.subscription.update({
where: { id: currentSubscription.id },
data: this.dataForSubscriptionRowSupersededByPaidChange(),
});
// Create new subscription
const licensesConsumed = currentSubscription.licenses_consumed ?? 0;
const licensesAvailable = Math.max(0, licenseCount - licensesConsumed);
const newSubscription = await this.prisma.client.subscription.create({
data: {
company_id: companyId,
package_id: packageId,
license_count: licenseCount,
licenses_available: licensesAvailable,
licenses_consumed: licensesConsumed,
status: "ACTIVE",
is_current: true,
subscription_type: "UPGRADE",
start_date: new Date(),
end_date: currentSubscription.next_billing_date ?? undefined,
},
});
// Update invoice with new subscription
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: {
subscription_id: newSubscription.id,
},
});
this.logger.log(
`Subscription change completed via payment_intent: invoiceId=${invoice.id}, newSubscriptionId=${newSubscription.id}`,
);
}
}
}
}
this.logger.log(
`Invoice ${invoice.id} marked as PAID via payment_intent.succeeded. Subscription ${invoice.subscription_id} is active.`,
);
// Check if this is a new signup (no company exists yet)
// For new signups, metadata will contain is_new_signup flag
const isNewSignup = paymentMetadata["is_new_signup"] === "true";
if (isNewSignup && !invoice.company_id) {
this.logger.log("New signup detected - account creation will be handled by frontend after redirect");
// Account creation will be handled by the frontend after payment success
// The frontend will call POST /api/account/create-from-payment endpoint
}
if (invoice.company_id) {
await this.billing.clearCompanySubscriptionExpiryWhenRenewalIsAhead(invoice.company_id);
}
}
}
private getBillingCycleMonths(billingCycle: string | null | undefined): number {
if (!billingCycle) {
return 3;
}
const normalized = billingCycle.toUpperCase();
if (normalized === "ANNUAL") return 12;
if (normalized === "HALF_YEARLY") return 6;
return 3;
}
private addMonthsSafe(date: Date, months: number): Date {
const d = new Date(date.getTime());
d.setMonth(d.getMonth() + months);
return d;
}
private isTrialLikeLocalPackage(pkg: { is_trial_package: boolean; package_type: PackageType }): boolean {
return (
pkg.is_trial_package ||
pkg.package_type === PackageType.FREE_TRIAL ||
pkg.package_type === PackageType.PRIVATE_VIP_TRIAL
);
}
/**
* First paid period after Stripe trialing ({@code billing_reason=subscription_cycle}): end the trial row as
* non-current (history only — do not rewrite its plan/dates/type) and create a new **STARTUP** current row
* linked to the same Stripe subscription. Skips the generic renewal rotation (would add a third row).
*/
private async trySupersedeTrialWithStartupSubscriptionRow(params: {
companyId: number;
oldSubscription: {
id: number;
license_count: number;
billing_cycle: BillingCycle | null;
next_billing_date: Date | null;
licenses_available: number;
licenses_consumed: number;
licenses_expired: number;
last_license_assignment: Date | null;
last_license_release: Date | null;
user_id_created_by: number | null;
user_id_updated_by: number | null;
package: { id: number; is_trial_package: boolean; package_type: PackageType };
};
stripeInvoice: Pick<
Stripe.Invoice,
"billing_reason" | "amount_paid" | "lines" | "period_start" | "period_end"
>;
stripeSub: Stripe.Subscription;
stripeSubscriptionId: string;
}): Promise<{ applied: boolean; newSubscriptionId: number | null }> {
const pkg = params.oldSubscription.package;
if (!this.isTrialLikeLocalPackage(pkg)) {
return { applied: false, newSubscriptionId: null };
}
const paid =
typeof params.stripeInvoice.amount_paid === "number" && params.stripeInvoice.amount_paid > 0;
if (!paid) {
return { applied: false, newSubscriptionId: null };
}
if (params.stripeInvoice.billing_reason !== "subscription_cycle") {
return { applied: false, newSubscriptionId: null };
}
const startup = await this.prisma.client.package.findFirst({
where: {
package_type: PackageType.STARTUP,
is_active: true,
OR: [{ special_slug: null }, { special_slug: "" }],
},
select: { id: true },
});
if (!startup) {
this.logger.error(
`trySupersedeTrialWithStartupSubscriptionRow: no active STARTUP package (company ${params.companyId})`,
);
return { applied: false, newSubscriptionId: null };
}
if (pkg.package_type === PackageType.STARTUP || pkg.id === startup.id) {
return { applied: false, newSubscriptionId: null };
}
const derived = this.billing.derivePeriodsFromStripeInvoice(params.stripeInvoice as Stripe.Invoice);
let nextBilling: Date | null = derived.periodEnd;
const subPeriod = params.stripeSub as StripeSubscriptionApi;
const cpe = subPeriod.current_period_end;
if (!nextBilling && typeof cpe === "number") {
nextBilling = new Date(cpe * 1000);
}
if (!nextBilling) {
nextBilling = params.oldSubscription.next_billing_date;
}
if (!nextBilling || Number.isNaN(nextBilling.getTime())) {
this.logger.warn(
`trySupersedeTrialWithStartupSubscriptionRow: could not resolve next_billing_date for company ${params.companyId} (old sub ${params.oldSubscription.id})`,
);
return { applied: false, newSubscriptionId: null };
}
const stripeCancelAtPeriodEnd = localStripeCancelAtPeriodEndFromSubscription(params.stripeSub);
const oldId = params.oldSubscription.id;
const newId = await this.prisma.client.$transaction(async (tx) => {
await tx.subscription.updateMany({
where: { company_id: params.companyId, id: { not: oldId } },
data: { is_current: false },
});
await tx.subscription.update({
where: { id: oldId },
data: {
is_current: false,
status: SubscriptionStatus.CANCELLED,
end_date: new Date(),
stripe_subscription_id: null,
},
});
const created = await tx.subscription.create({
data: {
company_id: params.companyId,
package_id: startup.id,
previous_subscription_id: oldId,
license_count: params.oldSubscription.license_count,
licenses_available: params.oldSubscription.licenses_available,
licenses_consumed: params.oldSubscription.licenses_consumed,
licenses_expired: params.oldSubscription.licenses_expired,
last_license_assignment: params.oldSubscription.last_license_assignment ?? undefined,
last_license_release: params.oldSubscription.last_license_release ?? undefined,
user_id_created_by: params.oldSubscription.user_id_created_by ?? undefined,
user_id_updated_by: params.oldSubscription.user_id_updated_by ?? undefined,
status: SubscriptionStatus.ACTIVE,
billing_cycle: params.oldSubscription.billing_cycle ?? BillingCycle.QUARTERLY,
start_date: new Date(),
end_date: nextBilling,
next_billing_date: nextBilling,
stripe_subscription_id: params.stripeSubscriptionId,
stripe_cancel_at_period_end: stripeCancelAtPeriodEnd,
is_current: true,
subscription_type: SubscriptionType.UPGRADE,
},
});
await tx.company.update({
where: { id: params.companyId },
data: {
plan: SubscriptionPlan.STARTUP,
trial_start_date: null,
trial_end_date: null,
},
});
return created.id;
});
this.logger.log(
`Trial graduation: company ${params.companyId} superseded subscription ${oldId} with new STARTUP row ${newId} (stripe billing_reason=subscription_cycle)`,
);
return { applied: true, newSubscriptionId: newId };
}
/**
* Handle failed payment
*/
async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
this.logger.log(`Payment failed: ${paymentIntent.id}`);
// Find invoice by payment intent ID
const invoice = await this.prisma.client.invoice.findUnique({
where: { stripe_payment_intent_id: paymentIntent.id },
});
if (invoice) {
await this.prisma.client.invoice.update({
where: { id: invoice.id },
data: {
status: InvoiceStatus.FAILED,
failure_code: paymentIntent.last_payment_error?.code,
failure_message: paymentIntent.last_payment_error?.message,
},
});
this.logger.log(`Invoice ${invoice.id} marked as FAILED`);
}
}
}