development
rs 390 lines 14.3 KB
Raw
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 }