// 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 = { 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[] { const lines = text.replace(/^/, '').split(/\r?\n/); const head = (lines[0] ?? '').split('\t').map(h => h.trim()); const out: Record[] = []; 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[], keyOf: (r: Record) => [string, string, string], unitsOf: (r: Record) => number, amountOf: (r: Record) => number): SummaryRow[] { const m = new Map(); 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[]): 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[]): 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[]): 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[]): 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(); 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[] => { 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()]))); };