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:
parent
882255a32f
commit
b0641c1fc8
12 changed files with 456 additions and 69 deletions
|
|
@ -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) ?? []) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue