Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
2e035b1e80
128 changed files with 20545 additions and 0 deletions
98
src/reports.ts
Normal file
98
src/reports.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue