98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
import { BillingType, Customer } from './auth';
|
|
import { TierConfig } from './pricing';
|
|
import { UsageEntry } from './usage';
|
|
|
|
export interface BillingRow {
|
|
customerId: string;
|
|
name: string;
|
|
billingType: BillingType;
|
|
calls: number;
|
|
totalCents: number;
|
|
creditCents: number;
|
|
billableCents: number;
|
|
}
|
|
|
|
/**
|
|
* Per-customer billing aggregation. Range is inclusive `from`, exclusive `to`;
|
|
* omit both for all time. Every customer matching the filters appears, even
|
|
* with zero usage.
|
|
*/
|
|
export function billingRows(args: {
|
|
entries: UsageEntry[];
|
|
customers: Customer[];
|
|
tiers: TierConfig[];
|
|
from?: Date;
|
|
to?: Date;
|
|
customerId?: string;
|
|
billingType?: BillingType;
|
|
}): BillingRow[] {
|
|
const { entries, customers, tiers, from, to, customerId, billingType } = args;
|
|
const inRange = entries.filter(
|
|
(e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to),
|
|
);
|
|
return customers
|
|
.filter((c) => (customerId ? c.id === customerId : true))
|
|
.filter((c) => (billingType ? (c.billingType ?? 'stripe') === billingType : true))
|
|
.map((c) => {
|
|
const mine = inRange.filter((e) => e.customerId === c.id);
|
|
const totalCents = mine.reduce((sum, e) => sum + e.cents, 0);
|
|
const tier = tiers.find((t) => t.id === c.tierId);
|
|
const creditCents = Math.min(totalCents, tier?.monthlyCreditCents ?? 0);
|
|
return {
|
|
customerId: c.id,
|
|
name: c.name,
|
|
billingType: c.billingType ?? 'stripe',
|
|
calls: mine.length,
|
|
totalCents,
|
|
creditCents,
|
|
billableCents: totalCents - creditCents,
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface TrendPoint {
|
|
/** Day bucket: YYYY-MM-DD. Week bucket: the Monday (UTC) of that week, YYYY-MM-DD. */
|
|
bucket: string;
|
|
calls: number;
|
|
cents: number;
|
|
}
|
|
|
|
export function usageTrend(entries: UsageEntry[], bucket: 'day' | 'week'): TrendPoint[] {
|
|
const key = (d: Date): string => {
|
|
const day = new Date(d);
|
|
day.setUTCHours(0, 0, 0, 0);
|
|
if (bucket === 'week') {
|
|
// Shift back to Monday (ISO weeks start Monday; getUTCDay: Sun=0).
|
|
const dow = (day.getUTCDay() + 6) % 7;
|
|
day.setUTCDate(day.getUTCDate() - dow);
|
|
}
|
|
return day.toISOString().slice(0, 10);
|
|
};
|
|
const buckets = new Map<string, TrendPoint>();
|
|
for (const e of entries) {
|
|
const k = key(e.timestamp);
|
|
const point = buckets.get(k) ?? { bucket: k, calls: 0, cents: 0 };
|
|
point.calls += 1;
|
|
point.cents += e.cents;
|
|
buckets.set(k, point);
|
|
}
|
|
return [...buckets.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
}
|
|
|
|
export interface CsvColumn {
|
|
key: string;
|
|
label: string;
|
|
}
|
|
|
|
/** RFC 4180 CSV with a header row. Values containing , " or newlines are quoted. */
|
|
export function toCsv<T extends object>(rows: T[], columns: CsvColumn[]): string {
|
|
const cell = (v: unknown): string => {
|
|
const s = String(v ?? '');
|
|
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
};
|
|
const lines = [columns.map((c) => cell(c.label)).join(',')];
|
|
for (const row of rows) {
|
|
lines.push(columns.map((c) => cell((row as Record<string, unknown>)[c.key])).join(','));
|
|
}
|
|
return lines.join('\n');
|
|
}
|