From fa697dc243d82fe415f640c1741a1a8bcbca0357 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:35:57 +0200 Subject: [PATCH] Add pricing commands --- Cargo.lock | 12 + Cargo.toml | 2 + README.md | 9 +- crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 60 ++++ crates/pricing/Cargo.toml | 20 ++ crates/pricing/src/app_price.rs | 391 +++++++++++++++++++++++ crates/pricing/src/app_price_schedule.rs | 94 ++++++ crates/pricing/src/lib.rs | 66 ++++ 9 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 crates/pricing/Cargo.toml create mode 100644 crates/pricing/src/app_price.rs create mode 100644 crates/pricing/src/app_price_schedule.rs create mode 100644 crates/pricing/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c87c1de..0b7aecd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1671,6 +1671,7 @@ dependencies = [ "smbcloud-ascapi-core", "smbcloud-ascapi-frontend", "smbcloud-ascapi-mcp", + "smbcloud-ascapi-pricing", "smbcloud-ascapi-signing", "tokio", ] @@ -1715,6 +1716,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "smbcloud-ascapi-pricing" +version = "0.1.1" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "smbcloud-ascapi-core", +] + [[package]] name = "smbcloud-ascapi-signing" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 487d136..fe775e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/core", "crates/aso", "crates/signing", + "crates/pricing", "crates/frontend", "crates/mcp", "crates/cli", @@ -20,6 +21,7 @@ repository = "https://github.com/smbcloudXYZ/smbcloud-ascapi" smbcloud-ascapi-core = { version = "0.1.0", path = "crates/core" } smbcloud-ascapi-aso = { version = "0.1.0", path = "crates/aso" } smbcloud-ascapi-signing = { version = "0.1.0", path = "crates/signing" } +smbcloud-ascapi-pricing = { version = "0.1.0", path = "crates/pricing" } smbcloud-ascapi-frontend = { version = "0.1.0", path = "crates/frontend" } smbcloud-ascapi-mcp = { version = "0.1.0", path = "crates/mcp" } anyhow = "1" diff --git a/README.md b/README.md index 726703a..3fec9b2 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,27 @@ `smbcloud-ascapi` is the app stores coolest API. -Six crates, split by domain and layered so neither front end can drift +Seven crates, split by domain and layered so neither front end can drift from the other: | Crate | What it is | | --- | --- | | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types | | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots | +| `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies | | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation | | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction | | `smbcloud-ascapi-mcp` | The MCP contract and stdio server | | `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` | -`aso` and `signing` know nothing about each other, and neither knows -anything about the front ends. Both add their calls to +`aso`, `pricing` and `signing` know nothing about each other, and neither knows +anything about the front ends. Each adds its calls to `smbcloud_ascapi_core::Client` as **extension traits**, since Rust only allows inherent impls in the crate that defines a type: ```rust use smbcloud_ascapi_core::{ApiKey, Client}; -use smbcloud_ascapi_aso::prelude::*; // or signing::prelude +use smbcloud_ascapi_aso::prelude::*; // or pricing::prelude, signing::prelude ``` MCP Registry name: `mcp-name: io.github.smbcloudXYZ/ascapi` diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 17bd5d9..9869608 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,6 +20,7 @@ serde = { workspace = true } serde_json = { workspace = true } smbcloud-ascapi-aso = { workspace = true } smbcloud-ascapi-core = { workspace = true } +smbcloud-ascapi-pricing = { workspace = true } smbcloud-ascapi-signing = { workspace = true } smbcloud-ascapi-frontend = { workspace = true } smbcloud-ascapi-mcp = { workspace = true } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 057b200..962a85f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -14,6 +14,8 @@ use smbcloud_ascapi_aso::app_store_version_localization::{ use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform}; use smbcloud_ascapi_aso::prelude::*; use smbcloud_ascapi_core::{ApiKey, Client}; +use smbcloud_ascapi_pricing::app_price::PriceKind; +use smbcloud_ascapi_pricing::prelude::*; use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType}; use smbcloud_ascapi_signing::csr::generate_certificate_request; use smbcloud_ascapi_signing::prelude::*; @@ -100,6 +102,12 @@ enum Command { #[command(subcommand)] command: AppScreenshotsCommand, }, + /// What an app costs, per territory — the manual price a human set + /// and the automatic ones Apple converted it into. + AppPrices { + #[command(subcommand)] + command: AppPricesCommand, + }, /// Signing certificates: list what the team holds, issue a new one, or /// revoke an old one. Certificates { @@ -300,6 +308,32 @@ impl From for CertificateType { } } +#[derive(Subcommand)] +enum AppPricesCommand { + /// `GET /v1/appPriceSchedules/{app_id}` — the base territory Apple + /// converts every other storefront from. + Schedule { app_id: String }, + /// `GET /v1/appPriceSchedules/{app_id}/manualPrices`, joined against + /// the price points and territories so the money is actually visible. + /// + /// Defaults to the manual prices — what a human set, usually one row + /// in the base territory. `--automatic` instead lists what Apple + /// converted that into for all ~178 storefronts, which is where a + /// scheduled conversion shows up as a near-future `endDate`. + List { + app_id: String, + /// List Apple's converted per-storefront prices rather than the + /// manually set ones. + #[arg(long)] + automatic: bool, + /// Narrow to one territory code, e.g. IDN. Applied by Apple as + /// `filter[territory]`, so it is much cheaper than fetching every + /// storefront. + #[arg(long)] + territory: Option, + }, +} + #[derive(Subcommand)] enum AppsCommand { /// `GET /v1/apps`. @@ -629,6 +663,7 @@ async fn main() -> Result<()> { Command::AppScreenshots { command } => { run_app_screenshots(&client, command, cli.dry_run).await } + Command::AppPrices { command } => run_app_prices(&client, command).await, Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await, Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await, } @@ -1038,6 +1073,31 @@ fn now_iso8601() -> String { ) } +/// Read-only throughout, so unlike its siblings it takes no `dry_run`: +/// there is no request body to preview. +async fn run_app_prices(client: &Client, command: AppPricesCommand) -> Result<()> { + match command { + AppPricesCommand::Schedule { app_id } => { + print_json(&client.get_app_price_schedule(&app_id).await?) + } + AppPricesCommand::List { + app_id, + automatic, + territory, + } => { + let kind = if automatic { + PriceKind::Automatic + } else { + PriceKind::Manual + }; + let prices = client + .list_app_prices(&app_id, kind, territory.as_deref()) + .await?; + print_json(&prices) + } + } +} + async fn run_certificates( client: &Client, command: CertificatesCommand, diff --git a/crates/pricing/Cargo.toml b/crates/pricing/Cargo.toml new file mode 100644 index 0000000..d3d11c0 --- /dev/null +++ b/crates/pricing/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "smbcloud-ascapi-pricing" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "App Store Connect pricing resources: app price schedules, and the per-territory prices and price points hanging off them." + +[lib] +path = "src/lib.rs" + +[dependencies] +async-trait = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +smbcloud-ascapi-core = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/pricing/src/app_price.rs b/crates/pricing/src/app_price.rs new file mode 100644 index 0000000..61bff44 --- /dev/null +++ b/crates/pricing/src/app_price.rs @@ -0,0 +1,391 @@ +//! `GET /v1/appPriceSchedules/{id}/manualPrices` and `.../automaticPrices`. +//! +//! The two collections answer different questions and are easy to confuse: +//! +//! - **Manual** prices are what a human set. Usually one row, in the +//! schedule's base territory. +//! - **Automatic** prices are what Apple converted that into for every +//! other storefront — currently ~178 rows. This is where you look to +//! find out what an app actually costs in Indonesia, and whether a +//! currency move has a conversion scheduled (`endDate` in the near +//! future means the price changes then). +//! +//! Both return `appPrices`, which hold no money. The amounts live on the +//! related `appPricePoint`, the currency on the related `territory`, so +//! every request here sends `include=appPricePoint,territory` and joins +//! the result into [`TerritoryPrice`] before returning it. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; +use std::collections::HashMap; + +pub const RESOURCE_TYPE: &str = "appPrices"; + +/// Apple caps `limit` at 200 on these collections. Territories number +/// ~178, so one page covers a whole app today; [`AppPricesApi`] still +/// follows `links.next` rather than trusting that to hold. +const PAGE_LIMIT: &str = "200"; + +/// Which of a schedule's two price collections to read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PriceKind { + /// Prices a human set, in the base territory. + Manual, + /// Prices Apple derived for the other storefronts. + Automatic, +} + +impl PriceKind { + fn path_segment(self) -> &'static str { + match self { + PriceKind::Manual => "manualPrices", + PriceKind::Automatic => "automaticPrices", + } + } +} + +/// One territory's price, with the price point and territory already +/// joined in — the shape callers actually want, rather than the three +/// resources App Store Connect splits it across. +/// +/// `customer_price` and `proceeds` are strings because Apple sends them +/// as strings ("7.99"). They are left that way rather than parsed into a +/// float, since money in a display path should not go through binary +/// floating point, and the caller knows better than this crate whether it +/// wants a decimal type or the literal text. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerritoryPrice { + /// Territory code, e.g. `USA`, `IDN`. + pub territory: String, + /// ISO currency of `customer_price`, e.g. `USD`. + pub currency: Option, + /// What the customer pays, as Apple formats it. + pub customer_price: Option, + /// What the developer receives, net of Apple's commission *and* of any + /// tax Apple collects and remits in that territory. + /// + /// Only in territories where Apple withholds nothing does the ratio to + /// `customer_price` equal the commission rate — USD 6.79 on USD 7.99 + /// is the Small Business Program's 85%. Indonesia's IDR 98,784 on IDR + /// 129,000 is ~77% for the same account, because local VAT comes out + /// first. Do not infer a commission tier from a single territory. + pub proceeds: Option, + /// True when a human set this price, false when Apple converted it. + pub manual: bool, + /// When this price takes effect. `None` means "already in effect". + pub start_date: Option, + /// When this price stops applying. A near-future date means a price + /// change is already scheduled for that territory. + pub end_date: Option, + /// The related `appPricePoint` id, for callers that want to look up + /// equalizations. Opaque — see the crate docs. + pub price_point_id: Option, + /// The `appPrices` row's own id. Opaque. + pub app_price_id: String, +} + +/// App prices: what an app costs, per territory. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppPricesApi { + /// `GET /v1/appPriceSchedules/{app_id}/{manual,automatic}Prices`, + /// joined against the included price points and territories. + /// + /// Takes the **app** id; a schedule shares its app's id. Pass + /// `territory` to narrow to one storefront (`filter[territory]`), + /// which is much cheaper than fetching all ~178 and filtering + /// locally. Rows come back sorted by territory so two runs diff + /// cleanly. + async fn list_app_prices( + &self, + app_id: &str, + kind: PriceKind, + territory: Option<&str>, + ) -> Result>; +} + +#[async_trait] +impl AppPricesApi for Client { + async fn list_app_prices( + &self, + app_id: &str, + kind: PriceKind, + territory: Option<&str>, + ) -> Result> { + let mut query = vec![ + ("include", "appPricePoint,territory"), + ("limit", PAGE_LIMIT), + ]; + if let Some(code) = territory { + query.push(("filter[territory]", code)); + } + + let mut path = format!("/v1/appPriceSchedules/{app_id}/{}", kind.path_segment()); + let mut query_for_this_page: &[(&str, &str)] = &query; + let mut prices = Vec::new(); + + loop { + let doc: PricesDocument = self + .request(Method::GET, &path, query_for_this_page, None::<&()>) + .await?; + prices.extend(join(&doc)); + + // `links.next` already carries include/limit/filter and a + // cursor, so following it must not re-append our own query. + let Some(next) = doc.links.and_then(|links| links.next) else { + break; + }; + let Some(next_path) = path_and_query(&next) else { + break; + }; + path = next_path; + query_for_this_page = &[]; + } + + prices.sort_by(|a, b| a.territory.cmp(&b.territory)); + Ok(prices) + } +} + +/// Flatten one page: index the `included` array by id, then attach each +/// price's point and territory. +fn join(doc: &PricesDocument) -> Vec { + let mut points: HashMap<&str, &PricePointAttributes> = HashMap::new(); + let mut currencies: HashMap<&str, Option<&str>> = HashMap::new(); + for resource in &doc.included { + match resource { + IncludedResource::AppPricePoint { id, attributes } => { + points.insert(id.as_str(), attributes); + } + IncludedResource::Territory { id, attributes } => { + currencies.insert(id.as_str(), attributes.currency.as_deref()); + } + IncludedResource::Unknown => {} + } + } + + doc.data + .iter() + .map(|price| { + let point_id = price + .relationships + .app_price_point + .as_ref() + .and_then(|rel| rel.data.as_ref()) + .map(|reference| reference.id.clone()); + let territory = price + .relationships + .territory + .as_ref() + .and_then(|rel| rel.data.as_ref()) + .map(|reference| reference.id.clone()) + .unwrap_or_default(); + let point = point_id.as_deref().and_then(|id| points.get(id)).copied(); + + TerritoryPrice { + currency: currencies + .get(territory.as_str()) + .copied() + .flatten() + .map(str::to_string), + customer_price: point.and_then(|p| p.customer_price.clone()), + proceeds: point.and_then(|p| p.proceeds.clone()), + manual: price.attributes.manual.unwrap_or(false), + start_date: price.attributes.start_date.clone(), + end_date: price.attributes.end_date.clone(), + price_point_id: point_id, + app_price_id: price.id.clone(), + territory, + } + }) + .collect() +} + +/// Turn an absolute `links.next` URL into the path+query +/// [`Client::request`] wants, since that method prefixes its own base +/// URL. Returns `None` for anything unparseable, which the caller treats +/// as "no more pages" rather than an error — a missing last page is +/// better than a panic on a link Apple changed the shape of. +fn path_and_query(url: &str) -> Option { + let after_scheme = url.split_once("://")?.1; + let (_host, rest) = after_scheme.split_once('/')?; + Some(format!("/{rest}")) +} + +#[derive(Debug, Clone, Deserialize)] +struct PricesDocument { + data: Vec, + #[serde(default)] + included: Vec, + #[serde(default)] + links: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct Links { + #[serde(default)] + next: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct PriceResource { + id: String, + #[serde(default)] + attributes: PriceAttributes, + #[serde(default)] + relationships: PriceRelationships, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PriceAttributes { + #[serde(default)] + manual: Option, + #[serde(default)] + start_date: Option, + #[serde(default)] + end_date: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PriceRelationships { + #[serde(default)] + app_price_point: Option, + #[serde(default)] + territory: Option, +} + +/// A to-one relationship as it comes *back* from Apple. Distinct from +/// [`smbcloud_ascapi_core::jsonapi::ToOne`], which is serialize-only and +/// requires `data` — a read can legitimately carry a relationship whose +/// `data` is absent (`"territory": {}` on an included price point). +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct ToOne { + #[serde(default)] + pub(crate) data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Reference { + pub(crate) id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +enum IncludedResource { + #[serde(rename = "appPricePoints")] + AppPricePoint { + id: String, + attributes: PricePointAttributes, + }, + #[serde(rename = "territories")] + Territory { + id: String, + attributes: TerritoryAttributes, + }, + /// Anything Apple adds to `included` later. Ignored rather than + /// fatal, so a new sideloaded type does not break price reading. + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PricePointAttributes { + #[serde(default)] + customer_price: Option, + #[serde(default)] + proceeds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TerritoryAttributes { + #[serde(default)] + currency: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + // The exact body App Store Connect returned for + // /v1/appPriceSchedules/6751143237/manualPrices, trimmed to the fields + // this module reads. The join is the whole point of the crate, so it + // is pinned against a real response rather than a hand-made one. + const MANUAL_PAGE: &str = r#"{ + "data": [{ + "type": "appPrices", + "id": "eyJzIjoiNjc1MTE0MzIzNyJ9", + "attributes": { "manual": true, "startDate": null, "endDate": null }, + "relationships": { + "appPricePoint": { "data": { "type": "appPricePoints", "id": "point-usa" } }, + "territory": { "data": { "type": "territories", "id": "USA" } } + } + }], + "included": [ + { "type": "appPricePoints", "id": "point-usa", + "attributes": { "customerPrice": "7.99", "proceeds": "6.79" }, + "relationships": { "territory": {} } }, + { "type": "territories", "id": "USA", "attributes": { "currency": "USD" } } + ], + "links": { "self": "https://api.appstoreconnect.apple.com/v1/x" } + }"#; + + #[test] + fn joins_price_point_and_territory_onto_the_price() { + let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap(); + let prices = join(&doc); + + assert_eq!(prices.len(), 1); + let price = &prices[0]; + assert_eq!(price.territory, "USA"); + assert_eq!(price.currency.as_deref(), Some("USD")); + assert_eq!(price.customer_price.as_deref(), Some("7.99")); + assert_eq!(price.proceeds.as_deref(), Some("6.79")); + assert!(price.manual); + assert_eq!(price.end_date, None); + } + + #[test] + fn tolerates_an_included_type_it_does_not_know() { + let body = MANUAL_PAGE.replace( + r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }"#, + r#"{ "type": "territories", "id": "USA", "attributes": { "currency": "USD" } }, + { "type": "somethingNew", "id": "z", "attributes": { "whatever": 1 } }"#, + ); + let doc: PricesDocument = serde_json::from_str(&body).unwrap(); + assert_eq!(join(&doc)[0].customer_price.as_deref(), Some("7.99")); + } + + // Apple schedules a conversion by giving the current row an endDate; + // surfacing that is how a caller sees a price change coming. + #[test] + fn keeps_scheduled_end_dates_on_automatic_prices() { + let body = MANUAL_PAGE + .replace(r#""manual": true"#, r#""manual": false"#) + .replace(r#""endDate": null"#, r#""endDate": "2026-09-14""#); + let doc: PricesDocument = serde_json::from_str(&body).unwrap(); + let price = &join(&doc)[0]; + + assert!(!price.manual); + assert_eq!(price.end_date.as_deref(), Some("2026-09-14")); + } + + #[test] + fn strips_the_host_off_a_next_link() { + assert_eq!( + path_and_query("https://api.appstoreconnect.apple.com/v1/appPriceSchedules/1/automaticPrices?cursor=AQ").as_deref(), + Some("/v1/appPriceSchedules/1/automaticPrices?cursor=AQ") + ); + assert_eq!(path_and_query("not-a-url"), None); + } +} diff --git a/crates/pricing/src/app_price_schedule.rs b/crates/pricing/src/app_price_schedule.rs new file mode 100644 index 0000000..509e201 --- /dev/null +++ b/crates/pricing/src/app_price_schedule.rs @@ -0,0 +1,94 @@ +//! `GET /v1/appPriceSchedules/{id}`. +//! +//! A price schedule is the container for everything an app costs. There is +//! no `POST` here: the schedule comes into existence with the app, and its +//! id **is** the app's id — `/v1/appPriceSchedules/6751143237` and +//! `/v1/apps/6751143237` describe the same product. Callers therefore pass +//! an app id, not a separately-looked-up schedule id. +//! +//! The resource carries no attributes at all. Its only payload is +//! relationships: the base territory, the manual prices, and the automatic +//! ones. This module reads the base territory — the storefront whose price +//! the developer set by hand, and from which Apple converts every other +//! storefront. Reading the prices themselves is [`crate::app_price`]. + +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use smbcloud_ascapi_core::Client; +use smbcloud_ascapi_core::Result; + +pub const RESOURCE_TYPE: &str = "appPriceSchedules"; + +/// An app's price schedule, flattened to the one fact it carries. +/// +/// Not [`Resource`](smbcloud_ascapi_core::jsonapi::Resource), because that +/// type requires an `attributes` object and `appPriceSchedules` returns +/// none — the resource is relationships and links only. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppPriceSchedule { + /// The schedule id, which is also the app's App Store Connect id. + pub id: String, + /// Territory code whose price was set manually and from which Apple + /// converts the rest, e.g. `USA`. `None` if Apple did not return the + /// relationship. + pub base_territory: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ScheduleDocument { + data: ScheduleResource, +} + +#[derive(Debug, Clone, Deserialize)] +struct ScheduleResource { + id: String, + #[serde(default)] + relationships: ScheduleRelationships, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScheduleRelationships { + #[serde(default)] + base_territory: Option, +} + +/// App price schedules: the container an app's prices hang off. +/// +/// An extension trait rather than inherent methods, because `Client` +/// lives in the core crate and Rust only allows inherent impls in the +/// crate that defines the type. Import it, or the crate's `prelude`, +/// to call these on a `Client`. +#[async_trait] +pub trait AppPriceSchedulesApi { + /// `GET /v1/appPriceSchedules/{app_id}` — the app's base territory. + /// + /// Takes the **app** id; schedule and app share one id. + async fn get_app_price_schedule(&self, app_id: &str) -> Result; +} + +#[async_trait] +impl AppPriceSchedulesApi for Client { + async fn get_app_price_schedule(&self, app_id: &str) -> Result { + let path = format!("/v1/appPriceSchedules/{app_id}"); + let doc: ScheduleDocument = self + .request( + Method::GET, + &path, + &[("include", "baseTerritory")], + None::<&()>, + ) + .await?; + Ok(AppPriceSchedule { + id: doc.data.id, + base_territory: doc + .data + .relationships + .base_territory + .and_then(|rel| rel.data) + .map(|reference| reference.id), + }) + } +} diff --git a/crates/pricing/src/lib.rs b/crates/pricing/src/lib.rs new file mode 100644 index 0000000..8c86ffe --- /dev/null +++ b/crates/pricing/src/lib.rs @@ -0,0 +1,66 @@ +//! App Store Connect's +//! [pricing](https://developer.apple.com/documentation/appstoreconnectapi/app-pricing) +//! resources: an app's price schedule, and the per-territory prices and +//! price points that hang off it. +//! +//! Everything here is an extension trait on +//! [`Client`](smbcloud_ascapi_core::Client), because that type belongs to +//! the core crate and Rust only allows inherent impls in the crate that +//! defines a type. Import [`prelude`] to get all of them at once. +//! +//! # Why this crate exists separately from `aso` +//! +//! Reading a price is the one App Metadata question that cannot be +//! answered by fetching a resource and reading its attributes. An +//! `appPrices` row carries no money at all — only `manual`, `startDate` +//! and `endDate`, plus relationships to an `appPricePoint` (which holds +//! `customerPrice` and `proceeds`) and a `territory` (which holds +//! `currency`). Three resources have to be joined before a price is +//! legible, and the join only works if the request asks for +//! `include=appPricePoint,territory` up front. +//! +//! That join is this crate's whole reason for being: callers get +//! [`app_price::TerritoryPrice`], one flat row per territory with the +//! money already attached, instead of a JSON:API document they have to +//! reassemble. +//! +//! ```no_run +//! use smbcloud_ascapi_core::{ApiKey, Client}; +//! use smbcloud_ascapi_pricing::app_price::PriceKind; +//! use smbcloud_ascapi_pricing::prelude::*; +//! +//! # async fn example() -> smbcloud_ascapi_core::Result<()> { +//! let api_key = ApiKey::from_p8_file("L84N624YQH", "b4e8d369-…", "AuthKey.p8")?; +//! let client = Client::new(api_key); +//! +//! // What the developer actually set, in the base territory. +//! let manual = client +//! .list_app_prices("6751143237", PriceKind::Manual, None) +//! .await?; +//! +//! // What Apple converted that into for one storefront. +//! let indonesia = client +//! .list_app_prices("6751143237", PriceKind::Automatic, Some("IDN")) +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # A note on the opaque ids +//! +//! `appPrices` and `appPricePoints` ids are base64 of a small JSON object +//! (`{"s":appId,"t":territory,"p":pricePointId,…}`), so it is tempting to +//! decode one and skip the `include=` round trip. Don't. Apple documents +//! these ids as opaque, the shape has no compatibility guarantee, and the +//! decoded `p` is an internal price-point identifier rather than an +//! amount — the money still has to come from `appPricePoints`. This crate +//! always joins through `include=`. + +pub mod app_price; +pub mod app_price_schedule; + +/// Every extension trait in this crate, for one glob import. +pub mod prelude { + pub use crate::app_price::AppPricesApi; + pub use crate::app_price_schedule::AppPriceSchedulesApi; +}