// 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 ] [--dry] [--json] // The raw report is saved as it came (the TSV / the CSV out of the zip) next to a .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 { 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'; 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): 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 (App Store Connect > Payments and Financial Reports, 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 { 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 (App Store Connect > Payments and Financial Reports)'); 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, ''); 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 ? Bun.gunzipSync(gz as Uint8Array) : 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 (Play Console > Download 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[] = []; 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}`); }