| 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::jsonapi::{path_and_query, Links}; |
| 22 | use smbcloud_ascapi_core::Client; |
| 23 | use smbcloud_ascapi_core::Result; |
| 24 | use std::collections::HashMap; |
| 25 | |
| 26 | pub const RESOURCE_TYPE: &str = "appPrices"; |
| 27 | |
| 28 | /// Apple caps `limit` at 200 on these collections. Territories number |
| 29 | /// ~178, so one page covers a whole app today; [`AppPricesApi`] still |
| 30 | /// follows `links.next` rather than trusting that to hold. |
| 31 | const PAGE_LIMIT: &str = "200"; |
| 32 | |
| 33 | /// Which of a schedule's two price collections to read. |
| 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 35 | pub enum PriceKind { |
| 36 | /// Prices a human set, in the base territory. |
| 37 | Manual, |
| 38 | /// Prices Apple derived for the other storefronts. |
| 39 | Automatic, |
| 40 | } |
| 41 | |
| 42 | impl PriceKind { |
| 43 | fn path_segment(self) -> &'static str { |
| 44 | match self { |
| 45 | PriceKind::Manual => "manualPrices", |
| 46 | PriceKind::Automatic => "automaticPrices", |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// One territory's price, with the price point and territory already |
| 52 | /// joined in — the shape callers actually want, rather than the three |
| 53 | /// resources App Store Connect splits it across. |
| 54 | /// |
| 55 | /// `customer_price` and `proceeds` are strings because Apple sends them |
| 56 | /// as strings ("7.99"). They are left that way rather than parsed into a |
| 57 | /// float, since money in a display path should not go through binary |
| 58 | /// floating point, and the caller knows better than this crate whether it |
| 59 | /// wants a decimal type or the literal text. |
| 60 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 61 | #[serde(rename_all = "camelCase")] |
| 62 | pub struct TerritoryPrice { |
| 63 | /// Territory code, e.g. `USA`, `IDN`. |
| 64 | pub territory: String, |
| 65 | /// ISO currency of `customer_price`, e.g. `USD`. |
| 66 | pub currency: Option<String>, |
| 67 | /// What the customer pays, as Apple formats it. |
| 68 | pub customer_price: Option<String>, |
| 69 | /// What the developer receives, net of Apple's commission *and* of any |
| 70 | /// tax Apple collects and remits in that territory. |
| 71 | /// |
| 72 | /// Only in territories where Apple withholds nothing does the ratio to |
| 73 | /// `customer_price` equal the commission rate — USD 6.79 on USD 7.99 |
| 74 | /// is the Small Business Program's 85%. Indonesia's IDR 98,784 on IDR |
| 75 | /// 129,000 is ~77% for the same account, because local VAT comes out |
| 76 | /// first. Do not infer a commission tier from a single territory. |
| 77 | pub proceeds: Option<String>, |
| 78 | /// True when a human set this price, false when Apple converted it. |
| 79 | pub manual: bool, |
| 80 | /// When this price takes effect. `None` means "already in effect". |
| 81 | pub start_date: Option<String>, |
| 82 | /// When this price stops applying. A near-future date means a price |
| 83 | /// change is already scheduled for that territory. |
| 84 | pub end_date: Option<String>, |
| 85 | /// The related `appPricePoint` id, for callers that want to look up |
| 86 | /// equalizations. Opaque — see the crate docs. |
| 87 | pub price_point_id: Option<String>, |
| 88 | /// The `appPrices` row's own id. Opaque. |
| 89 | pub app_price_id: String, |
| 90 | } |
| 91 | |
| 92 | /// App prices: what an app costs, per territory. |
| 93 | /// |
| 94 | /// An extension trait rather than inherent methods, because `Client` |
| 95 | /// lives in the core crate and Rust only allows inherent impls in the |
| 96 | /// crate that defines the type. Import it, or the crate's `prelude`, |
| 97 | /// to call these on a `Client`. |
| 98 | #[async_trait] |
| 99 | pub trait AppPricesApi { |
| 100 | /// `GET /v1/appPriceSchedules/{app_id}/{manual,automatic}Prices`, |
| 101 | /// joined against the included price points and territories. |
| 102 | /// |
| 103 | /// Takes the **app** id; a schedule shares its app's id. Pass |
| 104 | /// `territory` to narrow to one storefront (`filter[territory]`), |
| 105 | /// which is much cheaper than fetching all ~178 and filtering |
| 106 | /// locally. Rows come back sorted by territory so two runs diff |
| 107 | /// cleanly. |
| 108 | async fn list_app_prices( |
| 109 | &self, |
| 110 | app_id: &str, |
| 111 | kind: PriceKind, |
| 112 | territory: Option<&str>, |
| 113 | ) -> Result<Vec<TerritoryPrice>>; |
| 114 | } |
| 115 | |
| 116 | #[async_trait] |
| 117 | impl AppPricesApi for Client { |
| 118 | async fn list_app_prices( |
| 119 | &self, |
| 120 | app_id: &str, |
| 121 | kind: PriceKind, |
| 122 | territory: Option<&str>, |
| 123 | ) -> Result<Vec<TerritoryPrice>> { |
| 124 | let mut query = vec![ |
| 125 | ("include", "appPricePoint,territory"), |
| 126 | ("limit", PAGE_LIMIT), |
| 127 | ]; |
| 128 | if let Some(code) = territory { |
| 129 | query.push(("filter[territory]", code)); |
| 130 | } |
| 131 | |
| 132 | let mut path = format!("/v1/appPriceSchedules/{app_id}/{}", kind.path_segment()); |
| 133 | let mut query_for_this_page: &[(&str, &str)] = &query; |
| 134 | let mut prices = Vec::new(); |
| 135 | |
| 136 | loop { |
| 137 | let doc: PricesDocument = self |
| 138 | .request(Method::GET, &path, query_for_this_page, None::<&()>) |
| 139 | .await?; |
| 140 | prices.extend(join(&doc)); |
| 141 | |
| 142 | // `links.next` already carries include/limit/filter and a |
| 143 | // cursor, so following it must not re-append our own query. |
| 144 | let Some(next) = doc.links.and_then(|links| links.next) else { |
| 145 | break; |
| 146 | }; |
| 147 | let Some(next_path) = path_and_query(&next) else { |
| 148 | break; |
| 149 | }; |
| 150 | path = next_path; |
| 151 | query_for_this_page = &[]; |
| 152 | } |
| 153 | |
| 154 | prices.sort_by(|a, b| a.territory.cmp(&b.territory)); |
| 155 | Ok(prices) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// Flatten one page: index the `included` array by id, then attach each |
| 160 | /// price's point and territory. |
| 161 | fn join(doc: &PricesDocument) -> Vec<TerritoryPrice> { |
| 162 | let mut points: HashMap<&str, &PricePointAttributes> = HashMap::new(); |
| 163 | let mut currencies: HashMap<&str, Option<&str>> = HashMap::new(); |
| 164 | for resource in &doc.included { |
| 165 | match resource { |
| 166 | IncludedResource::AppPricePoint { id, attributes } => { |
| 167 | points.insert(id.as_str(), attributes); |
| 168 | } |
| 169 | IncludedResource::Territory { id, attributes } => { |
| 170 | currencies.insert(id.as_str(), attributes.currency.as_deref()); |
| 171 | } |
| 172 | IncludedResource::Unknown => {} |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | doc.data |
| 177 | .iter() |
| 178 | .map(|price| { |
| 179 | let point_id = price |
| 180 | .relationships |
| 181 | .app_price_point |
| 182 | .as_ref() |
| 183 | .and_then(|rel| rel.data.as_ref()) |
| 184 | .map(|reference| reference.id.clone()); |
| 185 | let territory = price |
| 186 | .relationships |
| 187 | .territory |
| 188 | .as_ref() |
| 189 | .and_then(|rel| rel.data.as_ref()) |
| 190 | .map(|reference| reference.id.clone()) |
| 191 | .unwrap_or_default(); |
| 192 | let point = point_id.as_deref().and_then(|id| points.get(id)).copied(); |
| 193 | |
| 194 | TerritoryPrice { |
| 195 | currency: currencies |
| 196 | .get(territory.as_str()) |
| 197 | .copied() |
| 198 | .flatten() |
| 199 | .map(str::to_string), |
| 200 | customer_price: point.and_then(|p| p.customer_price.clone()), |
| 201 | proceeds: point.and_then(|p| p.proceeds.clone()), |
| 202 | manual: price.attributes.manual.unwrap_or(false), |
| 203 | start_date: price.attributes.start_date.clone(), |
| 204 | end_date: price.attributes.end_date.clone(), |
| 205 | price_point_id: point_id, |
| 206 | app_price_id: price.id.clone(), |
| 207 | territory, |
| 208 | } |
| 209 | }) |
| 210 | .collect() |
| 211 | } |
| 212 | |
| 213 | /// The pricing collection body. Not [`ListDocument`](smbcloud_ascapi_core::jsonapi::ListDocument) |
| 214 | /// because these responses carry an `included` array the join depends on, |
| 215 | /// and a `PriceResource` here tolerates a missing `attributes` key that |
| 216 | /// core's stricter `Resource` would reject. `links` and the URL-stripping |
| 217 | /// helper are shared with core so the two paginators cannot drift. |
| 218 | #[derive(Debug, Clone, Deserialize)] |
| 219 | struct PricesDocument { |
| 220 | data: Vec<PriceResource>, |
| 221 | #[serde(default)] |
| 222 | included: Vec<IncludedResource>, |
| 223 | #[serde(default)] |
| 224 | links: Option<Links>, |
| 225 | } |
| 226 | |
| 227 | #[derive(Debug, Clone, Deserialize)] |
| 228 | struct PriceResource { |
| 229 | id: String, |
| 230 | #[serde(default)] |
| 231 | attributes: PriceAttributes, |
| 232 | #[serde(default)] |
| 233 | relationships: PriceRelationships, |
| 234 | } |
| 235 | |
| 236 | #[derive(Debug, Clone, Default, Deserialize)] |
| 237 | #[serde(rename_all = "camelCase")] |
| 238 | struct PriceAttributes { |
| 239 | #[serde(default)] |
| 240 | manual: Option<bool>, |
| 241 | #[serde(default)] |
| 242 | start_date: Option<String>, |
| 243 | #[serde(default)] |
| 244 | end_date: Option<String>, |
| 245 | } |
| 246 | |
| 247 | #[derive(Debug, Clone, Default, Deserialize)] |
| 248 | #[serde(rename_all = "camelCase")] |
| 249 | struct PriceRelationships { |
| 250 | #[serde(default)] |
| 251 | app_price_point: Option<ToOne>, |
| 252 | #[serde(default)] |
| 253 | territory: Option<ToOne>, |
| 254 | } |
| 255 | |
| 256 | /// A to-one relationship as it comes *back* from Apple. Distinct from |
| 257 | /// [`smbcloud_ascapi_core::jsonapi::ToOne`], which is serialize-only and |
| 258 | /// requires `data` — a read can legitimately carry a relationship whose |
| 259 | /// `data` is absent (`"territory": {}` on an included price point). |
| 260 | #[derive(Debug, Clone, Deserialize)] |
| 261 | pub(crate) struct ToOne { |
| 262 | #[serde(default)] |
| 263 | pub(crate) data: Option<Reference>, |
| 264 | } |
| 265 | |
| 266 | #[derive(Debug, Clone, Deserialize)] |
| 267 | pub(crate) struct Reference { |
| 268 | pub(crate) id: String, |
| 269 | } |
| 270 | |
| 271 | #[derive(Debug, Clone, Deserialize)] |
| 272 | #[serde(tag = "type")] |
| 273 | enum IncludedResource { |
| 274 | #[serde(rename = "appPricePoints")] |
| 275 | AppPricePoint { |
| 276 | id: String, |
| 277 | attributes: PricePointAttributes, |
| 278 | }, |
| 279 | #[serde(rename = "territories")] |
| 280 | Territory { |
| 281 | id: String, |
| 282 | attributes: TerritoryAttributes, |
| 283 | }, |
| 284 | /// Anything Apple adds to `included` later. Ignored rather than |
| 285 | /// fatal, so a new sideloaded type does not break price reading. |
| 286 | #[serde(other)] |
| 287 | Unknown, |
| 288 | } |
| 289 | |
| 290 | #[derive(Debug, Clone, Deserialize)] |
| 291 | #[serde(rename_all = "camelCase")] |
| 292 | struct PricePointAttributes { |
| 293 | #[serde(default)] |
| 294 | customer_price: Option<String>, |
| 295 | #[serde(default)] |
| 296 | proceeds: Option<String>, |
| 297 | } |
| 298 | |
| 299 | #[derive(Debug, Clone, Deserialize)] |
| 300 | struct TerritoryAttributes { |
| 301 | #[serde(default)] |
| 302 | currency: Option<String>, |
| 303 | } |
| 304 | |
| 305 | #[cfg(test)] |
| 306 | mod tests { |
| 307 | use super::*; |
| 308 | |
| 309 | // The exact body App Store Connect returned for |
| 310 | // /v1/appPriceSchedules/6751143237/manualPrices, trimmed to the fields |
| 311 | // this module reads. The join is the whole point of the crate, so it |
| 312 | // is pinned against a real response rather than a hand-made one. |
| 313 | const MANUAL_PAGE: &str = r#"{ |
| 314 | "data": [{ |
| 315 | "type": "appPrices", |
| 316 | "id": "eyJzIjoiNjc1MTE0MzIzNyJ9", |
| 317 | "attributes": { "manual": true, "startDate": null, "endDate": null }, |
| 318 | "relationships": { |
| 319 | "appPricePoint": { "data": { "type": "appPricePoints", "id": "point-usa" } }, |
| 320 | "territory": { "data": { "type": "territories", "id": "USA" } } |
| 321 | } |
| 322 | }], |
| 323 | "included": [ |
| 324 | { "type": "appPricePoints", "id": "point-usa", |
| 325 | "attributes": { "customerPrice": "7.99", "proceeds": "6.79" }, |
| 326 | "relationships": { "territory": {} } }, |
| 327 | { "type": "territories", "id": "USA", "attributes": { "currency": "USD" } } |
| 328 | ], |
| 329 | "links": { "self": "https://api.appstoreconnect.apple.com/v1/x" } |
| 330 | }"#; |
| 331 | |
| 332 | #[test] |
| 333 | fn joins_price_point_and_territory_onto_the_price() { |
| 334 | let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap(); |
| 335 | let prices = join(&doc); |
| 336 | |
| 337 | assert_eq!(prices.len(), 1); |
| 338 | let price = &prices[0]; |
| 339 | assert_eq!(price.territory, "USA"); |
| 340 | assert_eq!(price.currency.as_deref(), Some("USD")); |
| 341 | assert_eq!(price.customer_price.as_deref(), Some("7.99")); |
| 342 | assert_eq!(price.proceeds.as_deref(), Some("6.79")); |
| 343 | assert!(price.manual); |
| 344 | assert_eq!(price.end_date, None); |
| 345 | } |
| 346 | |
| 347 | #[test] |
| 348 | fn tolerates_an_included_type_it_does_not_know() { |
| 349 | let body = MANUAL_PAGE.replace( |
| 350 | r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }"#, |
| 351 | r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }, |
| 352 | { "type": "somethingNew", "id": "z", "attributes": { "whatever": 1 } }"#, |
| 353 | ); |
| 354 | let doc: PricesDocument = serde_json::from_str(&body).unwrap(); |
| 355 | assert_eq!(join(&doc)[0].customer_price.as_deref(), Some("7.99")); |
| 356 | } |
| 357 | |
| 358 | // Apple schedules a conversion by giving the current row an endDate; |
| 359 | // surfacing that is how a caller sees a price change coming. |
| 360 | #[test] |
| 361 | fn keeps_scheduled_end_dates_on_automatic_prices() { |
| 362 | let body = MANUAL_PAGE |
| 363 | .replace(r#""manual": true"#, r#""manual": false"#) |
| 364 | .replace(r#""endDate": null"#, r#""endDate": "2026-09-14""#); |
| 365 | let doc: PricesDocument = serde_json::from_str(&body).unwrap(); |
| 366 | let price = &join(&doc)[0]; |
| 367 | |
| 368 | assert!(!price.manual); |
| 369 | assert_eq!(price.end_date.as_deref(), Some("2026-09-14")); |
| 370 | } |
| 371 | |
| 372 | // `links` now comes from core, so this pins that the shared type still |
| 373 | // reads a real App Store Connect body — including the case where only |
| 374 | // `self` is present, which means "last page". |
| 375 | #[test] |
| 376 | fn a_next_link_is_absent_on_the_last_page() { |
| 377 | let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap(); |
| 378 | assert!(doc.links.expect("links present").next.is_none()); |
| 379 | |
| 380 | let with_next = MANUAL_PAGE.replace( |
| 381 | r#""links": { "self": "https://api.appstoreconnect.apple.com/v1/x" }"#, |
| 382 | r#""links": { "next": "https://api.appstoreconnect.apple.com/v1/y?cursor=AQ" }"#, |
| 383 | ); |
| 384 | let doc: PricesDocument = serde_json::from_str(&with_next).unwrap(); |
| 385 | assert_eq!( |
| 386 | path_and_query(&doc.links.unwrap().next.unwrap()).as_deref(), |
| 387 | Some("/v1/y?cursor=AQ") |
| 388 | ); |
| 389 | } |
| 390 | } |