analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores
This commit is contained in:
parent
b59665c661
commit
5833776a4b
15 changed files with 651 additions and 60 deletions
|
|
@ -3,6 +3,10 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- `analytics snapshot` and `reports` for a collector: a store-only config may name the App Store app by `apple.appId` alone (`bundleId` null or absent; every write still needs it); the Play reports bucket also comes from env `PLAY_REPORTS_BUCKET` or the credential profile's `reportsBucket` when `play.reportsBucket` is missing; `analytics snapshot --json` prints the snapshot as one JSON document on stdout (also when every store failed, with the exit code 1) and `--no-write` skips the file.
|
||||
- Snapshots carry active-user numbers per day: App Store App Usage (`sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Sessions and App Crashes reports; a missing usage report is a `notes` line, not a failure) and Play `activeDeviceInstalls` and `totalUserInstalls` (installs overview file, else the country file summed) next to `installs` (Daily User Installs).
|
||||
- `reports apple-sales --days N | --from YYYY-MM-DD [--to ...] --json` reads the daily SALES reports of a range into one JSON (per-day summary and totals per currency, range totals per currency, `missing` days listed, never errors); `reports play-sales --days N | --from ...` gives the daily revenue per currency from the estimated sales reports (orders, refunds, charged, refunded, net, by order charged date). MCP `reports` takes `days`, `from`, `to`.
|
||||
|
||||
## 0.8.50 · 2026-10-25
|
||||
|
||||
- App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/<resource id>`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
// Store analytics snapshots next to the listing changes.
|
||||
// storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)
|
||||
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/<date>.json
|
||||
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/<date>.json
|
||||
// --json prints the snapshot as one JSON document on stdout (the file is still written unless --no-write or --dry)
|
||||
// storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]
|
||||
// App Store: Analytics Reports API (Discovery and Engagement: impressions, product page views; Downloads: first-time
|
||||
// downloads and redownloads), every segment checked against its md5 checksum. Google Play has no statistics API:
|
||||
// the monthly CSV exports in your Cloud Storage bucket (play.reportsBucket = "pubsite_prod_rev_<id>", Play Console >
|
||||
// Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors,
|
||||
// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing
|
||||
// acquisitions, daily user installs, active device installs, total user installs). App Usage (App Store: sessions, active
|
||||
// devices, crashes per day from the App Sessions and App Crashes reports). A store-only config needs no play.reportsBucket
|
||||
// (env PLAY_REPORTS_BUCKET or the profile's reportsBucket) and no apple.bundleId (apple.appId is enough). compare lines the snapshots up with the git history of the store files (listing
|
||||
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
|
||||
import { consoleLink } from './lib/console-links';
|
||||
import { storeReadError } from './lib/google-errors';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createOnce } from './lib/upsert';
|
||||
import { playReportsBucket } from './lib/credentials';
|
||||
import { ascClient, type Asc, type J } from './apple/asc';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import {
|
||||
type AppleDay, APPLE_REPORTS, appleDaily, appleTotals, bucketName, checksumOk, compareAroundEvents, decodeText, effectMarkdown, effectTable, inWindow,
|
||||
latestPerDate, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
|
||||
latestPerDate, mergeAppleUsage, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
|
||||
} from './lib/analytics';
|
||||
import { STORAGE_READ_SCOPE } from './lib/google-auth';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
|
|
@ -55,9 +59,9 @@ async function reportRows(c: Asc, report: J, start: string, tag: string): Promis
|
|||
return latestPerDate(parts);
|
||||
}
|
||||
|
||||
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[] }> {
|
||||
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[]; notes: string[] }> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const c = await ascClient(root, config.apple!.bundleId ?? '', config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const req = await ongoingRequest(c, appId);
|
||||
if (!req) throw new Error('no active ONGOING analytics report request: run storebound analytics setup (Admin key), data starts 1-2 days later');
|
||||
|
|
@ -65,25 +69,36 @@ async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ da
|
|||
const eng = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.engagement.category), APPLE_REPORTS.engagement.match);
|
||||
const dl = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.downloads.category), APPLE_REPORTS.downloads.match);
|
||||
if (!eng || !dl) throw new Error(`reports not found yet (${reports.length} listed): wait a day after setup`);
|
||||
const daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
|
||||
return { daily: inWindow(daily, start, end) };
|
||||
let daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
|
||||
// App Usage is optional: a team whose request predates the usage reports, or whose usage reports are not out yet, still gets the rest
|
||||
const notes: string[] = [];
|
||||
const usage = (m: { category: string; match: RegExp }) => pickReport(reports.filter(r => r.attributes?.category === m.category), m.match);
|
||||
const ses = usage(APPLE_REPORTS.sessions), cr = usage(APPLE_REPORTS.crashes);
|
||||
if (ses || cr) {
|
||||
try {
|
||||
daily = mergeAppleUsage(daily, ses ? await reportRows(c, ses, start, ses.attributes.name) : [], cr ? await reportRows(c, cr, start, cr.attributes.name) : []);
|
||||
if (!ses || !cr) notes.push(`App Usage: the ${ses ? 'App Crashes' : 'App Sessions'} report is not listed yet`);
|
||||
} catch (e) { notes.push(`App Usage not read: ${storeReadError((e as Error).message).error.slice(0, 200)}`); }
|
||||
} else notes.push('App Usage: the App Sessions / App Crashes reports are not listed yet (they appear a day or two after the first report)');
|
||||
for (const n of notes) warn(TAG, `apple: ${n}`);
|
||||
return { daily: inWindow(daily, start, end), notes };
|
||||
}
|
||||
|
||||
async function playSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: PlayDay[] }> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const bucket = config.play?.reportsBucket;
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
const bucket = playReportsBucket(root, config.play?.reportsBucket);
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [];
|
||||
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [], ov: Record<string, string>[] = [];
|
||||
for (const month of monthsBetween(start, end)) {
|
||||
const o = playObjects(config.play!.packageName, month);
|
||||
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst]] as const) {
|
||||
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst], [o.installsOverview, ov]] as const) {
|
||||
const url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(name)}?alt=media`;
|
||||
try { into.push(...parseReport(decodeText(await g.bytes(url)))); log(TAG, `play: ${name}`); }
|
||||
catch (e) { if (/-> 404/.test(String(e))) warn(TAG, `play: ${name} not there (yet)`); else throw e; }
|
||||
}
|
||||
}
|
||||
return { daily: inWindow(playDaily(sp, inst), start, end) };
|
||||
return { daily: inWindow(playDaily(sp, inst, ov), start, end) };
|
||||
}
|
||||
|
||||
function readSnapshots(dir: string): Snapshot[] {
|
||||
|
|
@ -100,7 +115,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
if (sub === 'setup') {
|
||||
if (!config.apple) throw new Error('no apple section in the config (Google Play needs no setup: set play.reportsBucket)');
|
||||
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
||||
const c = await ascClient(root, config.apple.bundleId ?? '', config.apple.appId);
|
||||
const appId = await c.app();
|
||||
const cur = await ongoingRequest(c, appId);
|
||||
if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; }
|
||||
|
|
@ -121,20 +136,24 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const end = day(new Date(now.getTime() - 86_400_000)), start = day(new Date(now.getTime() - days * 86_400_000));
|
||||
const snap: Snapshot = { date: day(now), window: { start, end, days } };
|
||||
if ((!store || store === 'apple') && config.apple) {
|
||||
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; }
|
||||
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily), ...(r.notes.length ? { notes: r.notes } : {}) }; }
|
||||
catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); }
|
||||
}
|
||||
if ((!store || store === 'play') && config.play) {
|
||||
try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; }
|
||||
catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); }
|
||||
}
|
||||
const json = a.has('json');
|
||||
const rows: (string | number)[][] = [];
|
||||
if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); }
|
||||
if (snap.play && !('error' in snap.play)) { const t = snap.play.totals; rows.push(['play', `${t.visitors} visitors`, `${t.acquisitions} acquisitions`, `${t.installs} installs`, pct(t.conversion ?? null)]); }
|
||||
if (rows.length) console.log(textTable(['store', 'reach', 'interest', 'installs', 'conversion'], rows));
|
||||
const file = join(dir, `${snap.date}.json`);
|
||||
const problem = snapshotProblem(snap);
|
||||
// --json: the collector gets the document (with each store's error) on stdout even when nothing could be read; the exit code still says it failed
|
||||
if (json) printJson(snap);
|
||||
if (problem) throw new Error(`no store data, nothing written (${problem})`);
|
||||
if (a.has('no-write')) { done(TAG, `snapshot ${start} to ${end} (not written: --no-write)`); return; }
|
||||
if (dry) { done(TAG, `dry run: would write ${file} (${start} to ${end})`); return; }
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(snap, null, 2) + '\n');
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'],
|
||||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
'report': [() => import('./report'), 'shareable store readiness report: scan + check + validate (read-only; only the stores with credentials, else it says why) in one self-contained HTML page .storebound/reports/readiness-<date>.html, opened in the browser; --out <file>, --md, --json, --no-open, --offline'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, App Usage, conversion -> .storebound/analytics; --json prints it) | compare (vs listing changes in git)'],
|
||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||
'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from <file> --to <path>: one image of the app folder'],
|
||||
'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'],
|
||||
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; apple-sales and play-sales also --days N | --from/--to (per-day JSON); read-only'],
|
||||
'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'],
|
||||
'aso': [() => import('./aso'), 'rank | competitors | suggest | audit: App Store ranks, top apps per keyword, keyword field proposals (Apple Ads popularity with credentials) and one ASO report, from the public iTunes Search API -> .storebound/aso/'],
|
||||
'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes) | ppp --base <USD> (purchasing power price sheet, snapped to store price points)'],
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"days",
|
||||
"dir",
|
||||
"md",
|
||||
"no-write",
|
||||
"paths",
|
||||
"store"
|
||||
],
|
||||
|
|
@ -908,11 +909,14 @@
|
|||
"reports": [
|
||||
"csv",
|
||||
"date",
|
||||
"days",
|
||||
"detail",
|
||||
"frequency",
|
||||
"from",
|
||||
"month",
|
||||
"out",
|
||||
"region",
|
||||
"to",
|
||||
"type",
|
||||
"vendor"
|
||||
],
|
||||
|
|
@ -1282,6 +1286,7 @@
|
|||
"no-variants",
|
||||
"no-wait",
|
||||
"no-web",
|
||||
"no-write",
|
||||
"not-sent-for-review",
|
||||
"notify",
|
||||
"off",
|
||||
|
|
@ -1421,7 +1426,7 @@
|
|||
],
|
||||
"analytics": [
|
||||
"storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)",
|
||||
"storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/<date>.json",
|
||||
"storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/<date>.json",
|
||||
"storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]"
|
||||
],
|
||||
"api": [
|
||||
|
|
@ -1718,7 +1723,9 @@
|
|||
"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"
|
||||
"storebound reports play-sales --month YYYY-MM",
|
||||
"storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)",
|
||||
"storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)"
|
||||
],
|
||||
"revenuecat": [
|
||||
"storebound revenuecat diff [--json] read-only parity report; exit 1 when out of sync",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import { platformsOf, playCategoryOf, profileOf, sellsOn } from './lib/config';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
|
||||
import { credentialValue, maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
|
||||
import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations';
|
||||
import type { ProjectSummary } from './lib/desktop-protocol';
|
||||
import { log, printJson } from './lib/log';
|
||||
|
|
@ -42,7 +42,7 @@ export function summarize(ctx: Ctx): ProjectSummary {
|
|||
profile: profileOf(config),
|
||||
icon: findIcon(config, root),
|
||||
variants: Object.keys(config.shots?.variants ?? {}),
|
||||
playReports: !!config.play?.reportsBucket,
|
||||
playReports: !!config.play?.reportsBucket || !!credentialValue(root, 'PLAY_REPORTS_BUCKET'),
|
||||
playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null,
|
||||
games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import { mdTable, pct, textTable } from './table';
|
|||
export const APPLE_REPORTS = {
|
||||
engagement: { category: 'APP_STORE_ENGAGEMENT', match: /discovery and engagement/i },
|
||||
downloads: { category: 'COMMERCE', match: /download/i },
|
||||
// App Usage (Analytics Reports API category APP_USAGE): App Sessions (Sessions, Total Session Duration, Unique Devices) and
|
||||
// App Crashes (Crashes, Unique Devices), per day, version, device, platform version and territory
|
||||
sessions: { category: 'APP_USAGE', match: /app sessions/i },
|
||||
crashes: { category: 'APP_USAGE', match: /app crashes/i },
|
||||
} as const;
|
||||
|
||||
/** Standard over Detailed (Detailed adds noise for privacy and splits by campaign). */
|
||||
|
|
@ -54,8 +58,16 @@ export function latestPerDate(instances: { processingDate: string; rows: Record<
|
|||
return instances.flatMap(i => i.rows.filter(r => best.get(r.date ?? '') === i.processingDate));
|
||||
}
|
||||
|
||||
export interface AppleDay { date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number }
|
||||
export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number }
|
||||
export interface AppleDay {
|
||||
date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number;
|
||||
/** App Usage (only when the App Sessions / App Crashes reports have the day): app sessions, active devices (sum of the
|
||||
* rows' Unique Devices: a device seen in two rows, e.g. two app versions, counts twice), total session time in
|
||||
* seconds, crashes. */
|
||||
sessions?: number; activeDevices?: number; sessionSeconds?: number; crashes?: number;
|
||||
}
|
||||
/** installs = Daily User Installs. The two stock numbers are Play's own columns: devices with the app installed that
|
||||
* were active in the last 30 days (Active Device Installs) and all users who ever installed it (Total User Installs). */
|
||||
export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number; activeDeviceInstalls?: number; totalUserInstalls?: number }
|
||||
|
||||
const num = (v: string | undefined) => { const n = Number((v ?? '').replace(/,/g, '')); return Number.isFinite(n) ? n : 0; };
|
||||
|
||||
|
|
@ -80,12 +92,50 @@ export function appleDaily(engagement: Record<string, string>[], downloads: Reco
|
|||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. */
|
||||
export function playDaily(storePerformance: Record<string, string>[], installs: Record<string, string>[]): PlayDay[] {
|
||||
/** App Sessions + App Crashes rows -> App Usage fields per day, merged into the days (a day only in the usage reports
|
||||
* becomes a day with zero impressions and downloads). Sessions' Unique Devices are the active devices of the day. */
|
||||
export function mergeAppleUsage(daily: AppleDay[], sessions: Record<string, string>[], crashes: Record<string, string>[]): AppleDay[] {
|
||||
const days = new Map(daily.map(d => [d.date, { ...d }]));
|
||||
const day = (d: string) => days.get(d) ?? days.set(d, { date: d, impressions: 0, uniqueImpressions: 0, pageViews: 0, uniquePageViews: 0, downloads: 0, firstTimeDownloads: 0 }).get(d)!;
|
||||
for (const r of sessions) {
|
||||
if (!r.date) continue;
|
||||
const x = day(r.date);
|
||||
x.sessions = (x.sessions ?? 0) + num(r.sessions);
|
||||
x.activeDevices = (x.activeDevices ?? 0) + num(r.unique_devices);
|
||||
x.sessionSeconds = (x.sessionSeconds ?? 0) + num(r.total_session_duration);
|
||||
}
|
||||
for (const r of crashes) {
|
||||
if (!r.date) continue;
|
||||
const x = day(r.date);
|
||||
x.crashes = (x.crashes ?? 0) + num(r.crashes ?? r.counts);
|
||||
}
|
||||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. The stock
|
||||
* columns (Active Device Installs, Total User Installs) come from the overview rows when given (all countries in one
|
||||
* row per day), else from the country rows summed. */
|
||||
export function playDaily(storePerformance: Record<string, string>[], installs: Record<string, string>[], overview: Record<string, string>[] = []): PlayDay[] {
|
||||
const days = new Map<string, PlayDay>();
|
||||
const day = (d: string) => days.get(d) ?? days.set(d, { date: d, visitors: 0, acquisitions: 0, installs: 0 }).get(d)!;
|
||||
for (const r of storePerformance) if (r.date) { const x = day(r.date); x.visitors += num(r.store_listing_visitors); x.acquisitions += num(r.store_listing_acquisitions); }
|
||||
for (const r of installs) if (r.date) day(r.date).installs += num(r.daily_user_installs);
|
||||
const stock = (rows: Record<string, string>[]) => {
|
||||
const m = new Map<string, { active?: number; total?: number }>();
|
||||
for (const r of rows) {
|
||||
if (!r.date) continue;
|
||||
const x = m.get(r.date) ?? m.set(r.date, {}).get(r.date)!;
|
||||
if (r.active_device_installs !== undefined && r.active_device_installs !== '') x.active = (x.active ?? 0) + num(r.active_device_installs);
|
||||
if (r.total_user_installs !== undefined && r.total_user_installs !== '') x.total = (x.total ?? 0) + num(r.total_user_installs);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const fromCountries = stock(installs), fromOverview = stock(overview);
|
||||
for (const [date, x] of (fromOverview.size ? fromOverview : fromCountries)) {
|
||||
const d = day(date);
|
||||
if (x.active !== undefined) d.activeDeviceInstalls = x.active;
|
||||
if (x.total !== undefined) d.totalUserInstalls = x.total;
|
||||
}
|
||||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
|
|
@ -101,29 +151,50 @@ export function monthsBetween(start: string, end: string): string[] {
|
|||
export const playObjects = (pkg: string, month: string) => ({
|
||||
storePerformance: `stats/store_performance/store_performance_${pkg}_${month}_country.csv`,
|
||||
installs: `stats/installs/installs_${pkg}_${month}_country.csv`,
|
||||
installsOverview: `stats/installs/installs_${pkg}_${month}_overview.csv`,
|
||||
});
|
||||
|
||||
/** "gs://pubsite_prod_rev_123/" or "pubsite_prod_rev_123" -> bucket name. */
|
||||
export const bucketName = (b: string): string => b.replace(/^gs:\/\//, '').replace(/\/.*$/, '');
|
||||
|
||||
export interface Totals { impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number }
|
||||
export interface Totals {
|
||||
impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number;
|
||||
/** App Usage: sessions and crashes summed, active devices as the mean of the days that have usage data. */
|
||||
sessions?: number; crashes?: number; activeDevicesAvg?: number;
|
||||
/** Play stock numbers: the value of the newest day that has one (never summed). */
|
||||
activeDeviceInstalls?: number; totalUserInstalls?: number;
|
||||
}
|
||||
|
||||
export function appleTotals(days: AppleDay[]): Totals {
|
||||
const s = (k: keyof AppleDay) => days.reduce((n, d) => n + (d[k] as number), 0);
|
||||
const uniq = s('uniqueImpressions');
|
||||
return { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null };
|
||||
const t: Totals = { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null };
|
||||
const used = days.filter(d => d.sessions !== undefined || d.crashes !== undefined);
|
||||
if (used.length) {
|
||||
t.sessions = used.reduce((n, d) => n + (d.sessions ?? 0), 0);
|
||||
t.crashes = used.reduce((n, d) => n + (d.crashes ?? 0), 0);
|
||||
const act = used.filter(d => d.activeDevices !== undefined);
|
||||
if (act.length) t.activeDevicesAvg = Math.round(act.reduce((n, d) => n + d.activeDevices!, 0) / act.length);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
export function playTotals(days: PlayDay[]): Totals {
|
||||
const s = (k: keyof PlayDay) => days.reduce((n, d) => n + (d[k] as number), 0);
|
||||
const v = s('visitors');
|
||||
return { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null };
|
||||
const t: Totals = { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null };
|
||||
const last = (k: 'activeDeviceInstalls' | 'totalUserInstalls') => [...days].reverse().find(d => d[k] !== undefined)?.[k];
|
||||
const a = last('activeDeviceInstalls'), tot = last('totalUserInstalls');
|
||||
if (a !== undefined) t.activeDeviceInstalls = a;
|
||||
if (tot !== undefined) t.totalUserInstalls = tot;
|
||||
return t;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
date: string;
|
||||
window: { start: string; end: string; days: number };
|
||||
apple?: { daily: AppleDay[]; totals: Totals } | { error: string };
|
||||
play?: { daily: PlayDay[]; totals: Totals } | { error: string };
|
||||
/** notes: what is missing but not fatal (e.g. the App Usage reports are not listed yet). */
|
||||
apple?: { daily: AppleDay[]; totals: Totals; notes?: string[] } | { error: string };
|
||||
play?: { daily: PlayDay[]; totals: Totals; notes?: string[] } | { error: string };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -658,6 +658,7 @@ export interface ShipConfig {
|
|||
billingPrograms?: ('user-choice-billing' | 'alternative-billing' | 'external-offers' | 'external-content-links')[];
|
||||
};
|
||||
apple?: {
|
||||
/** Absent / null only in a store-only config with apple.appId (analytics and reports reads); every write needs it. */
|
||||
bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string;
|
||||
/** App Store name in the primary locale (at most 30 characters) when it differs from app.name, e.g. because the plain
|
||||
* name is taken on the App Store ("STAR DASH: Stone Age to 2100"). Default app.name. A `### name` block in the
|
||||
|
|
@ -1189,7 +1190,9 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b))))
|
||||
e.push(`play.billingPrograms: a list of ${PLAY_BILLING_PROGRAMS.join(' | ')}`);
|
||||
if (cfg.apple) {
|
||||
if (!ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)');
|
||||
// a store-only config for reads (analytics, reports) may name the app by apple.appId alone: bundleId null / absent
|
||||
const idOnly = cfg.apple.bundleId == null && /^\d+$/.test(cfg.apple.appId ?? '');
|
||||
if (!idOnly && !ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)');
|
||||
const an = cfg.apple.name;
|
||||
if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name');
|
||||
else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
|
||||
// 4. the default profile (none named): store keys only
|
||||
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "reportsBucket"?, "label"? };
|
||||
// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account
|
||||
// the broker mints tokens for, and "broker": false keeps a profile on its local key.
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
|
|
@ -46,7 +46,7 @@ function fileStore(root: string): Store {
|
|||
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
|
||||
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
|
||||
* for reports too; an app's Android upload keystore stays in the project. */
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL'] as const;
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'PLAY_REPORTS_BUCKET'] as const;
|
||||
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
|
||||
|
|
@ -66,7 +66,9 @@ export function profileEnv(profile: string): Record<string, string> {
|
|||
const out: Record<string, string> = {};
|
||||
for (const k of PROFILE_KEYS) {
|
||||
const env = process.env[profileEnvName(profile, k)];
|
||||
const v = env || (typeof stored[k] === 'string' ? stored[k] as string : '');
|
||||
// PLAY_REPORTS_BUCKET is also the profile field `reportsBucket` (a bucket name, not a secret)
|
||||
const own = stored[k] ?? (k === 'PLAY_REPORTS_BUCKET' ? stored.reportsBucket : undefined);
|
||||
const v = env || (typeof own === 'string' ? own : '');
|
||||
if (!v) continue;
|
||||
// a path in the file: relative to the profile folder (inline JSON / PEM stays as it is)
|
||||
const isPath = !env && !/^\s*\{/.test(v) && !/BEGIN PRIVATE KEY/.test(v) && (k === 'PLAY_SERVICE_ACCOUNT' || k === 'ASC_P8');
|
||||
|
|
@ -346,6 +348,16 @@ export function ascVendorNumber(root: string): string | null {
|
|||
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
|
||||
}
|
||||
|
||||
/** The Play reports bucket (analytics statistics, sales / earnings reports): play.reportsBucket of the config, else env
|
||||
* PLAY_REPORTS_BUCKET, else the credential profile's `reportsBucket` (a store-only config has no play.reportsBucket).
|
||||
* Null when none is set; a value that is not a Play reports bucket throws (a bucket name is not a secret). */
|
||||
export function playReportsBucket(root: string, fromConfig?: string): string | null {
|
||||
const v = (fromConfig ?? value(root, 'PLAY_REPORTS_BUCKET') ?? '').trim();
|
||||
if (!v) return null;
|
||||
if (!/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(v)) throw new Error(`reports bucket "${v}": the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (play.reportsBucket, env PLAY_REPORTS_BUCKET or the profile field reportsBucket)`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore
|
||||
* the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */
|
||||
export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] {
|
||||
|
|
|
|||
|
|
@ -148,3 +148,95 @@ export const parsePlayCsv = (text: string): Record<string, string>[] => {
|
|||
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<string, string>[], from: string, to: string): PlayDaySales[] {
|
||||
const m = new Map<string, PlayDaySales>();
|
||||
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<string, { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }>();
|
||||
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)))];
|
||||
|
|
|
|||
|
|
@ -412,8 +412,9 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Download a sales or finance report into .storebound/reports/ (gitignored) and print a summary per product and currency (amounts never added across currencies) plus a summary CSV. Apple needs ASC_VENDOR_NUMBER and a team key; Play needs play.reportsBucket and "View financial data". dry_run shows the request only.',
|
||||
props: { kind: { type: 'string', enum: ['apple-sales', 'apple-finance', 'play-earnings', 'play-sales'] }, month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM (apple-finance: Apple fiscal month; Play reports)' },
|
||||
date: { type: 'string', pattern: '^\\d{4}(-\\d{2}(-\\d{2})?)?$', description: 'apple-sales report date (DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY)' },
|
||||
days: { type: 'integer', minimum: 1, maximum: 366, description: 'apple-sales / play-sales: the last N days as one per-day JSON' }, from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'apple-sales / play-sales: first day of a range' }, to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'last day of the range (default yesterday)' },
|
||||
frequency: { type: 'string', enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }, type: { type: 'string', enum: ['SALES', 'PRE_ORDER', 'SUBSCRIPTION', 'SUBSCRIPTION_EVENT', 'SUBSCRIBER', 'SUBSCRIPTION_OFFER_CODE_REDEMPTION', 'WIN_BACK_ELIGIBILITY'] } },
|
||||
required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] },
|
||||
required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('days', i.days), ...opt('from', i.from), ...opt('to', i.to), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] },
|
||||
{ name: 'triage', title: 'Crash and feedback triage', command: ['triage'], write: false, network: true,
|
||||
description: 'Read-only: TestFlight crash reports (signature from each crash log), TestFlight screenshot feedback and Google Play error reports of the top issues, grouped by signature with their top devices, OS versions and builds. Tester emails are never read.',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 14 }, build: { type: 'string', pattern: '^[0-9][0-9.]*$' },
|
||||
|
|
|
|||
138
cli/reports.ts
138
cli/reports.ts
|
|
@ -3,6 +3,9 @@
|
|||
// 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
|
||||
// a day range in one JSON (per-day summary and totals per currency; days with no report listed in "missing", not an error):
|
||||
// storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)
|
||||
// storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)
|
||||
// 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
|
||||
|
|
@ -14,15 +17,15 @@ 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 { ascVendorNumber, playReportsBucket } 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,
|
||||
APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, type DaySummary, decodeReport, monthsOfRange, parsePlayCsv, parseTsv, playReportPrefix, playSalesTotals, rangeCsvRows, rangeReport,
|
||||
reportRange, type SummaryRow, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summarizePlaySalesByDay, summaryCsvRows, totalsByCurrency,
|
||||
} from './lib/reports';
|
||||
import { textTable } from './lib/table';
|
||||
import { unzip } from './lib/zip';
|
||||
|
|
@ -30,7 +33,15 @@ 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';
|
||||
const USAGE = 'usage: storebound reports apple-sales [--days N | --from YYYY-MM-DD [--to ...]] | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM | play-sales --days N | --from YYYY-MM-DD [--to ...]';
|
||||
|
||||
/** Runs fn over items, at most `limit` at a time, results in item order. */
|
||||
async function pool<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!); } }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function show(name: string, rows: SummaryRow[], json: boolean, extra: Record<string, unknown>): void {
|
||||
const totals = totalsByCurrency(rows);
|
||||
|
|
@ -57,6 +68,28 @@ export function appleReportHint(msg: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
type Google = ReturnType<typeof googleClient>;
|
||||
|
||||
/** The CSV rows of a Play report month (all zips under the prefix, all CSVs in them); null when the bucket has none. */
|
||||
async function playMonth(g: Google, bucket: string, kind: 'earnings' | 'sales', month: string): Promise<{ rows: Record<string, string>[]; text: string } | null> {
|
||||
const prefix = playReportPrefix(kind, month);
|
||||
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
|
||||
const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
|
||||
if (!items.length) return null;
|
||||
const rows: 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;
|
||||
rows.push(...parsePlayCsv(t));
|
||||
}
|
||||
}
|
||||
return { rows, text };
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
|
|
@ -82,9 +115,46 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
path = appleFinancePath(vendor, month, { type, region: a.get('region') });
|
||||
name = `apple-${type === 'FINANCIAL' ? 'finance' : 'finance-detail'}-${month}.tsv`;
|
||||
}
|
||||
const range = sub === 'apple-sales' ? reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') }) : null;
|
||||
if (sub === 'apple-finance' && (a.has('days') || a.has('from') || a.has('to'))) throw new Error('--days / --from / --to are for apple-sales and play-sales (finance reports are monthly: --month)');
|
||||
if (range) {
|
||||
const type = a.str('type', 'SALES').toUpperCase();
|
||||
if (type !== 'SALES') throw new Error('--days / --from / --to read the daily SALES reports: leave out --type (or SALES)');
|
||||
if (a.has('frequency') && a.str('frequency', 'DAILY').toUpperCase() !== 'DAILY') throw new Error('--days / --from / --to read DAILY reports');
|
||||
const pathOf = (date: string) => appleSalesPath(vendor, { type: 'SALES', frequency: 'DAILY', date });
|
||||
const shownRange = `GET ${pathOf('<date>').replace(vendor, '<vendor>')} for ${range.dates.length} day(s) ${range.from}..${range.to}`;
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: shownRange, dir: out }); else log(TAG, `would ${shownRange} -> ${out}`); return; }
|
||||
const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId);
|
||||
mkdirSync(out, { recursive: true });
|
||||
let rowCount = 0;
|
||||
const got = await pool(range.dates, 4, async (date): Promise<DaySummary | null> => {
|
||||
let gz: Uint8Array | null;
|
||||
try { gz = await c.bytes(pathOf(date)); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}
|
||||
${hint}` : (e as Error).message); }
|
||||
if (!gz) return null;
|
||||
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
|
||||
writeFileSync(join(out, `apple-sales-daily-${date}.tsv`), text);
|
||||
const rows = parseTsv(text);
|
||||
rowCount += rows.length;
|
||||
const summary = summarizeAppleSales(rows);
|
||||
return { date, rows: rows.length, summary, totals: totalsByCurrency(summary) };
|
||||
});
|
||||
const rep = rangeReport(got.filter((x): x is DaySummary => !!x), range.dates);
|
||||
const summaryFile = csvOut ?? join(out, `apple-sales-daily-${range.from}_${range.to}.summary.csv`);
|
||||
mkdirSync(dirname(summaryFile), { recursive: true });
|
||||
writeFileSync(summaryFile, toCsv(rangeCsvRows(rep.days)));
|
||||
if (rep.missing.length) warn(TAG, `no report for ${rep.missing.join(', ')} (no sales then, or not published yet: daily reports come out the next day)`);
|
||||
if (a.has('json')) printJson({ report: 'apple-sales', type: 'SALES', frequency: 'DAILY', from: range.from, to: range.to, rows: rowCount, days: rep.days, totals: rep.totals, missing: rep.missing, files: { dir: out, summary: summaryFile } });
|
||||
else {
|
||||
console.log(rep.days.length ? textTable(['date', 'currency', 'units', 'amount'], rep.days.flatMap(d => d.totals.map(t => [d.date, t.currency, t.units, t.amount.toFixed(2)]))) : 'no rows');
|
||||
if (rep.totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], rep.totals.map(t => [t.currency, t.units, t.amount.toFixed(2)])));
|
||||
}
|
||||
done(TAG, `${rep.days.length}/${range.dates.length} day(s), ${rowCount} rows -> ${out}; summary ${summaryFile}`);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
const c = await ascClient(root, config.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); }
|
||||
|
|
@ -100,27 +170,51 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
|
||||
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 bucket = playReportsBucket(root, config.play?.reportsBucket);
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
|
||||
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
|
||||
const range = reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') });
|
||||
if (range && kind === 'earnings') throw new Error('play-earnings is monthly (--month YYYY-MM); the daily revenue is play-sales --days N');
|
||||
if (range) {
|
||||
const months = monthsOfRange(range.from, range.to);
|
||||
const where = months.map(m => `gs://${bucketName(bucket)}/${playReportPrefix('sales', m)}*.zip`);
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${where.join(' ')}`, from: range.from, to: range.to, dir: out }); else log(TAG, `would read ${where.join(', ')} -> ${out}`); return; }
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const all: Record<string, string>[] = [];
|
||||
const missingMonths: string[] = [];
|
||||
const files: string[] = [];
|
||||
mkdirSync(out, { recursive: true });
|
||||
for (const m of months) {
|
||||
const r = await playMonth(g, bucket, 'sales', m);
|
||||
if (!r) { missingMonths.push(m); continue; }
|
||||
all.push(...r.rows);
|
||||
const file = join(out, `play-sales-${m}.csv`);
|
||||
writeFileSync(file, r.text);
|
||||
files.push(file);
|
||||
}
|
||||
const days = summarizePlaySalesByDay(all, range.from, range.to);
|
||||
const have = new Set(days.map(d => d.date));
|
||||
const emptyDays = range.dates.filter(d => !have.has(d));
|
||||
const summaryFile = csvOut ?? join(out, `play-sales-daily-${range.from}_${range.to}.summary.csv`);
|
||||
mkdirSync(dirname(summaryFile), { recursive: true });
|
||||
writeFileSync(summaryFile, toCsv([['date', 'currency', 'orders', 'refunds', 'item_price', 'taxes', 'charged', 'refunded', 'net'], ...days.map(d => [d.date, d.currency, d.orders, d.refunds, d.itemPrice, d.taxes, d.charged, d.refunded, d.net])]));
|
||||
const totals = playSalesTotals(days);
|
||||
if (missingMonths.length) warn(TAG, `no sales report for month(s) ${missingMonths.join(', ')} in the bucket (not published yet, or the service account lacks "View financial data")`);
|
||||
if (a.has('json')) printJson({ report: 'play-sales', from: range.from, to: range.to, rows: all.length, days, totals, emptyDays, missingMonths, estimated: true, files: { raw: files, summary: summaryFile } });
|
||||
else {
|
||||
console.log(days.length ? textTable(['date', 'currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], days.map(d => [d.date, d.currency, d.orders, d.refunds, d.charged.toFixed(2), d.refunded.toFixed(2), d.net.toFixed(2)])) : 'no orders in the range');
|
||||
if (totals.length) console.log('\n' + textTable(['currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], totals.map(t => [t.currency, t.orders, t.refunds, t.charged.toFixed(2), t.refunded.toFixed(2), t.net.toFixed(2)])));
|
||||
}
|
||||
done(TAG, `${days.length} order day line(s) of ${range.dates.length} day(s) -> ${summaryFile}`);
|
||||
return;
|
||||
}
|
||||
const month = need(a.get('month'), '--month YYYY-MM (or --days N / --from YYYY-MM-DD for play-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));
|
||||
}
|
||||
}
|
||||
const month1 = await playMonth(g, bucket, kind, month);
|
||||
if (!month1) { 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 = month1.rows, text = month1.text;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -127,9 +127,9 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
|||
| `prices export [--no-subscriptions]` | API + local | current prices of every product and subscription (Apple current + scheduled, Play base plan regions), both stores, as `store/prices.csv`; subscription rows feed `apple subscriptions --csv` / `play subscriptions --csv` |
|
||||
| `prices import [--reconvert]` | API | diff table, then (asks first) Apple price schedules incl. future-dated changes and Play regional prices |
|
||||
| `analytics setup` | API | App Store analytics report request (once, Admin key) |
|
||||
| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` |
|
||||
| `analytics snapshot [--days 28] [--store apple\|play] [--json] [--no-write]` | API + local | impressions, page views, downloads, App Usage (sessions, active devices, crashes) per day (App Store); visitors, acquisitions, daily user installs, active device installs, total user installs (Play reports bucket, also from env `PLAY_REPORTS_BUCKET` or the profile's `reportsBucket`) -> `.storebound/analytics/`; `--json` prints the snapshot on stdout; a store-only config needs `apple.appId` only |
|
||||
| `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change |
|
||||
| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export |
|
||||
| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export; `apple-sales` and `play-sales` take `--days N` or `--from/--to` for a per-day JSON (daily summary, totals per currency, missing days) |
|
||||
| `triage [--days 14]` | API, read-only | TestFlight crash and screenshot feedback, Play error reports: grouped by signature with devices, OS versions, builds |
|
||||
| `aso rank [--storefronts us,de]` | network + local | App Store rank per keyword and storefront plus competitors from the public iTunes Search API (rate limited) -> `.storebound/aso/` |
|
||||
| `aso competitors [--term <kw>] [--storefront us,tr] [--top 10]` | network + local | top apps per keyword (name, developer, rating count, first release, last update, localized genre; no subtitles: not in any public API) and the words in their names you do not cover, same rate limit -> `.storebound/aso/competitors/` |
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ CLI: `storebound analytics setup | snapshot | compare`. MCP: `analytics_snapshot
|
|||
(`stoppedDueToInactivity`); `setup` makes a new one. storebound reads "App Store Discovery and Engagement" (Event
|
||||
Impression / Page view, Counts and Unique Counts) and "App Store Downloads" (First-time Download, Redownload; updates
|
||||
are left out), checks each segment's md5 checksum and keeps, per day, the newest processing of that day.
|
||||
Conversion = (first-time downloads + redownloads) / unique impressions. Source:
|
||||
Conversion = (first-time downloads + redownloads) / unique impressions. App Usage (sessions, active devices, crashes) comes from the
|
||||
"App Sessions" and "App Crashes" reports of the same request. Source:
|
||||
https://developer.apple.com/documentation/appstoreconnectapi/downloading-analytics-reports
|
||||
- **Google Play**: there is **no API** for store listing statistics (the Play Developer Reporting API only has
|
||||
vitals). Play Console writes monthly CSV exports into your own Cloud Storage bucket; storebound reads them with
|
||||
|
|
@ -28,6 +29,29 @@ CLI: `storebound analytics setup | snapshot | compare`. MCP: `analytics_snapshot
|
|||
results (read them in the consoles; `storebound apple ppo status` shows the App Store test state), Play
|
||||
"Explore" search terms beyond the traffic source file.
|
||||
|
||||
## For a collector (store-only config, JSON on stdout)
|
||||
A script that reads many apps needs no project: a config with `apple.appId` (`bundleId` null or left out) and
|
||||
`play.packageName` is valid for `analytics` and `reports` reads (every write still needs the bundle id).
|
||||
- `storebound analytics snapshot --store apple|play --days 30 --config <virtual.json> --account <profile> --json`
|
||||
prints the snapshot as one JSON document on stdout (logs go to stderr); the file in `--dir` is still written, add
|
||||
`--no-write` to skip it. A store that failed carries `{ "error": ... }`; when every store failed the JSON is printed
|
||||
and the exit code is 1. Non-fatal gaps are in `notes`.
|
||||
- Play bucket without `play.reportsBucket`: env `PLAY_REPORTS_BUCKET`, else the credential profile's `reportsBucket`
|
||||
(a bucket name, not a secret). Same for `reports play-*`.
|
||||
- Active users, per day. App Store: `sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Usage
|
||||
reports (App Sessions, App Crashes; `activeDevices` is the sum of the rows' Unique Devices, so a device seen in two
|
||||
rows, e.g. two app versions, counts twice; totals: `sessions`, `crashes`, `activeDevicesAvg`). They appear a day or
|
||||
two after the report request exists; until then the snapshot has the rest and a `notes` line. Play: `installs` (Daily
|
||||
User Installs), `activeDeviceInstalls` (Active Device Installs) and `totalUserInstalls` (Total User Installs) from the
|
||||
installs overview file (all countries; else the country file summed). The last two are stock numbers: totals take the
|
||||
newest day, never a sum.
|
||||
- Revenue per day: `storebound reports apple-sales --days 30 --json` (or `--from 2026-10-01 [--to 2026-10-25]`) reads
|
||||
the daily SALES reports of the range (4 at a time): `days[]` with `summary` per SKU and currency and `totals` per
|
||||
currency, `totals` for the range per currency, `missing` (days with no report: no sales, or not out yet; never an
|
||||
error). `storebound reports play-sales --days 30 --json` groups the estimated sales by order charged date and
|
||||
currency of sale: `orders`, `refunds`, `itemPrice`, `taxes`, `charged` (tax included), `refunded` (negative),
|
||||
`net`, plus `emptyDays` and `missingMonths`. Play's earnings (proceeds after Google's fee) are monthly only.
|
||||
|
||||
## Flow
|
||||
1. Once: `storebound analytics setup` (App Store) and `play.reportsBucket` (Play).
|
||||
2. Weekly: `storebound analytics snapshot` (default the last 28 days; `--dry` prints totals only).
|
||||
|
|
|
|||
264
tests/analytics-collector.test.ts
Normal file
264
tests/analytics-collector.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// What an external collector needs from `analytics snapshot` and `reports`: a store-only config (apple.appId without a
|
||||
// bundle id, no play.reportsBucket), --json on stdout, App Usage and Play install stock numbers per day, and per-day
|
||||
// sales JSON for a day range of both stores. Fixtures are made-up report files; nothing reaches the network.
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { run as analytics } from '../cli/analytics';
|
||||
import { appleDaily, appleTotals, mergeAppleUsage, parseReport, playDaily, playTotals } from '../cli/lib/analytics';
|
||||
import { type ShipConfig, validateConfig } from '../cli/lib/config';
|
||||
import { playReportsBucket, profileEnv } from '../cli/lib/credentials';
|
||||
import { dateRange, parsePlayCsv, parseTsv, playSalesTotals, rangeReport, reportRange, summarizeAppleSales, summarizePlaySalesByDay, totalsByCurrency } from '../cli/lib/reports';
|
||||
import { run as reports } from '../cli/reports';
|
||||
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock';
|
||||
|
||||
const BUCKET = 'pubsite_prod_rev_01234567890123456789';
|
||||
afterEach(() => { restore(); delete process.env.PLAY_REPORTS_BUCKET; delete process.env.STOREBOUND_CREDENTIALS_DIR; delete process.env.STOREBOUND_PROFILE; });
|
||||
|
||||
const iso = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
|
||||
type Req = { path: string };
|
||||
|
||||
function zip(files: Record<string, string>): Uint8Array {
|
||||
const parts: Uint8Array[] = [], central: Uint8Array[] = [];
|
||||
let off = 0;
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
const raw = new TextEncoder().encode(text), data = deflateRawSync(raw), n = new TextEncoder().encode(name);
|
||||
const h = new DataView(new ArrayBuffer(30));
|
||||
h.setUint32(0, 0x04034b50, true); h.setUint16(8, 8, true); h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true); h.setUint16(26, n.length, true);
|
||||
const c = new DataView(new ArrayBuffer(46));
|
||||
c.setUint32(0, 0x02014b50, true); c.setUint16(10, 8, true); c.setUint32(20, data.length, true); c.setUint32(24, raw.length, true); c.setUint16(28, n.length, true); c.setUint32(42, off, true);
|
||||
parts.push(new Uint8Array(h.buffer), n, data);
|
||||
central.push(new Uint8Array(c.buffer), n);
|
||||
off += 30 + n.length + data.length;
|
||||
}
|
||||
const cdSize = central.reduce((s, x) => s + x.length, 0);
|
||||
const e = new DataView(new ArrayBuffer(22));
|
||||
e.setUint32(0, 0x06054b50, true); e.setUint16(8, Object.keys(files).length, true); e.setUint16(10, Object.keys(files).length, true); e.setUint32(12, cdSize, true); e.setUint32(16, off, true);
|
||||
const all = [...parts, ...central, new Uint8Array(e.buffer)];
|
||||
const out = new Uint8Array(all.reduce((s, x) => s + x.length, 0));
|
||||
let p = 0;
|
||||
for (const x of all) { out.set(x, p); p += x.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- pure parts ----
|
||||
|
||||
describe('App Usage and Play install stock numbers', () => {
|
||||
const SESSIONS = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tSource Type\tSource Info\tTerritory\tSessions\tTotal Session Duration\tUnique Devices\n' +
|
||||
'2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t100\t3000\t40\n2026-10-01\tExample\t1\t1.0\tiPad\tiPadOS 26\t\t\tTR\t20\t600\t8\n2026-10-02\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t50\t1000\t30\n';
|
||||
const CRASHES = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tCrashes\tUnique Devices\n2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t4\t3\n2026-10-03\tExample\t1\t1.0\tiPhone\tiOS 26\t1\t1\n';
|
||||
test('sessions, active devices, session time and crashes per day, merged into the engagement days', () => {
|
||||
const base = appleDaily(parseReport('Date\tEvent\tCounts\tUnique Counts\n2026-10-01\tImpression\t10\t8\n'), []);
|
||||
const d = mergeAppleUsage(base, parseReport(SESSIONS), parseReport(CRASHES));
|
||||
expect(d.map(x => x.date)).toEqual(['2026-10-01', '2026-10-02', '2026-10-03']);
|
||||
expect(d[0]).toMatchObject({ impressions: 10, sessions: 120, activeDevices: 48, sessionSeconds: 3600, crashes: 4 });
|
||||
expect(d[1]).toMatchObject({ impressions: 0, sessions: 50, activeDevices: 30 });
|
||||
expect(d[1]!.crashes).toBeUndefined();
|
||||
expect(d[2]).toMatchObject({ crashes: 1 });
|
||||
expect(appleTotals(d)).toMatchObject({ sessions: 170, crashes: 5, activeDevicesAvg: 39 });
|
||||
expect(appleTotals(base).sessions).toBeUndefined();
|
||||
});
|
||||
test('Play: stock columns from the overview file, else summed over the country rows; totals take the newest day', () => {
|
||||
const country = parseReport('Date,Package Name,Country,Daily Device Installs,Daily User Installs,Total User Installs,Active Device Installs\n' +
|
||||
'2026-10-01,p,US,5,4,100,60\n2026-10-01,p,TR,2,2,50,30\n2026-10-02,p,US,1,1,101,61\n');
|
||||
expect(playDaily([], country)).toEqual([
|
||||
{ date: '2026-10-01', visitors: 0, acquisitions: 0, installs: 6, activeDeviceInstalls: 90, totalUserInstalls: 150 },
|
||||
{ date: '2026-10-02', visitors: 0, acquisitions: 0, installs: 1, activeDeviceInstalls: 61, totalUserInstalls: 101 }]);
|
||||
const overview = parseReport('Date,Package Name,Total User Installs,Active Device Installs\n2026-10-01,p,160,95\n2026-10-02,p,162,96\n');
|
||||
const d = playDaily([], country, overview);
|
||||
expect(d[0]).toMatchObject({ installs: 6, activeDeviceInstalls: 95, totalUserInstalls: 160 });
|
||||
expect(playTotals(d)).toMatchObject({ installs: 7, activeDeviceInstalls: 96, totalUserInstalls: 162 });
|
||||
expect(playDaily([], parseReport('Date,Daily User Installs\n2026-10-01,3\n'))[0]!.activeDeviceInstalls).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('store-only config and bucket fallback', () => {
|
||||
const virtual = (apple: Record<string, unknown>): unknown => ({ ...BASE, apple, play: { packageName: 'com.example.notes' } });
|
||||
test('apple.appId alone is enough; a missing id is still an error', () => {
|
||||
expect(validateConfig(virtual({ bundleId: null, appId: '6400000101' }))).toEqual([]);
|
||||
expect(validateConfig(virtual({ appId: '6400000101' }))).toEqual([]);
|
||||
expect(validateConfig(virtual({ bundleId: null }))[0]).toContain('apple.bundleId');
|
||||
expect(validateConfig(virtual({ bundleId: 'nope' }))[0]).toContain('apple.bundleId');
|
||||
expect(validateConfig(virtual({ bundleId: null, appId: 'abc' }))[0]).toContain('apple.bundleId');
|
||||
});
|
||||
test('reports bucket: config, then env PLAY_REPORTS_BUCKET, then the profile field reportsBucket', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-prof-'));
|
||||
writeFileSync(join(dir, 'acme.json'), JSON.stringify({ reportsBucket: `gs://${BUCKET}/` }));
|
||||
process.env.STOREBOUND_CREDENTIALS_DIR = dir;
|
||||
process.env.STOREBOUND_PROFILE = 'acme';
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-root-'));
|
||||
expect(profileEnv('acme').PLAY_REPORTS_BUCKET).toBe(`gs://${BUCKET}/`);
|
||||
expect(playReportsBucket(root)).toBe(`gs://${BUCKET}/`);
|
||||
process.env.PLAY_REPORTS_BUCKET = 'pubsite_prod_rev_999';
|
||||
expect(playReportsBucket(root)).toBe('pubsite_prod_rev_999');
|
||||
expect(playReportsBucket(root, BUCKET)).toBe(BUCKET);
|
||||
process.env.PLAY_REPORTS_BUCKET = 'my-bucket';
|
||||
expect(() => playReportsBucket(root)).toThrow('PLAY_REPORTS_BUCKET');
|
||||
delete process.env.PLAY_REPORTS_BUCKET;
|
||||
process.env.STOREBOUND_PROFILE = '';
|
||||
expect(playReportsBucket(root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('day ranges and daily sales', () => {
|
||||
test('reportRange: --days ends yesterday, --from/--to inclusive, checks', () => {
|
||||
const now = new Date('2026-10-26T10:00:00Z');
|
||||
expect(reportRange({}, now)).toBeNull();
|
||||
expect(reportRange({ days: '3' }, now)).toEqual({ from: '2026-10-23', to: '2026-10-25', dates: ['2026-10-23', '2026-10-24', '2026-10-25'] });
|
||||
expect(reportRange({ from: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24', '2026-10-25']);
|
||||
expect(reportRange({ from: '2026-10-24', to: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24']);
|
||||
expect(() => reportRange({ days: '0' }, now)).toThrow('--days');
|
||||
expect(() => reportRange({ days: '2', from: '2026-10-01' }, now)).toThrow('not both');
|
||||
expect(() => reportRange({ from: '2026-10-03', to: '2026-10-01' }, now)).toThrow('after');
|
||||
expect(() => reportRange({ from: '10/03/2026' }, now)).toThrow('YYYY-MM-DD');
|
||||
expect(() => reportRange({ to: '2026-10-03' }, now)).toThrow('--from');
|
||||
expect(dateRange('2026-02-27', '2026-03-02')).toHaveLength(4);
|
||||
});
|
||||
test('rangeReport: totals per currency over the days, the dates without a report listed', () => {
|
||||
const tsv = (units: number) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.pro\tPro\t${units}\t2.5\tUSD`, 'APPLE\tcom.example.pro\tPro\t1\t3\tEUR', ''].join('\n');
|
||||
const day = (date: string, units: number) => { const summary = summarizeAppleSales(parseTsv(tsv(units))); return { date, rows: 2, summary, totals: totalsByCurrency(summary) }; };
|
||||
const r = rangeReport([day('2026-10-02', 4), day('2026-10-01', 2)], ['2026-10-01', '2026-10-02', '2026-10-03']);
|
||||
expect(r.days.map(d => d.date)).toEqual(['2026-10-01', '2026-10-02']);
|
||||
expect(r.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6 }, { currency: 'USD', units: 6, amount: 15 }]);
|
||||
expect(r.missing).toEqual(['2026-10-03']);
|
||||
});
|
||||
test('Play sales per order-charged date and currency: charged, refunds (negative), net', () => {
|
||||
const csv = 'Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\n' +
|
||||
'A1,2026-10-01,Charged,pro,USD,4.99,0.00,4.99\nA2,2026-10-01,Charged,pro,USD,4.00,0.50,4.50\nA3,2026-10-01,Charged,pro,EUR,5.00,1.00,6.00\n' +
|
||||
'A4,2026-10-02,Refund,pro,USD,4.99,0.00,4.99\nA5,2026-10-02,Refund,pro,USD,-1.00,0.00,-1.00\nA6,2026-09-30,Charged,pro,USD,9,0,9\nA7,2026-10-02,Other,pro,USD,9,0,9\n';
|
||||
const d = summarizePlaySalesByDay(parsePlayCsv(csv), '2026-10-01', '2026-10-03');
|
||||
expect(d).toEqual([
|
||||
{ date: '2026-10-01', currency: 'EUR', orders: 1, refunds: 0, itemPrice: 5, taxes: 1, charged: 6, refunded: 0, net: 6 },
|
||||
{ date: '2026-10-01', currency: 'USD', orders: 2, refunds: 0, itemPrice: 8.99, taxes: 0.5, charged: 9.49, refunded: 0, net: 9.49 },
|
||||
{ date: '2026-10-02', currency: 'USD', orders: 0, refunds: 2, itemPrice: 0, taxes: 0, charged: 0, refunded: -5.99, net: -5.99 }]);
|
||||
expect(playSalesTotals(d)).toEqual([{ currency: 'EUR', orders: 1, refunds: 0, charged: 6, refunded: 0, net: 6 }, { currency: 'USD', orders: 2, refunds: 2, charged: 9.49, refunded: -5.99, net: 3.5 }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the commands, as the collector calls them ----
|
||||
|
||||
const md5 = (s: string) => createHash('md5').update(s).digest('hex');
|
||||
|
||||
describe('analytics snapshot on a store-only config', () => {
|
||||
const d1 = iso(-1), d2 = iso(-2);
|
||||
const ENG = `Date\tEvent\tCounts\tUnique Counts\n${d1}\tImpression\t100\t80\n${d1}\tPage view\t30\t25\n${d2}\tImpression\t60\t50\n`;
|
||||
const DL = `Date,Download Type,Counts\n${d1},First-time Download,10\n${d2},Redownload,2\n`;
|
||||
const SES = `Date\tSessions\tTotal Session Duration\tUnique Devices\n${d1}\t200\t6000\t90\n${d2}\t150\t4000\t70\n`;
|
||||
const CRA = `Date\tCrashes\tUnique Devices\n${d1}\t3\t2\n`;
|
||||
const files: Record<string, string> = { eng: ENG, dl: DL, ses: SES, cra: CRA };
|
||||
const reportsList = [
|
||||
{ id: 'r-eng', attributes: { category: 'APP_STORE_ENGAGEMENT', name: 'App Store Discovery and Engagement Standard' } },
|
||||
{ id: 'r-dl', attributes: { category: 'COMMERCE', name: 'App Store Downloads Standard' } },
|
||||
{ id: 'r-ses', attributes: { category: 'APP_USAGE', name: 'App Sessions Standard' } },
|
||||
{ id: 'r-cra', attributes: { category: 'APP_USAGE', name: 'App Crashes Standard' } },
|
||||
];
|
||||
const appleRoutes = (list = reportsList) => [
|
||||
{ match: /\/v1\/apps\/6400000101\/analyticsReportRequests\?/, answer: { data: [{ id: 'req1', attributes: {} }] } },
|
||||
{ match: /\/v1\/analyticsReportRequests\/req1\/reports\?/, answer: { data: list } },
|
||||
{ match: /\/v1\/analyticsReports\/r-(\w+)\/instances\?/, answer: (c: Req) => { const k = /r-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `i-${k}`, attributes: { processingDate: iso(0) } }] }; } },
|
||||
{ match: /\/v1\/analyticsReportInstances\/i-(\w+)\/segments\?/, answer: (c: Req) => { const k = /i-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `s-${k}`, attributes: { url: `https://segments.example.com/seg/${k}`, checksum: md5(files[k]!) } }] }; } },
|
||||
{ match: /^\/seg\/(\w+)$/, answer: (c: Req) => new Response(files[c.path.slice(5)]!) },
|
||||
];
|
||||
const virtualConfig = (): ShipConfig => ({ ...BASE, apple: { bundleId: null as unknown as string, appId: '6400000101' }, play: { packageName: 'com.example.notes' } });
|
||||
|
||||
test('apple: appId only, App Usage per day, --json on stdout, --no-write leaves no file', async () => {
|
||||
ascEnv();
|
||||
const p = project(virtualConfig());
|
||||
const calls = mockFetch(appleRoutes());
|
||||
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json', '--no-write'])));
|
||||
const snap = jsonOut(r.out) as { apple: { daily: Record<string, string | number>[]; totals: Record<string, number> } };
|
||||
expect(snap.apple.daily.find(x => x.date === d1)).toMatchObject({ impressions: 100, pageViews: 30, downloads: 10, sessions: 200, activeDevices: 90, sessionSeconds: 6000, crashes: 3 });
|
||||
expect(snap.apple.daily.find(x => x.date === d2)).toMatchObject({ sessions: 150, activeDevices: 70 });
|
||||
expect(snap.apple.totals).toMatchObject({ sessions: 350, crashes: 3, activeDevicesAvg: 80 });
|
||||
expect(calls.some(c => /filter\[bundleId\]/.test(decodeURIComponent(c.path)))).toBe(false);
|
||||
expect(existsSync(join(p.root, '.storebound/analytics'))).toBe(false);
|
||||
});
|
||||
test('apple: without the usage reports the rest still comes, with a note', async () => {
|
||||
ascEnv();
|
||||
const p = project(virtualConfig());
|
||||
mockFetch(appleRoutes(reportsList.slice(0, 2)));
|
||||
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json'])));
|
||||
const snap = jsonOut(r.out) as { apple: { daily: Record<string, string | number>[]; notes: string[] } };
|
||||
expect(snap.apple.daily.find(x => x.date === d1)!.impressions).toBe(100);
|
||||
expect(snap.apple.daily[0]!.sessions).toBeUndefined();
|
||||
expect(snap.apple.notes[0]).toContain('App Usage');
|
||||
expect(existsSync(join(p.root, '.storebound/analytics', `${iso(0)}.json`))).toBe(true);
|
||||
});
|
||||
test('play: the bucket from env, install stock numbers, daily user installs', async () => {
|
||||
playEnv();
|
||||
process.env.PLAY_REPORTS_BUCKET = BUCKET;
|
||||
const p = project(virtualConfig());
|
||||
const month = (d: string) => d.slice(0, 7).replace('-', '');
|
||||
const inMonth = (m: string) => [d2, d1].filter(d => month(d) === m);
|
||||
const mOf = (c: Req) => /_(\d{6})_/.exec(decodeURIComponent(c.path))![1]!;
|
||||
mockFetch([
|
||||
{ match: /store_performance_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package name,Country,Store listing acquisitions,Store listing visitors\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,5,50`).join('\n')}\n`) },
|
||||
{ match: /installs_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Country,Daily User Installs,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,4,100,60`).join('\n')}\n`) },
|
||||
{ match: /installs_com\.example\.notes_(\d{6})_overview\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,${d === d1 ? 120 : 110},${d === d1 ? 70 : 65}`).join('\n')}\n`) },
|
||||
]);
|
||||
const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json', '--dry'])));
|
||||
const snap = jsonOut(r.out) as { play: { daily: Record<string, string | number>[]; totals: Record<string, number> } };
|
||||
expect(snap.play.daily.find(x => x.date === d1)).toMatchObject({ visitors: 50, acquisitions: 5, installs: 4, activeDeviceInstalls: 70, totalUserInstalls: 120 });
|
||||
expect(snap.play.totals).toMatchObject({ installs: 8, activeDeviceInstalls: 70, totalUserInstalls: 120 });
|
||||
});
|
||||
test('play without any bucket: the command fails (nothing to write)', async () => {
|
||||
playEnv();
|
||||
const p = project(virtualConfig());
|
||||
mockFetch([]);
|
||||
await expect(capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json'])))).rejects.toThrow('nothing written');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reports over a day range', () => {
|
||||
test('apple-sales --days: one JSON with the days, totals per currency, the missing day listed', async () => {
|
||||
ascEnv();
|
||||
process.env.ASC_VENDOR_NUMBER = '12345678';
|
||||
const p = project();
|
||||
const yesterday = iso(-1), before = iso(-2);
|
||||
const tsv = (units: number, proceeds: string, cur: string) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.notes.pro\tPro\t${units}\t${proceeds}\t${cur}`, ''].join('\n');
|
||||
const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: (c: Req) => {
|
||||
const date = /reportDate\]=(\d{4}-\d{2}-\d{2})/.exec(decodeURIComponent(c.path))![1]!;
|
||||
return date === yesterday ? new Response('{"errors":[{"status":"404","code":"NOT_FOUND"}]}', { status: 404 }) : new Response(Bun.gzipSync(new TextEncoder().encode(tsv(2, '3.49', date === before ? 'USD' : 'EUR'))));
|
||||
} }]);
|
||||
const r = await capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--json'])));
|
||||
const j = jsonOut(r.out) as { from: string; to: string; days: { date: string; totals: unknown[] }[]; totals: unknown[]; missing: string[]; rows: number };
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(j.to).toBe(yesterday);
|
||||
expect(j.days.map(d => d.date)).toEqual([iso(-3), before]);
|
||||
expect(j.missing).toEqual([yesterday]);
|
||||
expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6.98 }, { currency: 'USD', units: 2, amount: 6.98 }]);
|
||||
expect(j.rows).toBe(2);
|
||||
expect(existsSync(join(p.root, `.storebound/reports/apple-sales-daily-${before}.tsv`))).toBe(true);
|
||||
expect(readFileSync(join(p.root, `.storebound/reports/apple-sales-daily-${iso(-3)}_${yesterday}.summary.csv`), 'utf8')).toContain('date,key,title,currency,units,amount,rows');
|
||||
});
|
||||
test('apple-sales with a --type other than SALES, or --days on finance, is refused', async () => {
|
||||
ascEnv();
|
||||
const p = project();
|
||||
await expect(capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--type', 'SUBSCRIPTION', '--vendor', '12345678'])))).rejects.toThrow('SALES');
|
||||
await expect(capture(() => reports(p.ctx(['apple-finance', '--days', '3', '--month', '2026-09', '--vendor', '12345678'])))).rejects.toThrow('monthly');
|
||||
});
|
||||
test('play-sales --days: the daily revenue per currency from the monthly sales zips; play-earnings --days is refused', async () => {
|
||||
playEnv();
|
||||
process.env.PLAY_REPORTS_BUCKET = BUCKET;
|
||||
const p = project({ ...BASE, play: { packageName: 'com.example.notes' } });
|
||||
const d1 = iso(-1), d2 = iso(-2);
|
||||
const months = new Set([d1, d2].map(d => d.slice(0, 7).replace('-', '')));
|
||||
const csv = `Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\nA1,${d1},Charged,pro,USD,4.99,0,4.99\nA2,${d2},Charged,pro,USD,4.99,0,4.99\nA3,${d2},Refund,pro,USD,4.99,0,4.99\n`;
|
||||
mockFetch([
|
||||
{ match: /\/o\?prefix=sales\/salesreport_(\d{6})$/, answer: (c: Req) => { const m = /salesreport_(\d{6})/.exec(decodeURIComponent(c.path))![1]!; return { items: months.has(m) ? [{ name: `sales/salesreport_${m}.zip` }] : [] }; } },
|
||||
{ match: /\/o\/sales\/salesreport_\d{6}\.zip\?alt=media$/, answer: () => new Response(zip({ 'salesreport.csv': csv }) as Uint8Array<ArrayBuffer>) },
|
||||
]);
|
||||
const r = await capture(() => reports(p.ctx(['play-sales', '--days', '2', '--json'])));
|
||||
const j = jsonOut(r.out) as { days: { date: string; net: number }[]; totals: unknown[]; emptyDays: string[] };
|
||||
// one zip per month the window touches: a window across a month end reads the same fixture twice, so compare per day
|
||||
const once = months.size === 1;
|
||||
expect(j.days).toMatchObject([{ date: d2, currency: 'USD', orders: once ? 1 : 2, refunds: once ? 1 : 2 }, { date: d1, orders: once ? 1 : 2 }]);
|
||||
if (once) expect(j.totals).toEqual([{ currency: 'USD', orders: 2, refunds: 1, charged: 9.98, refunded: -4.99, net: 4.99 }]);
|
||||
expect(j.emptyDays).toEqual([]);
|
||||
expect(existsSync(join(p.root, `.storebound/reports/play-sales-daily-${d2}_${d1}.summary.csv`))).toBe(true);
|
||||
await expect(capture(() => reports(p.ctx(['play-earnings', '--days', '2'])))).rejects.toThrow('monthly');
|
||||
});
|
||||
});
|
||||
|
|
@ -51,7 +51,7 @@ describe('apple analytics reports', () => {
|
|||
describe('play statistics exports', () => {
|
||||
test('file names, months, bucket', () => {
|
||||
expect(monthsBetween('2026-11-20', '2027-02-01')).toEqual(['202611', '202612', '202701', '202702']);
|
||||
expect(playObjects('com.example.game', '202610')).toEqual({ storePerformance: 'stats/store_performance/store_performance_com.example.game_202610_country.csv', installs: 'stats/installs/installs_com.example.game_202610_country.csv' });
|
||||
expect(playObjects('com.example.game', '202610')).toEqual({ storePerformance: 'stats/store_performance/store_performance_com.example.game_202610_country.csv', installs: 'stats/installs/installs_com.example.game_202610_country.csv', installsOverview: 'stats/installs/installs_com.example.game_202610_overview.csv' });
|
||||
expect(bucketName('gs://pubsite_prod_rev_0000/stats')).toBe('pubsite_prod_rev_0000');
|
||||
});
|
||||
test('daily sums across countries', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue