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
+}