feat(cli): add sales report downloads

Add App Store Connect sales report downloads to the CLI, including account-level sales report retrieval, gzip TSV parsing, and JSON output. Co-Authored-By: siGit Code <sigit@sigit.si>

Seto Elkahfi committed Sep 12, 2026 at 19:05 UTC 4f38474d44229527d04b54d483e21795dfd6858e
8 files changed +505 -71
Cargo.lock
+166 -69
@@ -2,6 +2,12 @@
2 # It is not intended for manual editing.
3 version = 4
4
5 +[[package]]
6 +name = "adler2"
7 +version = "2.0.1"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 [[package]]
12 name = "android_system_properties"
13 version = "0.1.6"
@@ -114,7 +120,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
120 dependencies = [
121 "proc-macro2",
122 "quote",
117 - "syn 3.0.4",
123 + "syn 3.0.5",
124 ]
125
126 [[package]]
@@ -131,9 +137,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
137
138 [[package]]
139 name = "aws-lc-rs"
134 -version = "1.18.0"
140 +version = "1.18.1"
141 source = "registry+https://github.com/rust-lang/crates.io-index"
136 -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
142 +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
143 dependencies = [
144 "aws-lc-sys",
145 "untrusted 0.7.1",
@@ -142,9 +148,9 @@ dependencies = [
148
149 [[package]]
150 name = "aws-lc-sys"
145 -version = "0.44.0"
151 +version = "0.45.0"
152 source = "registry+https://github.com/rust-lang/crates.io-index"
147 -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
153 +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
154 dependencies = [
155 "cc",
156 "cmake",
@@ -159,6 +165,12 @@ version = "0.22.1"
165 source = "registry+https://github.com/rust-lang/crates.io-index"
166 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
167
168 +[[package]]
169 +name = "base64"
170 +version = "0.23.1"
171 +source = "registry+https://github.com/rust-lang/crates.io-index"
172 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
173 +
174 [[package]]
175 name = "base64ct"
176 version = "1.8.3"
@@ -176,9 +188,9 @@ dependencies = [
188
189 [[package]]
190 name = "bitflags"
179 -version = "2.13.1"
191 +version = "2.13.2"
192 source = "registry+https://github.com/rust-lang/crates.io-index"
181 -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
193 +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
194
195 [[package]]
196 name = "bumpalo"
@@ -194,9 +206,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
206
207 [[package]]
208 name = "cc"
197 -version = "1.4.4"
209 +version = "1.4.5"
210 source = "registry+https://github.com/rust-lang/crates.io-index"
199 -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
211 +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
212 dependencies = [
213 "find-msvc-tools",
214 "jobserver",
@@ -270,7 +282,7 @@ dependencies = [
282 "heck",
283 "proc-macro2",
284 "quote",
273 - "syn 3.0.4",
285 + "syn 3.0.5",
286 ]
287
288 [[package]]
@@ -318,13 +330,22 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
330
331 [[package]]
332 name = "cpufeatures"
321 -version = "0.3.0"
333 +version = "0.3.1"
334 source = "registry+https://github.com/rust-lang/crates.io-index"
323 -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
335 +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
336 dependencies = [
337 "libc",
338 ]
339
340 +[[package]]
341 +name = "crc32fast"
342 +version = "1.5.1"
343 +source = "registry+https://github.com/rust-lang/crates.io-index"
344 +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
345 +dependencies = [
346 + "cfg-if",
347 +]
348 +
349 [[package]]
350 name = "crypto-common"
351 version = "0.1.7"
@@ -335,6 +356,27 @@ dependencies = [
356 "typenum",
357 ]
358
359 +[[package]]
360 +name = "csv"
361 +version = "1.4.0"
362 +source = "registry+https://github.com/rust-lang/crates.io-index"
363 +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
364 +dependencies = [
365 + "csv-core",
366 + "itoa",
367 + "ryu",
368 + "serde_core",
369 +]
370 +
371 +[[package]]
372 +name = "csv-core"
373 +version = "0.1.13"
374 +source = "registry+https://github.com/rust-lang/crates.io-index"
375 +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
376 +dependencies = [
377 + "memchr",
378 +]
379 +
380 [[package]]
381 name = "darling"
382 version = "0.23.0"
@@ -424,7 +466,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
466 dependencies = [
467 "proc-macro2",
468 "quote",
427 - "syn 3.0.4",
469 + "syn 3.0.5",
470 ]
471
472 [[package]]
@@ -441,9 +483,20 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
483
484 [[package]]
485 name = "find-msvc-tools"
444 -version = "0.1.11"
486 +version = "0.1.12"
487 +source = "registry+https://github.com/rust-lang/crates.io-index"
488 +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
489 +
490 +[[package]]
491 +name = "flate2"
492 +version = "1.1.10"
493 source = "registry+https://github.com/rust-lang/crates.io-index"
446 -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
494 +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
495 +dependencies = [
496 + "crc32fast",
497 + "miniz_oxide",
498 + "zlib-rs",
499 +]
500
501 [[package]]
502 name = "form_urlencoded"
@@ -516,7 +569,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
569 dependencies = [
570 "proc-macro2",
571 "quote",
519 - "syn 3.0.4",
572 + "syn 3.0.5",
573 ]
574
575 [[package]]
@@ -632,9 +685,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
685
686 [[package]]
687 name = "hyper"
635 -version = "1.11.0"
688 +version = "1.11.1"
689 source = "registry+https://github.com/rust-lang/crates.io-index"
637 -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
690 +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
691 dependencies = [
692 "atomic-waker",
693 "bytes",
@@ -672,7 +725,7 @@ version = "0.1.20"
725 source = "registry+https://github.com/rust-lang/crates.io-index"
726 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
727 dependencies = [
675 - "base64",
728 + "base64 0.22.1",
729 "bytes",
730 "futures-channel",
731 "futures-util",
@@ -825,9 +878,9 @@ dependencies = [
878
879 [[package]]
880 name = "ipnet"
828 -version = "2.12.1"
881 +version = "2.12.2"
882 source = "registry+https://github.com/rust-lang/crates.io-index"
830 -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
883 +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
884
885 [[package]]
886 name = "is_terminal_polyfill"
@@ -853,9 +906,9 @@ dependencies = [
906
907 [[package]]
908 name = "js-sys"
856 -version = "0.3.104"
909 +version = "0.3.105"
910 source = "registry+https://github.com/rust-lang/crates.io-index"
858 -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
911 +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
912 dependencies = [
913 "cfg-if",
914 "futures-util",
@@ -868,9 +921,9 @@ version = "9.3.1"
921 source = "registry+https://github.com/rust-lang/crates.io-index"
922 checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
923 dependencies = [
871 - "base64",
924 + "base64 0.22.1",
925 "js-sys",
873 - "pem",
926 + "pem 3.0.6",
927 "ring",
928 "serde",
929 "serde_json",
@@ -934,11 +987,21 @@ version = "0.2.1"
987 source = "registry+https://github.com/rust-lang/crates.io-index"
988 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
989
990 +[[package]]
991 +name = "miniz_oxide"
992 +version = "0.9.1"
993 +source = "registry+https://github.com/rust-lang/crates.io-index"
994 +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
995 +dependencies = [
996 + "adler2",
997 + "simd-adler32",
998 +]
999 +
1000 [[package]]
1001 name = "mio"
939 -version = "1.2.2"
1002 +version = "1.2.3"
1003 source = "registry+https://github.com/rust-lang/crates.io-index"
941 -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
1004 +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
1005 dependencies = [
1006 "libc",
1007 "wasi",
@@ -1055,7 +1118,17 @@ version = "3.0.6"
1118 source = "registry+https://github.com/rust-lang/crates.io-index"
1119 checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
1120 dependencies = [
1058 - "base64",
1121 + "base64 0.22.1",
1122 + "serde_core",
1123 +]
1124 +
1125 +[[package]]
1126 +name = "pem"
1127 +version = "4.0.0"
1128 +source = "registry+https://github.com/rust-lang/crates.io-index"
1129 +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
1130 +dependencies = [
1131 + "base64 0.23.1",
1132 "serde_core",
1133 ]
1134
@@ -1269,12 +1342,12 @@ dependencies = [
1342
1343 [[package]]
1344 name = "rcgen"
1272 -version = "0.14.9"
1345 +version = "0.14.10"
1346 source = "registry+https://github.com/rust-lang/crates.io-index"
1274 -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
1347 +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
1348 dependencies = [
1349 "aws-lc-rs",
1277 - "pem",
1350 + "pem 4.0.0",
1351 "rustls-pki-types",
1352 "time",
1353 "x509-parser",
@@ -1298,7 +1371,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
1371 dependencies = [
1372 "proc-macro2",
1373 "quote",
1301 - "syn 3.0.4",
1374 + "syn 3.0.5",
1375 ]
1376
1377 [[package]]
@@ -1307,7 +1380,7 @@ version = "0.12.28"
1380 source = "registry+https://github.com/rust-lang/crates.io-index"
1381 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1382 dependencies = [
1310 - "base64",
1383 + "base64 0.22.1",
1384 "bytes",
1385 "futures-core",
1386 "http",
@@ -1360,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1433 checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
1434 dependencies = [
1435 "async-trait",
1363 - "base64",
1436 + "base64 0.22.1",
1437 "chrono",
1438 "futures",
1439 "pastey",
@@ -1425,9 +1498,9 @@ dependencies = [
1498
1499 [[package]]
1500 name = "rustls"
1428 -version = "0.23.43"
1501 +version = "0.23.44"
1502 source = "registry+https://github.com/rust-lang/crates.io-index"
1430 -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
1503 +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
1504 dependencies = [
1505 "once_cell",
1506 "ring",
@@ -1514,7 +1587,7 @@ dependencies = [
1587 "proc-macro2",
1588 "quote",
1589 "serde_derive_internals",
1517 - "syn 3.0.4",
1590 + "syn 3.0.5",
1591 ]
1592
1593 [[package]]
@@ -1567,7 +1640,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1640 dependencies = [
1641 "proc-macro2",
1642 "quote",
1570 - "syn 3.0.4",
1643 + "syn 3.0.5",
1644 ]
1645
1646 [[package]]
@@ -1578,7 +1651,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
1651 dependencies = [
1652 "proc-macro2",
1653 "quote",
1581 - "syn 3.0.4",
1654 + "syn 3.0.5",
1655 ]
1656
1657 [[package]]
@@ -1622,6 +1695,12 @@ dependencies = [
1695 "rand_core 0.6.4",
1696 ]
1697
1698 +[[package]]
1699 +name = "simd-adler32"
1700 +version = "0.3.10"
1701 +source = "registry+https://github.com/rust-lang/crates.io-index"
1702 +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
1703 +
1704 [[package]]
1705 name = "simple_asn1"
1706 version = "0.6.4"
@@ -1642,9 +1721,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1721
1722 [[package]]
1723 name = "smallvec"
1645 -version = "1.15.2"
1724 +version = "1.16.1"
1725 source = "registry+https://github.com/rust-lang/crates.io-index"
1647 -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1726 +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
1727
1728 [[package]]
1729 name = "smbcloud-ascapi-aso"
@@ -1663,8 +1742,10 @@ name = "smbcloud-ascapi-cli"
1742 version = "0.1.1"
1743 dependencies = [
1744 "anyhow",
1666 - "base64",
1745 + "base64 0.22.1",
1746 "clap",
1747 + "csv",
1748 + "flate2",
1749 "serde",
1750 "serde_json",
1751 "smbcloud-ascapi-aso",
@@ -1672,6 +1753,7 @@ dependencies = [
1753 "smbcloud-ascapi-frontend",
1754 "smbcloud-ascapi-mcp",
1755 "smbcloud-ascapi-pricing",
1756 + "smbcloud-ascapi-reports",
1757 "smbcloud-ascapi-signing",
1758 "tokio",
1759 ]
@@ -1691,7 +1773,7 @@ dependencies = [
1773 name = "smbcloud-ascapi-frontend"
1774 version = "0.1.1"
1775 dependencies = [
1694 - "base64",
1776 + "base64 0.22.1",
1777 "schemars",
1778 "serde",
1779 "serde_json",
@@ -1727,6 +1809,15 @@ dependencies = [
1809 "smbcloud-ascapi-core",
1810 ]
1811
1812 +[[package]]
1813 +name = "smbcloud-ascapi-reports"
1814 +version = "0.1.1"
1815 +dependencies = [
1816 + "async-trait",
1817 + "reqwest",
1818 + "smbcloud-ascapi-core",
1819 +]
1820 +
1821 [[package]]
1822 name = "smbcloud-ascapi-signing"
1823 version = "0.1.1"
@@ -1799,9 +1890,9 @@ dependencies = [
1890
1891 [[package]]
1892 name = "syn"
1802 -version = "3.0.4"
1893 +version = "3.0.5"
1894 source = "registry+https://github.com/rust-lang/crates.io-index"
1804 -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
1895 +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
1896 dependencies = [
1897 "proc-macro2",
1898 "quote",
@@ -1845,7 +1936,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1936 dependencies = [
1937 "proc-macro2",
1938 "quote",
1848 - "syn 3.0.4",
1939 + "syn 3.0.5",
1940 ]
1941
1942 [[package]]
@@ -1890,9 +1981,9 @@ dependencies = [
1981
1982 [[package]]
1983 name = "tinyvec"
1893 -version = "1.12.0"
1984 +version = "1.13.2"
1985 source = "registry+https://github.com/rust-lang/crates.io-index"
1895 -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
1986 +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
1987 dependencies = [
1988 "tinyvec_macros",
1989 ]
@@ -1926,14 +2017,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
2017 dependencies = [
2018 "proc-macro2",
2019 "quote",
1929 - "syn 3.0.4",
2020 + "syn 3.0.5",
2021 ]
2022
2023 [[package]]
2024 name = "tokio-rustls"
1934 -version = "0.26.4"
2025 +version = "0.26.5"
2026 source = "registry+https://github.com/rust-lang/crates.io-index"
1936 -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
2027 +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
2028 dependencies = [
2029 "rustls",
2030 "tokio",
@@ -2106,9 +2197,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2197
2198 [[package]]
2199 name = "wasm-bindgen"
2109 -version = "0.2.127"
2200 +version = "0.2.128"
2201 source = "registry+https://github.com/rust-lang/crates.io-index"
2111 -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
2202 +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
2203 dependencies = [
2204 "cfg-if",
2205 "once_cell",
@@ -2119,9 +2210,9 @@ dependencies = [
2210
2211 [[package]]
2212 name = "wasm-bindgen-futures"
2122 -version = "0.4.77"
2213 +version = "0.4.78"
2214 source = "registry+https://github.com/rust-lang/crates.io-index"
2124 -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
2215 +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
2216 dependencies = [
2217 "js-sys",
2218 "wasm-bindgen",
@@ -2129,9 +2220,9 @@ dependencies = [
2220
2221 [[package]]
2222 name = "wasm-bindgen-macro"
2132 -version = "0.2.127"
2223 +version = "0.2.128"
2224 source = "registry+https://github.com/rust-lang/crates.io-index"
2134 -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
2225 +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
2226 dependencies = [
2227 "quote",
2228 "wasm-bindgen-macro-support",
@@ -2139,31 +2230,31 @@ dependencies = [
2230
2231 [[package]]
2232 name = "wasm-bindgen-macro-support"
2142 -version = "0.2.127"
2233 +version = "0.2.128"
2234 source = "registry+https://github.com/rust-lang/crates.io-index"
2144 -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
2235 +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
2236 dependencies = [
2237 "bumpalo",
2238 "proc-macro2",
2239 "quote",
2149 - "syn 2.0.119",
2240 + "syn 3.0.5",
2241 "wasm-bindgen-shared",
2242 ]
2243
2244 [[package]]
2245 name = "wasm-bindgen-shared"
2155 -version = "0.2.127"
2246 +version = "0.2.128"
2247 source = "registry+https://github.com/rust-lang/crates.io-index"
2157 -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
2248 +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
2249 dependencies = [
2250 "unicode-ident",
2251 ]
2252
2253 [[package]]
2254 name = "web-sys"
2164 -version = "0.3.104"
2255 +version = "0.3.105"
2256 source = "registry+https://github.com/rust-lang/crates.io-index"
2166 -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
2257 +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
2258 dependencies = [
2259 "js-sys",
2260 "wasm-bindgen",
@@ -2379,18 +2470,18 @@ dependencies = [
2470
2471 [[package]]
2472 name = "zerocopy"
2382 -version = "0.8.56"
2473 +version = "0.8.57"
2474 source = "registry+https://github.com/rust-lang/crates.io-index"
2384 -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
2475 +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
2476 dependencies = [
2477 "zerocopy-derive",
2478 ]
2479
2480 [[package]]
2481 name = "zerocopy-derive"
2391 -version = "0.8.56"
2482 +version = "0.8.57"
2483 source = "registry+https://github.com/rust-lang/crates.io-index"
2393 -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
2484 +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
2485 dependencies = [
2486 "proc-macro2",
2487 "quote",
@@ -2454,9 +2545,15 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
2545 dependencies = [
2546 "proc-macro2",
2547 "quote",
2457 - "syn 3.0.4",
2548 + "syn 3.0.5",
2549 ]
2550
2551 +[[package]]
2552 +name = "zlib-rs"
2553 +version = "0.6.7"
2554 +source = "registry+https://github.com/rust-lang/crates.io-index"
2555 +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
2556 +
2557 [[package]]
2558 name = "zmij"
2559 version = "1.0.23"
Cargo.toml
+4
@@ -5,6 +5,7 @@ members = [
5 "crates/aso",
6 "crates/signing",
7 "crates/pricing",
8 + "crates/reports",
9 "crates/frontend",
10 "crates/mcp",
11 "crates/cli",
@@ -22,6 +23,7 @@ smbcloud-ascapi-core = { version = "0.1.0", path = "crates/core" }
23 smbcloud-ascapi-aso = { version = "0.1.0", path = "crates/aso" }
24 smbcloud-ascapi-signing = { version = "0.1.0", path = "crates/signing" }
25 smbcloud-ascapi-pricing = { version = "0.1.0", path = "crates/pricing" }
26 +smbcloud-ascapi-reports = { version = "0.1.0", path = "crates/reports" }
27 smbcloud-ascapi-frontend = { version = "0.1.0", path = "crates/frontend" }
28 smbcloud-ascapi-mcp = { version = "0.1.0", path = "crates/mcp" }
29 anyhow = "1"
@@ -34,6 +36,8 @@ thiserror = "2.0"
36 tokio = "1"
37 async-trait = "0.1"
38 base64 = "0.22"
39 +csv = "1"
40 +flate2 = "1"
41 rcgen = { version = "0.14", default-features = false, features = ["pem", "crypto", "aws_lc_rs"] }
42 rsa = "0.9"
43 rand = "0.8"
crates/cli/Cargo.toml
+3
@@ -16,12 +16,15 @@ path = "src/main.rs"
16 anyhow = { workspace = true }
17 base64 = { workspace = true }
18 clap = { workspace = true, features = ["derive", "env"] }
19 +csv = { workspace = true }
20 +flate2 = { workspace = true }
21 serde = { workspace = true }
22 serde_json = { workspace = true }
23 smbcloud-ascapi-aso = { workspace = true }
24 smbcloud-ascapi-core = { workspace = true }
25 smbcloud-ascapi-pricing = { workspace = true }
26 smbcloud-ascapi-signing = { workspace = true }
27 +smbcloud-ascapi-reports = { workspace = true }
28 smbcloud-ascapi-frontend = { workspace = true }
29 smbcloud-ascapi-mcp = { workspace = true }
30 tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
crates/cli/README.md
+29
@@ -25,6 +25,35 @@ export ASC_ISSUER_ID=<issuer id>
25 export ASC_PRIVATE_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
26 ```
27
28 +## Sales reports
29 +
30 +`sales-reports download` retrieves account-level App Store sales data. It is
31 +for paid-app and in-app purchase reporting across the team; it is not the
32 +per-customer transaction-history API. Reports are returned as a JSON array
33 +whose keys are Apple's original TSV column names and whose values are strings.
34 +
35 +Set the vendor number shown in App Store Connect → Payments and Financial
36 +Reports, then request a report date and frequency:
37 +
38 +```bash
39 +export ASC_VENDOR_NUMBER=12345678
40 +
41 +# Yesterday's paid-app and in-app purchase sales.
42 +ascapi sales-reports download \
43 + --frequency DAILY \
44 + --report-date 2026-09-11
45 +
46 +# Save a monthly report rather than printing it to stdout.
47 +ascapi sales-reports download \
48 + --frequency MONTHLY \
49 + --report-date 2026-08 \
50 + --output sales-2026-08.json
51 +```
52 +
53 +`--report-type` and `--report-subtype` default to `SALES` and `SUMMARY`.
54 +Pass the exact Apple values to request another supported sales-report layout;
55 +use `--version` where that layout requires a report format version.
56 +
57 ## Signing certificates
58
59 ```bash
crates/cli/src/main.rs
+171
@@ -16,12 +16,15 @@ use smbcloud_ascapi_aso::prelude::*;
16 use smbcloud_ascapi_core::{ApiKey, Client};
17 use smbcloud_ascapi_pricing::app_price::PriceKind;
18 use smbcloud_ascapi_pricing::prelude::*;
19 +use smbcloud_ascapi_reports::prelude::*;
20 +use smbcloud_ascapi_reports::SalesReportRequest;
21 use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType};
22 use smbcloud_ascapi_signing::csr::generate_certificate_request;
23 use smbcloud_ascapi_signing::prelude::*;
24 use smbcloud_ascapi_signing::profile::{
25 ProfileCreateAttributes, ProfileCreateRelationships, ProfileType,
26 };
27 +use std::collections::HashSet;
28 use std::path::PathBuf;
29
30 /// Add/update App Store Connect app metadata (apps, app infos, app store
@@ -121,6 +124,40 @@ enum Command {
124 #[command(subcommand)]
125 command: ProfilesCommand,
126 },
127 + /// Download account-level App Store sales reports as JSON.
128 + SalesReports {
129 + #[command(subcommand)]
130 + command: SalesReportsCommand,
131 + },
132 +}
133 +
134 +#[derive(Subcommand)]
135 +enum SalesReportsCommand {
136 + /// Download, decompress, and convert one Apple sales report to JSON.
137 + Download {
138 + /// App Store Connect vendor number, from Payments and Financial
139 + /// Reports. May also be set through ASC_VENDOR_NUMBER.
140 + #[arg(long, env = "ASC_VENDOR_NUMBER")]
141 + vendor_number: String,
142 + /// Apple report frequency, such as DAILY, WEEKLY, MONTHLY, or YEARLY.
143 + #[arg(long)]
144 + frequency: String,
145 + /// Report date in Apple's required format for the selected frequency.
146 + #[arg(long)]
147 + report_date: String,
148 + /// Apple report type. Defaults to SALES for paid-app and IAP sales.
149 + #[arg(long, default_value = "SALES")]
150 + report_type: String,
151 + /// Apple report subtype. Defaults to SUMMARY.
152 + #[arg(long, default_value = "SUMMARY")]
153 + report_subtype: String,
154 + /// Optional Apple report format version, for report types that use one.
155 + #[arg(long)]
156 + version: Option<String>,
157 + /// Write the JSON array to this file instead of stdout.
158 + #[arg(long)]
159 + output: Option<PathBuf>,
160 + },
161 }
162
163 #[derive(Subcommand)]
@@ -610,6 +647,54 @@ fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
647 Ok(dry_run)
648 }
649
650 +/// Convert Apple's gzip-compressed TSV report to JSON without normalizing
651 +/// columns: report layouts vary by report type and Apple can add columns.
652 +fn sales_report_as_json(gzip: &[u8]) -> Result<Vec<serde_json::Value>> {
653 + use flate2::read::GzDecoder;
654 + use serde_json::{Map, Value};
655 +
656 + let decoder = GzDecoder::new(gzip);
657 + let mut reader = csv::ReaderBuilder::new()
658 + .delimiter(b'\t')
659 + .has_headers(true)
660 + .flexible(false)
661 + .from_reader(decoder);
662 +
663 + let headers = reader
664 + .headers()
665 + .context("reading the TSV header from Apple's sales report")?
666 + .iter()
667 + .map(str::to_owned)
668 + .collect::<Vec<_>>();
669 + if headers.is_empty() || headers.iter().any(|header| header.is_empty()) {
670 + anyhow::bail!("Apple's sales report has an empty TSV header")
671 + }
672 + let unique_headers = headers.iter().collect::<HashSet<_>>();
673 + if unique_headers.len() != headers.len() {
674 + anyhow::bail!("Apple's sales report has duplicate TSV headers")
675 + }
676 +
677 + let mut rows = Vec::new();
678 + for (index, record) in reader.records().enumerate() {
679 + let record = record.with_context(|| format!("parsing TSV row {}", index + 2))?;
680 + if record.len() != headers.len() {
681 + anyhow::bail!(
682 + "TSV row {} has {} columns; expected {}",
683 + index + 2,
684 + record.len(),
685 + headers.len()
686 + )
687 + }
688 + let object = headers
689 + .iter()
690 + .zip(record.iter())
691 + .map(|(header, value)| (header.clone(), Value::String(value.to_owned())))
692 + .collect::<Map<_, _>>();
693 + rows.push(Value::Object(object));
694 + }
695 + Ok(rows)
696 +}
697 +
698 #[tokio::main]
699 async fn main() -> Result<()> {
700 let cli = Cli::parse();
@@ -666,6 +751,7 @@ async fn main() -> Result<()> {
751 Command::AppPrices { command } => run_app_prices(&client, command).await,
752 Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
753 Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
754 + Command::SalesReports { command } => run_sales_reports(&client, command).await,
755 }
756 }
757
@@ -677,6 +763,40 @@ fn dirs_home() -> PathBuf {
763 .unwrap_or_else(|| PathBuf::from("."))
764 }
765
766 +async fn run_sales_reports(client: &Client, command: SalesReportsCommand) -> Result<()> {
767 + match command {
768 + SalesReportsCommand::Download {
769 + vendor_number,
770 + frequency,
771 + report_date,
772 + report_type,
773 + report_subtype,
774 + version,
775 + output,
776 + } => {
777 + let report = client
778 + .download_sales_report(&SalesReportRequest {
779 + vendor_number,
780 + frequency,
781 + report_date,
782 + report_type,
783 + report_subtype,
784 + version,
785 + })
786 + .await?;
787 + let rows = sales_report_as_json(&report)?;
788 + if let Some(path) = output {
789 + let json = serde_json::to_vec_pretty(&rows)?;
790 + std::fs::write(&path, json)
791 + .with_context(|| format!("writing sales report JSON to {}", path.display()))?;
792 + } else {
793 + print_json(&rows)?;
794 + }
795 + Ok(())
796 + }
797 + }
798 +}
799 +
800 async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Result<()> {
801 match command {
802 AppsCommand::List { bundle_id } => {
@@ -1313,3 +1433,54 @@ fn base64_decode(input: &str) -> Result<Vec<u8>> {
1433 use base64::Engine;
1434 Ok(base64::engine::general_purpose::STANDARD.decode(input.trim())?)
1435 }
1436 +
1437 +#[cfg(test)]
1438 +mod tests {
1439 + use super::sales_report_as_json;
1440 + use flate2::write::GzEncoder;
1441 + use flate2::Compression;
1442 + use std::io::Write;
1443 +
1444 + fn gzip(tsv: &str) -> Vec<u8> {
1445 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1446 + encoder.write_all(tsv.as_bytes()).unwrap();
1447 + encoder.finish().unwrap()
1448 + }
1449 +
1450 + #[test]
1451 + fn sales_report_preserves_headers_and_string_values() {
1452 + let rows = sales_report_as_json(&gzip(
1453 + "Provider Country\tUnits\tTitle\nUS\t12\t\"An app\twith tab\"\n",
1454 + ))
1455 + .unwrap();
1456 +
1457 + assert_eq!(
1458 + rows,
1459 + vec![serde_json::json!({
1460 + "Provider Country": "US",
1461 + "Units": "12",
1462 + "Title": "An app\twith tab",
1463 + })]
1464 + );
1465 + }
1466 +
1467 + #[test]
1468 + fn sales_report_rejects_mismatched_rows() {
1469 + let error = sales_report_as_json(&gzip("Country\tUnits\nUS\n")).unwrap_err();
1470 + assert!(error.to_string().contains("parsing TSV row 2"));
1471 + }
1472 +
1473 + #[test]
1474 + fn sales_report_rejects_invalid_gzip() {
1475 + let error = sales_report_as_json(b"not a gzip file").unwrap_err();
1476 + assert!(error
1477 + .to_string()
1478 + .contains("reading the TSV header from Apple's sales report"));
1479 + }
1480 +
1481 + #[test]
1482 + fn sales_report_rejects_duplicate_headers() {
1483 + let error = sales_report_as_json(&gzip("Country\tCountry\nUS\tCA\n")).unwrap_err();
1484 + assert!(error.to_string().contains("duplicate TSV headers"));
1485 + }
1486 +}
crates/core/src/client.rs
+24 -2
@@ -83,7 +83,7 @@ impl Client {
83 query: &[(&str, &str)],
84 body: Option<&B>,
85 ) -> Result<T> {
86 - let (status, bytes) = self.send(method, path, query, body).await?;
86 + let (status, bytes) = self.send(method, path, query, body, &[]).await?;
87 if !status.is_success() {
88 return Err(api_error(status, &bytes));
89 }
@@ -98,19 +98,38 @@ impl Client {
98 query: &[(&str, &str)],
99 body: Option<&B>,
100 ) -> Result<()> {
101 - let (status, bytes) = self.send(method, path, query, body).await?;
101 + let (status, bytes) = self.send(method, path, query, body, &[]).await?;
102 if !status.is_success() {
103 return Err(api_error(status, &bytes));
104 }
105 Ok(())
106 }
107
108 + /// Send an authenticated request whose successful response is raw bytes.
109 + /// Domain APIs use this for endpoints such as sales reports that return a
110 + /// compressed file instead of a JSON:API document.
111 + pub async fn request_bytes<B: Serialize>(
112 + &self,
113 + method: Method,
114 + path: &str,
115 + query: &[(&str, &str)],
116 + body: Option<&B>,
117 + headers: &[(&str, &str)],
118 + ) -> Result<Vec<u8>> {
119 + let (status, bytes) = self.send(method, path, query, body, headers).await?;
120 + if !status.is_success() {
121 + return Err(api_error(status, &bytes));
122 + }
123 + Ok(bytes)
124 + }
125 +
126 async fn send<B: Serialize>(
127 &self,
128 method: Method,
129 path: &str,
130 query: &[(&str, &str)],
131 body: Option<&B>,
132 + headers: &[(&str, &str)],
133 ) -> Result<(StatusCode, Vec<u8>)> {
134 let url = format!("{}{}", self.base_url, path);
135 let token = self.bearer_token()?;
@@ -121,6 +140,9 @@ impl Client {
140 if let Some(b) = body {
141 req = req.json(b);
142 }
143 + for (name, value) in headers {
144 + req = req.header(*name, *value);
145 + }
146
147 let response = req.send().await?;
148 let status = response.status();
crates/reports/Cargo.toml new
+16
@@ -0,0 +1,16 @@
1 +[package]
2 +name = "smbcloud-ascapi-reports"
3 +version.workspace = true
4 +edition.workspace = true
5 +authors.workspace = true
6 +license.workspace = true
7 +repository.workspace = true
8 +description = "Sales report downloads through the App Store Connect API."
9 +
10 +[lib]
11 +path = "src/lib.rs"
12 +
13 +[dependencies]
14 +async-trait = { workspace = true }
15 +reqwest = { workspace = true }
16 +smbcloud-ascapi-core = { workspace = true }
crates/reports/src/lib.rs new
+92
@@ -0,0 +1,92 @@
1 +//! Sales report downloads from App Store Connect.
2 +//!
3 +//! Apple returns these reports as gzip-compressed TSV rather than JSON:API.
4 +//! This crate owns the request shape; consumers decide how to persist or
5 +//! transform the downloaded file.
6 +
7 +use async_trait::async_trait;
8 +use reqwest::Method;
9 +use smbcloud_ascapi_core::{Client, Result};
10 +
11 +/// Arguments accepted by `GET /v1/salesReports`.
12 +#[derive(Debug, Clone)]
13 +pub struct SalesReportRequest {
14 + pub vendor_number: String,
15 + pub frequency: String,
16 + pub report_date: String,
17 + pub report_type: String,
18 + pub report_subtype: String,
19 + pub version: Option<String>,
20 +}
21 +
22 +impl SalesReportRequest {
23 + /// Build the documented App Store Connect query filters.
24 + pub fn query(&self) -> Vec<(&str, &str)> {
25 + let mut query = vec![
26 + ("filter[frequency]", self.frequency.as_str()),
27 + ("filter[reportDate]", self.report_date.as_str()),
28 + ("filter[reportSubType]", self.report_subtype.as_str()),
29 + ("filter[reportType]", self.report_type.as_str()),
30 + ("filter[vendorNumber]", self.vendor_number.as_str()),
31 + ];
32 + if let Some(version) = self.version.as_deref() {
33 + query.push(("filter[version]", version));
34 + }
35 + query
36 + }
37 +}
38 +
39 +/// Download account-level sales data. The successful body is Apple’s
40 +/// gzip-compressed TSV report.
41 +#[async_trait]
42 +pub trait SalesReportsApi {
43 + async fn download_sales_report(&self, request: &SalesReportRequest) -> Result<Vec<u8>>;
44 +}
45 +
46 +#[async_trait]
47 +impl SalesReportsApi for Client {
48 + async fn download_sales_report(&self, request: &SalesReportRequest) -> Result<Vec<u8>> {
49 + self.request_bytes(
50 + Method::GET,
51 + "/v1/salesReports",
52 + &request.query(),
53 + None::<&()>,
54 + &[("Accept", "application/a-gzip")],
55 + )
56 + .await
57 + }
58 +}
59 +
60 +/// Traits to import when using this crate.
61 +pub mod prelude {
62 + pub use crate::SalesReportsApi;
63 +}
64 +
65 +#[cfg(test)]
66 +mod tests {
67 + use super::SalesReportRequest;
68 +
69 + #[test]
70 + fn query_contains_defaults_and_optional_version() {
71 + let request = SalesReportRequest {
72 + vendor_number: "12345678".into(),
73 + frequency: "DAILY".into(),
74 + report_date: "2026-09-11".into(),
75 + report_type: "SALES".into(),
76 + report_subtype: "SUMMARY".into(),
77 + version: Some("1_0".into()),
78 + };
79 +
80 + assert_eq!(
81 + request.query(),
82 + vec![
83 + ("filter[frequency]", "DAILY"),
84 + ("filter[reportDate]", "2026-09-11"),
85 + ("filter[reportSubType]", "SUMMARY"),
86 + ("filter[reportType]", "SALES"),
87 + ("filter[vendorNumber]", "12345678"),
88 + ("filter[version]", "1_0"),
89 + ]
90 + );
91 + }
92 +}