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
|
|
@ -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)))];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue