storebound/cli/reports.ts

226 lines
17 KiB
TypeScript

// Sales and finance reports of both stores into .storebound/reports/ (gitignored), with a summary table and a CSV export.
// storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]
// storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]
// storebound reports play-earnings --month YYYY-MM
// storebound reports play-sales --month YYYY-MM
// a day range in one JSON (per-day summary and totals per currency; days with no report listed in "missing", not an error):
// storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)
// storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)
// common: [--out .storebound/reports] [--csv <summary.csv>] [--dry] [--json]
// The raw report is saved as it came (the TSV / the CSV out of the zip) next to a <name>.summary.csv with only
// aggregates (per product and currency; amounts are never added across currencies). --dry prints what would be
// fetched and fetches nothing. Apple needs a team API key (not an individual key) with access to reports and the vendor
// number (ASC_VENDOR_NUMBER in env or .storebound/credentials.json; --vendor). Play needs play.reportsBucket and the
// service account with "View financial data". Read-only on both stores. Pure parts and sources: cli/lib/reports.ts.
import { consoleLink } from './lib/console-links';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { ascClient } from './apple/asc';
import { bucketName } from './lib/analytics';
import { ascVendorNumber, playReportsBucket } from './lib/credentials';
import type { Ctx } from './lib/ctx';
import { need } from './lib/ctx';
import { toCsv } from './lib/csv';
import { STORAGE_READ_SCOPE } from './lib/google-auth';
import { done, log, printJson, warn } from './lib/log';
import {
APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, type DaySummary, decodeReport, monthsOfRange, parsePlayCsv, parseTsv, playReportPrefix, playSalesTotals, rangeCsvRows, rangeReport,
reportRange, type SummaryRow, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summarizePlaySalesByDay, summaryCsvRows, totalsByCurrency,
} from './lib/reports';
import { textTable } from './lib/table';
import { unzip } from './lib/zip';
import { googleClient } from './play/google';
import * as rt from './lib/rt';
const TAG = 'reports';
const USAGE = 'usage: storebound reports apple-sales [--days N | --from YYYY-MM-DD [--to ...]] | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM | play-sales --days N | --from YYYY-MM-DD [--to ...]';
/** Runs fn over items, at most `limit` at a time, results in item order. */
async function pool<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!); } }));
return out;
}
function show(name: string, rows: SummaryRow[], json: boolean, extra: Record<string, unknown>): void {
const totals = totalsByCurrency(rows);
if (json) { printJson({ report: name, ...extra, summary: rows, totals }); return; }
console.log(rows.length ? textTable(['product', 'title', 'currency', 'units', 'amount'], rows.map(r => [r.key, r.title.slice(0, 30), r.currency, r.units, r.amount.toFixed(2)])) : 'no rows');
if (totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], totals.map(t => [t.currency, t.units, t.amount.toFixed(2)])));
}
function save(dir: string, name: string, raw: string | Uint8Array, rows: SummaryRow[], csvOut: string | undefined): { raw: string; summary: string } {
mkdirSync(dir, { recursive: true });
const rawFile = join(dir, name);
writeFileSync(rawFile, raw);
const summary = csvOut ?? join(dir, name.replace(/\.(tsv|csv|txt)$/, '') + '.summary.csv');
mkdirSync(dirname(summary), { recursive: true });
writeFileSync(summary, toCsv(summaryCsvRows(rows)));
return { raw: rawFile, summary };
}
/** What a failed Sales and Trends / finance download usually means. Seen live: a wrong vendor number gets a 500
* (after the retries) from salesReports and a 400 PARAMETER_ERROR.INVALID from financeReports. Pure. */
export function appleReportHint(msg: string): string | null {
if (/salesReports -> 500|financeReports -> 400 .*vendor number/i.test(msg)) return `check the vendor number (${consoleLink('asc.payments')}, the number next to the legal entity name); reports also need a team key with Sales or Finance access`;
if (/-> 403/.test(msg)) return 'the API key may not read reports: it needs the Admin, Finance or Sales role (Users and Access > Integrations)';
return null;
}
type Google = ReturnType<typeof googleClient>;
/** The CSV rows of a Play report month (all zips under the prefix, all CSVs in them); null when the bucket has none. */
async function playMonth(g: Google, bucket: string, kind: 'earnings' | 'sales', month: string): Promise<{ rows: Record<string, string>[]; text: string } | null> {
const prefix = playReportPrefix(kind, month);
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
if (!items.length) return null;
const rows: Record<string, string>[] = [];
let text = '';
for (const o of items) {
log(TAG, `gs://${bucketName(bucket)}/${o.name}`);
const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`);
for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) {
const t = decodeReport(e.data);
text += (text ? '\n' : '') + t;
rows.push(...parsePlayCsv(t));
}
}
return { rows, text };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const sub = a._[0];
if (!sub) throw new Error(USAGE);
const dry = a.has('dry');
const out = resolve(a.get('out') ?? '.storebound/reports');
const csvOut = a.get('csv') ? resolve(a.get('csv')!) : undefined;
if (sub === 'apple-sales' || sub === 'apple-finance') {
const vendor = a.get('vendor') ?? ascVendorNumber(root);
if (!vendor || !/^\d{5,12}$/.test(vendor)) throw new Error(`vendor number missing: ASC_VENDOR_NUMBER in env, .storebound/credentials.json or the credential profile (--account), or --vendor (${consoleLink('asc.payments')})`);
let path: string, name: string;
if (sub === 'apple-sales') {
const type = a.str('type', 'SALES').toUpperCase();
const frequency = a.str('frequency', APPLE_SALES_COMBOS[type]?.frequencies[0] ?? 'DAILY').toUpperCase();
const date = appleReportDate(frequency, a.get('date'));
path = appleSalesPath(vendor, { type, frequency, date });
name = `apple-${type.toLowerCase()}-${frequency.toLowerCase()}-${date}.tsv`;
} else {
const month = need(a.get('month'), '--month YYYY-MM');
const type = a.has('detail') ? 'FINANCE_DETAIL' as const : 'FINANCIAL' as const;
path = appleFinancePath(vendor, month, { type, region: a.get('region') });
name = `apple-${type === 'FINANCIAL' ? 'finance' : 'finance-detail'}-${month}.tsv`;
}
const range = sub === 'apple-sales' ? reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') }) : null;
if (sub === 'apple-finance' && (a.has('days') || a.has('from') || a.has('to'))) throw new Error('--days / --from / --to are for apple-sales and play-sales (finance reports are monthly: --month)');
if (range) {
const type = a.str('type', 'SALES').toUpperCase();
if (type !== 'SALES') throw new Error('--days / --from / --to read the daily SALES reports: leave out --type (or SALES)');
if (a.has('frequency') && a.str('frequency', 'DAILY').toUpperCase() !== 'DAILY') throw new Error('--days / --from / --to read DAILY reports');
const pathOf = (date: string) => appleSalesPath(vendor, { type: 'SALES', frequency: 'DAILY', date });
const shownRange = `GET ${pathOf('<date>').replace(vendor, '<vendor>')} for ${range.dates.length} day(s) ${range.from}..${range.to}`;
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: shownRange, dir: out }); else log(TAG, `would ${shownRange} -> ${out}`); return; }
const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId);
mkdirSync(out, { recursive: true });
let rowCount = 0;
const got = await pool(range.dates, 4, async (date): Promise<DaySummary | null> => {
let gz: Uint8Array | null;
try { gz = await c.bytes(pathOf(date)); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}
${hint}` : (e as Error).message); }
if (!gz) return null;
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
writeFileSync(join(out, `apple-sales-daily-${date}.tsv`), text);
const rows = parseTsv(text);
rowCount += rows.length;
const summary = summarizeAppleSales(rows);
return { date, rows: rows.length, summary, totals: totalsByCurrency(summary) };
});
const rep = rangeReport(got.filter((x): x is DaySummary => !!x), range.dates);
const summaryFile = csvOut ?? join(out, `apple-sales-daily-${range.from}_${range.to}.summary.csv`);
mkdirSync(dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, toCsv(rangeCsvRows(rep.days)));
if (rep.missing.length) warn(TAG, `no report for ${rep.missing.join(', ')} (no sales then, or not published yet: daily reports come out the next day)`);
if (a.has('json')) printJson({ report: 'apple-sales', type: 'SALES', frequency: 'DAILY', from: range.from, to: range.to, rows: rowCount, days: rep.days, totals: rep.totals, missing: rep.missing, files: { dir: out, summary: summaryFile } });
else {
console.log(rep.days.length ? textTable(['date', 'currency', 'units', 'amount'], rep.days.flatMap(d => d.totals.map(t => [d.date, t.currency, t.units, t.amount.toFixed(2)]))) : 'no rows');
if (rep.totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], rep.totals.map(t => [t.currency, t.units, t.amount.toFixed(2)])));
}
done(TAG, `${rep.days.length}/${range.dates.length} day(s), ${rowCount} rows -> ${out}; summary ${summaryFile}`);
return;
}
const shown = path.replace(vendor, '<vendor>');
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${shown}`, file: join(out, name) }); else log(TAG, `would GET ${shown} -> ${join(out, name)}`); return; }
const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId);
log(TAG, `GET ${shown}`);
let gz: Uint8Array | null;
try { gz = await c.bytes(path); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}\n${hint}` : (e as Error).message); }
if (!gz) { done(TAG, 'no report for that date (no sales then, or not published yet: daily reports come out the next day, monthly ones about a week later)'); if (a.has('json')) printJson({ report: name, empty: true }); return; }
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
const rows = parseTsv(text);
const summary = sub === 'apple-sales' ? (a.str('type', 'SALES').toUpperCase() === 'SALES' ? summarizeAppleSales(rows) : []) : summarizeAppleFinance(rows);
const files = save(out, name, text, summary, csvOut);
show(name, summary, a.has('json'), { rows: rows.length, files });
if (sub === 'apple-sales' && a.str('type', 'SALES').toUpperCase() !== 'SALES') log(TAG, `${rows.length} rows saved; the summary table covers SALES reports, open the TSV for this type`);
done(TAG, `${rows.length} rows -> ${files.raw}; summary ${files.summary}`);
return;
}
if (sub === 'play-earnings' || sub === 'play-sales') {
const bucket = playReportsBucket(root, config.play?.reportsBucket);
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
const range = reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') });
if (range && kind === 'earnings') throw new Error('play-earnings is monthly (--month YYYY-MM); the daily revenue is play-sales --days N');
if (range) {
const months = monthsOfRange(range.from, range.to);
const where = months.map(m => `gs://${bucketName(bucket)}/${playReportPrefix('sales', m)}*.zip`);
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${where.join(' ')}`, from: range.from, to: range.to, dir: out }); else log(TAG, `would read ${where.join(', ')} -> ${out}`); return; }
const g = googleClient(root, STORAGE_READ_SCOPE);
const all: Record<string, string>[] = [];
const missingMonths: string[] = [];
const files: string[] = [];
mkdirSync(out, { recursive: true });
for (const m of months) {
const r = await playMonth(g, bucket, 'sales', m);
if (!r) { missingMonths.push(m); continue; }
all.push(...r.rows);
const file = join(out, `play-sales-${m}.csv`);
writeFileSync(file, r.text);
files.push(file);
}
const days = summarizePlaySalesByDay(all, range.from, range.to);
const have = new Set(days.map(d => d.date));
const emptyDays = range.dates.filter(d => !have.has(d));
const summaryFile = csvOut ?? join(out, `play-sales-daily-${range.from}_${range.to}.summary.csv`);
mkdirSync(dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, toCsv([['date', 'currency', 'orders', 'refunds', 'item_price', 'taxes', 'charged', 'refunded', 'net'], ...days.map(d => [d.date, d.currency, d.orders, d.refunds, d.itemPrice, d.taxes, d.charged, d.refunded, d.net])]));
const totals = playSalesTotals(days);
if (missingMonths.length) warn(TAG, `no sales report for month(s) ${missingMonths.join(', ')} in the bucket (not published yet, or the service account lacks "View financial data")`);
if (a.has('json')) printJson({ report: 'play-sales', from: range.from, to: range.to, rows: all.length, days, totals, emptyDays, missingMonths, estimated: true, files: { raw: files, summary: summaryFile } });
else {
console.log(days.length ? textTable(['date', 'currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], days.map(d => [d.date, d.currency, d.orders, d.refunds, d.charged.toFixed(2), d.refunded.toFixed(2), d.net.toFixed(2)])) : 'no orders in the range');
if (totals.length) console.log('\n' + textTable(['currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], totals.map(t => [t.currency, t.orders, t.refunds, t.charged.toFixed(2), t.refunded.toFixed(2), t.net.toFixed(2)])));
}
done(TAG, `${days.length} order day line(s) of ${range.dates.length} day(s) -> ${summaryFile}`);
return;
}
const month = need(a.get('month'), '--month YYYY-MM (or --days N / --from YYYY-MM-DD for play-sales)');
const prefix = playReportPrefix(kind, month);
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET gs://${bucketName(bucket)}/${prefix}*.zip`, dir: out }); else log(TAG, `would read gs://${bucketName(bucket)}/${prefix}*.zip -> ${out}`); return; }
const g = googleClient(root, STORAGE_READ_SCOPE);
const month1 = await playMonth(g, bucket, kind, month);
if (!month1) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; }
const all = month1.rows, text = month1.text;
if (!all.length) warn(TAG, 'the zip(s) held no CSV rows');
const summary = kind === 'earnings' ? summarizePlayEarnings(all) : summarizePlaySales(all);
const files = save(out, `play-${kind}-${month}.csv`, text, summary, csvOut);
show(`play-${kind}-${month}`, summary, a.has('json'), { rows: all.length, files });
done(TAG, `${all.length} rows -> ${files.raw}; summary ${files.summary}`);
return;
}
throw new Error(`unknown reports command "${sub}"\n${USAGE}`);
}