storebound/cli/lib/reports.ts

242 lines
16 KiB
TypeScript

// Sales and finance reports, as pure parts: request paths, file names and the summaries. The command
// (cli/reports.ts) downloads into .storebound/reports/ (gitignored: these files hold per-order and per-buyer-location
// rows) and prints / exports only aggregates.
// Sources (checked October 2026):
// Apple: GET /v1/salesReports (filter[vendorNumber], filter[reportType], filter[reportSubType], filter[frequency],
// filter[reportDate], filter[version]; answer application/a-gzip, tab-separated text) and GET /v1/financeReports
// (filter[vendorNumber], filter[reportType] FINANCIAL | FINANCE_DETAIL, filter[regionCode], filter[reportDate] =
// fiscal month YYYY-MM). Team key only, not an individual key.
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-salesreports
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-financereports
// Allowed combinations (docs table): SALES SUMMARY DAILY|WEEKLY|MONTHLY|YEARLY 1_0; SUBSCRIPTION SUMMARY DAILY 1_3;
// SUBSCRIPTION_EVENT SUMMARY DAILY 1_3; SUBSCRIBER DETAILED DAILY 1_3; PRE_ORDER SUMMARY ... 1_0;
// SUBSCRIPTION_OFFER_CODE_REDEMPTION SUMMARY DAILY 1_0; WIN_BACK_ELIGIBILITY SUMMARY DAILY 1_0; INSTALLS ...
// Region codes (Help, "Financial report regions and currencies"): ZZ = all regions consolidated (FINANCIAL),
// Z1 = the Financial Detail report (FINANCE_DETAIL).
// Summary Sales Report columns (Help): Provider, Provider Country, SKU, Developer, Title, Version, Product Type
// Identifier, Units, Developer Proceeds, Begin Date, End Date, Customer Currency, Country Code, Currency of Proceeds,
// Apple Identifier, Customer Price, ...
// Google Play: Cloud Storage bucket of the developer account (play.reportsBucket), read-only scope
// devstorage.read_only: earnings/earnings_YYYYMM*.zip and sales/salesreport_YYYYMM*.zip, one CSV inside each.
// Earnings columns: Description, Transaction Date, Transaction Time, Tax Type, Transaction Type, Refund Type,
// Product Title, Package ID, Product Type, SKU ID, Hardware, Buyer Country, ..., Merchant Currency, Amount
// (Merchant Currency), ... Estimated sales columns: Order Number, Order Charged Date, ..., Financial Status, ...,
// Product Title, Package ID, Product Type, SKU ID, Currency of Sale, Item Price, Taxes Collected, Charged Amount,
// ..., Country of Buyer, ... The service account needs "View financial data" in Play Console.
// https://support.google.com/googleplay/android-developer/answer/6135870
import { parseCsv } from './csv';
export const APPLE_SALES_COMBOS: Record<string, { subType: string; frequencies: string[]; version: string }> = {
SALES: { subType: 'SUMMARY', frequencies: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], version: '1_0' },
PRE_ORDER: { subType: 'SUMMARY', frequencies: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'], version: '1_0' },
SUBSCRIPTION: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_3' },
SUBSCRIPTION_EVENT: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_3' },
SUBSCRIBER: { subType: 'DETAILED', frequencies: ['DAILY'], version: '1_3' },
SUBSCRIPTION_OFFER_CODE_REDEMPTION: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_0' },
WIN_BACK_ELIGIBILITY: { subType: 'SUMMARY', frequencies: ['DAILY'], version: '1_0' },
};
/** The report date for a frequency: DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY. Default: the newest that
* is usually there (yesterday minus one day, last month, last year). */
export function appleReportDate(frequency: string, given: string | undefined, now = new Date()): string {
if (given) {
const ok = frequency === 'YEARLY' ? /^\d{4}$/ : frequency === 'MONTHLY' ? /^\d{4}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}$/;
if (!ok.test(given)) throw new Error(`--date for ${frequency}: ${frequency === 'YEARLY' ? 'YYYY' : frequency === 'MONTHLY' ? 'YYYY-MM' : 'YYYY-MM-DD'}`);
return given;
}
const d = new Date(now);
if (frequency === 'YEARLY') return String(d.getUTCFullYear() - 1);
if (frequency === 'MONTHLY') { d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() - 1); return d.toISOString().slice(0, 7); }
d.setUTCDate(d.getUTCDate() - 2);
return d.toISOString().slice(0, 10);
}
export function appleSalesPath(vendor: string, o: { type: string; frequency: string; date: string; subType?: string; version?: string }): string {
const combo = APPLE_SALES_COMBOS[o.type];
if (!combo) throw new Error(`--type: one of ${Object.keys(APPLE_SALES_COMBOS).join(', ')}`);
if (!combo.frequencies.includes(o.frequency)) throw new Error(`${o.type} reports come ${combo.frequencies.join(' / ')} only`);
const q = new URLSearchParams({ 'filter[vendorNumber]': vendor, 'filter[reportType]': o.type, 'filter[reportSubType]': o.subType ?? combo.subType,
'filter[frequency]': o.frequency, 'filter[reportDate]': o.date, 'filter[version]': o.version ?? combo.version });
return `/v1/salesReports?${q}`;
}
export function appleFinancePath(vendor: string, month: string, o: { type?: 'FINANCIAL' | 'FINANCE_DETAIL'; region?: string } = {}): string {
if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('--month YYYY-MM (Apple fiscal month)');
const type = o.type ?? 'FINANCIAL';
const region = o.region ?? (type === 'FINANCE_DETAIL' ? 'Z1' : 'ZZ');
return `/v1/financeReports?${new URLSearchParams({ 'filter[vendorNumber]': vendor, 'filter[reportType]': type, 'filter[regionCode]': region, 'filter[reportDate]': month })}`;
}
/** Tab-separated report text -> records (header row first). Apple's finance reports end with total lines after a
* blank line; rows without the first header column are dropped. Pure. */
export function parseTsv(text: string): Record<string, string>[] {
const lines = text.replace(/^/, '').split(/\r?\n/);
const head = (lines[0] ?? '').split('\t').map(h => h.trim());
const out: Record<string, string>[] = [];
for (const l of lines.slice(1)) {
if (!l.trim()) break;
const cells = l.split('\t');
out.push(Object.fromEntries(head.map((h, i) => [h, (cells[i] ?? '').trim()])));
}
return out;
}
export interface SummaryRow { key: string; title: string; currency: string; units: number; amount: number; rows: number }
const num = (s: string | undefined): number => { const n = Number(String(s ?? '').replace(/,/g, '')); return Number.isFinite(n) ? n : 0; };
const round2 = (n: number): number => Math.round(n * 100) / 100;
function group(rows: Record<string, string>[], keyOf: (r: Record<string, string>) => [string, string, string], unitsOf: (r: Record<string, string>) => number, amountOf: (r: Record<string, string>) => number): SummaryRow[] {
const m = new Map<string, SummaryRow>();
for (const r of rows) {
const [key, title, currency] = keyOf(r);
const id = `${key}\u0000${currency}`;
const cur = m.get(id) ?? { key, title, currency, units: 0, amount: 0, rows: 0 };
cur.units += unitsOf(r); cur.amount += amountOf(r); cur.rows++;
m.set(id, cur);
}
return [...m.values()].map(x => ({ ...x, amount: round2(x.amount) })).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
/** Summary Sales Report: units and proceeds (units x Developer Proceeds) per SKU and proceeds currency. Pure. */
export function summarizeAppleSales(rows: Record<string, string>[]): SummaryRow[] {
return group(rows, r => [r.SKU || r['Apple Identifier'] || '?', r.Title ?? '', r['Currency of Proceeds'] ?? ''], r => num(r.Units), r => num(r.Units) * num(r['Developer Proceeds']));
}
/** Financial report: quantity and Extended Partner Share per Vendor Identifier and currency. Pure. */
export function summarizeAppleFinance(rows: Record<string, string>[]): SummaryRow[] {
return group(rows.filter(r => r['Vendor Identifier'] !== undefined && r['Vendor Identifier'] !== ''),
r => [r['Vendor Identifier']!, r.Title ?? '', r['Partner Share Currency'] ?? ''], r => num(r.Quantity), r => num(r['Extended Partner Share']));
}
/** Play earnings: amount in the merchant currency per SKU, transaction type and currency. Pure. */
export function summarizePlayEarnings(rows: Record<string, string>[]): SummaryRow[] {
return group(rows, r => [`${r['SKU ID'] || r['Product Type'] || '?'} · ${r['Transaction Type'] ?? ''}`, r['Product Title'] ?? '', r['Merchant Currency'] ?? ''],
r => (/^charge$/i.test(r['Transaction Type'] ?? '') ? 1 : 0), r => num(r['Amount (Merchant Currency)']));
}
/** Play estimated sales: charged amount per SKU, financial status and currency of sale. Pure. */
export function summarizePlaySales(rows: Record<string, string>[]): SummaryRow[] {
return group(rows, r => [`${r['SKU ID'] || '?'} · ${r['Financial Status'] ?? ''}`, r['Product Title'] ?? '', r['Currency of Sale'] ?? ''], () => 1, r => num(r['Charged Amount']));
}
/** Total per currency (summaries are never added across currencies). */
export function totalsByCurrency(rows: SummaryRow[]): { currency: string; units: number; amount: number }[] {
const m = new Map<string, { currency: string; units: number; amount: number }>();
for (const r of rows) { const t = m.get(r.currency) ?? { currency: r.currency, units: 0, amount: 0 }; t.units += r.units; t.amount = round2(t.amount + r.amount); m.set(r.currency, t); }
return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
export const summaryCsvRows = (rows: SummaryRow[]): (string | number)[][] => [['key', 'title', 'currency', 'units', 'amount', 'rows'], ...rows.map(r => [r.key, r.title, r.currency, r.units, r.amount, r.rows])];
/** Play month -> object name prefix in the bucket. */
export const playReportPrefix = (kind: 'earnings' | 'sales', month: string): string => {
if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('--month YYYY-MM');
return kind === 'earnings' ? `earnings/earnings_${month.replace('-', '')}` : `sales/salesreport_${month.replace('-', '')}`;
};
/** CSV text of a Play report (they are UTF-8, some exports UTF-16 with a BOM). Pure. */
export function decodeReport(b: Uint8Array): string {
if (b[0] === 0xff && b[1] === 0xfe) return new TextDecoder('utf-16le').decode(b.subarray(2));
return new TextDecoder().decode(b).replace(/^/, '');
}
/** Play report CSV -> records keyed by the original header names. Pure. */
export const parsePlayCsv = (text: string): Record<string, string>[] => {
const [head, ...rest] = parseCsv(text);
if (!head) return [];
const keys = head.map(h => h.trim());
return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
};
// ---- day ranges (a collector reads N days in one JSON) ----
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const addDay = (d: string, n: number): string => new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
export const MAX_RANGE_DAYS = 366;
/** Every date from..to inclusive (YYYY-MM-DD). Pure. */
export function dateRange(from: string, to: string): string[] {
const out: string[] = [];
for (let d = from; d <= to; d = addDay(d, 1)) out.push(d);
return out;
}
/**
* --days N (the N days ending yesterday) or --from/--to (inclusive; --to defaults to yesterday): the dates to read, or
* null when none of the three is given. Daily reports of both stores come out the next day, so the newest day may be
* missing; that is reported, not an error. Pure.
*/
export function reportRange(o: { days?: string; from?: string; to?: string }, now = new Date()): { from: string; to: string; dates: string[] } | null {
if (o.days === undefined && o.from === undefined && o.to === undefined) return null;
if (o.days !== undefined && (o.from !== undefined || o.to !== undefined)) throw new Error('--days or --from/--to, not both');
const yesterday = addDay(now.toISOString().slice(0, 10), -1);
let from: string, to: string;
if (o.days !== undefined) {
const n = Number(o.days);
if (!Number.isInteger(n) || n < 1 || n > MAX_RANGE_DAYS) throw new Error(`--days: 1-${MAX_RANGE_DAYS}`);
to = yesterday; from = addDay(to, -(n - 1));
} else {
if (o.from === undefined) throw new Error('--to needs --from YYYY-MM-DD');
to = o.to ?? yesterday; from = o.from;
if (!DAY_RE.test(from) || !DAY_RE.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('--from / --to: YYYY-MM-DD');
if (from > to) throw new Error('--from is after --to');
if (dateRange(from, to).length > MAX_RANGE_DAYS) throw new Error(`--from/--to: at most ${MAX_RANGE_DAYS} days`);
}
return { from, to, dates: dateRange(from, to) };
}
export interface DaySummary { date: string; rows: number; summary: SummaryRow[]; totals: { currency: string; units: number; amount: number }[] }
/** The days of a range as the collector reads them: per-day summary and totals, totals over the range per currency
* (never added across currencies), and the dates that had no report. Pure. */
export function rangeReport(days: DaySummary[], dates: string[]): { days: DaySummary[]; totals: { currency: string; units: number; amount: number }[]; missing: string[] } {
const have = new Set(days.map(d => d.date));
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
return { days: sorted, totals: totalsByCurrency(sorted.flatMap(d => d.summary)), missing: dates.filter(d => !have.has(d)) };
}
export const rangeCsvRows = (days: DaySummary[]): (string | number)[][] =>
[['date', 'key', 'title', 'currency', 'units', 'amount', 'rows'], ...days.flatMap(d => d.summary.map(r => [d.date, r.key, r.title, r.currency, r.units, r.amount, r.rows]))];
export interface PlayDaySales { date: string; currency: string; orders: number; refunds: number; itemPrice: number; taxes: number; charged: number; refunded: number; net: number }
/**
* Play estimated sales rows -> one line per order-charged date and currency of sale. charged = Charged Amount (tax
* included) of the Charged rows, refunded = the Refund rows (always negative), net = charged + refunded; itemPrice and
* taxes are the Charged rows' Item Price and Taxes Collected. Rows outside from..to are left out. Estimated figures
* before Google's service fee: the earnings report (monthly) has the proceeds. Pure.
*/
export function summarizePlaySalesByDay(rows: Record<string, string>[], from: string, to: string): PlayDaySales[] {
const m = new Map<string, PlayDaySales>();
for (const r of rows) {
const date = (r['Order Charged Date'] ?? '').slice(0, 10);
if (!DAY_RE.test(date) || date < from || date > to) continue;
const status = r['Financial Status'] ?? '';
const refund = /refund|chargeback/i.test(status);
if (!refund && !/^charge/i.test(status)) continue;
const currency = r['Currency of Sale'] ?? '';
const id = `${date}\u0000${currency}`;
const x = m.get(id) ?? { date, currency, orders: 0, refunds: 0, itemPrice: 0, taxes: 0, charged: 0, refunded: 0, net: 0 };
const amount = num(r['Charged Amount']);
if (refund) { x.refunds++; x.refunded += -Math.abs(amount); }
else { x.orders++; x.charged += amount; x.itemPrice += num(r['Item Price']); x.taxes += num(r['Taxes Collected']); }
m.set(id, x);
}
return [...m.values()].map(x => ({ ...x, itemPrice: round2(x.itemPrice), taxes: round2(x.taxes), charged: round2(x.charged), refunded: round2(x.refunded), net: round2(x.charged + x.refunded) }))
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
/** Range totals per currency of the daily Play sales lines. Pure. */
export function playSalesTotals(days: PlayDaySales[]): { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }[] {
const m = new Map<string, { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }>();
for (const d of days) {
const t = m.get(d.currency) ?? { currency: d.currency, orders: 0, refunds: 0, charged: 0, refunded: 0, net: 0 };
t.orders += d.orders; t.refunds += d.refunds; t.charged = round2(t.charged + d.charged); t.refunded = round2(t.refunded + d.refunded); t.net = round2(t.net + d.net);
m.set(d.currency, t);
}
return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
}
/** The months (YYYY-MM) a date window touches, for the Play sales file names. Pure. */
export const monthsOfRange = (from: string, to: string): string[] => [...new Set(dateRange(from, to).map(d => d.slice(0, 7)))];