Add pricing commands

Seto Elkahfi committed Aug 29, 2026 at 19:35 UTC fa697dc243d82fe415f640c1741a1a8bcbca0357
9 files changed +651 -4
Cargo.lock
+12
@@ -1671,6 +1671,7 @@ dependencies = [
1671 "smbcloud-ascapi-core",
1672 "smbcloud-ascapi-frontend",
1673 "smbcloud-ascapi-mcp",
1674 + "smbcloud-ascapi-pricing",
1675 "smbcloud-ascapi-signing",
1676 "tokio",
1677 ]
@@ -1715,6 +1716,17 @@ dependencies = [
1716 "tokio",
1717 ]
1718
1719 +[[package]]
1720 +name = "smbcloud-ascapi-pricing"
1721 +version = "0.1.1"
1722 +dependencies = [
1723 + "async-trait",
1724 + "reqwest",
1725 + "serde",
1726 + "serde_json",
1727 + "smbcloud-ascapi-core",
1728 +]
1729 +
1730 [[package]]
1731 name = "smbcloud-ascapi-signing"
1732 version = "0.1.1"
Cargo.toml
+2
@@ -4,6 +4,7 @@ members = [
4 "crates/core",
5 "crates/aso",
6 "crates/signing",
7 + "crates/pricing",
8 "crates/frontend",
9 "crates/mcp",
10 "crates/cli",
@@ -20,6 +21,7 @@ repository = "https://github.com/smbcloudXYZ/smbcloud-ascapi"
21 smbcloud-ascapi-core = { version = "0.1.0", path = "crates/core" }
22 smbcloud-ascapi-aso = { version = "0.1.0", path = "crates/aso" }
23 smbcloud-ascapi-signing = { version = "0.1.0", path = "crates/signing" }
24 +smbcloud-ascapi-pricing = { version = "0.1.0", path = "crates/pricing" }
25 smbcloud-ascapi-frontend = { version = "0.1.0", path = "crates/frontend" }
26 smbcloud-ascapi-mcp = { version = "0.1.0", path = "crates/mcp" }
27 anyhow = "1"
README.md
+5 -4
@@ -2,26 +2,27 @@
2
3 `smbcloud-ascapi` is the app stores coolest API.
4
5 -Six crates, split by domain and layered so neither front end can drift
5 +Seven crates, split by domain and layered so neither front end can drift
6 from the other:
7
8 | Crate | What it is |
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
12 +| `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies |
13 | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
14 | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
15 | `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
16 | `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` |
17
17 -`aso` and `signing` know nothing about each other, and neither knows
18 -anything about the front ends. Both add their calls to
18 +`aso`, `pricing` and `signing` know nothing about each other, and neither knows
19 +anything about the front ends. Each adds its calls to
20 `smbcloud_ascapi_core::Client` as **extension traits**, since Rust only
21 allows inherent impls in the crate that defines a type:
22
23 ```rust
24 use smbcloud_ascapi_core::{ApiKey, Client};
24 -use smbcloud_ascapi_aso::prelude::*; // or signing::prelude
25 +use smbcloud_ascapi_aso::prelude::*; // or pricing::prelude, signing::prelude
26 ```
27
28 MCP Registry name: `mcp-name: io.github.smbcloudXYZ/ascapi`
crates/cli/Cargo.toml
+1
@@ -20,6 +20,7 @@ serde = { workspace = true }
20 serde_json = { workspace = true }
21 smbcloud-ascapi-aso = { workspace = true }
22 smbcloud-ascapi-core = { workspace = true }
23 +smbcloud-ascapi-pricing = { workspace = true }
24 smbcloud-ascapi-signing = { workspace = true }
25 smbcloud-ascapi-frontend = { workspace = true }
26 smbcloud-ascapi-mcp = { workspace = true }
crates/cli/src/main.rs
+60
@@ -14,6 +14,8 @@ use smbcloud_ascapi_aso::app_store_version_localization::{
14 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
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_signing::certificate::{CertificateCreateAttributes, CertificateType};
20 use smbcloud_ascapi_signing::csr::generate_certificate_request;
21 use smbcloud_ascapi_signing::prelude::*;
@@ -100,6 +102,12 @@ enum Command {
102 #[command(subcommand)]
103 command: AppScreenshotsCommand,
104 },
105 + /// What an app costs, per territory — the manual price a human set
106 + /// and the automatic ones Apple converted it into.
107 + AppPrices {
108 + #[command(subcommand)]
109 + command: AppPricesCommand,
110 + },
111 /// Signing certificates: list what the team holds, issue a new one, or
112 /// revoke an old one.
113 Certificates {
@@ -300,6 +308,32 @@ impl From<CliCertificateType> for CertificateType {
308 }
309 }
310
311 +#[derive(Subcommand)]
312 +enum AppPricesCommand {
313 + /// `GET /v1/appPriceSchedules/{app_id}` — the base territory Apple
314 + /// converts every other storefront from.
315 + Schedule { app_id: String },
316 + /// `GET /v1/appPriceSchedules/{app_id}/manualPrices`, joined against
317 + /// the price points and territories so the money is actually visible.
318 + ///
319 + /// Defaults to the manual prices — what a human set, usually one row
320 + /// in the base territory. `--automatic` instead lists what Apple
321 + /// converted that into for all ~178 storefronts, which is where a
322 + /// scheduled conversion shows up as a near-future `endDate`.
323 + List {
324 + app_id: String,
325 + /// List Apple's converted per-storefront prices rather than the
326 + /// manually set ones.
327 + #[arg(long)]
328 + automatic: bool,
329 + /// Narrow to one territory code, e.g. IDN. Applied by Apple as
330 + /// `filter[territory]`, so it is much cheaper than fetching every
331 + /// storefront.
332 + #[arg(long)]
333 + territory: Option<String>,
334 + },
335 +}
336 +
337 #[derive(Subcommand)]
338 enum AppsCommand {
339 /// `GET /v1/apps`.
@@ -629,6 +663,7 @@ async fn main() -> Result<()> {
663 Command::AppScreenshots { command } => {
664 run_app_screenshots(&client, command, cli.dry_run).await
665 }
666 + Command::AppPrices { command } => run_app_prices(&client, command).await,
667 Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
668 Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
669 }
@@ -1038,6 +1073,31 @@ fn now_iso8601() -> String {
1073 )
1074 }
1075
1076 +/// Read-only throughout, so unlike its siblings it takes no `dry_run`:
1077 +/// there is no request body to preview.
1078 +async fn run_app_prices(client: &Client, command: AppPricesCommand) -> Result<()> {
1079 + match command {
1080 + AppPricesCommand::Schedule { app_id } => {
1081 + print_json(&client.get_app_price_schedule(&app_id).await?)
1082 + }
1083 + AppPricesCommand::List {
1084 + app_id,
1085 + automatic,
1086 + territory,
1087 + } => {
1088 + let kind = if automatic {
1089 + PriceKind::Automatic
1090 + } else {
1091 + PriceKind::Manual
1092 + };
1093 + let prices = client
1094 + .list_app_prices(&app_id, kind, territory.as_deref())
1095 + .await?;
1096 + print_json(&prices)
1097 + }
1098 + }
1099 +}
1100 +
1101 async fn run_certificates(
1102 client: &Client,
1103 command: CertificatesCommand,
crates/pricing/Cargo.toml new
+20
@@ -0,0 +1,20 @@
1 +[package]
2 +name = "smbcloud-ascapi-pricing"
3 +version.workspace = true
4 +edition.workspace = true
5 +authors.workspace = true
6 +license.workspace = true
7 +repository.workspace = true
8 +description = "App Store Connect pricing resources: app price schedules, and the per-territory prices and price points hanging off them."
9 +
10 +[lib]
11 +path = "src/lib.rs"
12 +
13 +[dependencies]
14 +async-trait = { workspace = true }
15 +reqwest = { workspace = true }
16 +serde = { workspace = true, features = ["derive"] }
17 +smbcloud-ascapi-core = { workspace = true }
18 +
19 +[dev-dependencies]
20 +serde_json = { workspace = true }
crates/pricing/src/app_price.rs new
+391
@@ -0,0 +1,391 @@
1 +//! `GET /v1/appPriceSchedules/{id}/manualPrices` and `.../automaticPrices`.
2 +//!
3 +//! The two collections answer different questions and are easy to confuse:
4 +//!
5 +//! - **Manual** prices are what a human set. Usually one row, in the
6 +//! schedule's base territory.
7 +//! - **Automatic** prices are what Apple converted that into for every
8 +//! other storefront — currently ~178 rows. This is where you look to
9 +//! find out what an app actually costs in Indonesia, and whether a
10 +//! currency move has a conversion scheduled (`endDate` in the near
11 +//! future means the price changes then).
12 +//!
13 +//! Both return `appPrices`, which hold no money. The amounts live on the
14 +//! related `appPricePoint`, the currency on the related `territory`, so
15 +//! every request here sends `include=appPricePoint,territory` and joins
16 +//! the result into [`TerritoryPrice`] before returning it.
17 +
18 +use async_trait::async_trait;
19 +use reqwest::Method;
20 +use serde::{Deserialize, Serialize};
21 +use smbcloud_ascapi_core::Client;
22 +use smbcloud_ascapi_core::Result;
23 +use std::collections::HashMap;
24 +
25 +pub const RESOURCE_TYPE: &str = "appPrices";
26 +
27 +/// Apple caps `limit` at 200 on these collections. Territories number
28 +/// ~178, so one page covers a whole app today; [`AppPricesApi`] still
29 +/// follows `links.next` rather than trusting that to hold.
30 +const PAGE_LIMIT: &str = "200";
31 +
32 +/// Which of a schedule's two price collections to read.
33 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 +pub enum PriceKind {
35 + /// Prices a human set, in the base territory.
36 + Manual,
37 + /// Prices Apple derived for the other storefronts.
38 + Automatic,
39 +}
40 +
41 +impl PriceKind {
42 + fn path_segment(self) -> &'static str {
43 + match self {
44 + PriceKind::Manual => "manualPrices",
45 + PriceKind::Automatic => "automaticPrices",
46 + }
47 + }
48 +}
49 +
50 +/// One territory's price, with the price point and territory already
51 +/// joined in — the shape callers actually want, rather than the three
52 +/// resources App Store Connect splits it across.
53 +///
54 +/// `customer_price` and `proceeds` are strings because Apple sends them
55 +/// as strings ("7.99"). They are left that way rather than parsed into a
56 +/// float, since money in a display path should not go through binary
57 +/// floating point, and the caller knows better than this crate whether it
58 +/// wants a decimal type or the literal text.
59 +#[derive(Debug, Clone, Serialize, Deserialize)]
60 +#[serde(rename_all = "camelCase")]
61 +pub struct TerritoryPrice {
62 + /// Territory code, e.g. `USA`, `IDN`.
63 + pub territory: String,
64 + /// ISO currency of `customer_price`, e.g. `USD`.
65 + pub currency: Option<String>,
66 + /// What the customer pays, as Apple formats it.
67 + pub customer_price: Option<String>,
68 + /// What the developer receives, net of Apple's commission *and* of any
69 + /// tax Apple collects and remits in that territory.
70 + ///
71 + /// Only in territories where Apple withholds nothing does the ratio to
72 + /// `customer_price` equal the commission rate — USD 6.79 on USD 7.99
73 + /// is the Small Business Program's 85%. Indonesia's IDR 98,784 on IDR
74 + /// 129,000 is ~77% for the same account, because local VAT comes out
75 + /// first. Do not infer a commission tier from a single territory.
76 + pub proceeds: Option<String>,
77 + /// True when a human set this price, false when Apple converted it.
78 + pub manual: bool,
79 + /// When this price takes effect. `None` means "already in effect".
80 + pub start_date: Option<String>,
81 + /// When this price stops applying. A near-future date means a price
82 + /// change is already scheduled for that territory.
83 + pub end_date: Option<String>,
84 + /// The related `appPricePoint` id, for callers that want to look up
85 + /// equalizations. Opaque — see the crate docs.
86 + pub price_point_id: Option<String>,
87 + /// The `appPrices` row's own id. Opaque.
88 + pub app_price_id: String,
89 +}
90 +
91 +/// App prices: what an app costs, per territory.
92 +///
93 +/// An extension trait rather than inherent methods, because `Client`
94 +/// lives in the core crate and Rust only allows inherent impls in the
95 +/// crate that defines the type. Import it, or the crate's `prelude`,
96 +/// to call these on a `Client`.
97 +#[async_trait]
98 +pub trait AppPricesApi {
99 + /// `GET /v1/appPriceSchedules/{app_id}/{manual,automatic}Prices`,
100 + /// joined against the included price points and territories.
101 + ///
102 + /// Takes the **app** id; a schedule shares its app's id. Pass
103 + /// `territory` to narrow to one storefront (`filter[territory]`),
104 + /// which is much cheaper than fetching all ~178 and filtering
105 + /// locally. Rows come back sorted by territory so two runs diff
106 + /// cleanly.
107 + async fn list_app_prices(
108 + &self,
109 + app_id: &str,
110 + kind: PriceKind,
111 + territory: Option<&str>,
112 + ) -> Result<Vec<TerritoryPrice>>;
113 +}
114 +
115 +#[async_trait]
116 +impl AppPricesApi for Client {
117 + async fn list_app_prices(
118 + &self,
119 + app_id: &str,
120 + kind: PriceKind,
121 + territory: Option<&str>,
122 + ) -> Result<Vec<TerritoryPrice>> {
123 + let mut query = vec![
124 + ("include", "appPricePoint,territory"),
125 + ("limit", PAGE_LIMIT),
126 + ];
127 + if let Some(code) = territory {
128 + query.push(("filter[territory]", code));
129 + }
130 +
131 + let mut path = format!("/v1/appPriceSchedules/{app_id}/{}", kind.path_segment());
132 + let mut query_for_this_page: &[(&str, &str)] = &query;
133 + let mut prices = Vec::new();
134 +
135 + loop {
136 + let doc: PricesDocument = self
137 + .request(Method::GET, &path, query_for_this_page, None::<&()>)
138 + .await?;
139 + prices.extend(join(&doc));
140 +
141 + // `links.next` already carries include/limit/filter and a
142 + // cursor, so following it must not re-append our own query.
143 + let Some(next) = doc.links.and_then(|links| links.next) else {
144 + break;
145 + };
146 + let Some(next_path) = path_and_query(&next) else {
147 + break;
148 + };
149 + path = next_path;
150 + query_for_this_page = &[];
151 + }
152 +
153 + prices.sort_by(|a, b| a.territory.cmp(&b.territory));
154 + Ok(prices)
155 + }
156 +}
157 +
158 +/// Flatten one page: index the `included` array by id, then attach each
159 +/// price's point and territory.
160 +fn join(doc: &PricesDocument) -> Vec<TerritoryPrice> {
161 + let mut points: HashMap<&str, &PricePointAttributes> = HashMap::new();
162 + let mut currencies: HashMap<&str, Option<&str>> = HashMap::new();
163 + for resource in &doc.included {
164 + match resource {
165 + IncludedResource::AppPricePoint { id, attributes } => {
166 + points.insert(id.as_str(), attributes);
167 + }
168 + IncludedResource::Territory { id, attributes } => {
169 + currencies.insert(id.as_str(), attributes.currency.as_deref());
170 + }
171 + IncludedResource::Unknown => {}
172 + }
173 + }
174 +
175 + doc.data
176 + .iter()
177 + .map(|price| {
178 + let point_id = price
179 + .relationships
180 + .app_price_point
181 + .as_ref()
182 + .and_then(|rel| rel.data.as_ref())
183 + .map(|reference| reference.id.clone());
184 + let territory = price
185 + .relationships
186 + .territory
187 + .as_ref()
188 + .and_then(|rel| rel.data.as_ref())
189 + .map(|reference| reference.id.clone())
190 + .unwrap_or_default();
191 + let point = point_id.as_deref().and_then(|id| points.get(id)).copied();
192 +
193 + TerritoryPrice {
194 + currency: currencies
195 + .get(territory.as_str())
196 + .copied()
197 + .flatten()
198 + .map(str::to_string),
199 + customer_price: point.and_then(|p| p.customer_price.clone()),
200 + proceeds: point.and_then(|p| p.proceeds.clone()),
201 + manual: price.attributes.manual.unwrap_or(false),
202 + start_date: price.attributes.start_date.clone(),
203 + end_date: price.attributes.end_date.clone(),
204 + price_point_id: point_id,
205 + app_price_id: price.id.clone(),
206 + territory,
207 + }
208 + })
209 + .collect()
210 +}
211 +
212 +/// Turn an absolute `links.next` URL into the path+query
213 +/// [`Client::request`] wants, since that method prefixes its own base
214 +/// URL. Returns `None` for anything unparseable, which the caller treats
215 +/// as "no more pages" rather than an error — a missing last page is
216 +/// better than a panic on a link Apple changed the shape of.
217 +fn path_and_query(url: &str) -> Option<String> {
218 + let after_scheme = url.split_once("://")?.1;
219 + let (_host, rest) = after_scheme.split_once('/')?;
220 + Some(format!("/{rest}"))
221 +}
222 +
223 +#[derive(Debug, Clone, Deserialize)]
224 +struct PricesDocument {
225 + data: Vec<PriceResource>,
226 + #[serde(default)]
227 + included: Vec<IncludedResource>,
228 + #[serde(default)]
229 + links: Option<Links>,
230 +}
231 +
232 +#[derive(Debug, Clone, Deserialize)]
233 +struct Links {
234 + #[serde(default)]
235 + next: Option<String>,
236 +}
237 +
238 +#[derive(Debug, Clone, Deserialize)]
239 +struct PriceResource {
240 + id: String,
241 + #[serde(default)]
242 + attributes: PriceAttributes,
243 + #[serde(default)]
244 + relationships: PriceRelationships,
245 +}
246 +
247 +#[derive(Debug, Clone, Default, Deserialize)]
248 +#[serde(rename_all = "camelCase")]
249 +struct PriceAttributes {
250 + #[serde(default)]
251 + manual: Option<bool>,
252 + #[serde(default)]
253 + start_date: Option<String>,
254 + #[serde(default)]
255 + end_date: Option<String>,
256 +}
257 +
258 +#[derive(Debug, Clone, Default, Deserialize)]
259 +#[serde(rename_all = "camelCase")]
260 +struct PriceRelationships {
261 + #[serde(default)]
262 + app_price_point: Option<ToOne>,
263 + #[serde(default)]
264 + territory: Option<ToOne>,
265 +}
266 +
267 +/// A to-one relationship as it comes *back* from Apple. Distinct from
268 +/// [`smbcloud_ascapi_core::jsonapi::ToOne`], which is serialize-only and
269 +/// requires `data` — a read can legitimately carry a relationship whose
270 +/// `data` is absent (`"territory": {}` on an included price point).
271 +#[derive(Debug, Clone, Deserialize)]
272 +pub(crate) struct ToOne {
273 + #[serde(default)]
274 + pub(crate) data: Option<Reference>,
275 +}
276 +
277 +#[derive(Debug, Clone, Deserialize)]
278 +pub(crate) struct Reference {
279 + pub(crate) id: String,
280 +}
281 +
282 +#[derive(Debug, Clone, Deserialize)]
283 +#[serde(tag = "type")]
284 +enum IncludedResource {
285 + #[serde(rename = "appPricePoints")]
286 + AppPricePoint {
287 + id: String,
288 + attributes: PricePointAttributes,
289 + },
290 + #[serde(rename = "territories")]
291 + Territory {
292 + id: String,
293 + attributes: TerritoryAttributes,
294 + },
295 + /// Anything Apple adds to `included` later. Ignored rather than
296 + /// fatal, so a new sideloaded type does not break price reading.
297 + #[serde(other)]
298 + Unknown,
299 +}
300 +
301 +#[derive(Debug, Clone, Deserialize)]
302 +#[serde(rename_all = "camelCase")]
303 +struct PricePointAttributes {
304 + #[serde(default)]
305 + customer_price: Option<String>,
306 + #[serde(default)]
307 + proceeds: Option<String>,
308 +}
309 +
310 +#[derive(Debug, Clone, Deserialize)]
311 +struct TerritoryAttributes {
312 + #[serde(default)]
313 + currency: Option<String>,
314 +}
315 +
316 +#[cfg(test)]
317 +mod tests {
318 + use super::*;
319 +
320 + // The exact body App Store Connect returned for
321 + // /v1/appPriceSchedules/6751143237/manualPrices, trimmed to the fields
322 + // this module reads. The join is the whole point of the crate, so it
323 + // is pinned against a real response rather than a hand-made one.
324 + const MANUAL_PAGE: &str = r#"{
325 + "data": [{
326 + "type": "appPrices",
327 + "id": "eyJzIjoiNjc1MTE0MzIzNyJ9",
328 + "attributes": { "manual": true, "startDate": null, "endDate": null },
329 + "relationships": {
330 + "appPricePoint": { "data": { "type": "appPricePoints", "id": "point-usa" } },
331 + "territory": { "data": { "type": "territories", "id": "USA" } }
332 + }
333 + }],
334 + "included": [
335 + { "type": "appPricePoints", "id": "point-usa",
336 + "attributes": { "customerPrice": "7.99", "proceeds": "6.79" },
337 + "relationships": { "territory": {} } },
338 + { "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }
339 + ],
340 + "links": { "self": "https://api.appstoreconnect.apple.com/v1/x" }
341 + }"#;
342 +
343 + #[test]
344 + fn joins_price_point_and_territory_onto_the_price() {
345 + let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap();
346 + let prices = join(&doc);
347 +
348 + assert_eq!(prices.len(), 1);
349 + let price = &prices[0];
350 + assert_eq!(price.territory, "USA");
351 + assert_eq!(price.currency.as_deref(), Some("USD"));
352 + assert_eq!(price.customer_price.as_deref(), Some("7.99"));
353 + assert_eq!(price.proceeds.as_deref(), Some("6.79"));
354 + assert!(price.manual);
355 + assert_eq!(price.end_date, None);
356 + }
357 +
358 + #[test]
359 + fn tolerates_an_included_type_it_does_not_know() {
360 + let body = MANUAL_PAGE.replace(
361 + r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }"#,
362 + r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } },
363 + { "type": "somethingNew", "id": "z", "attributes": { "whatever": 1 } }"#,
364 + );
365 + let doc: PricesDocument = serde_json::from_str(&body).unwrap();
366 + assert_eq!(join(&doc)[0].customer_price.as_deref(), Some("7.99"));
367 + }
368 +
369 + // Apple schedules a conversion by giving the current row an endDate;
370 + // surfacing that is how a caller sees a price change coming.
371 + #[test]
372 + fn keeps_scheduled_end_dates_on_automatic_prices() {
373 + let body = MANUAL_PAGE
374 + .replace(r#""manual": true"#, r#""manual": false"#)
375 + .replace(r#""endDate": null"#, r#""endDate": "2026-09-14""#);
376 + let doc: PricesDocument = serde_json::from_str(&body).unwrap();
377 + let price = &join(&doc)[0];
378 +
379 + assert!(!price.manual);
380 + assert_eq!(price.end_date.as_deref(), Some("2026-09-14"));
381 + }
382 +
383 + #[test]
384 + fn strips_the_host_off_a_next_link() {
385 + assert_eq!(
386 + path_and_query("https://api.appstoreconnect.apple.com/v1/appPriceSchedules/1/automaticPrices?cursor=AQ").as_deref(),
387 + Some("/v1/appPriceSchedules/1/automaticPrices?cursor=AQ")
388 + );
389 + assert_eq!(path_and_query("not-a-url"), None);
390 + }
391 +}
crates/pricing/src/app_price_schedule.rs new
+94
@@ -0,0 +1,94 @@
1 +//! `GET /v1/appPriceSchedules/{id}`.
2 +//!
3 +//! A price schedule is the container for everything an app costs. There is
4 +//! no `POST` here: the schedule comes into existence with the app, and its
5 +//! id **is** the app's id — `/v1/appPriceSchedules/6751143237` and
6 +//! `/v1/apps/6751143237` describe the same product. Callers therefore pass
7 +//! an app id, not a separately-looked-up schedule id.
8 +//!
9 +//! The resource carries no attributes at all. Its only payload is
10 +//! relationships: the base territory, the manual prices, and the automatic
11 +//! ones. This module reads the base territory — the storefront whose price
12 +//! the developer set by hand, and from which Apple converts every other
13 +//! storefront. Reading the prices themselves is [`crate::app_price`].
14 +
15 +use async_trait::async_trait;
16 +use reqwest::Method;
17 +use serde::{Deserialize, Serialize};
18 +use smbcloud_ascapi_core::Client;
19 +use smbcloud_ascapi_core::Result;
20 +
21 +pub const RESOURCE_TYPE: &str = "appPriceSchedules";
22 +
23 +/// An app's price schedule, flattened to the one fact it carries.
24 +///
25 +/// Not [`Resource`](smbcloud_ascapi_core::jsonapi::Resource), because that
26 +/// type requires an `attributes` object and `appPriceSchedules` returns
27 +/// none — the resource is relationships and links only.
28 +#[derive(Debug, Clone, Serialize, Deserialize)]
29 +#[serde(rename_all = "camelCase")]
30 +pub struct AppPriceSchedule {
31 + /// The schedule id, which is also the app's App Store Connect id.
32 + pub id: String,
33 + /// Territory code whose price was set manually and from which Apple
34 + /// converts the rest, e.g. `USA`. `None` if Apple did not return the
35 + /// relationship.
36 + pub base_territory: Option<String>,
37 +}
38 +
39 +#[derive(Debug, Clone, Deserialize)]
40 +struct ScheduleDocument {
41 + data: ScheduleResource,
42 +}
43 +
44 +#[derive(Debug, Clone, Deserialize)]
45 +struct ScheduleResource {
46 + id: String,
47 + #[serde(default)]
48 + relationships: ScheduleRelationships,
49 +}
50 +
51 +#[derive(Debug, Clone, Default, Deserialize)]
52 +#[serde(rename_all = "camelCase")]
53 +struct ScheduleRelationships {
54 + #[serde(default)]
55 + base_territory: Option<crate::app_price::ToOne>,
56 +}
57 +
58 +/// App price schedules: the container an app's prices hang off.
59 +///
60 +/// An extension trait rather than inherent methods, because `Client`
61 +/// lives in the core crate and Rust only allows inherent impls in the
62 +/// crate that defines the type. Import it, or the crate's `prelude`,
63 +/// to call these on a `Client`.
64 +#[async_trait]
65 +pub trait AppPriceSchedulesApi {
66 + /// `GET /v1/appPriceSchedules/{app_id}` — the app's base territory.
67 + ///
68 + /// Takes the **app** id; schedule and app share one id.
69 + async fn get_app_price_schedule(&self, app_id: &str) -> Result<AppPriceSchedule>;
70 +}
71 +
72 +#[async_trait]
73 +impl AppPriceSchedulesApi for Client {
74 + async fn get_app_price_schedule(&self, app_id: &str) -> Result<AppPriceSchedule> {
75 + let path = format!("/v1/appPriceSchedules/{app_id}");
76 + let doc: ScheduleDocument = self
77 + .request(
78 + Method::GET,
79 + &path,
80 + &[("include", "baseTerritory")],
81 + None::<&()>,
82 + )
83 + .await?;
84 + Ok(AppPriceSchedule {
85 + id: doc.data.id,
86 + base_territory: doc
87 + .data
88 + .relationships
89 + .base_territory
90 + .and_then(|rel| rel.data)
91 + .map(|reference| reference.id),
92 + })
93 + }
94 +}
crates/pricing/src/lib.rs new
+66
@@ -0,0 +1,66 @@
1 +//! App Store Connect's
2 +//! [pricing](https://developer.apple.com/documentation/appstoreconnectapi/app-pricing)
3 +//! resources: an app's price schedule, and the per-territory prices and
4 +//! price points that hang off it.
5 +//!
6 +//! Everything here is an extension trait on
7 +//! [`Client`](smbcloud_ascapi_core::Client), because that type belongs to
8 +//! the core crate and Rust only allows inherent impls in the crate that
9 +//! defines a type. Import [`prelude`] to get all of them at once.
10 +//!
11 +//! # Why this crate exists separately from `aso`
12 +//!
13 +//! Reading a price is the one App Metadata question that cannot be
14 +//! answered by fetching a resource and reading its attributes. An
15 +//! `appPrices` row carries no money at all — only `manual`, `startDate`
16 +//! and `endDate`, plus relationships to an `appPricePoint` (which holds
17 +//! `customerPrice` and `proceeds`) and a `territory` (which holds
18 +//! `currency`). Three resources have to be joined before a price is
19 +//! legible, and the join only works if the request asks for
20 +//! `include=appPricePoint,territory` up front.
21 +//!
22 +//! That join is this crate's whole reason for being: callers get
23 +//! [`app_price::TerritoryPrice`], one flat row per territory with the
24 +//! money already attached, instead of a JSON:API document they have to
25 +//! reassemble.
26 +//!
27 +//! ```no_run
28 +//! use smbcloud_ascapi_core::{ApiKey, Client};
29 +//! use smbcloud_ascapi_pricing::app_price::PriceKind;
30 +//! use smbcloud_ascapi_pricing::prelude::*;
31 +//!
32 +//! # async fn example() -> smbcloud_ascapi_core::Result<()> {
33 +//! let api_key = ApiKey::from_p8_file("L84N624YQH", "b4e8d369-…", "AuthKey.p8")?;
34 +//! let client = Client::new(api_key);
35 +//!
36 +//! // What the developer actually set, in the base territory.
37 +//! let manual = client
38 +//! .list_app_prices("6751143237", PriceKind::Manual, None)
39 +//! .await?;
40 +//!
41 +//! // What Apple converted that into for one storefront.
42 +//! let indonesia = client
43 +//! .list_app_prices("6751143237", PriceKind::Automatic, Some("IDN"))
44 +//! .await?;
45 +//! # Ok(())
46 +//! # }
47 +//! ```
48 +//!
49 +//! # A note on the opaque ids
50 +//!
51 +//! `appPrices` and `appPricePoints` ids are base64 of a small JSON object
52 +//! (`{"s":appId,"t":territory,"p":pricePointId,…}`), so it is tempting to
53 +//! decode one and skip the `include=` round trip. Don't. Apple documents
54 +//! these ids as opaque, the shape has no compatibility guarantee, and the
55 +//! decoded `p` is an internal price-point identifier rather than an
56 +//! amount — the money still has to come from `appPricePoints`. This crate
57 +//! always joins through `include=`.
58 +
59 +pub mod app_price;
60 +pub mod app_price_schedule;
61 +
62 +/// Every extension trait in this crate, for one glob import.
63 +pub mod prelude {
64 + pub use crate::app_price::AppPricesApi;
65 + pub use crate::app_price_schedule::AppPriceSchedulesApi;
66 +}