play products: clamp regional prices to Play's min/max, per-product results; apple iap: skip live localizations, per-product results

This commit is contained in:
CRTkafa 2026-10-23 16:52:28 +08:00
parent 882255a32f
commit b0641c1fc8
12 changed files with 456 additions and 69 deletions

View file

@ -143,6 +143,10 @@ export class FakeAsc {
/** The attached builds carry com.apple.developer.game-center: submitting answers Apple's 409 (seen live) until the
* app has a gameCenterDetail and the version a gameCenterAppVersion. */
gameCenterBuild = false;
/** localization ids whose PATCH answers 409 UNMODIFIABLE besides those in state APPROVED / ACTIVE */
unmodifiable = new Set<string>();
/** ids whose PATCH was refused with that 409 */
patchRefused: string[] = [];
constructor(readonly appId = '6400000101') {
this.put({ type: 'apps', id: appId, attributes: { bundleId: 'com.example.notes', name: 'Example Notes' }, rels: {} });
@ -327,6 +331,12 @@ export class FakeAsc {
// the age rating declaration is addressed by its app info's id
if (self && type && self.type !== type) self = this.children(self.id, type)[0];
if (!self) return json({ errors: [{ status: '404' }] }, 404);
// seen live (2026-10-23): a live (approved) localization cannot be edited
if (/^(inAppPurchase|subscription|subscriptionGroup)Localizations$/.test(self.type) && (['APPROVED', 'ACTIVE'].includes(String(self.attributes.state)) || this.unmodifiable.has(self.id))) {
this.patchRefused.push(self.id);
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.UNMODIFIABLE', title: 'The provided entity includes an attribute with an invalid value',
detail: `Cannot edit ${self.type.replace(/s$/, '').replace(/^./, c => c.toUpperCase())} when it is in ACTIVE state` }] }, 409);
}
const attrs = (body as { data: { attributes?: Record<string, unknown> } }).data.attributes ?? {};
for (const [k, v] of Object.entries((body as { data: { relationships?: Record<string, { data: { id: string } | null }> } }).data.relationships ?? {})) {
if (v.data) self.rels[k] = v.data.id; else delete self.rels[k];
@ -405,6 +415,12 @@ export class FakePlay {
/** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot
* be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */
refuseAutoReview = 0;
/** convertRegionPrices also answers KR (KRW, 1450 per USD) */
convertKr = false;
/** per region the min / max Play accepts in a product's regional price (live: KR 70-600000 KRW); a PATCH outside answers 400 */
priceLimits: Record<string, { min: number; max: number; symbol: string }> = {};
/** product id -> 400 message for its PATCH */
rejectProducts: Record<string, string> = {};
/** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */
validateWithNotSent = 0;
@ -454,7 +470,8 @@ export class FakePlay {
if (method === 'POST' && p === '/pricing:convertRegionPrices') {
const usd = body as { price: { units?: string; nanos?: number } };
const price = { currencyCode: 'USD', units: usd.price.units ?? '0', nanos: usd.price.nanos ?? 0 };
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } } },
const kr = this.convertKr ? { KR: { regionCode: 'KR', price: { currencyCode: 'KRW', units: String(Math.round((Number(price.units) + price.nanos / 1e9) * 1450)), nanos: 0 } } } : {};
return json({ convertedRegionPrices: { US: { regionCode: 'US', price }, DE: { regionCode: 'DE', price: { ...price, currencyCode: 'EUR' } }, ...kr },
convertedOtherRegionsPrice: { usdPrice: price, eurPrice: { ...price, currencyCode: 'EUR' } }, regionVersion: { version: '2022/02' } });
}
if (method === 'GET' && p === '/oneTimeProducts') return json({ oneTimeProducts: [...this.products.values()] });
@ -463,7 +480,14 @@ export class FakePlay {
if (method === 'PATCH') {
const cur = this.products.get(id);
if (!cur && u.searchParams.get('allowMissing') !== 'true') return err(404, 'NOT_FOUND', 'no product');
if (this.rejectProducts[id]) return err(400, 'INVALID_ARGUMENT', this.rejectProducts[id]!);
const b = clone(body as Record<string, unknown>);
for (const o of (b.purchaseOptions as { regionalPricingAndAvailabilityConfigs?: { regionCode: string; price: { units?: string; nanos?: number } }[] }[] | undefined) ?? [])
for (const r of o.regionalPricingAndAvailabilityConfigs ?? []) {
const l = this.priceLimits[r.regionCode];
const v = Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9;
if (l && (v < l.min || v > l.max)) return err(400, 'INVALID_ARGUMENT', `Price for ${r.regionCode} must be between ${l.symbol}${l.min.toLocaleString('en-US')} and ${l.symbol}${l.max.toLocaleString('en-US')}, found ${l.symbol}${v.toLocaleString('en-US')}`);
}
const mask = (u.searchParams.get('updateMask') ?? '').split(',');
const next = cur ? { ...cur, ...(mask.includes('listings') ? { listings: b.listings } : {}), ...(mask.includes('purchaseOptions') ? { purchaseOptions: b.purchaseOptions } : {}) } : b;
for (const o of (next.purchaseOptions as { state?: string; purchaseOptionId: string }[] | undefined) ?? []) {