// Sales / finance reports and crash / feedback triage: parsers and summaries on sanitized report files, plus the // commands against recorded App Store Connect, Cloud Storage and Play Developer Reporting answers. import { afterEach, describe, expect, test } from 'bun:test'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { deflateRawSync } from 'node:zlib'; import { appleFinancePath, appleReportDate, appleSalesPath, parsePlayCsv, parseTsv, playReportPrefix, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, totalsByCurrency, } from '../cli/lib/reports'; import { unzip } from '../cli/lib/zip'; import { cluster, crashSignature, feedbackItems, playIssues, playReportItems, reportsQuery } from '../cli/lib/triage'; import { run as reports } from '../cli/reports'; import { run as triage } from '../cli/triage'; import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock'; afterEach(restore); /** A ZIP with deflated entries (what Play puts in the reports bucket). */ function zip(files: Record): 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; } // Summary Sales Report 1_0 (tab-separated, header as in App Store Connect Help); values are made up const SALES_TSV = ['Provider\tProvider Country\tSKU\tDeveloper\tTitle\tVersion\tProduct Type Identifier\tUnits\tDeveloper Proceeds\tBegin Date\tEnd Date\tCustomer Currency\tCountry Code\tCurrency of Proceeds\tApple Identifier\tCustomer Price', 'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t3\t3.49\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000301\t4.99', 'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t1\t3.49\t10/09/2026\t10/09/2026\tUSD\tCA\tUSD\t6400000301\t4.99', 'APPLE\tUS\tcom.example.notes.pro\tExample\tPro\t\tIA1\t2\t4.12\t10/09/2026\t10/09/2026\tEUR\tDE\tEUR\t6400000301\t5.99', 'APPLE\tUS\tNOTES1\tExample\tExample Notes\t1.2.0\t1F\t40\t0\t10/09/2026\t10/09/2026\tUSD\tUS\tUSD\t6400000101\t0', ''].join('\n'); const FINANCE_TSV = ['Start Date\tEnd Date\tUPC\tISRC/ISBN\tVendor Identifier\tQuantity\tPartner Share\tExtended Partner Share\tPartner Share Currency\tSales or Return\tApple Identifier\tArtist/Show/Developer/Author\tTitle', '09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t10\t3.49\t34.90\tUSD\tS\t6400000301\tExample\tPro', '09/01/2026\t09/30/2026\t\t\tcom.example.notes.pro\t-1\t3.49\t-3.49\tUSD\tR\t6400000301\tExample\tPro', '', 'Total_Rows\t2', 'Total_Amount\t31.41'].join('\n'); const EARNINGS_CSV = 'Description,Transaction Date,Transaction Time,Tax Type,Transaction Type,Refund Type,Product Title,Package ID,Product Type,SKU ID,Hardware,Buyer Country,Buyer State,Buyer Postcode,Buyer Currency,Amount (Buyer Currency),Currency Conversion Rate,Merchant Currency,Amount (Merchant Currency)\n' + 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,4.99,1,USD,4.99\n' + 'GPA.0000-0000-0000-00001,"Sep 3, 2026",10:00:00 AM PDT,,Google fee,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,US,CA,9xxxx,USD,-0.75,1,USD,-0.75\n' + 'GPA.0000-0000-0000-00002,"Sep 4, 2026",11:00:00 AM PDT,,Charge,,Pro (Example Notes),com.example.notes,inapp,com.example.notes.pro,phone,DE,,,EUR,5.49,1.08,USD,5.93\n'; const SALES_CSV = 'Order Number,Order Charged Date,Order Charged Timestamp,Financial Status,Device Model,Product Title,Package ID,Product Type,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount,City of Buyer,State of Buyer,Postcode of Buyer,Country of Buyer\n' + 'GPA.0000-0000-0000-00001,2026-09-03,1788000000,Charged,phone1,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,CA,,US\n' + 'GPA.0000-0000-0000-00003,2026-09-05,1788100000,Refund,phone2,Pro,com.example.notes,inapp,com.example.notes.pro,USD,4.99,0.00,4.99,,TX,,US\n'; describe('report parsers and summaries', () => { test('request paths follow the allowed combinations', () => { expect(appleSalesPath('12345678', { type: 'SALES', frequency: 'DAILY', date: '2026-10-09' })).toBe('/v1/salesReports?filter%5BvendorNumber%5D=12345678&filter%5BreportType%5D=SALES&filter%5BreportSubType%5D=SUMMARY&filter%5Bfrequency%5D=DAILY&filter%5BreportDate%5D=2026-10-09&filter%5Bversion%5D=1_0'); expect(appleSalesPath('12345678', { type: 'SUBSCRIPTION', frequency: 'DAILY', date: '2026-10-09' })).toContain('filter%5Bversion%5D=1_3'); expect(() => appleSalesPath('1', { type: 'SUBSCRIPTION', frequency: 'MONTHLY', date: '2026-09' })).toThrow('DAILY only'); expect(appleFinancePath('12345678', '2026-09')).toContain('filter%5BreportType%5D=FINANCIAL&filter%5BregionCode%5D=ZZ&filter%5BreportDate%5D=2026-09'); expect(appleFinancePath('12345678', '2026-09', { type: 'FINANCE_DETAIL' })).toContain('regionCode%5D=Z1'); expect(appleReportDate('MONTHLY', undefined, new Date('2026-01-15T00:00:00Z'))).toBe('2025-12'); expect(appleReportDate('DAILY', undefined, new Date('2026-10-11T12:00:00Z'))).toBe('2026-10-09'); expect(() => appleReportDate('YEARLY', '2026-01')).toThrow('YYYY'); expect(playReportPrefix('earnings', '2026-09')).toBe('earnings/earnings_202609'); }); test('Apple sales and finance: units x proceeds per SKU and currency, totals never mix currencies', () => { const s = summarizeAppleSales(parseTsv(SALES_TSV)); expect(s).toEqual([ { key: 'NOTES1', title: 'Example Notes', currency: 'USD', units: 40, amount: 0, rows: 1 }, { key: 'com.example.notes.pro', title: 'Pro', currency: 'EUR', units: 2, amount: 8.24, rows: 1 }, { key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 4, amount: 13.96, rows: 2 }, ]); expect(totalsByCurrency(s)).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]); expect(summarizeAppleFinance(parseTsv(FINANCE_TSV))).toEqual([{ key: 'com.example.notes.pro', title: 'Pro', currency: 'USD', units: 9, amount: 31.41, rows: 2 }]); }); test('Play earnings and estimated sales out of the zip', () => { const files = unzip(zip({ 'PlayApps_202609.csv': EARNINGS_CSV })); expect(files.map(f => f.name)).toEqual(['PlayApps_202609.csv']); const e = summarizePlayEarnings(parsePlayCsv(new TextDecoder().decode(files[0]!.data))); expect(e).toEqual([ { key: 'com.example.notes.pro · Charge', title: 'Pro (Example Notes)', currency: 'USD', units: 2, amount: 10.92, rows: 2 }, { key: 'com.example.notes.pro · Google fee', title: 'Pro (Example Notes)', currency: 'USD', units: 0, amount: -0.75, rows: 1 }, ]); expect(summarizePlaySales(parsePlayCsv(SALES_CSV)).map(r => [r.key, r.amount])).toEqual([['com.example.notes.pro · Charged', 4.99], ['com.example.notes.pro · Refund', 4.99]]); expect(() => unzip(new Uint8Array(40))).toThrow('not a ZIP'); }); }); describe('reports command', () => { test('apple-sales: --dry hides the vendor number; a run saves the TSV and the summary CSV', async () => { ascEnv(); process.env.ASC_VENDOR_NUMBER = '12345678'; const p = project(); const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: new Response(Bun.gzipSync(new TextEncoder().encode(SALES_TSV)), { headers: { 'content-type': 'application/a-gzip' } }) }]); let r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--dry', '--json']))); expect(jsonOut(r.out).request).toContain('filter[vendorNumber]='.replace(/[[\]]/g, m => encodeURIComponent(m))); expect(calls).toHaveLength(0); r = await capture(() => reports(p.ctx(['apple-sales', '--date', '2026-10-09', '--json']))); const j = jsonOut(r.out); expect(j.rows).toBe(4); expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 8.24 }, { currency: 'USD', units: 44, amount: 13.96 }]); expect(calls[0]!.path).toContain('filter%5BvendorNumber%5D=12345678'); expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.tsv'), 'utf8')).toBe(SALES_TSV); expect(readFileSync(join(p.root, '.storebound/reports/apple-sales-daily-2026-10-09.summary.csv'), 'utf8')).toStartWith('key,title,currency,units,amount,rows\nNOTES1,Example Notes,USD,40,0,1\n'); }); test('apple-finance: 404 means no report yet; missing vendor number is explained', async () => { ascEnv(); const p = project(); await expect(capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09'])))).rejects.toThrow('ASC_VENDOR_NUMBER'); mockFetch([{ match: /^\/v1\/financeReports\?/, answer: new Response('{"errors":[{"status":"404","code":"NOT_FOUND","title":"The request expected results but none were found"}]}', { status: 404 }) }]); const r = await capture(() => reports(p.ctx(['apple-finance', '--month', '2026-09', '--vendor', '12345678']))); expect(r.out).toContain('no report for that date'); }); test('play-earnings: lists the bucket, unzips, summarizes', async () => { playEnv(); const p = project(); const calls = mockFetch([ { match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\?prefix=earnings\/earnings_202609$/, answer: { kind: 'storage#objects', items: [{ kind: 'storage#object', name: 'earnings/earnings_202609_0123456789-1.zip', size: '512' }] } }, { match: /^\/storage\/v1\/b\/pubsite_prod_rev_01234567890123456789\/o\/earnings\/earnings_202609_0123456789-1\.zip\?alt=media$/, answer: new Response(zip({ 'PlayApps_202609.csv': EARNINGS_CSV }) as Uint8Array) }, ]); const r = await capture(() => reports(p.ctx(['play-earnings', '--month', '2026-09', '--json']))); expect(jsonOut(r.out)).toMatchObject({ rows: 3, totals: [{ currency: 'USD', units: 2, amount: 10.17 }] }); expect(calls).toHaveLength(2); expect(existsSync(join(p.root, '.storebound/reports/play-earnings-2026-09.csv'))).toBe(true); }); }); // ---- triage ---- const TEXT_CRASH = `Incident Identifier: 00000000-0000-0000-0000-000000000000 Hardware Model: iPhone16,2 Process: ExampleNotes [1234] Identifier: com.example.notes Version: 1.2.0 (50) OS Version: iPhone OS 26.0 (23A341) Exception Type: EXC_BAD_ACCESS (SIGSEGV) Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010 Crashed Thread: 3 Thread 0 name: Dispatch queue: com.apple.main-thread Thread 0: 0 libsystem_kernel.dylib 0x00000001e0f0a1b4 mach_msg2_trap + 8 Thread 3 Crashed: 0 libswiftCore.dylib 0x0000000190a1b2c4 swift_retain + 12 1 ExampleNotes 0x0000000100f1a2b4 ListStore.sync(_:) + 220 (ListStore.swift:88) 2 ExampleNotes 0x0000000100f1a000 closure #1 in ListStore.start() + 40 Thread 4: 0 libsystem_pthread.dylib 0x00000001f0a0b0c0 start_wqthread + 0 `; const IPS_CRASH = `{"app_name":"ExampleNotes","bundleID":"com.example.notes","app_version":"1.2.0","build_version":"50","os_version":"iPhone OS 26.0 (23A341)","name":"ExampleNotes"} {"procName":"ExampleNotes","exception":{"type":"EXC_CRASH","signal":"SIGABRT"},"faultingThread":0,"threads":[{"triggered":true,"frames":[{"imageIndex":1,"symbol":"__pthread_kill","imageOffset":8},{"imageIndex":0,"symbol":"ListStore.save()","imageOffset":4096}]}],"usedImages":[{"name":"ExampleNotes"},{"name":"libsystem_kernel.dylib"}]}`; describe('triage parts', () => { test('crash signatures: first app frame of the crashed thread, text and .ips', () => { expect(crashSignature(TEXT_CRASH)).toBe('EXC_BAD_ACCESS (SIGSEGV) @ ExampleNotes ListStore.sync(_:) + 220 (ListStore.swift:88)'); expect(crashSignature(IPS_CRASH)).toBe('EXC_CRASH SIGABRT @ ExampleNotes ListStore.save()'); expect(crashSignature('garbage')).toBe('crash'); }); test('clusters with top devices, OS versions and builds; Play reports grouped by issue', () => { const items = feedbackItems([ { id: 'c1', attributes: { createdDate: '2026-10-09T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0', comment: 'It crashed when I synced' }, relationships: { build: { data: { id: 'b-50' } } } }, { id: 'c2', attributes: { createdDate: '2026-10-10T10:00:00Z', deviceModel: 'iPhone16,2', osVersion: '26.0.1' }, relationships: { build: { data: { id: 'b-50' } } } }, { id: 'c3', attributes: { createdDate: '2026-10-10T11:00:00Z', deviceModel: 'iPhone15,3', osVersion: '26.0' }, relationships: { build: { data: { id: 'b-49' } } } }, ], new Map([['b-50', '50'], ['b-49', '49']]), new Map([['c1', 'A'], ['c2', 'A'], ['c3', 'B']])); const cs = cluster(items, { comments: 2 }); expect(cs[0]).toEqual({ signature: 'A', count: 2, devices: [['iPhone16,2', 2]], os: [['26.0', 1], ['26.0.1', 1]], builds: [['50', 2]], first: '2026-10-09T10:00:00Z', last: '2026-10-10T10:00:00Z', comments: ['It crashed when I synced'] }); const issues = playIssues({ errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.notes.ListStore.sync', errorReportCount: '12', distinctUsers: '9' }] }); const play = playReportItems({ errorReports: [ { issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', eventTime: '2026-10-09T00:00:00Z', deviceModel: { marketingName: 'Pixel 9', deviceId: { buildBrand: 'google', buildDevice: 'tokay' } }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } }, { issue: 'apps/com.example.notes/errorIssues/1111', type: 'CRASH', deviceModel: { deviceId: { buildBrand: 'samsung', buildDevice: 'dm3q' } }, osVersion: { apiLevel: '35' }, appVersion: { versionCode: '120' } }, ] }, issues); expect(cluster(play)[0]).toMatchObject({ signature: 'CRASH java.lang.IllegalStateException @ com.example.notes.ListStore.sync', count: 2, devices: [['Pixel 9', 1], ['samsung/dm3q', 1]], os: [['API 35', 1], ['API 36', 1]], builds: [['120', 2]] }); expect(reportsQuery('1111', new Date('2026-09-27T00:00:00Z'), new Date('2026-10-11T00:00:00Z'))).toContain('filter=errorIssueId+%3D+1111'); }); test('command: both stores, emails never requested, --json', async () => { ascEnv(); playEnv(); const p = project(); const calls = mockFetch([ { match: /^\/v1\/builds\?filter\[app\]=6400000101/, answer: { data: [{ type: 'builds', id: 'b-50', attributes: { version: '50' } }], links: {} } }, { match: /^\/v1\/apps\/6400000101\/betaFeedbackCrashSubmissions\?/, answer: { data: [{ type: 'betaFeedbackCrashSubmissions', id: 'c1', attributes: { createdDate: new Date().toISOString(), deviceModel: 'iPhone16,2', osVersion: '26.0' }, relationships: { build: { data: { type: 'builds', id: 'b-50' } } } }], links: {} } }, { match: /^\/v1\/betaFeedbackCrashSubmissions\/c1\/crashLog$/, answer: { data: { type: 'betaCrashLogs', id: 'cl1', attributes: { logText: TEXT_CRASH } } } }, { match: /^\/v1\/apps\/6400000101\/betaFeedbackScreenshotSubmissions\?/, answer: { data: [], links: {} } }, { match: /^\/v1beta1\/apps\/com\.example\.notes\/errorIssues:search\?/, answer: { errorIssues: [{ name: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.notes.MainActivity', errorReportCount: '4', distinctUsers: '3' }] } }, { match: /^\/v1beta1\/apps\/com\.example\.notes\/errorReports:search\?/, answer: { errorReports: [{ issue: 'apps/com.example.notes/errorIssues/1111', type: 'ANR', deviceModel: { marketingName: 'Pixel 9' }, osVersion: { apiLevel: '36' }, appVersion: { versionCode: '120' } }] } }, ]); const r = await capture(() => triage(p.ctx(['--days', '14', '--json']))); const j = jsonOut(r.out) as { apple: { crashes: { signature: string }[] }; play: { issues: { signature: string; reports: number }[] } }; expect(j.apple.crashes[0]!.signature).toContain('ListStore.sync'); expect(j.play.issues[0]).toMatchObject({ signature: 'ANR Input dispatching timed out @ com.example.notes.MainActivity', count: 1, reports: 4 }); const fb = calls.filter(c => c.path.includes('betaFeedback') && c.path.includes('fields')); expect(fb).toHaveLength(2); for (const c of fb) { expect(decodeURIComponent(c.path)).not.toContain('email'); expect(decodeURIComponent(c.path)).not.toContain('tester'); } expect(BASE.app.contactEmail).toBe('support@example.com'); }); });