132 lines
9 KiB
TypeScript
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}`);
|
|
}
|