storebound/cli/reports.ts

132 lines
9 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
// 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 } 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, decodeReport, parsePlayCsv, parseTsv, playReportPrefix, type SummaryRow, summarizeAppleFinance,
summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, 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 | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM';
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;
}
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 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, need(config.apple?.bundleId, '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 = config.play?.reportsBucket;
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
const month = need(a.get('month'), '--month YYYY-MM');
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
const prefix = playReportPrefix(kind, month);
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
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 items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
if (!items.length) { 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: 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;
all.push(...parsePlayCsv(t));
}
}
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}`);
}