- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks) - apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app version (--with-version), --no-submit keeps the draft; read-only MCP plan - apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten) - whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits - apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API) - apple cpp and apple events: list, create, upload, submit (asks) - reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV - triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS - aso rank: iTunes Search API ranks and competitors, rate limited - ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks - tests against recorded, sanitized answers; skills, README, CHANGELOG
150 lines
10 KiB
TypeScript
150 lines
10 KiB
TypeScript
// 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<string, { subType: string; frequencies: string[]; version: string }> = {
|
|
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<string, string>[] {
|
|
const lines = text.replace(/^/, '').split(/\r?\n/);
|
|
const head = (lines[0] ?? '').split('\t').map(h => h.trim());
|
|
const out: Record<string, string>[] = [];
|
|
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<string, string>[], keyOf: (r: Record<string, string>) => [string, string, string], unitsOf: (r: Record<string, string>) => number, amountOf: (r: Record<string, string>) => number): SummaryRow[] {
|
|
const m = new Map<string, SummaryRow>();
|
|
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<string, string>[]): 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<string, string>[]): 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<string, string>[]): 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<string, string>[]): 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<string, { currency: string; units: number; amount: number }>();
|
|
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<string, string>[] => {
|
|
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()])));
|
|
};
|