// 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()]))); }; // ---- day ranges (a collector reads N days in one JSON) ---- const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; const addDay = (d: string, n: number): string => new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10); export const MAX_RANGE_DAYS = 366; /** Every date from..to inclusive (YYYY-MM-DD). Pure. */ export function dateRange(from: string, to: string): string[] { const out: string[] = []; for (let d = from; d <= to; d = addDay(d, 1)) out.push(d); return out; } /** * --days N (the N days ending yesterday) or --from/--to (inclusive; --to defaults to yesterday): the dates to read, or * null when none of the three is given. Daily reports of both stores come out the next day, so the newest day may be * missing; that is reported, not an error. Pure. */ export function reportRange(o: { days?: string; from?: string; to?: string }, now = new Date()): { from: string; to: string; dates: string[] } | null { if (o.days === undefined && o.from === undefined && o.to === undefined) return null; if (o.days !== undefined && (o.from !== undefined || o.to !== undefined)) throw new Error('--days or --from/--to, not both'); const yesterday = addDay(now.toISOString().slice(0, 10), -1); let from: string, to: string; if (o.days !== undefined) { const n = Number(o.days); if (!Number.isInteger(n) || n < 1 || n > MAX_RANGE_DAYS) throw new Error(`--days: 1-${MAX_RANGE_DAYS}`); to = yesterday; from = addDay(to, -(n - 1)); } else { if (o.from === undefined) throw new Error('--to needs --from YYYY-MM-DD'); to = o.to ?? yesterday; from = o.from; if (!DAY_RE.test(from) || !DAY_RE.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('--from / --to: YYYY-MM-DD'); if (from > to) throw new Error('--from is after --to'); if (dateRange(from, to).length > MAX_RANGE_DAYS) throw new Error(`--from/--to: at most ${MAX_RANGE_DAYS} days`); } return { from, to, dates: dateRange(from, to) }; } export interface DaySummary { date: string; rows: number; summary: SummaryRow[]; totals: { currency: string; units: number; amount: number }[] } /** The days of a range as the collector reads them: per-day summary and totals, totals over the range per currency * (never added across currencies), and the dates that had no report. Pure. */ export function rangeReport(days: DaySummary[], dates: string[]): { days: DaySummary[]; totals: { currency: string; units: number; amount: number }[]; missing: string[] } { const have = new Set(days.map(d => d.date)); const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); return { days: sorted, totals: totalsByCurrency(sorted.flatMap(d => d.summary)), missing: dates.filter(d => !have.has(d)) }; } export const rangeCsvRows = (days: DaySummary[]): (string | number)[][] => [['date', 'key', 'title', 'currency', 'units', 'amount', 'rows'], ...days.flatMap(d => d.summary.map(r => [d.date, r.key, r.title, r.currency, r.units, r.amount, r.rows]))]; export interface PlayDaySales { date: string; currency: string; orders: number; refunds: number; itemPrice: number; taxes: number; charged: number; refunded: number; net: number } /** * Play estimated sales rows -> one line per order-charged date and currency of sale. charged = Charged Amount (tax * included) of the Charged rows, refunded = the Refund rows (always negative), net = charged + refunded; itemPrice and * taxes are the Charged rows' Item Price and Taxes Collected. Rows outside from..to are left out. Estimated figures * before Google's service fee: the earnings report (monthly) has the proceeds. Pure. */ export function summarizePlaySalesByDay(rows: Record[], from: string, to: string): PlayDaySales[] { const m = new Map(); for (const r of rows) { const date = (r['Order Charged Date'] ?? '').slice(0, 10); if (!DAY_RE.test(date) || date < from || date > to) continue; const status = r['Financial Status'] ?? ''; const refund = /refund|chargeback/i.test(status); if (!refund && !/^charge/i.test(status)) continue; const currency = r['Currency of Sale'] ?? ''; const id = `${date}\u0000${currency}`; const x = m.get(id) ?? { date, currency, orders: 0, refunds: 0, itemPrice: 0, taxes: 0, charged: 0, refunded: 0, net: 0 }; const amount = num(r['Charged Amount']); if (refund) { x.refunds++; x.refunded += -Math.abs(amount); } else { x.orders++; x.charged += amount; x.itemPrice += num(r['Item Price']); x.taxes += num(r['Taxes Collected']); } m.set(id, x); } return [...m.values()].map(x => ({ ...x, itemPrice: round2(x.itemPrice), taxes: round2(x.taxes), charged: round2(x.charged), refunded: round2(x.refunded), net: round2(x.charged + x.refunded) })) .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0)); } /** Range totals per currency of the daily Play sales lines. Pure. */ export function playSalesTotals(days: PlayDaySales[]): { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }[] { const m = new Map(); for (const d of days) { const t = m.get(d.currency) ?? { currency: d.currency, orders: 0, refunds: 0, charged: 0, refunded: 0, net: 0 }; t.orders += d.orders; t.refunds += d.refunds; t.charged = round2(t.charged + d.charged); t.refunded = round2(t.refunded + d.refunded); t.net = round2(t.net + d.net); m.set(d.currency, t); } return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0)); } /** The months (YYYY-MM) a date window touches, for the Play sales file names. Pure. */ export const monthsOfRange = (from: string, to: string): string[] => [...new Set(dateRange(from, to).map(d => d.slice(0, 7)))];