scripts/stripe_paid_invoice_export_utils.ts
Rama main · réplica de lectura del repositorio original.
import type Stripe from 'stripe'
import { chmod, mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_MONTH_MS = 30 * ONE_DAY_MS
const MERGE_TOLERANCE_MS = ONE_DAY_MS
export interface PaidInterval {
endMs: number
startMs: number
}
export interface CustomerPaidCoverage {
activePaying: boolean
intervals: PaidInterval[]
}
export interface CustomerPaidSummary {
activePaying: boolean
customerId: string
paidDurationMonths: number
paidDurationMs: number
}
export interface CustomerPaidCoverageResult {
coverageByCustomerId: Map<string, CustomerPaidCoverage>
customerIdsWithPositivePaidInvoices: Set<string>
}
interface CollectPaidCoverageOptions {
customerId?: string | null
excludeCustomerIds?: Set<string>
includeCustomerIds?: Set<string>
invoiceLimit?: number | null
nowMs: number
}
export function toStripeId(value: string | { id?: string } | null | undefined) {
if (typeof value === 'string')
return value
return value?.id ?? null
}
function getInvoiceAmountPaid(invoice: Stripe.Invoice) {
const amountPaid = (invoice as { amount_paid?: number | null }).amount_paid
return typeof amountPaid === 'number' ? amountPaid : 0
}
function isPaidSubscriptionInvoice(invoice: Stripe.Invoice) {
if (invoice.status !== 'paid' || getInvoiceAmountPaid(invoice) <= 0)
return false
const billingReason = (invoice as { billing_reason?: string | null }).billing_reason
if (billingReason?.startsWith('subscription'))
return true
return !!(invoice as { subscription?: string | Stripe.Subscription | null }).subscription
}
function isSubscriptionLine(line: Stripe.InvoiceLineItem) {
const typedLine = line as Stripe.InvoiceLineItem & {
parent?: {
subscription_item_details?: {
subscription?: string | null
subscription_item?: string | null
}
type?: string | null
} | null
subscription?: string | null
subscription_item?: string | null
type?: string | null
}
return typedLine.type === 'subscription'
|| !!typedLine.subscription
|| !!typedLine.subscription_item
|| typedLine.parent?.type === 'subscription_item_details'
|| !!typedLine.parent?.subscription_item_details?.subscription
|| !!typedLine.parent?.subscription_item_details?.subscription_item
}
function getLineInterval(line: Stripe.InvoiceLineItem) {
const amount = (line as { amount?: number | null }).amount
const period = (line as { period?: { end?: number | null, start?: number | null } | null }).period
const start = period?.start
const end = period?.end
if (typeof amount === 'number' && amount <= 0)
return null
if (typeof start !== 'number' || typeof end !== 'number' || end <= start)
return null
return {
startMs: start * 1000,
endMs: end * 1000,
}
}
function getInvoiceFallbackInterval(invoice: Stripe.Invoice) {
const periodStart = (invoice as { period_start?: number | null }).period_start
const periodEnd = (invoice as { period_end?: number | null }).period_end
if (typeof periodStart !== 'number' || typeof periodEnd !== 'number' || periodEnd <= periodStart)
return null
return {
startMs: periodStart * 1000,
endMs: periodEnd * 1000,
}
}
async function getInvoiceLines(stripe: Stripe, invoice: Stripe.Invoice) {
const lines = [...invoice.lines.data]
if (!invoice.lines.has_more)
return lines
const params: Stripe.InvoiceListLineItemsParams = { limit: 100 }
const startingAfter = lines.at(-1)?.id
if (startingAfter)
params.starting_after = startingAfter
for await (const line of stripe.invoices.listLineItems(invoice.id, params))
lines.push(line)
return lines
}
async function getInvoicePaidIntervals(stripe: Stripe, invoice: Stripe.Invoice) {
if (!isPaidSubscriptionInvoice(invoice))
return []
const lines = await getInvoiceLines(stripe, invoice)
const subscriptionIntervals = lines
.filter(isSubscriptionLine)
.map(getLineInterval)
.filter((interval): interval is PaidInterval => !!interval)
if (subscriptionIntervals.length > 0)
return subscriptionIntervals
const positiveLineIntervals = lines
.map(getLineInterval)
.filter((interval): interval is PaidInterval => !!interval)
if (positiveLineIntervals.length > 0)
return positiveLineIntervals
const fallback = getInvoiceFallbackInterval(invoice)
return fallback ? [fallback] : []
}
function addPaidCoverage(
coverageByCustomerId: Map<string, CustomerPaidCoverage>,
customerId: string,
intervals: PaidInterval[],
nowMs: number,
) {
const current = coverageByCustomerId.get(customerId) ?? {
activePaying: false,
intervals: [],
}
current.intervals.push(...intervals)
current.activePaying = current.activePaying
|| intervals.some(interval => interval.startMs <= nowMs && interval.endMs > nowMs)
coverageByCustomerId.set(customerId, current)
}
function shouldIncludeCustomer(customerId: string, options: CollectPaidCoverageOptions) {
if (options.customerId && customerId !== options.customerId)
return false
if (options.includeCustomerIds && !options.includeCustomerIds.has(customerId))
return false
if (options.excludeCustomerIds?.has(customerId))
return false
return true
}
export async function collectPaidCoverageByCustomerId(
stripe: Stripe,
options: CollectPaidCoverageOptions,
): Promise<CustomerPaidCoverageResult> {
const coverageByCustomerId = new Map<string, CustomerPaidCoverage>()
const customerIdsWithPositivePaidInvoices = new Set<string>()
const invoiceListParams: Stripe.InvoiceListParams = {
limit: 100,
status: 'paid',
}
if (options.customerId)
invoiceListParams.customer = options.customerId
let checkedInvoices = 0
let matchedInvoices = 0
for await (const invoice of stripe.invoices.list(invoiceListParams)) {
checkedInvoices++
const customerId = toStripeId(invoice.customer)
if (customerId && shouldIncludeCustomer(customerId, options)) {
if (getInvoiceAmountPaid(invoice) > 0)
customerIdsWithPositivePaidInvoices.add(customerId)
const intervals = await getInvoicePaidIntervals(stripe, invoice)
if (intervals.length > 0) {
addPaidCoverage(coverageByCustomerId, customerId, intervals, options.nowMs)
matchedInvoices++
}
}
if (checkedInvoices % 500 === 0)
console.log(`Checked ${checkedInvoices} paid Stripe invoices (${matchedInvoices} matched subscription invoices)`)
if (options.invoiceLimit && checkedInvoices >= options.invoiceLimit)
break
}
console.log(`Checked ${checkedInvoices} paid Stripe invoices (${matchedInvoices} matched subscription invoices)`)
return {
coverageByCustomerId,
customerIdsWithPositivePaidInvoices,
}
}
function mergeElapsedIntervals(intervals: PaidInterval[], nowMs: number) {
const elapsedIntervals = intervals
.map(interval => ({
startMs: interval.startMs,
endMs: Math.min(interval.endMs, nowMs),
}))
.filter(interval => interval.endMs > interval.startMs)
.sort((left, right) => left.startMs - right.startMs)
const merged: PaidInterval[] = []
for (const interval of elapsedIntervals) {
const previous = merged.at(-1)
if (!previous || interval.startMs > previous.endMs + MERGE_TOLERANCE_MS) {
merged.push({ ...interval })
continue
}
previous.endMs = Math.max(previous.endMs, interval.endMs)
}
return merged
}
export function getPaidDurationMs(intervals: PaidInterval[], nowMs: number) {
return mergeElapsedIntervals(intervals, nowMs)
.reduce((total, interval) => total + interval.endMs - interval.startMs, 0)
}
export function toPaidDurationMonths(paidDurationMs: number) {
return Number((paidDurationMs / ONE_MONTH_MS).toFixed(2))
}
export function buildPaidCustomerSummaries(
coverageByCustomerId: Map<string, CustomerPaidCoverage>,
options: {
minMonths?: number
nowMs: number
},
) {
const minPaidDurationMs = (options.minMonths ?? 0) * ONE_MONTH_MS
const summaries: CustomerPaidSummary[] = []
for (const [customerId, coverage] of coverageByCustomerId.entries()) {
const paidDurationMs = getPaidDurationMs(coverage.intervals, options.nowMs)
if (paidDurationMs < minPaidDurationMs)
continue
summaries.push({
activePaying: coverage.activePaying,
customerId,
paidDurationMs,
paidDurationMonths: toPaidDurationMonths(paidDurationMs),
})
}
return summaries.sort((left, right) => {
if (left.activePaying !== right.activePaying)
return left.activePaying ? -1 : 1
return right.paidDurationMs - left.paidDurationMs
})
}
export function escapeCsv(value: string | number | boolean | null) {
const rawText = String(value ?? '')
const text = typeof value === 'string' && /^[=+\-@]/.test(rawText)
? `'${rawText}`
: rawText
if (!/[",\n\r]/.test(text))
return text
return `"${text.replaceAll('"', '""')}"`
}
export async function writeCsv(outputPath: string, csv: string) {
const resolvedOutputPath = resolve(outputPath)
await mkdir(dirname(resolvedOutputPath), { recursive: true })
await writeFile(resolvedOutputPath, csv, { mode: 0o600 })
await chmod(resolvedOutputPath, 0o600)
}