storebound/cli/lib/money.ts
CRT 44e64cf9c6 apps / health / catalog / validate: fixes from a read-only run against live store accounts
- API error bodies on one line (errorBody): Google and App Store Connect pretty-print errors, which hid
  SERVICE_DISABLED from the Play account check (reported permission instead of api-disabled), broke tables
  and turned serve's apps.list error into a JSON fragment
- apps list: failure says the reason and the fix (same words as apps check)
- newest App Store version by createdDate, iOS first (the API groups versions by platform); all versions
  read so no platform is missed; per-app reads four at a time
- apps check (apple): team app count from meta.paging.total with a one-app read
- Play Money without units (prices under 1.00) read as 0.xx, not NaN (catalog diff, subscription diffs)
- health: App Store Connect 404 for perfPowerMetrics / diagnosticSignatures means no field data yet;
  Play vitals behind a disabled Reporting API say so with the fix
- validate: gracRatingClassificationNumber optional, socialMediaAgeRestricted only when socialMedia is yes,
  socialMedia unanswered is a warning
- tests with sanitized recorded responses (tests/store-real-shapes.test.ts)
2026-10-11 04:50:26 +08:00

57 lines
4 KiB
TypeScript

// Price helpers shared by the Play and App Store product commands. Pure.
import type { PriceOverride } from './config';
export interface Money { currencyCode: string; units: string; nanos?: number }
/** 2.99 -> { units: "2", nanos: 990000000 } without float noise (works in cents). */
export function toMoney(currencyCode: string, value: number): Money {
if (!(value >= 0)) throw new Error(`bad price ${value}`);
const cents = Math.round(value * 100);
return { currencyCode, units: String(Math.floor(cents / 100)), nanos: (cents % 100) * 10_000_000 };
}
/** Google Money -> number. Proto3 JSON leaves out zero fields: a 0.99 price comes back as { nanos: 990000000 } with no
* units (and 1.00 with no nanos), so both default to 0. */
export const fromMoney = (m: Omit<Money, 'units'> & { units?: string | number | null }): number => Number(m.units ?? 0) + (m.nanos ?? 0) / 1e9;
export interface RegionConfig { regionCode: string; availability: 'AVAILABLE'; price: Money }
/**
* Play: take convertRegionPrices' result (every region Play sells in, rounded to local price points) and replace the
* regions you set explicitly. Throws when an override names a region Play does not offer.
*/
export function playRegionConfigs(converted: Record<string, { regionCode: string; price: Money }>, overrides: PriceOverride[] = []): RegionConfig[] {
const byRegion = new Map(Object.values(converted).map(r => [r.regionCode, r.price] as const));
for (const o of overrides) if (!byRegion.has(o.region)) throw new Error(`Play does not sell in region ${o.region}`);
const ov = new Map(overrides.map(o => [o.region, toMoney(o.currency, o.price)] as const));
return [...byRegion.entries()].sort(([a], [b]) => a.localeCompare(b))
.map(([regionCode, price]) => ({ regionCode, availability: 'AVAILABLE' as const, price: ov.get(regionCode) ?? price }));
}
/** Apple price points carry customerPrice as a string ("2.99", "30.0"); compare as cents. */
export const samePrice = (customerPrice: string | number, want: number): boolean =>
Math.round(Number(customerPrice) * 100) === Math.round(want * 100);
/** Nearest few price points, for a helpful error. */
export function nearestPrices(have: number[], want: number, n = 3): number[] {
return [...have].sort((a, b) => Math.abs(a - want) - Math.abs(b - want)).slice(0, n);
}
// ISO 3166 alpha-2 -> alpha-3 for the App Store territories (the 175 storefronts). Unknown: set `territory` yourself.
const A3 = `AE ARE AF AFG AG ATG AI AIA AL ALB AM ARM AO AGO AR ARG AT AUT AU AUS AZ AZE BA BIH BB BRB BE BEL BF BFA BG BGR
BH BHR BJ BEN BM BMU BN BRN BO BOL BR BRA BS BHS BT BTN BW BWA BY BLR BZ BLZ CA CAN CD COD CG COG CH CHE CI CIV CL CHL CM CMR
CN CHN CO COL CR CRI CV CPV CY CYP CZ CZE DE DEU DK DNK DM DMA DO DOM DZ DZA EC ECU EE EST EG EGY ES ESP FI FIN FJ FJI FM FSM
FR FRA GA GAB GB GBR GD GRD GE GEO GH GHA GM GMB GR GRC GT GTM GW GNB GY GUY HK HKG HN HND HR HRV HU HUN ID IDN IE IRL IL ISR
IN IND IQ IRQ IS ISL IT ITA JM JAM JO JOR JP JPN KE KEN KG KGZ KH KHM KN KNA KR KOR KW KWT KY CYM KZ KAZ LA LAO LB LBN LC LCA
LK LKA LR LBR LT LTU LU LUX LV LVA LY LBY MA MAR MD MDA ME MNE MG MDG MK MKD ML MLI MM MMR MN MNG MO MAC MR MRT MS MSR MT MLT
MU MUS MV MDV MW MWI MX MEX MY MYS MZ MOZ NA NAM NE NER NG NGA NI NIC NL NLD NO NOR NP NPL NR NRU NZ NZL OM OMN PA PAN PE PER
PG PNG PH PHL PK PAK PL POL PT PRT PW PLW PY PRY QA QAT RO ROU RS SRB RU RUS RW RWA SA SAU SB SLB SC SYC SE SWE SG SGP SI SVN
SK SVK SL SLE SN SEN SR SUR ST STP SV SLV SZ SWZ TC TCA TD TCD TH THA TJ TJK TM TKM TN TUN TO TON TR TUR TT TTO TW TWN TZ TZA
UA UKR UG UGA US USA UY URY UZ UZB VC VCT VE VEN VG VGB VN VNM VU VUT XK XKS YE YEM ZA ZAF ZM ZMB ZW ZWE`.split(/\s+/);
export const ALPHA3: Record<string, string> = Object.fromEntries(Array.from({ length: A3.length / 2 }, (_, i) => [A3[2 * i]!, A3[2 * i + 1]!]));
export function appleTerritory(o: PriceOverride): string {
const t = o.territory ?? ALPHA3[o.region];
if (!t) throw new Error(`no App Store territory known for region ${o.region}; set "territory" (alpha-3) on the override`);
return t;
}