import { BNestPrismaService } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InvoiceStatus, InvoiceType, PackageType, SubscriptionStatus } from "@prisma/client";
import Stripe from "stripe";
import { StripeService } from "../stripe.service";
import { StripePaymentBillingHelpersService } from "./stripe-payment-billing-helpers.service";
import { StripeSubscriptionApi } from "./stripe-payment.types";
@Injectable()
export class StripePaymentSyncRefundService {
private readonly logger = new Logger(StripePaymentSyncRefundService.name);
constructor(
private readonly prisma: BNestPrismaService,
private readonly stripeService: StripeService,
private readonly billing: StripePaymentBillingHelpersService,
) {}
async updateAdminStripeNextBillingDate(
companyId: number,
nextBillingDate: string,
): Promise<{ success: boolean; message: string; next_billing_date: string }> {
if (!this.stripeService.isConfigured()) {
throw new BadRequestException("Stripe is not configured");
}
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { id: true, stripe_subscription_id: true },
});
if (!company) {
throw new NotFoundException(`Company ${companyId} not found`);
}
if (!company.stripe_subscription_id) {
throw new BadRequestException("No Stripe subscription linked to this company.");
}
const parsed = new Date(`${nextBillingDate}T00:00:00.000Z`);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException("Invalid next billing date.");
}
const anchorUnix = Math.floor(parsed.getTime() / 1000);
const nowUnix = Math.floor(Date.now() / 1000);
if (anchorUnix <= nowUnix) {
throw new BadRequestException("next_billing_date must be in the future.");
}
await this.stripeService.updateSubscriptionBillingCycleAnchor(company.stripe_subscription_id, anchorUnix);
const updatedSub = await this.stripeService.retrieveSubscription(company.stripe_subscription_id);
const periodEndUnix = (updatedSub as StripeSubscriptionApi).current_period_end;
const normalizedNext =
typeof periodEndUnix === "number" ? new Date(periodEndUnix * 1000) : parsed;
await this.prisma.client.subscription.updateMany({
where: { company_id: companyId, is_current: true },
data: {
next_billing_date: normalizedNext,
end_date: normalizedNext,
},
});
return {
success: true,
message: `Next billing date updated to ${normalizedNext.toISOString().slice(0, 10)}.`,
next_billing_date: normalizedNext.toISOString(),
};
}
async syncCompanyInvoicesAndRefundsFromStripe(companyId: number): Promise<{
success: boolean;
message: string;
invoices_created: number;
refunds_created: number;
}> {
if (!this.stripeService.isConfigured()) {
return {
success: false,
message: "Stripe is not configured on this API environment.",
invoices_created: 0,
refunds_created: 0,
};
}
const company = await this.prisma.client.company.findUnique({
where: { id: companyId },
select: { id: true, stripe_customer_id: true },
});
if (!company) {
return {
success: false,
message: `Company ${companyId} not found`,
invoices_created: 0,
refunds_created: 0,
};
}
if (!company.stripe_customer_id) {
return {
success: false,
message: "Company has no stripe_customer_id.",
invoices_created: 0,
refunds_created: 0,
};
}
const latestSub = await this.prisma.client.subscription.findFirst({
where: { company_id: companyId },
orderBy: { id: "desc" },
select: { id: true, billing_cycle: true, package: { select: { package_type: true } } },
});
const fallbackInvoice = await this.prisma.client.invoice.findFirst({
where: { company_id: companyId },
orderBy: { id: "desc" },
select: { subscription_id: true, package_type: true, billing_cycle: true },
});
const subscriptionId = latestSub?.id ?? fallbackInvoice?.subscription_id ?? null;
const packageType = latestSub?.package?.package_type ?? fallbackInvoice?.package_type ?? null;
// Snapshot the cycle for every backfilled row; the PDF builder renders
// from this column and must not need to JOIN back to the live subscription.
const backfillBillingCycle = latestSub?.billing_cycle ?? fallbackInvoice?.billing_cycle ?? null;
if (!subscriptionId || !packageType) {
return {
success: false,
message: "Could not resolve subscription/package for backfilled invoices.",
invoices_created: 0,
refunds_created: 0,
};
}
let invoicesCreated = 0;
let refundsCreated = 0;
const stripeInvoices = await this.stripeService.listInvoicesForCustomer({
customerId: company.stripe_customer_id,
max: 200,
status: "all",
});
for (const inv of stripeInvoices) {
const stripeInvoiceId = String(inv.id);
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: stripeInvoiceId },
select: { id: true },
});
if (existing) continue;
if (inv.status !== "paid") continue;
const centsPaid = typeof inv.amount_paid === "number" ? inv.amount_paid : 0;
// Backfill only billable invoices; skip zero-amount Stripe invoices.
if (centsPaid <= 0) continue;
const totalStr = (centsPaid / 100).toFixed(2);
const paidUnix = (inv.status_transitions as any)?.paid_at;
const paidDate =
typeof paidUnix === "number" && paidUnix > 0
? new Date(paidUnix * 1000)
: typeof inv.created === "number"
? new Date(inv.created * 1000)
: new Date();
const invoiceNumber =
inv.number && String(inv.number).trim().length > 0 ? String(inv.number) : `STRP-${stripeInvoiceId}`;
let expandedForIds: Stripe.Invoice = inv;
try {
expandedForIds = await this.stripeService.getInvoiceExpanded(stripeInvoiceId);
} catch {
// keep list payload; charge id may be missing without expand
}
const { paymentIntentId, chargeId, checkoutSessionId } =
this.billing.extractPaymentIntentAndChargeIds(expandedForIds);
try {
await this.prisma.client.invoice.create({
data: {
company_id: companyId,
subscription_id: subscriptionId,
invoice_number: invoiceNumber,
unit_price_per_license: "0",
license_quantity: 0,
subtotal_amount: totalStr,
total_amount: totalStr,
package_type: packageType,
billing_cycle: backfillBillingCycle,
status: InvoiceStatus.PAID,
invoice_type: InvoiceType.RENEWAL,
stripe_invoice_id: stripeInvoiceId,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
paid_date: paidDate,
billing_reference_notes: `Backfilled from Stripe invoice ${stripeInvoiceId}`,
},
});
invoicesCreated += 1;
} catch (e) {
// Likely invoice_number uniqueness conflict; fall back to guaranteed unique number
const altNumber = `STRP-${stripeInvoiceId}`;
await this.prisma.client.invoice.create({
data: {
company_id: companyId,
subscription_id: subscriptionId,
invoice_number: altNumber,
unit_price_per_license: "0",
license_quantity: 0,
subtotal_amount: totalStr,
total_amount: totalStr,
package_type: packageType,
billing_cycle: backfillBillingCycle,
status: InvoiceStatus.PAID,
invoice_type: InvoiceType.RENEWAL,
stripe_invoice_id: stripeInvoiceId,
stripe_payment_intent_id: paymentIntentId ?? undefined,
stripe_charge_id: chargeId ?? undefined,
stripe_checkout_session_id: checkoutSessionId ?? undefined,
paid_date: paidDate,
billing_reference_notes: `Backfilled from Stripe invoice ${stripeInvoiceId}`,
},
});
invoicesCreated += 1;
}
}
const invoicesMissingPi = await this.prisma.client.invoice.findMany({
where: {
company_id: companyId,
stripe_invoice_id: { not: null },
stripe_payment_intent_id: null,
invoice_type: { not: InvoiceType.REFUND },
},
select: { id: true, stripe_invoice_id: true },
take: 100,
});
for (const row of invoicesMissingPi) {
const sid = row.stripe_invoice_id;
if (!sid) continue;
try {
const expanded = await this.stripeService.getInvoiceExpanded(sid);
const {
paymentIntentId: pi,
chargeId,
checkoutSessionId,
} = this.billing.extractPaymentIntentAndChargeIds(expanded);
const patch: {
stripe_payment_intent_id?: string;
stripe_charge_id?: string;
stripe_checkout_session_id?: string;
} = {};
if (pi) patch.stripe_payment_intent_id = pi;
if (chargeId) patch.stripe_charge_id = chargeId;
if (checkoutSessionId) patch.stripe_checkout_session_id = checkoutSessionId;
if (Object.keys(patch).length > 0) {
try {
await this.prisma.client.invoice.update({
where: { id: row.id },
data: patch,
});
} catch {
// e.g. unique stripe_payment_intent_id already used by another row
}
}
} catch {
// ignore per-invoice Stripe errors
}
}
const invoicesMissingCharge = await this.prisma.client.invoice.findMany({
where: {
company_id: companyId,
stripe_invoice_id: { not: null },
stripe_charge_id: null,
stripe_payment_intent_id: { not: null },
invoice_type: { not: InvoiceType.REFUND },
},
select: { id: true, stripe_invoice_id: true },
take: 100,
});
for (const row of invoicesMissingCharge) {
const sid = row.stripe_invoice_id;
if (!sid) continue;
try {
const expanded = await this.stripeService.getInvoiceExpanded(sid);
const { chargeId, checkoutSessionId } = this.billing.extractPaymentIntentAndChargeIds(expanded);
const patch: { stripe_charge_id?: string; stripe_checkout_session_id?: string } = {};
if (chargeId) patch.stripe_charge_id = chargeId;
if (checkoutSessionId) patch.stripe_checkout_session_id = checkoutSessionId;
if (Object.keys(patch).length > 0) {
try {
await this.prisma.client.invoice.update({ where: { id: row.id }, data: patch });
} catch {
// ignore
}
}
} catch {
// ignore per-invoice Stripe errors
}
}
const charges = await this.stripeService.listChargesForCustomer({
customerId: company.stripe_customer_id,
max: 200,
});
for (const ch of charges) {
const amountRefunded = typeof ch.amount_refunded === "number" ? ch.amount_refunded : 0;
if (!ch.refunded && amountRefunded <= 0) continue;
const refunds = await this.stripeService.listRefundsForCharge({ chargeId: ch.id, max: 50 });
for (const r of refunds) {
const created = await this.handleRefundCreated(r);
if (created) refundsCreated += 1;
}
}
const piRows = await this.prisma.client.invoice.findMany({
where: {
company_id: companyId,
stripe_payment_intent_id: { not: null },
invoice_type: { not: InvoiceType.REFUND },
},
select: { stripe_payment_intent_id: true },
distinct: ["stripe_payment_intent_id"],
});
const seenPi = new Set<string>();
for (const row of piRows) {
const pi = row.stripe_payment_intent_id;
if (!pi || seenPi.has(pi)) continue;
seenPi.add(pi);
const refundsByPi = await this.stripeService.listRefundsForPaymentIntent({ paymentIntentId: pi, max: 50 });
for (const r of refundsByPi) {
const created = await this.handleRefundCreated(r);
if (created) refundsCreated += 1;
}
}
return {
success: true,
message: `Synced from Stripe for customer ${company.stripe_customer_id}.`,
invoices_created: invoicesCreated,
refunds_created: refundsCreated,
};
}
async handleRefundCreated(refund: Stripe.Refund): Promise<boolean> {
if (!this.stripeService.isConfigured()) {
return false;
}
const stripeRefundId = String(refund.id);
const existing = await this.prisma.client.invoice.findFirst({
where: { stripe_refund_id: stripeRefundId },
select: { id: true },
});
if (existing) {
return false;
}
const chargeId =
typeof refund.charge === "string"
? refund.charge
: refund.charge && typeof refund.charge === "object"
? refund.charge.id
: null;
if (!chargeId) {
this.logger.warn(`handleRefundCreated: refund ${stripeRefundId} has no charge id; skipping local invoice.`);
return false;
}
let charge: Stripe.Charge;
try {
charge = await this.stripeService.retrieveCharge(chargeId);
} catch {
this.logger.warn(
`handleRefundCreated: failed to retrieve charge ${chargeId} for refund ${stripeRefundId}; skipping.`,
);
return false;
}
const paymentIntentId =
typeof charge.payment_intent === "string"
? charge.payment_intent
: charge.payment_intent && typeof charge.payment_intent === "object"
? charge.payment_intent.id
: null;
const stripeInvoiceId =
typeof (charge as any).invoice === "string"
? String((charge as any).invoice)
: (charge as any).invoice && typeof (charge as any).invoice === "object"
? String((charge as any).invoice.id)
: null;
const or: Array<Record<string, string>> = [{ stripe_charge_id: chargeId }];
if (paymentIntentId) or.push({ stripe_payment_intent_id: paymentIntentId });
if (stripeInvoiceId) or.push({ stripe_invoice_id: stripeInvoiceId });
let original = await this.prisma.client.invoice.findFirst({
where: { OR: or },
orderBy: { id: "desc" },
});
let matchNote = "";
if (!original && paymentIntentId) {
try {
const piExpanded = await this.stripeService.retrievePaymentIntent(paymentIntentId, ["invoice"]);
const invRef = (piExpanded as Stripe.PaymentIntent & { invoice?: string | Stripe.Invoice | null }).invoice;
const invIdFromPi =
typeof invRef === "string"
? invRef
: invRef && typeof invRef === "object" && "id" in invRef
? invRef.id
: null;
if (invIdFromPi) {
original = await this.prisma.client.invoice.findFirst({
where: { stripe_invoice_id: invIdFromPi },
orderBy: { id: "desc" },
});
if (original) {
matchNote = `Matched via Stripe PaymentIntent → invoice ${invIdFromPi}.`;
const patch: { stripe_payment_intent_id?: string; stripe_charge_id?: string } = {};
if (!original.stripe_payment_intent_id && paymentIntentId) {
patch.stripe_payment_intent_id = paymentIntentId;
}
if (!original.stripe_charge_id) {
patch.stripe_charge_id = chargeId;
}
if (Object.keys(patch).length > 0) {
try {
await this.prisma.client.invoice.update({ where: { id: original.id }, data: patch });
} catch {
// unique stripe_payment_intent_id / race — ignore
}
}
}
}
} catch {
// ignore PI retrieve errors; fall through to company fallback
}
}
if (!original) {
const customerId = this.billing.extractStripeCustomerId(charge.customer);
if (customerId) {
const co = await this.prisma.client.company.findFirst({
where: { stripe_customer_id: customerId },
select: { id: true },
});
if (co) {
original = await this.prisma.client.invoice.findFirst({
where: {
company_id: co.id,
invoice_type: { not: InvoiceType.REFUND },
status: InvoiceStatus.PAID,
},
orderBy: [{ paid_date: "desc" }, { id: "desc" }],
});
if (original) {
matchNote = `Fallback: linked to latest paid invoice for Stripe customer ${customerId}; verify amount matches.`;
this.logger.warn(
`handleRefundCreated: refund ${stripeRefundId} had no exact local match; using invoice id=${original.id} for company ${co.id}.`,
);
}
}
}
}
if (!original) {
this.logger.warn(
`handleRefundCreated: could not match refund ${stripeRefundId} (charge=${chargeId}, pi=${paymentIntentId ?? "null"}, invoice=${stripeInvoiceId ?? "null"}) to a local invoice; skipping.`,
);
return false;
}
const refundAmountCents = typeof refund.amount === "number" ? refund.amount : 0;
const refundAmount = refundAmountCents / 100;
const totalStr = (-refundAmount).toFixed(2);
const paidDate =
typeof refund.created === "number" && refund.created > 0 ? new Date(refund.created * 1000) : new Date();
// Refund rows are credit notes in our model: they link back to the paid
// invoice being refunded via `parent_invoice_id`, and they carry the same
// `billing_cycle` snapshot so the PDF labels the period correctly even if
// the subscription's live cycle has since changed.
await this.prisma.client.invoice.create({
data: {
company_id: original.company_id,
subscription_id: original.subscription_id,
invoice_number: `RFND-${stripeRefundId}`,
unit_price_per_license: "0",
license_quantity: 0,
subtotal_amount: totalStr,
total_amount: totalStr,
package_type: original.package_type,
billing_cycle: original.billing_cycle,
parent_invoice_id: original.id,
status: InvoiceStatus.PAID,
invoice_type: InvoiceType.REFUND,
stripe_invoice_id: stripeInvoiceId ?? undefined,
stripe_payment_intent_id: undefined,
stripe_charge_id: chargeId,
stripe_refund_id: stripeRefundId,
paid_date: paidDate,
billing_reference_notes: [`Stripe refund ${stripeRefundId} (charge=${chargeId})`, matchNote || undefined]
.filter(Boolean)
.join(" "),
},
});
return true;
}
}