zappier-edge/src/reports.ts

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');
}