apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe.service.ts
constructor(stripeConfig: StripeConfig)
|
||||||
|
Parameters :
|
| Async attachPaymentMethodToCustomer |
attachPaymentMethodToCustomer(paymentMethodId: string, customerId: string)
|
|
Attach a payment method to a customer (safe to call if already attached). Needed when a PaymentIntent succeeds but does not persist the card as a saved PM.
Returns :
Promise<void>
|
| Async attemptPayLatestSubscriptionInvoice | ||||||
attemptPayLatestSubscriptionInvoice(subscriptionId: string)
|
||||||
|
Attempt to pay the latest invoice of a subscription now. Finalizes draft invoices, passes the customer's invoice default payment method, and uses off_session.
Parameters :
Returns :
Promise<literal type>
|
| Async cancelCustomerBillableSubscriptions | ||||||
cancelCustomerBillableSubscriptions(customerId: string)
|
||||||
|
Parameters :
Returns :
Promise<string[]>
|
| Async createCheckoutSession | ||||||
createCheckoutSession(params: literal type)
|
||||||
|
Create a Checkout Session for participant payment All payments go to RecallAssess's single Stripe account
Parameters :
Returns :
Promise<Stripe.Checkout.Session>
|
| Async createCustomer | ||||||
createCustomer(params: literal type)
|
||||||
|
Create a Stripe customer so we can attach subscriptions and payment methods later.
Parameters :
Returns :
Promise<Stripe.Customer>
|
| Async createCustomerSubscription | ||||||
createCustomerSubscription(params: literal type)
|
||||||
|
Create a Stripe subscription for a customer using inline price_data. This is used to bootstrap renewal in Stripe when local subscription exists but no Stripe subscription record is present.
Parameters :
Returns :
Promise<Stripe.Subscription>
|
| Async createFinalizeAndMarkInvoicePaidOutOfBand | ||||||
createFinalizeAndMarkInvoicePaidOutOfBand(params: literal type)
|
||||||
|
Create a Stripe invoice for reporting/records and mark it paid out-of-band (no additional charge). Use when a separate PaymentIntent already charged the customer but you still want a Stripe Invoice with the correct amount in Billing → Invoices.
Parameters :
Returns :
Promise<Stripe.Invoice>
|
| Async createFinalizeAndPayOneOffInvoice | ||||||
createFinalizeAndPayOneOffInvoice(params: literal type)
|
||||||
|
One-off invoice for a fixed USD amount, finalized and paid off-session with a specific payment method. Used for expired recovery “immediate charge” without creating a subscription invoice first (avoids SetupIntent-only flows).
Parameters :
Returns :
Promise<Stripe.Invoice>
|
| Async createPaymentIntent | ||||||
createPaymentIntent(params: literal type)
|
||||||
|
Create a Payment Intent (for custom payment flows)
Parameters :
Returns :
Promise<Stripe.PaymentIntent>
|
| Private Async createRecallAssessRecurringPrice | |||||||||
createRecallAssessRecurringPrice(billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL", unitAmountCents: number)
|
|||||||||
|
Parameters :
Returns :
Promise<Stripe.Price>
|
| Async createSetupIntent | ||||||
createSetupIntent(params: literal type)
|
||||||
|
Create a Setup Intent (for saving payment methods without charging) Used for free trials where we want to collect card details but charge later
Parameters :
Returns :
Promise<Stripe.SetupIntent>
|
| Async createTrialingSubscription | ||||||
createTrialingSubscription(params: literal type)
|
||||||
|
Recurring subscription in
Parameters :
Returns :
Promise<Stripe.Subscription>
|
| Async customerExists | ||||||
customerExists(customerId: string)
|
||||||
|
Checks whether a Stripe customer id exists in the currently configured Stripe account/mode.
Parameters :
Returns :
Promise<boolean>
|
| Async ensureDefaultPaymentMethodFromSavedCards | ||||||
ensureDefaultPaymentMethodFromSavedCards(customerId: string)
|
||||||
|
If the customer has saved card payment methods but no invoice default, set the newest card as Stripe.Customer.invoice_settings.default_payment_method so subscriptions can charge.
Parameters :
Returns :
Promise<boolean>
true when a default was already set or was just assigned |
| Async getCustomerDefaultPaymentMethod | |||||||||
getCustomerDefaultPaymentMethod(customerId: string, companyContext?: literal type)
|
|||||||||
|
Retrieve default payment method details for a Stripe customer. Lookup order (each branch logged so missing-card issues are debuggable):
NEVER throws. Top-level failures (network/auth) are logged and return nulls so the overview endpoint stays up — frontend treats nulls as "no card on file" which renders the "Add payment method" CTA.
Parameters :
Returns :
Promise<literal type>
|
| Async getCustomerInvoiceSettings | ||||||
getCustomerInvoiceSettings(customerId: string)
|
||||||
|
Get customer invoice settings (collection method for auto payment) Note: collection_method is subscription-scoped, so we check active subscriptions
Parameters :
Returns :
Promise<literal type>
|
| getDashboardAccountMode |
getDashboardAccountMode()
|
|
Matches StripeConfig.getDashboardAccountMode — for admin dashboard links.
Returns :
"test" | "live"
|
| Async getInvoice | ||||||
getInvoice(invoiceId: string)
|
||||||
|
Retrieve invoice by ID
Parameters :
Returns :
Promise<Stripe.Invoice>
|
| Async getInvoiceDefaultPaymentMethodId | ||||||
getInvoiceDefaultPaymentMethodId(customerId: string)
|
||||||
|
Customer's invoice default payment method id (use after ensureDefaultPaymentMethodFromSavedCards).
Parameters :
Returns :
Promise<string | null>
|
| Async getInvoiceExpanded | ||||||
getInvoiceExpanded(invoiceId: string)
|
||||||
|
Same as Stripe CLI:
Parameters :
Returns :
Promise<Stripe.Invoice>
|
| Async getPaymentIntent | ||||||
getPaymentIntent(paymentIntentId: string)
|
||||||
|
Get payment intent by ID
Parameters :
Returns :
Promise<Stripe.PaymentIntent>
|
| getPublishableKey |
getPublishableKey()
|
|
Get publishable key (safe to expose to frontend)
Returns :
string
|
| Private getStripe |
getStripe()
|
|
Returns :
Stripe
|
| Private isBillableSubscriptionStatus | ||||||
isBillableSubscriptionStatus(status: Stripe.Subscription.Status)
|
||||||
|
Parameters :
Returns :
boolean
|
| isConfigured |
isConfigured()
|
|
Check if Stripe is properly configured
Returns :
boolean
|
| Private isRetryableStripeError | ||||||
isRetryableStripeError(error: unknown)
|
||||||
|
True for transient Stripe/network issues worth retrying after SDK-level retries exhaust.
Parameters :
Returns :
boolean
|
| Async listChargesForCustomer | ||||||
listChargesForCustomer(params: literal type)
|
||||||
|
List charges for a Stripe customer (paginates up to
Parameters :
Returns :
Promise<Stripe.Charge[]>
|
| Async listInvoicesForCustomer | ||||||
listInvoicesForCustomer(params: literal type)
|
||||||
|
List invoices for a Stripe customer (paginates up to
Parameters :
Returns :
Promise<Stripe.Invoice[]>
|
| Async listRefundsForCharge | ||||||
listRefundsForCharge(params: literal type)
|
||||||
|
Parameters :
Returns :
Promise<Stripe.Refund[]>
|
| Async listRefundsForPaymentIntent | ||||||
listRefundsForPaymentIntent(params: literal type)
|
||||||
|
Parameters :
Returns :
Promise<Stripe.Refund[]>
|
| Async listSubscriptionsForCustomer | |||||||||
listSubscriptionsForCustomer(customerId: string, statuses: Stripe.Subscription.Status[])
|
|||||||||
|
List subscriptions for a customer (one Stripe API call per status).
Parameters :
Returns :
Promise<Stripe.Subscription[]>
|
| Async markSubscriptionLatestInvoicePaidOutOfBand | ||||||
markSubscriptionLatestInvoicePaidOutOfBand(subscriptionId: string)
|
||||||
|
Paid signup funds are captured on a standalone PaymentIntent; createCustomerSubscription still creates a first-cycle invoice under {@code payment_behavior: default_incomplete}. Without this step that invoice stays “requires payment” even though the customer already paid. Close it via {@code paid_out_of_band} (no second charge — aligns Stripe Billing with the signup PI).
Parameters :
Returns :
Promise<void>
|
| Private normalizePlanBillingCycle | ||||||
normalizePlanBillingCycle(billingCycle: string)
|
||||||
|
Parameters :
Returns :
"QUARTERLY" | "HALF_YEARLY" | "ANNUAL"
|
| Async retrieveCharge | ||||||
retrieveCharge(chargeId: string)
|
||||||
|
Retrieve Charge (e.g. for receipt_url when PaymentIntent.latest_charge is only an id string)
Parameters :
Returns :
Promise<Stripe.Charge>
|
| Async retrieveCheckoutSession | |||||||||
retrieveCheckoutSession(sessionId: string, expand?: string[])
|
|||||||||
|
Retrieve Checkout Session
Parameters :
Returns :
Promise<Stripe.Checkout.Session>
|
| Async retrievePaymentIntent | |||||||||
retrievePaymentIntent(paymentIntentId: string, expand?: string[])
|
|||||||||
|
Retrieve Payment Intent
Parameters :
Returns :
Promise<Stripe.PaymentIntent>
|
| Async retrieveRefund | ||||||
retrieveRefund(refundId: string)
|
||||||
|
Retrieve Refund (useful for debugging/manual sync)
Parameters :
Returns :
Promise<Stripe.Refund>
|
| Async retrieveSetupIntent | ||||||
retrieveSetupIntent(setupIntentId: string)
|
||||||
|
Retrieve Setup Intent
Parameters :
Returns :
Promise<Stripe.SetupIntent>
|
| Async retrieveSubscription | ||||||
retrieveSubscription(subscriptionId: string)
|
||||||
|
Parameters :
Returns :
Promise<Stripe.Subscription>
|
| Async retrieveSubscriptionWithLatestInvoice | ||||||
retrieveSubscriptionWithLatestInvoice(subscriptionId: string)
|
||||||
|
Subscription with latest_invoice expanded (used after create when status may already be active).
Parameters :
Returns :
Promise<Stripe.Subscription>
|
| Async retrieveUpcomingInvoiceAmountForSubscription | ||||||
retrieveUpcomingInvoiceAmountForSubscription(subscriptionId: string)
|
||||||
|
Next invoice preview for a subscription (
Parameters :
Returns :
Promise<literal type>
|
| Private Async runWithStripeRetry |
runWithStripeRetry(label: string, fn: () => void)
|
Type parameters :
|
|
Extra retries for flaky networks (SDK already retries a few times).
Returns :
Promise<T>
|
| Async setCustomerDefaultPaymentMethod |
setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string)
|
|
Set default payment method for a customer
Returns :
Promise<Stripe.Customer>
|
| Async setSubscriptionCancelAtPeriodEnd |
setSubscriptionCancelAtPeriodEnd(subscriptionId: string, cancelAtPeriodEnd: boolean)
|
|
Schedule or clear end-of-period cancellation (Stripe Billing).
Returns :
Promise<Stripe.Subscription>
|
| Private stripeMinorToMajor |
stripeMinorToMajor(amountMinor: number, currencyLower: string)
|
|
Convert Stripe smallest-currency-unit amounts to major units (handles zero-decimal currencies).
Returns :
number
|
| Async updateCustomerSubscriptionsCollectionMethod |
updateCustomerSubscriptionsCollectionMethod(customerId: string, autoPaymentEnabled: boolean)
|
|
Update all active subscriptions for a customer's collection method
Returns :
Promise<Stripe.Subscription[]>
|
| Async updateSubscriptionBillingCycleAnchor |
updateSubscriptionBillingCycleAnchor(subscriptionId: string, billingCycleAnchorUnix: number)
|
|
Move Stripe subscription next invoice anchor to a target future date without proration. Useful after immediate recovery charge so next renewal aligns to local schedule.
Returns :
Promise<Stripe.Subscription>
|
| Async updateSubscriptionCollectionMethod |
updateSubscriptionCollectionMethod(subscriptionId: string, autoPaymentEnabled: boolean)
|
|
Update subscription collection method (enable/disable auto payment) Note: collection_method is set on subscriptions, not directly on customers
Returns :
Promise<Stripe.Subscription>
|
| verifyWebhookSignature | |||||||||
verifyWebhookSignature(payload: string | Buffer, signature: string)
|
|||||||||
|
Verify webhook signature
Parameters :
Returns :
Stripe.Event
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(StripeService.name)
|
| Private stripe |
Type : Stripe | undefined
|
import { Injectable, Logger } from "@nestjs/common";
import Stripe from "stripe";
import { StripeConfig } from "../../../../config/stripe.config";
@Injectable()
export class StripeService {
private readonly logger = new Logger(StripeService.name);
private stripe: Stripe | undefined;
constructor(private readonly stripeConfig: StripeConfig) {
if (!this.stripeConfig.isConfigured()) {
this.logger.warn("Stripe is not configured. Payment features will be disabled.");
} else {
// Note: apiVersion must match the types installed with `stripe` package
// If you update the Stripe API version, also update @stripe/stripe-node
// Using type assertion to support newer API version before types are updated
this.stripe = new Stripe(this.stripeConfig.secretKey, {
apiVersion: "2025-12-15.clover" as any,
typescript: true,
maxNetworkRetries: 5,
timeout: 120_000,
});
this.logger.log("Stripe service initialized successfully");
}
}
/**
* Create a Checkout Session for participant payment
* All payments go to RecallAssess's single Stripe account
*/
async createCheckoutSession(params: {
amount: number; // in cents
currency: string;
successUrl: string;
cancelUrl: string;
customerEmail?: string;
customerId?: string; // Stripe customer ID - if provided, payment method will be saved
metadata?: Record<string, string>;
lineItems?: Stripe.Checkout.SessionCreateParams.LineItem[];
}): Promise<Stripe.Checkout.Session> {
try {
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: "payment",
payment_method_types: ["card"],
line_items: params.lineItems || [
{
price_data: {
currency: params.currency,
product_data: {
name: "Recall Course License",
description: "Access to learning course",
},
unit_amount: params.amount,
},
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
// Note: payment_method_collection is only valid for subscription mode
// For one-time payments (mode: "payment"), payment method is automatically saved when customer ID is provided
};
// If customer ID is provided, attach to customer (saves payment method automatically)
if (params.customerId) {
sessionParams.customer = params.customerId;
// When customer is set, payment method is automatically saved for one-time payments
this.logger.log(`Checkout session will save payment method to customer: ${params.customerId}`);
} else if (params.customerEmail) {
// If no customer ID but email provided, Stripe will create/use customer by email
sessionParams.customer_email = params.customerEmail;
// Payment method will still be saved if customer exists
this.logger.log(`Checkout session will use customer email: ${params.customerEmail}`);
}
const session = await this.getStripe().checkout.sessions.create(sessionParams);
return session;
} catch (error) {
this.logger.error("Failed to create checkout session", error);
throw new Error("Failed to create payment session");
}
}
/**
* Create a Payment Intent (for custom payment flows)
*/
async createPaymentIntent(params: {
amount: number;
currency: string;
metadata?: Record<string, string>;
customerId?: string;
automaticPaymentMethods?: Stripe.PaymentIntentCreateParams.AutomaticPaymentMethods;
}): Promise<Stripe.PaymentIntent> {
try {
// Payment Intent should only be used for amounts > 0
// For $0 amounts, use Setup Intent instead (handled in stripe-payment.service.ts)
return await this.getStripe().paymentIntents.create({
amount: params.amount,
currency: params.currency,
metadata: params.metadata,
customer: params.customerId,
...(params.customerId
? {
// Required so the card can be reused as default for invoices/subscriptions after signup charge.
setup_future_usage: "off_session" as const,
}
: {}),
automatic_payment_methods: params.automaticPaymentMethods,
});
} catch (error) {
this.logger.error("Failed to create payment intent", error);
throw new Error("Failed to create payment intent");
}
}
/**
* Create a Setup Intent (for saving payment methods without charging)
* Used for free trials where we want to collect card details but charge later
*/
async createSetupIntent(params: {
customerId: string;
metadata?: Record<string, string>;
}): Promise<Stripe.SetupIntent> {
try {
return await this.getStripe().setupIntents.create({
customer: params.customerId,
payment_method_types: ["card"],
usage: "off_session", // Save for future use
metadata: params.metadata,
});
} catch (error) {
this.logger.error("Failed to create setup intent", error);
throw new Error("Failed to create setup intent");
}
}
/**
* Retrieve Setup Intent
*/
async retrieveSetupIntent(setupIntentId: string): Promise<Stripe.SetupIntent> {
try {
return await this.getStripe().setupIntents.retrieve(setupIntentId);
} catch (error) {
this.logger.error("Failed to retrieve setup intent", error);
throw new Error("Failed to retrieve setup intent");
}
}
/**
* Retrieve Payment Intent
*/
async retrievePaymentIntent(paymentIntentId: string, expand?: string[]): Promise<Stripe.PaymentIntent> {
try {
return await this.getStripe().paymentIntents.retrieve(
paymentIntentId,
expand?.length ? { expand } : undefined,
);
} catch (error) {
this.logger.error(`Failed to retrieve payment intent ${paymentIntentId}`, error);
throw new Error("Failed to retrieve payment");
}
}
/**
* Retrieve Charge (e.g. for receipt_url when PaymentIntent.latest_charge is only an id string)
*/
async retrieveCharge(chargeId: string): Promise<Stripe.Charge> {
try {
return await this.getStripe().charges.retrieve(chargeId);
} catch (error) {
this.logger.error(`Failed to retrieve charge ${chargeId}`, error);
throw new Error("Failed to retrieve charge");
}
}
/**
* Retrieve Refund (useful for debugging/manual sync)
*/
async retrieveRefund(refundId: string): Promise<Stripe.Refund> {
try {
return await this.getStripe().refunds.retrieve(refundId);
} catch (error) {
this.logger.error(`Failed to retrieve refund ${refundId}`, error);
throw new Error("Failed to retrieve refund");
}
}
/**
* List invoices for a Stripe customer (paginates up to `max`, newest first).
*/
async listInvoicesForCustomer(params: {
customerId: string;
max?: number;
status?: Stripe.Invoice.Status | "all";
}): Promise<Stripe.Invoice[]> {
const max = Math.max(1, Math.min(params.max ?? 200, 500));
return this.runWithStripeRetry(`listInvoicesForCustomer(${params.customerId})`, async () => {
const out: Stripe.Invoice[] = [];
let startingAfter: string | undefined;
while (out.length < max) {
const status = params.status;
const res = await this.getStripe().invoices.list({
customer: params.customerId,
...(status && status !== "all" ? { status } : {}),
limit: Math.min(100, max - out.length),
...(startingAfter ? { starting_after: startingAfter } : {}),
});
out.push(...res.data);
if (!res.has_more || res.data.length === 0) break;
startingAfter = res.data[res.data.length - 1]?.id;
if (!startingAfter) break;
}
return out;
});
}
/**
* List charges for a Stripe customer (paginates up to `max`, newest first).
*/
async listChargesForCustomer(params: { customerId: string; max?: number }): Promise<Stripe.Charge[]> {
const max = Math.max(1, Math.min(params.max ?? 200, 500));
return this.runWithStripeRetry(`listChargesForCustomer(${params.customerId})`, async () => {
const out: Stripe.Charge[] = [];
let startingAfter: string | undefined;
while (out.length < max) {
const res = await this.getStripe().charges.list({
customer: params.customerId,
limit: Math.min(100, max - out.length),
...(startingAfter ? { starting_after: startingAfter } : {}),
});
out.push(...res.data);
if (!res.has_more || res.data.length === 0) break;
startingAfter = res.data[res.data.length - 1]?.id;
if (!startingAfter) break;
}
return out;
});
}
async listRefundsForCharge(params: { chargeId: string; max?: number }): Promise<Stripe.Refund[]> {
const max = Math.max(1, Math.min(params.max ?? 100, 500));
return this.runWithStripeRetry(`listRefundsForCharge(${params.chargeId})`, async () => {
const out: Stripe.Refund[] = [];
let startingAfter: string | undefined;
while (out.length < max) {
const res = await this.getStripe().refunds.list({
charge: params.chargeId,
limit: Math.min(100, max - out.length),
...(startingAfter ? { starting_after: startingAfter } : {}),
});
out.push(...res.data);
if (!res.has_more || res.data.length === 0) break;
startingAfter = res.data[res.data.length - 1]?.id;
if (!startingAfter) break;
}
return out;
});
}
async listRefundsForPaymentIntent(params: { paymentIntentId: string; max?: number }): Promise<Stripe.Refund[]> {
const max = Math.max(1, Math.min(params.max ?? 100, 500));
return this.runWithStripeRetry(`listRefundsForPaymentIntent(${params.paymentIntentId})`, async () => {
const out: Stripe.Refund[] = [];
let startingAfter: string | undefined;
while (out.length < max) {
const res = await this.getStripe().refunds.list({
payment_intent: params.paymentIntentId,
limit: Math.min(100, max - out.length),
...(startingAfter ? { starting_after: startingAfter } : {}),
});
out.push(...res.data);
if (!res.has_more || res.data.length === 0) break;
startingAfter = res.data[res.data.length - 1]?.id;
if (!startingAfter) break;
}
return out;
});
}
/**
* Set default payment method for a customer
*/
async setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string): Promise<Stripe.Customer> {
try {
return await this.getStripe().customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
} as any,
});
} catch (error) {
this.logger.error(`Failed to set default payment method for customer ${customerId}`, error);
throw new Error("Failed to set default payment method");
}
}
/**
* Attach a payment method to a customer (safe to call if already attached).
* Needed when a PaymentIntent succeeds but does not persist the card as a saved PM.
*/
async attachPaymentMethodToCustomer(paymentMethodId: string, customerId: string): Promise<void> {
try {
await this.getStripe().paymentMethods.attach(paymentMethodId, { customer: customerId });
} catch (error) {
// Stripe throws when already attached to the same customer; treat that as OK.
const msg = error instanceof Error ? error.message : String(error);
if (/already attached/i.test(msg)) {
return;
}
this.logger.error(
`Failed to attach payment method ${paymentMethodId} to customer ${customerId}`,
error,
);
throw new Error("Failed to attach payment method");
}
}
/**
* If the customer has saved card payment methods but no invoice default, set the newest card
* as {@link Stripe.Customer.invoice_settings.default_payment_method} so subscriptions can charge.
* @returns true when a default was already set or was just assigned
*/
async ensureDefaultPaymentMethodFromSavedCards(customerId: string): Promise<boolean> {
return this.runWithStripeRetry(`ensureDefaultPaymentMethodFromSavedCards(${customerId})`, async () => {
const customer = await this.getStripe().customers.retrieve(customerId, {
expand: ["invoice_settings.default_payment_method"],
});
if ("deleted" in customer && customer.deleted) {
return false;
}
const inv = customer.invoice_settings?.default_payment_method;
if (inv && (typeof inv === "string" || inv.object === "payment_method")) {
return true;
}
const pms = await this.getStripe().paymentMethods.list({
customer: customerId,
type: "card",
limit: 100,
});
if (pms.data.length === 0) {
return false;
}
const newest = [...pms.data].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))[0];
if (!newest) {
return false;
}
await this.getStripe().customers.update(customerId, {
invoice_settings: {
default_payment_method: newest.id,
},
});
this.logger.log(
`Set invoice default payment method for customer ${customerId} to saved card ${newest.id} (newest by created time)`,
);
return true;
});
}
/**
* Customer's invoice default payment method id (use after {@link ensureDefaultPaymentMethodFromSavedCards}).
*/
async getInvoiceDefaultPaymentMethodId(customerId: string): Promise<string | null> {
return this.runWithStripeRetry(`getInvoiceDefaultPaymentMethodId(${customerId})`, async () => {
const customer = await this.getStripe().customers.retrieve(customerId, {
expand: ["invoice_settings.default_payment_method"],
});
if ("deleted" in customer && customer.deleted) {
return null;
}
const inv = customer.invoice_settings?.default_payment_method;
if (typeof inv === "string") {
return inv;
}
if (inv && inv.object === "payment_method") {
return inv.id;
}
return null;
});
}
/**
* Retrieve Checkout Session
*/
async retrieveCheckoutSession(sessionId: string, expand?: string[]): Promise<Stripe.Checkout.Session> {
try {
return await this.getStripe().checkout.sessions.retrieve(sessionId, expand?.length ? { expand } : undefined);
} catch (error) {
this.logger.error(`Failed to retrieve checkout session ${sessionId}`, error);
throw new Error("Failed to retrieve checkout session");
}
}
/**
* Verify webhook signature
*/
verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
const secrets = this.stripeConfig.getWebhookSecrets();
if (secrets.length === 0) {
throw new Error("No STRIPE_WEBHOOK_SECRET configured");
}
let lastError: unknown;
for (const secret of secrets) {
try {
return this.getStripe().webhooks.constructEvent(payload, signature, secret);
} catch (error) {
lastError = error;
}
}
this.logger.error(
"Webhook signature verification failed for all configured secrets. If using Stripe CLI, append the current `whsec_` from `stripe listen` to STRIPE_WEBHOOK_SECRET (comma-separated).",
lastError,
);
throw new Error("Invalid webhook signature");
}
/**
* Check if Stripe is properly configured
*/
isConfigured(): boolean {
return this.stripeConfig.isConfigured();
}
/** Matches {@link StripeConfig.getDashboardAccountMode} — for admin dashboard links. */
getDashboardAccountMode(): "test" | "live" {
return this.stripeConfig.getDashboardAccountMode();
}
/**
* Get publishable key (safe to expose to frontend)
*/
getPublishableKey(): string {
return this.stripeConfig.publishableKey;
}
/**
* Get payment intent by ID
*/
async getPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
try {
return await this.getStripe().paymentIntents.retrieve(paymentIntentId);
} catch (error) {
this.logger.error(`Failed to retrieve payment intent ${paymentIntentId}`, error);
throw new Error("Failed to retrieve payment intent");
}
}
/**
* Retrieve default payment method details for a Stripe customer.
*
* Lookup order (each branch logged so missing-card issues are debuggable):
* 1. customer.invoice_settings.default_payment_method (the canonical default)
* 2. Default PM on any active/trialing subscription for this customer
* 3. Most recently attached card on the customer (catches Portal-added cards
* that haven't propagated to invoice_settings yet)
* 4. ORPHAN-CUSTOMER FALLBACK: if `companyEmail` and/or `companyId` provided,
* search Stripe customers by email — pick the one whose metadata.company_id
* matches AND has a card. Catches the case where a previous Stripe customer
* record was orphaned (e.g. duplicate created) and the card lives there.
* 5. Self-heal: if a card is found but not set as customer default, set it now
* so subsequent retrievals are fast and the next charge cycle works.
*
* NEVER throws. Top-level failures (network/auth) are logged and return nulls
* so the overview endpoint stays up — frontend treats nulls as "no card on file"
* which renders the "Add payment method" CTA.
*/
async getCustomerDefaultPaymentMethod(
customerId: string,
companyContext?: { companyId?: number | null; email?: string | null },
): Promise<{
last4: string | null;
brand: string | null;
exp_month: number | null;
exp_year: number | null;
/** Effective customer ID where the card was actually found — may differ from input customerId for orphan-recovery cases. Caller can persist this to fix company.stripe_customer_id. */
effective_customer_id?: string | null;
}> {
const empty = { last4: null, brand: null, exp_month: null, exp_year: null };
let defaultPaymentMethod: Stripe.PaymentMethod | null = null;
let foundVia: "invoice_settings" | "subscription" | "attached_pm_list" | "email_fallback" | null = null;
let effectiveCustomerId: string = customerId;
try {
// ── Branch 1: customer.invoice_settings.default_payment_method ───────────
let customer: Stripe.Customer | Stripe.DeletedCustomer;
try {
customer = await this.getStripe().customers.retrieve(customerId, {
expand: ["invoice_settings.default_payment_method"],
});
} catch (retrieveError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: customers.retrieve failed for ${customerId}: ` +
(retrieveError instanceof Error ? retrieveError.message : String(retrieveError)),
);
// Don't return early — drop into email fallback below using a fake customer.
// We still want orphan-recovery if the configured customer is invalid.
customer = { id: customerId, deleted: true } as unknown as Stripe.DeletedCustomer;
}
if ("deleted" in customer && customer.deleted) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: customer ${customerId} is deleted/invalid in Stripe — ` +
`attempting email fallback if email provided`,
);
// Skip branches 1-3 (need a valid customer), go straight to email fallback
}
const customerIsValid = !("deleted" in customer && customer.deleted);
const invSettingsPm = customerIsValid
? (customer as Stripe.Customer).invoice_settings?.default_payment_method
: null;
if (invSettingsPm && typeof invSettingsPm === "object" && invSettingsPm.object === "payment_method") {
defaultPaymentMethod = invSettingsPm as Stripe.PaymentMethod;
foundVia = "invoice_settings";
} else if (typeof invSettingsPm === "string") {
// Defensive: expansion didn't resolve — retrieve manually
try {
defaultPaymentMethod = await this.getStripe().paymentMethods.retrieve(invSettingsPm);
foundVia = "invoice_settings";
} catch (pmRetrieveError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: invoice_settings PM ${invSettingsPm} retrieve failed: ` +
(pmRetrieveError instanceof Error ? pmRetrieveError.message : String(pmRetrieveError)),
);
}
}
// ── Branch 2: subscription.default_payment_method (active/trialing) ──────
if (!defaultPaymentMethod && customerIsValid) {
for (const status of ["active", "trialing"] as const) {
try {
const subscriptions = await this.getStripe().subscriptions.list({
customer: customerId,
status,
limit: 1,
expand: ["data.default_payment_method"],
});
const sub = subscriptions.data[0];
if (sub?.default_payment_method) {
defaultPaymentMethod =
typeof sub.default_payment_method === "string"
? await this.getStripe().paymentMethods.retrieve(sub.default_payment_method)
: (sub.default_payment_method as Stripe.PaymentMethod);
foundVia = "subscription";
break;
}
} catch (subError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: ${status}-subscription lookup failed for ${customerId}: ` +
(subError instanceof Error ? subError.message : String(subError)),
);
}
}
}
// ── Branch 3: most recent attached card (Portal-added cards land here) ───
if (!defaultPaymentMethod && customerIsValid) {
try {
const list = await this.getStripe().paymentMethods.list({
customer: customerId,
type: "card",
limit: 10,
});
if (list.data.length > 0) {
const newest = [...list.data].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))[0];
defaultPaymentMethod = newest;
foundVia = "attached_pm_list";
this.logger.debug(
`getCustomerDefaultPaymentMethod: using most-recent attached PM ${newest.id} for ${customerId} ` +
`(${list.data.length} card(s) attached, none set as default)`,
);
} else {
this.logger.debug(
`getCustomerDefaultPaymentMethod: paymentMethods.list returned 0 cards for ${customerId}`,
);
}
} catch (listError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: paymentMethods.list failed for ${customerId}: ` +
(listError instanceof Error ? listError.message : String(listError)),
);
}
}
// ── Branch 4: ORPHAN-CUSTOMER FALLBACK via email + metadata.company_id ───
// Catches the rare-but-real case where the company's stripe_customer_id
// points to a customer that has no card, but an older/duplicate Stripe
// customer for the same company DOES have one (typically because a
// previous integration created a fresh customer without migrating PMs).
if (!defaultPaymentMethod && companyContext?.email) {
try {
const candidates = await this.getStripe().customers.list({
email: companyContext.email,
limit: 20,
});
const expectedCompanyIdStr =
companyContext.companyId != null ? String(companyContext.companyId) : null;
// Score candidates: matching company_id metadata > any candidate with a card
const otherCandidates = candidates.data.filter((c) => c.id !== customerId);
this.logger.debug(
`getCustomerDefaultPaymentMethod: email fallback for ${customerId} found ` +
`${otherCandidates.length} other Stripe customer(s) sharing email ${companyContext.email}`,
);
for (const candidate of otherCandidates) {
const metadataCompanyId = candidate.metadata?.["company_id"] ?? null;
const matchesCompany =
!expectedCompanyIdStr || metadataCompanyId === expectedCompanyIdStr;
if (!matchesCompany) continue;
try {
const candidatePMs = await this.getStripe().paymentMethods.list({
customer: candidate.id,
type: "card",
limit: 10,
});
if (candidatePMs.data.length === 0) continue;
const newest = [...candidatePMs.data].sort(
(a, b) => (b.created ?? 0) - (a.created ?? 0),
)[0];
defaultPaymentMethod = newest;
foundVia = "email_fallback";
effectiveCustomerId = candidate.id;
this.logger.warn(
`getCustomerDefaultPaymentMethod: ORPHAN customer detected — ` +
`configured customer=${customerId} has no card, but candidate ` +
`${candidate.id} (metadata.company_id=${metadataCompanyId ?? "<none>"}) ` +
`has card ${newest.id}. Caller should reconcile company.stripe_customer_id.`,
);
break;
} catch (candidatePMError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: candidate ${candidate.id} PM list failed: ` +
(candidatePMError instanceof Error ? candidatePMError.message : String(candidatePMError)),
);
}
}
} catch (emailError) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: email-fallback customers.list failed for ${companyContext.email}: ` +
(emailError instanceof Error ? emailError.message : String(emailError)),
);
}
}
if (!defaultPaymentMethod || defaultPaymentMethod.object !== "payment_method") {
this.logger.log(
`getCustomerDefaultPaymentMethod: no card found for customer ${customerId} ` +
`(all branches empty${companyContext?.email ? ", including email fallback" : ", email fallback skipped — no email"})`,
);
return { ...empty, effective_customer_id: null };
}
const card = defaultPaymentMethod.card;
if (!card) {
this.logger.warn(
`getCustomerDefaultPaymentMethod: PM ${defaultPaymentMethod.id} has no .card object (type=${defaultPaymentMethod.type})`,
);
return { ...empty, effective_customer_id: null };
}
// ── Branch 5 (self-heal): persist the discovered PM as customer default ──
// For invoice_settings/subscription/attached_pm_list cases, update the
// SAME customer. For email_fallback, update the candidate customer (the
// one that actually owns the card) — caller is responsible for moving
// company.stripe_customer_id to effectiveCustomerId.
if (foundVia !== "invoice_settings") {
this.getStripe()
.customers.update(effectiveCustomerId, {
invoice_settings: { default_payment_method: defaultPaymentMethod.id },
})
.then(() => {
this.logger.debug(
`getCustomerDefaultPaymentMethod: self-healed invoice_settings.default_payment_method=` +
`${defaultPaymentMethod!.id} on ${effectiveCustomerId}`,
);
})
.catch((healError: unknown) => {
this.logger.warn(
`getCustomerDefaultPaymentMethod: self-heal update failed for ${effectiveCustomerId}: ` +
(healError instanceof Error ? healError.message : String(healError)),
);
});
}
this.logger.debug(
`getCustomerDefaultPaymentMethod: found ${card.brand} •••• ${card.last4} via ${foundVia} ` +
`(input=${customerId}, effective=${effectiveCustomerId})`,
);
return {
last4: card.last4 ?? null,
brand: card.brand ?? null,
exp_month: card.exp_month ?? null,
exp_year: card.exp_year ?? null,
effective_customer_id: effectiveCustomerId,
};
} catch (error) {
// Final safety net — never propagate to the overview endpoint.
this.logger.error(
`getCustomerDefaultPaymentMethod: unexpected top-level error for ${customerId}`,
error,
);
return { ...empty, effective_customer_id: null };
}
}
/**
* Get customer invoice settings (collection method for auto payment)
* Note: collection_method is subscription-scoped, so we check active subscriptions
*/
async getCustomerInvoiceSettings(customerId: string): Promise<{
collection_method: "charge_automatically" | "send_invoice" | null;
}> {
try {
// Get subscriptions for the customer and pick the first billable one.
// Using `all` avoids false negatives for trialing/past_due/unpaid subscriptions.
const subscriptions = await this.getStripe().subscriptions.list({
customer: customerId,
status: "all",
limit: 100,
});
const billableSubscription = subscriptions.data.find((subscription) =>
this.isBillableSubscriptionStatus(subscription.status),
);
if (!billableSubscription) {
// No active subscriptions, return null
return {
collection_method: null,
};
}
// Return the collection_method from the first billable subscription.
return {
collection_method:
(billableSubscription.collection_method as "charge_automatically" | "send_invoice") ?? null,
};
} catch (error) {
this.logger.error(`Failed to retrieve invoice settings for customer ${customerId}`, error);
throw new Error("Failed to retrieve invoice settings");
}
}
/**
* Update subscription collection method (enable/disable auto payment)
* Note: collection_method is set on subscriptions, not directly on customers
*/
async updateSubscriptionCollectionMethod(
subscriptionId: string,
autoPaymentEnabled: boolean,
): Promise<Stripe.Subscription> {
try {
const collectionMethod = autoPaymentEnabled ? "charge_automatically" : "send_invoice";
const data: Stripe.SubscriptionUpdateParams = {
collection_method: collectionMethod,
cancel_at_period_end: !autoPaymentEnabled,
};
if (!autoPaymentEnabled) {
// Stripe requires days_until_due when using send_invoice.
data.days_until_due = 30;
}
return await this.getStripe().subscriptions.update(subscriptionId, data);
} catch (error) {
this.logger.error(`Failed to update subscription collection method for ${subscriptionId}`, error);
throw new Error("Failed to update subscription collection method");
}
}
/**
* Update all active subscriptions for a customer's collection method
*/
async updateCustomerSubscriptionsCollectionMethod(
customerId: string,
autoPaymentEnabled: boolean,
): Promise<Stripe.Subscription[]> {
// List all subscriptions and update billable non-canceled ones.
// This prevents "success with no-op" when subscription is trialing/past_due/unpaid.
const subscriptions = await this.getStripe().subscriptions.list({
customer: customerId,
status: "all",
limit: 100,
});
const collectionMethod = autoPaymentEnabled ? "charge_automatically" : "send_invoice";
const updatedSubscriptions: Stripe.Subscription[] = [];
const targetSubscriptions = subscriptions.data.filter((subscription) =>
this.isBillableSubscriptionStatus(subscription.status),
);
this.logger.log(
`Updating ${targetSubscriptions.length} subscription(s) for customer ${customerId} to collection_method: ${collectionMethod}`,
);
// Update each billable subscription. Do not fail the full operation for one bad row.
for (const subscription of targetSubscriptions) {
try {
const data: Stripe.SubscriptionUpdateParams = {
collection_method: collectionMethod,
cancel_at_period_end: !autoPaymentEnabled,
};
if (!autoPaymentEnabled) {
// Stripe requires days_until_due when using send_invoice.
data.days_until_due = 30;
}
const updated = await this.getStripe().subscriptions.update(subscription.id, data);
updatedSubscriptions.push(updated);
this.logger.debug(
`Updated subscription ${subscription.id}: collection_method = ${updated.collection_method}`,
);
} catch (error) {
this.logger.warn(
`Failed updating subscription ${subscription.id} collection_method: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
this.logger.log(
`Successfully updated ${updatedSubscriptions.length} subscription(s) collection method to ${collectionMethod}`,
);
return updatedSubscriptions;
}
private isBillableSubscriptionStatus(status: Stripe.Subscription.Status): boolean {
return ["active", "trialing", "past_due", "unpaid", "incomplete"].includes(status);
}
/** True for transient Stripe/network issues worth retrying after SDK-level retries exhaust. */
private isRetryableStripeError(error: unknown): boolean {
if (error && typeof error === "object" && "type" in error) {
const t = (error as { type?: string }).type;
if (t === "StripeConnectionError") {
return true;
}
}
const msg = error instanceof Error ? error.message : String(error);
if (
/connection to Stripe|ECONNRESET|ETIMEDOUT|socket hang up|ENETUNREACH|EAI_AGAIN|network/i.test(msg)
) {
return true;
}
const code = (error as { statusCode?: number }).statusCode;
if (code === 429) {
return true;
}
return false;
}
/** Extra retries for flaky networks (SDK already retries a few times). */
private async runWithStripeRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
const maxAttempts = 4;
const baseMs = 1000;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
const canRetry = this.isRetryableStripeError(e) && attempt < maxAttempts - 1;
if (!canRetry) {
throw e;
}
const wait = baseMs * 2 ** attempt;
this.logger.warn(
`${label} failed (attempt ${attempt + 1}/${maxAttempts}): ${
e instanceof Error ? e.message : String(e)
} — retrying in ${wait}ms`,
);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastError;
}
async retrieveSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await this.getStripe().subscriptions.retrieve(subscriptionId);
} catch (error) {
this.logger.error(`Failed to retrieve subscription ${subscriptionId}`, error);
throw new Error("Failed to retrieve subscription");
}
}
/**
* Next invoice preview for a subscription (`invoices.createPreview`): amount Stripe will charge
* (`amount_due`), in major currency units. Null when preview is unavailable.
*/
async retrieveUpcomingInvoiceAmountForSubscription(subscriptionId: string): Promise<{
amount: number | null;
currency: string | null;
}> {
if (!this.isConfigured()) {
return { amount: null, currency: null };
}
try {
return await this.runWithStripeRetry(`createPreviewInvoice(${subscriptionId})`, async () => {
const inv = await this.getStripe().invoices.createPreview({
subscription: subscriptionId,
});
const currency = (inv.currency ?? "usd").toLowerCase();
const cents = inv.amount_due ?? inv.total ?? 0;
const amount = this.stripeMinorToMajor(cents, currency);
return { amount, currency: currency.toUpperCase() };
});
} catch (error) {
this.logger.debug(
`Upcoming invoice not available for subscription ${subscriptionId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { amount: null, currency: null };
}
}
/** Convert Stripe smallest-currency-unit amounts to major units (handles zero-decimal currencies). */
private stripeMinorToMajor(amountMinor: number, currencyLower: string): number {
const zeroDecimal = new Set([
"bif",
"clp",
"djf",
"gnf",
"jpy",
"kmf",
"krw",
"mga",
"pyg",
"rwf",
"ugx",
"vnd",
"vuv",
"xaf",
"xof",
"xpf",
]);
if (zeroDecimal.has(currencyLower)) {
return amountMinor;
}
return Math.round(amountMinor) / 100;
}
/** Schedule or clear end-of-period cancellation (Stripe Billing). */
async setSubscriptionCancelAtPeriodEnd(
subscriptionId: string,
cancelAtPeriodEnd: boolean,
): Promise<Stripe.Subscription> {
return this.runWithStripeRetry(`setSubscriptionCancelAtPeriodEnd(${subscriptionId})`, async () => {
return await this.getStripe().subscriptions.update(subscriptionId, {
cancel_at_period_end: cancelAtPeriodEnd,
});
});
}
/**
* Subscription with latest_invoice expanded (used after create when status may already be active).
*/
async retrieveSubscriptionWithLatestInvoice(subscriptionId: string): Promise<Stripe.Subscription> {
return this.runWithStripeRetry(`retrieveSubscriptionWithLatestInvoice(${subscriptionId})`, async () => {
return await this.getStripe().subscriptions.retrieve(subscriptionId, {
expand: ["latest_invoice"],
});
});
}
/**
* List subscriptions for a customer (one Stripe API call per status).
*/
async listSubscriptionsForCustomer(
customerId: string,
statuses: Stripe.Subscription.Status[],
): Promise<Stripe.Subscription[]> {
return this.runWithStripeRetry(`listSubscriptionsForCustomer(${customerId})`, async () => {
const out: Stripe.Subscription[] = [];
for (const status of statuses) {
const res = await this.getStripe().subscriptions.list({
customer: customerId,
status,
limit: 100,
});
out.push(...res.data);
}
return out;
});
}
async cancelCustomerBillableSubscriptions(customerId: string): Promise<string[]> {
const subscriptions = await this.runWithStripeRetry(
`cancelCustomerBillableSubscriptions.list(${customerId})`,
() =>
this.getStripe().subscriptions.list({
customer: customerId,
status: "all",
limit: 100,
}),
);
const targetSubscriptions = subscriptions.data.filter((subscription) =>
this.isBillableSubscriptionStatus(subscription.status),
);
const cancelledIds: string[] = [];
for (const subscription of targetSubscriptions) {
try {
await this.getStripe().subscriptions.cancel(subscription.id);
cancelledIds.push(subscription.id);
} catch (error) {
this.logger.warn(
`Failed to cancel subscription ${subscription.id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return cancelledIds;
}
/**
* Retrieve invoice by ID
*/
async getInvoice(invoiceId: string): Promise<Stripe.Invoice> {
try {
return await this.getStripe().invoices.retrieve(invoiceId);
} catch (error) {
this.logger.error(`Failed to retrieve invoice ${invoiceId}`, error);
throw new Error("Failed to retrieve invoice");
}
}
/**
* Same as Stripe CLI:
* `stripe invoices retrieve <id> --expand 'payment_intent.latest_charge'`
* plus `lines.data` (useful for line items / periods).
*/
async getInvoiceExpanded(invoiceId: string): Promise<Stripe.Invoice> {
return this.runWithStripeRetry(`getInvoiceExpanded(${invoiceId})`, async () => {
return await this.getStripe().invoices.retrieve(invoiceId, {
expand: ["payment_intent.latest_charge", "lines.data"],
});
});
}
/**
* Attempt to pay the latest invoice of a subscription now.
* Finalizes draft invoices, passes the customer's invoice default payment method, and uses off_session.
*/
async attemptPayLatestSubscriptionInvoice(subscriptionId: string): Promise<{
invoiceId: string | null;
status: string | null;
paid: boolean;
}> {
return this.runWithStripeRetry(`attemptPayLatestSubscriptionInvoice(${subscriptionId})`, async () => {
const sub = await this.getStripe().subscriptions.retrieve(subscriptionId, {
expand: ["latest_invoice"],
});
const customerId =
typeof sub.customer === "string" ? sub.customer : sub.customer && "id" in sub.customer ? sub.customer.id : null;
if (!customerId) {
return { invoiceId: null, status: null, paid: false };
}
const latest = sub.latest_invoice;
let invoiceId =
typeof latest === "string" ? latest : latest && latest.object === "invoice" ? latest.id : null;
let invoice: Stripe.Invoice | null =
typeof latest === "object" && latest?.object === "invoice" ? latest : null;
if (!invoiceId) {
return { invoiceId: null, status: null, paid: false };
}
if (!invoice) {
invoice = await this.getStripe().invoices.retrieve(invoiceId);
}
if (invoice.status === "paid") {
return { invoiceId: invoice.id, status: "paid", paid: true };
}
if (invoice.status === "draft") {
invoice = await this.getStripe().invoices.finalizeInvoice(invoiceId);
invoiceId = invoice.id;
}
if (invoice.status === "paid") {
return { invoiceId: invoice.id, status: "paid", paid: true };
}
const customer = await this.getStripe().customers.retrieve(customerId, {
expand: ["invoice_settings.default_payment_method"],
});
if ("deleted" in customer && customer.deleted) {
this.logger.warn(`attemptPayLatestSubscriptionInvoice: customer ${customerId} is deleted`);
return { invoiceId: invoice.id, status: invoice.status ?? null, paid: false };
}
const invPm = customer.invoice_settings?.default_payment_method;
const pmId =
typeof invPm === "string" ? invPm : invPm && invPm.object === "payment_method" ? invPm.id : null;
const payParams: Stripe.InvoicePayParams = {};
if (pmId) {
payParams.payment_method = pmId;
payParams.off_session = true;
}
try {
const paidInv = await this.getStripe().invoices.pay(invoice.id, payParams);
return {
invoiceId: paidInv.id,
status: paidInv.status ?? null,
paid: paidInv.status === "paid",
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(
`invoices.pay failed for invoice ${invoice.id} (subscription ${subscriptionId}): ${msg}`,
);
return { invoiceId: invoice.id, status: invoice.status ?? null, paid: false };
}
});
}
/**
* Paid signup funds are captured on a standalone PaymentIntent; {@link createCustomerSubscription}
* still creates a first-cycle invoice under {@code payment_behavior: default_incomplete}.
* Without this step that invoice stays “requires payment” even though the customer already paid.
* Close it via {@code paid_out_of_band} (no second charge — aligns Stripe Billing with the signup PI).
*/
async markSubscriptionLatestInvoicePaidOutOfBand(subscriptionId: string): Promise<void> {
await this.runWithStripeRetry(`markSubscriptionLatestInvoicePaidOutOfBand(${subscriptionId})`, async () => {
const sub = await this.getStripe().subscriptions.retrieve(subscriptionId, {
expand: ["latest_invoice"],
});
const latest = sub.latest_invoice;
let invoice: Stripe.Invoice | null =
typeof latest === "object" && latest?.object === "invoice" ? latest : null;
let invoiceId =
typeof latest === "string" ? latest : invoice?.id ?? null;
if (!invoiceId) {
return;
}
if (!invoice) {
invoice = await this.getStripe().invoices.retrieve(invoiceId);
}
if (invoice.status === "paid") {
return;
}
if (invoice.status === "draft") {
invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
invoiceId = invoice.id;
}
if (invoice.status === "paid") {
return;
}
if (invoice.status === "void" || invoice.status === "uncollectible") {
return;
}
if (typeof invoice.amount_due === "number" && invoice.amount_due <= 0) {
return;
}
await this.getStripe().invoices.pay(invoice.id, { paid_out_of_band: true });
});
}
/**
* Move Stripe subscription next invoice anchor to a target future date without proration.
* Useful after immediate recovery charge so next renewal aligns to local schedule.
*/
async updateSubscriptionBillingCycleAnchor(
subscriptionId: string,
billingCycleAnchorUnix: number,
): Promise<Stripe.Subscription> {
return this.runWithStripeRetry(`updateSubscriptionBillingCycleAnchor(${subscriptionId})`, async () => {
try {
// Attempt direct anchor move first (older API behavior).
const updateParams = {
billing_cycle_anchor: billingCycleAnchorUnix,
proration_behavior: "none",
} as unknown as Stripe.SubscriptionUpdateParams;
return await this.getStripe().subscriptions.update(subscriptionId, updateParams);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const anchorRestriction =
/billing_cycle_anchor must be either unset, 'now', or 'unchanged'/i.test(msg);
if (!anchorRestriction) {
throw error;
}
// Newer Stripe API behavior: timestamp anchor updates on existing subscriptions are blocked.
// Fallback: set trial_end to the desired timestamp (no proration), which moves next invoice date.
this.logger.warn(
`Stripe rejected direct billing_cycle_anchor timestamp update for ${subscriptionId}; falling back to trial_end anchor.`,
);
return await this.getStripe().subscriptions.update(subscriptionId, {
trial_end: billingCycleAnchorUnix,
proration_behavior: "none",
});
}
});
}
/**
* Create a Stripe billing portal session so customers can manage payment methods
*/
async createBillingPortalSession(params: { customerId: string; returnUrl: string }): Promise<{ url: string }> {
try {
const session = await this.getStripe().billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
if (!session.url) {
throw new Error("Stripe did not return a billing portal URL");
}
return {
url: session.url,
};
} catch (error) {
this.logger.error(`Failed to create billing portal session for customer ${params.customerId}`, error);
throw new Error("Failed to create billing portal session");
}
}
/**
* One-off invoice for a fixed USD amount, finalized and paid off-session with a specific payment method.
* Used for expired recovery “immediate charge” without creating a subscription invoice first (avoids SetupIntent-only flows).
*/
async createFinalizeAndPayOneOffInvoice(params: {
customerId: string;
amountCents: number;
currency: string;
description: string;
paymentMethodId: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Invoice> {
return this.runWithStripeRetry(`createFinalizeAndPayOneOffInvoice(${params.customerId})`, async () => {
// Create an empty draft first, then attach the line to this invoice explicitly.
// Relying on "pending" invoice items + invoices.create() can yield $0 invoices with no lines
// (items attach to another invoice or are skipped depending on timing / account behavior).
let invoice = await this.getStripe().invoices.create({
customer: params.customerId,
currency: params.currency,
collection_method: "charge_automatically",
auto_advance: false,
metadata: params.metadata,
});
await this.getStripe().invoiceItems.create({
customer: params.customerId,
invoice: invoice.id,
currency: params.currency,
amount: params.amountCents,
description: params.description,
metadata: params.metadata,
});
invoice = await this.getStripe().invoices.retrieve(invoice.id);
if (invoice.status === "draft") {
invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
}
if (invoice.status === "paid") {
return invoice;
}
return await this.getStripe().invoices.pay(invoice.id, {
payment_method: params.paymentMethodId,
off_session: true,
});
});
}
/**
* Create a Stripe invoice for reporting/records and mark it paid out-of-band (no additional charge).
* Use when a separate PaymentIntent already charged the customer but you still want a Stripe Invoice
* with the correct amount in Billing → Invoices.
*/
async createFinalizeAndMarkInvoicePaidOutOfBand(params: {
customerId: string;
amountCents: number;
currency: string;
description: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Invoice> {
return this.runWithStripeRetry(`createFinalizeAndMarkInvoicePaidOutOfBand(${params.customerId})`, async () => {
// `charge_automatically` + finalize always spun up an invoice PI canceled by immediate `paid_out_of_band`.
// `send_invoice` reduces that; Stripe may still attach a PI to the open invoice until we pay OOB (signup PI already captured funds).
let invoice = await this.getStripe().invoices.create({
customer: params.customerId,
currency: params.currency,
collection_method: "send_invoice",
days_until_due: 30,
auto_advance: false,
metadata: params.metadata,
});
await this.getStripe().invoiceItems.create({
customer: params.customerId,
invoice: invoice.id,
currency: params.currency,
amount: params.amountCents,
description: params.description,
metadata: params.metadata,
});
invoice = await this.getStripe().invoices.retrieve(invoice.id);
if (invoice.status === "draft") {
invoice = await this.getStripe().invoices.finalizeInvoice(invoice.id);
}
if (invoice.status === "paid") {
return invoice;
}
// Mark paid without charging (PaymentIntent already captured funds).
return await this.getStripe().invoices.pay(invoice.id, {
paid_out_of_band: true,
});
});
}
/**
* Recurring subscription in `trialing` until {@param trialEndUnix} — no first invoice until trial ends.
* Use after a separate one-off invoice has collected the lapsed period.
*/
async createTrialingSubscription(params: {
customerId: string;
defaultPaymentMethodId: string;
billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
totalAmountCentsPerCycle: number;
trialEndUnix: number;
metadata: Record<string, string>;
}): Promise<Stripe.Subscription> {
return this.runWithStripeRetry(`createTrialingSubscription(${params.customerId})`, async () => {
const price = await this.createRecallAssessRecurringPrice(
params.billingCycle,
params.totalAmountCentsPerCycle,
);
return await this.getStripe().subscriptions.create({
customer: params.customerId,
items: [{ price: price.id }],
collection_method: "charge_automatically",
default_payment_method: params.defaultPaymentMethodId,
// Do not enable Stripe automatic_tax: totals already include app-level processing + UAE VAT
// (see calculateRenewalChargeCents), and automatic_tax requires a valid customer tax address.
trial_end: params.trialEndUnix,
metadata: params.metadata,
});
});
}
private normalizePlanBillingCycle(billingCycle: string): "QUARTERLY" | "HALF_YEARLY" | "ANNUAL" {
const u = (billingCycle ?? "QUARTERLY").trim().toUpperCase();
if (u === "HALF_YEARLY") return "HALF_YEARLY";
if (u === "ANNUAL") return "ANNUAL";
return "QUARTERLY";
}
private async createRecallAssessRecurringPrice(
billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL",
unitAmountCents: number,
): Promise<Stripe.Price> {
const bc = this.normalizePlanBillingCycle(billingCycle);
const recurring =
bc === "ANNUAL"
? { interval: "year" as const, interval_count: 1 }
: {
interval: "month" as const,
interval_count: bc === "HALF_YEARLY" ? 6 : 3,
};
const product = await this.getStripe().products.create({
name: "RecallAssess Subscription",
});
return await this.getStripe().prices.create({
currency: "usd",
unit_amount: unitAmountCents,
recurring,
product: product.id,
});
}
/**
* Create a Stripe subscription for a customer using inline price_data.
* This is used to bootstrap renewal in Stripe when local subscription exists
* but no Stripe subscription record is present.
*/
async createCustomerSubscription(params: {
customerId: string;
billingCycle: "QUARTERLY" | "HALF_YEARLY" | "ANNUAL";
totalAmountCentsPerCycle: number;
autoPaymentEnabled: boolean;
trialEndUnix?: number;
/** Used for expired recovery to align next invoice date to a past renewal anchor. */
backdateStartDateUnix?: number;
/** Optional one-time discount applied to the first invoice only. */
firstInvoiceAmountOffCents?: number;
metadata?: Record<string, string>;
}): Promise<Stripe.Subscription> {
try {
return await this.runWithStripeRetry(
`createCustomerSubscription(${params.customerId})`,
async () => {
const price = await this.createRecallAssessRecurringPrice(
params.billingCycle,
params.totalAmountCentsPerCycle,
);
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: params.customerId,
collection_method: params.autoPaymentEnabled ? "charge_automatically" : "send_invoice",
cancel_at_period_end: !params.autoPaymentEnabled,
items: [
{
price: price.id,
},
],
metadata: params.metadata,
};
if (!params.autoPaymentEnabled) {
subscriptionParams.days_until_due = 30;
} else {
subscriptionParams.payment_behavior = "default_incomplete";
subscriptionParams.payment_settings = {
save_default_payment_method: "on_subscription",
};
// Do not enable Stripe automatic_tax here: unit_amount already includes app fees/VAT;
// automatic_tax requires customer_tax location and can fail signup (customer_tax_location_invalid).
}
if (params.trialEndUnix && params.trialEndUnix > Math.floor(Date.now() / 1000)) {
subscriptionParams.trial_end = params.trialEndUnix;
}
if (typeof params.backdateStartDateUnix === "number" && params.backdateStartDateUnix > 0) {
subscriptionParams.backdate_start_date = params.backdateStartDateUnix;
subscriptionParams.proration_behavior = "none";
}
if (
typeof params.firstInvoiceAmountOffCents === "number" &&
Number.isFinite(params.firstInvoiceAmountOffCents) &&
params.firstInvoiceAmountOffCents > 0 &&
params.firstInvoiceAmountOffCents < params.totalAmountCentsPerCycle
) {
// Create a one-time coupon for the first invoice only.
const coupon = await this.getStripe().coupons.create({
duration: "once",
amount_off: Math.round(params.firstInvoiceAmountOffCents),
currency: "usd",
name: "VIP offer (one-time)",
});
subscriptionParams.discounts = [{ coupon: coupon.id }];
}
return await this.getStripe().subscriptions.create(subscriptionParams);
},
);
} catch (error) {
const stripeMsg =
error && typeof error === "object" && "message" in error
? String((error as { message?: string }).message)
: String(error);
this.logger.error(`Failed to create subscription for customer ${params.customerId}: ${stripeMsg}`, error);
throw new Error(`Failed to create Stripe subscription: ${stripeMsg}`);
}
}
private getStripe(): Stripe {
if (!this.stripe) {
throw new Error("Stripe is not configured");
}
return this.stripe;
}
/**
* Create a Stripe customer so we can attach subscriptions and payment methods later.
*/
async createCustomer(params: {
email?: string;
name?: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Customer> {
try {
return await this.getStripe().customers.create({
email: params.email,
name: params.name,
metadata: params.metadata,
});
} catch (error) {
this.logger.error(`Failed to create customer for ${params.email ?? "unknown email"}`, error);
throw new Error("Failed to create customer");
}
}
/**
* Checks whether a Stripe customer id exists in the currently configured Stripe account/mode.
*/
async customerExists(customerId: string): Promise<boolean> {
try {
const customer = await this.getStripe().customers.retrieve(customerId);
if ("deleted" in customer && customer.deleted) {
return false;
}
return true;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.logger.warn(`customerExists: could not retrieve customer ${customerId}: ${msg}`);
return false;
}
}
}
/**
* Maps a Stripe subscription to our DB `stripe_cancel_at_period_end` flag.
* Stripe clears `cancel_at_period_end` once `status` is `canceled`, but we still want `true`
* so admin/history rows (e.g. superseded upgrades) reflect that the sub no longer renews.
*/
/** Minimal subscription fields; `status` is `string` so callers can use API-shaped objects without Stripe enum friction. */
export function localStripeCancelAtPeriodEndFromSubscription(sub: {
status: string;
cancel_at_period_end?: boolean | null;
cancel_at?: number | null;
}): boolean {
// Stripe may set `cancel_at` (scheduled cancellation timestamp) while `cancel_at_period_end` is false/null.
return sub.cancel_at_period_end === true || typeof sub.cancel_at === "number" || sub.status === "canceled";
}