@mpgd/cli 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-store-iap-asc.d.ts +23 -0
- package/dist/app-store-iap-asc.js +326 -0
- package/dist/app-store-iap-port.d.ts +125 -0
- package/dist/app-store-iap-port.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +95 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +344 -339
- package/dist/play-product-port.d.ts +101 -0
- package/dist/play-product-port.js +7 -0
- package/dist/play-sdk-adapter.js +67 -0
- package/dist/store-products-app-store.d.ts +144 -0
- package/dist/store-products-app-store.js +632 -0
- package/dist/store-products-command.d.ts +50 -0
- package/dist/store-products-command.js +182 -0
- package/dist/store-products-play.d.ts +112 -0
- package/dist/store-products-play.js +622 -0
- package/dist/store-products.d.ts +46 -0
- package/dist/store-products.js +124 -0
- package/dist/testflight-submission.d.ts +1 -1
- package/package.json +14 -14
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { resolveProductPlatformId, resolveStoreListingLocalizations, } from '@mpgd/catalog';
|
|
2
|
+
import { canonicalLanguageTag, decimalToUnitsAndNanos, stableJson, stableSha256, StoreProductPlanStaleError, } from './store-products.js';
|
|
3
|
+
// OneTimeProduct field limits (API reference): title 55, description 200,
|
|
4
|
+
// product ID of digits, lowercase letters, `_` and `.`.
|
|
5
|
+
const maxTitleLength = 55;
|
|
6
|
+
const maxDescriptionLength = 200;
|
|
7
|
+
const productIdPattern = /^[a-z0-9][a-z0-9_.]*$/u;
|
|
8
|
+
const purchaseOptionIdPattern = /^[a-z0-9][a-z0-9-]{0,62}$/u;
|
|
9
|
+
const defaultPurchaseOptionId = 'buy';
|
|
10
|
+
const maxPrintedPriceChanges = 20;
|
|
11
|
+
export async function planPlayProducts(input, port) {
|
|
12
|
+
const catalogTarget = input.catalogTarget ?? 'android';
|
|
13
|
+
const keepLivePrices = input.keepLivePrices === true;
|
|
14
|
+
const remote = await port.listOneTimeProducts(input.packageName, input.signal);
|
|
15
|
+
const remoteById = new Map();
|
|
16
|
+
for (const product of remote) {
|
|
17
|
+
if (typeof product.productId === 'string') {
|
|
18
|
+
remoteById.set(product.productId, product);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const conversions = new Map();
|
|
22
|
+
const convert = (money, taxCategory) => {
|
|
23
|
+
const key = stableJson({ money, taxCategory });
|
|
24
|
+
let pending = conversions.get(key);
|
|
25
|
+
if (pending === undefined) {
|
|
26
|
+
pending = port.convertRegionPrices({
|
|
27
|
+
packageName: input.packageName,
|
|
28
|
+
price: toPlayMoney(money),
|
|
29
|
+
...(taxCategory === undefined ? {} : { productTaxCategoryCode: taxCategory }),
|
|
30
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
31
|
+
});
|
|
32
|
+
conversions.set(key, pending);
|
|
33
|
+
}
|
|
34
|
+
return pending;
|
|
35
|
+
};
|
|
36
|
+
const products = [];
|
|
37
|
+
const skipped = [];
|
|
38
|
+
const named = new Set();
|
|
39
|
+
for (const product of input.catalog.products) {
|
|
40
|
+
const productId = resolveProductPlatformId(product, catalogTarget);
|
|
41
|
+
if (productId === undefined) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
named.add(productId);
|
|
45
|
+
if (product.type === 'subscription') {
|
|
46
|
+
skipped.push({
|
|
47
|
+
logicalId: product.id,
|
|
48
|
+
productId,
|
|
49
|
+
reason: 'subscriptions are outside the sync',
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const entry = input.listings.products[product.id];
|
|
54
|
+
if (entry === undefined) {
|
|
55
|
+
skipped.push({ logicalId: product.id, productId, reason: 'no store listing' });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!productIdPattern.test(productId)) {
|
|
59
|
+
throw new Error(`Google Play product ID ${productId} (${product.id}) may contain only digits, lowercase letters, "_" and ".".`);
|
|
60
|
+
}
|
|
61
|
+
const remoteProduct = remoteById.get(productId);
|
|
62
|
+
const planned = await planOne({
|
|
63
|
+
packageName: input.packageName,
|
|
64
|
+
logicalId: product.id,
|
|
65
|
+
productId,
|
|
66
|
+
localizations: resolveStoreListingLocalizations(entry, 'googlePlay'),
|
|
67
|
+
googlePlay: entry.googlePlay ?? {},
|
|
68
|
+
remote: remoteProduct,
|
|
69
|
+
keepLivePrices,
|
|
70
|
+
convert,
|
|
71
|
+
});
|
|
72
|
+
if ('skip' in planned) {
|
|
73
|
+
skipped.push({ logicalId: product.id, productId, reason: planned.skip });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
products.push(planned);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const remoteOnly = [...remoteById.keys()].filter((id) => !named.has(id)).sort();
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
store: 'google-play',
|
|
83
|
+
packageName: input.packageName,
|
|
84
|
+
catalogTarget,
|
|
85
|
+
keepLivePrices,
|
|
86
|
+
products,
|
|
87
|
+
skipped,
|
|
88
|
+
remoteOnly,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function planOne(input) {
|
|
92
|
+
const { productId, remote, googlePlay } = input;
|
|
93
|
+
const label = `Google Play product ${productId} (${input.logicalId})`;
|
|
94
|
+
const notes = [];
|
|
95
|
+
// Listings: the file's languages replace Play's; other languages stay.
|
|
96
|
+
const desiredListings = Object.entries(input.localizations).map(([languageCode, text]) => {
|
|
97
|
+
if ([...text.title].length > maxTitleLength) {
|
|
98
|
+
throw new Error(`${label}: the ${languageCode} title is longer than ${maxTitleLength} characters.`);
|
|
99
|
+
}
|
|
100
|
+
if ([...text.description].length > maxDescriptionLength) {
|
|
101
|
+
throw new Error(`${label}: the ${languageCode} description is longer than ${maxDescriptionLength} characters.`);
|
|
102
|
+
}
|
|
103
|
+
// Play's own spelling when it already lists the language, else BCP-47 casing.
|
|
104
|
+
const remoteSpelling = remote?.listings?.find((listing) => fold(listing.languageCode) === fold(languageCode))?.languageCode;
|
|
105
|
+
return {
|
|
106
|
+
languageCode: typeof remoteSpelling === 'string' ? remoteSpelling : canonicalLanguageTag(languageCode),
|
|
107
|
+
title: text.title,
|
|
108
|
+
description: text.description,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
const desiredLanguages = new Set(desiredListings.map((listing) => fold(listing.languageCode)));
|
|
112
|
+
const keptListings = (remote?.listings ?? []).filter((listing) => !desiredLanguages.has(fold(listing.languageCode)));
|
|
113
|
+
if (keptListings.length > 0) {
|
|
114
|
+
notes.push(`Play keeps listing languages the file does not name: ${keptListings.map((listing) => String(listing.languageCode)).join(', ')}.`);
|
|
115
|
+
}
|
|
116
|
+
const listings = sortListings([...desiredListings, ...keptListings]);
|
|
117
|
+
// The managed purchase option.
|
|
118
|
+
const remoteOptions = remote?.purchaseOptions ?? [];
|
|
119
|
+
let purchaseOptionId = googlePlay.purchaseOptionId;
|
|
120
|
+
if (purchaseOptionId === undefined) {
|
|
121
|
+
const buyOptions = remoteOptions.filter((option) => option.buyOption !== undefined);
|
|
122
|
+
if (remoteOptions.length === 0) {
|
|
123
|
+
purchaseOptionId = defaultPurchaseOptionId;
|
|
124
|
+
}
|
|
125
|
+
else if (buyOptions.length === 1 && typeof buyOptions[0]?.purchaseOptionId === 'string') {
|
|
126
|
+
purchaseOptionId = buyOptions[0].purchaseOptionId;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
throw new Error(`${label} has ${remoteOptions.length} purchase options on Play; name the one to manage with `
|
|
130
|
+
+ 'googlePlay.purchaseOptionId.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!purchaseOptionIdPattern.test(purchaseOptionId)) {
|
|
134
|
+
throw new Error(`${label}: purchase option ID ${purchaseOptionId} is not a valid Play purchase option ID.`);
|
|
135
|
+
}
|
|
136
|
+
const remoteOption = remoteOptions.find((option) => option.purchaseOptionId === purchaseOptionId);
|
|
137
|
+
if (remoteOption?.rentOption !== undefined && remoteOption.rentOption !== null) {
|
|
138
|
+
throw new Error(`${label}: purchase option ${purchaseOptionId} is a rent option; the sync manages buy options only.`);
|
|
139
|
+
}
|
|
140
|
+
if (googlePlay.price === undefined) {
|
|
141
|
+
if (remote === undefined) {
|
|
142
|
+
return { skip: 'needs googlePlay.price to create the product' };
|
|
143
|
+
}
|
|
144
|
+
if (remoteOption === undefined) {
|
|
145
|
+
throw new Error(`${label}: purchase option ${purchaseOptionId} is not on Play and the file gives no price for it.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Prices: Play's conversion of the base price, then the file's overrides.
|
|
149
|
+
let pricing;
|
|
150
|
+
if (googlePlay.price !== undefined) {
|
|
151
|
+
// Convert with the category the product keeps: the file's, else Play's.
|
|
152
|
+
const conversionTaxCategory = googlePlay.productTaxCategoryCode
|
|
153
|
+
?? remote?.taxAndComplianceSettings?.productTaxCategoryCode ?? undefined;
|
|
154
|
+
const converted = await input.convert(googlePlay.price.base, conversionTaxCategory);
|
|
155
|
+
const regionsVersion = converted.regionVersion?.version;
|
|
156
|
+
if (typeof regionsVersion !== 'string' || regionsVersion === '') {
|
|
157
|
+
throw new Error('Google Play price conversion returned no regions version.');
|
|
158
|
+
}
|
|
159
|
+
const byRegion = new Map();
|
|
160
|
+
for (const [key, value] of Object.entries(converted.convertedRegionPrices ?? {})) {
|
|
161
|
+
const regionCode = value.regionCode ?? key;
|
|
162
|
+
if (value.price?.currencyCode === undefined || value.price.currencyCode === null) {
|
|
163
|
+
throw new Error(`Google Play price conversion returned no price for ${regionCode}.`);
|
|
164
|
+
}
|
|
165
|
+
byRegion.set(regionCode, {
|
|
166
|
+
regionCode,
|
|
167
|
+
price: normalizeMoney(value.price),
|
|
168
|
+
availability: 'AVAILABLE',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
for (const [regionCode, money] of Object.entries(googlePlay.price.regions ?? {})) {
|
|
172
|
+
const convertedRegion = byRegion.get(regionCode);
|
|
173
|
+
if (convertedRegion === undefined) {
|
|
174
|
+
throw new Error(`${label}: Google Play does not sell in ${regionCode} at regions version ${regionsVersion}.`);
|
|
175
|
+
}
|
|
176
|
+
if (convertedRegion.price?.currencyCode !== money.currency) {
|
|
177
|
+
throw new Error(`${label}: the ${regionCode} price must be in ${String(convertedRegion.price?.currencyCode)}, not ${money.currency}.`);
|
|
178
|
+
}
|
|
179
|
+
byRegion.set(regionCode, {
|
|
180
|
+
regionCode,
|
|
181
|
+
price: toPlayMoney(money),
|
|
182
|
+
availability: 'AVAILABLE',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const usd = converted.convertedOtherRegionsPrice?.usdPrice;
|
|
186
|
+
const eur = converted.convertedOtherRegionsPrice?.eurPrice;
|
|
187
|
+
if (usd === undefined || eur === undefined) {
|
|
188
|
+
throw new Error('Google Play price conversion returned no USD and EUR prices for new regions.');
|
|
189
|
+
}
|
|
190
|
+
// Prices follow the file; a region's availability stays as Play has it.
|
|
191
|
+
const remoteRegions = remoteOption?.regionalPricingAndAvailabilityConfigs ?? [];
|
|
192
|
+
// An existing option is not opted in to regions it does not list (for
|
|
193
|
+
// example one Play launched after the option opted out of new regions),
|
|
194
|
+
// unless the file names the region. A new option sells everywhere.
|
|
195
|
+
if (remoteRegions.length > 0) {
|
|
196
|
+
const listed = new Set(remoteRegions.map((config) => String(config.regionCode)));
|
|
197
|
+
const named = new Set(Object.keys(googlePlay.price.regions ?? {}));
|
|
198
|
+
const unlisted = [...byRegion.keys()].filter((region) => !listed.has(region) && !named.has(region));
|
|
199
|
+
for (const region of unlisted) {
|
|
200
|
+
byRegion.delete(region);
|
|
201
|
+
}
|
|
202
|
+
if (unlisted.length > 0) {
|
|
203
|
+
notes.push(`Play sells in ${unlisted.length} region(s) this purchase option does not list; the sync `
|
|
204
|
+
+ `leaves them out (name them in googlePlay.price.regions or add them in Play Console): ${unlisted.sort().join(', ')}.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const config of remoteRegions) {
|
|
208
|
+
const desired = typeof config.regionCode === 'string'
|
|
209
|
+
? byRegion.get(config.regionCode)
|
|
210
|
+
: undefined;
|
|
211
|
+
if (desired !== undefined && typeof config.availability === 'string'
|
|
212
|
+
&& config.availability !== desired.availability) {
|
|
213
|
+
byRegion.set(String(config.regionCode), { ...desired, availability: config.availability });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const keptRegions = remoteRegions.filter((config) => typeof config.regionCode === 'string' && !byRegion.has(config.regionCode));
|
|
217
|
+
if (keptRegions.length > 0) {
|
|
218
|
+
notes.push(`Play keeps ${keptRegions.length} region(s) the conversion does not list: ${keptRegions.map((config) => String(config.regionCode)).join(', ')}.`);
|
|
219
|
+
}
|
|
220
|
+
// New regions: a new option opts in; an existing one keeps its choice.
|
|
221
|
+
const remoteNewRegions = remoteOption === undefined ? undefined : remoteOption.newRegionsConfig;
|
|
222
|
+
pricing = {
|
|
223
|
+
regional: sortRegions([...byRegion.values(), ...keptRegions]),
|
|
224
|
+
newRegions: remoteOption !== undefined && remoteNewRegions === undefined
|
|
225
|
+
? undefined
|
|
226
|
+
: {
|
|
227
|
+
usdPrice: normalizeMoney(usd),
|
|
228
|
+
eurPrice: normalizeMoney(eur),
|
|
229
|
+
availability: remoteNewRegions?.availability ?? 'AVAILABLE',
|
|
230
|
+
},
|
|
231
|
+
regionsVersion,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const live = remoteOption?.state === 'ACTIVE';
|
|
235
|
+
if (remoteOption?.state === 'INACTIVE' || remoteOption?.state === 'INACTIVE_PUBLISHED') {
|
|
236
|
+
notes.push(`Purchase option ${purchaseOptionId} is ${remoteOption.state} on Play; the sync does not reactivate it.`);
|
|
237
|
+
}
|
|
238
|
+
const otherLegacy = remoteOptions.some((option) => option.purchaseOptionId !== purchaseOptionId && option.buyOption?.legacyCompatible === true);
|
|
239
|
+
const baseOption = remoteOption === undefined
|
|
240
|
+
? {
|
|
241
|
+
purchaseOptionId,
|
|
242
|
+
// Play Billing Library 7 and older see only the legacy-compatible buy option.
|
|
243
|
+
buyOption: { legacyCompatible: !otherLegacy },
|
|
244
|
+
}
|
|
245
|
+
: withoutState(remoteOption);
|
|
246
|
+
let desiredOption = {
|
|
247
|
+
...baseOption,
|
|
248
|
+
buyOption: {
|
|
249
|
+
...baseOption.buyOption,
|
|
250
|
+
// The backend grants one unit per purchase token and rejects any other
|
|
251
|
+
// quantity (GOOGLE_PLAY_QUANTITY_UNSUPPORTED), so multi-quantity stays off.
|
|
252
|
+
multiQuantityEnabled: false,
|
|
253
|
+
},
|
|
254
|
+
...(pricing === undefined ? {} : {
|
|
255
|
+
regionalPricingAndAvailabilityConfigs: pricing.regional,
|
|
256
|
+
...(pricing.newRegions === undefined ? {} : { newRegionsConfig: pricing.newRegions }),
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
const changes = [];
|
|
260
|
+
const isNewOption = remoteOption === undefined;
|
|
261
|
+
if (remote === undefined) {
|
|
262
|
+
changes.push('listings', 'prices', 'purchase-option');
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
if (!sameListings(remote.listings ?? [], listings)) {
|
|
266
|
+
changes.push('listings');
|
|
267
|
+
}
|
|
268
|
+
if (isNewOption) {
|
|
269
|
+
changes.push('purchase-option', 'prices');
|
|
270
|
+
}
|
|
271
|
+
else if (pricing !== undefined && !samePricing(remoteOption, desiredOption)) {
|
|
272
|
+
changes.push('prices');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!isNewOption && remoteOption.buyOption?.multiQuantityEnabled === true) {
|
|
276
|
+
changes.push('multi-quantity');
|
|
277
|
+
notes.push('Multi-quantity is turned off: the kit backend grants one unit per purchase and rejects larger quantities.');
|
|
278
|
+
}
|
|
279
|
+
const taxCategory = googlePlay.productTaxCategoryCode;
|
|
280
|
+
if (taxCategory !== undefined && remote?.taxAndComplianceSettings?.productTaxCategoryCode !== taxCategory) {
|
|
281
|
+
changes.push('tax-category');
|
|
282
|
+
}
|
|
283
|
+
let livePriceChange = live && changes.includes('prices');
|
|
284
|
+
let pricesHeld = false;
|
|
285
|
+
if (livePriceChange && input.keepLivePrices && remoteOption !== undefined) {
|
|
286
|
+
pricesHeld = true;
|
|
287
|
+
desiredOption = {
|
|
288
|
+
...desiredOption,
|
|
289
|
+
regionalPricingAndAvailabilityConfigs: remoteOption.regionalPricingAndAvailabilityConfigs,
|
|
290
|
+
newRegionsConfig: remoteOption.newRegionsConfig,
|
|
291
|
+
};
|
|
292
|
+
changes.splice(changes.indexOf('prices'), 1);
|
|
293
|
+
livePriceChange = false;
|
|
294
|
+
notes.push('A price change on this live product is held (--keep-live-prices).');
|
|
295
|
+
}
|
|
296
|
+
const priceChanges = changes.includes('prices') ? diffPrices(remoteOption, desiredOption) : [];
|
|
297
|
+
if (isNewOption || remoteOption.state === 'DRAFT') {
|
|
298
|
+
if ((desiredOption.regionalPricingAndAvailabilityConfigs ?? []).length === 0) {
|
|
299
|
+
notes.push(`Purchase option ${purchaseOptionId} has no prices on Play; give googlePlay.price to activate it.`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
changes.push('activate');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const remoteSha256 = remote === undefined ? null : stableSha256(remote);
|
|
306
|
+
if (changes.length === 0) {
|
|
307
|
+
return {
|
|
308
|
+
logicalId: input.logicalId,
|
|
309
|
+
productId,
|
|
310
|
+
action: 'unchanged',
|
|
311
|
+
changes,
|
|
312
|
+
purchaseOptionId,
|
|
313
|
+
live,
|
|
314
|
+
livePriceChange: false,
|
|
315
|
+
priceChanges: [],
|
|
316
|
+
notes,
|
|
317
|
+
remoteSha256,
|
|
318
|
+
desiredSha256: stableSha256({ productId, listings, desiredOption }),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const mask = new Set();
|
|
322
|
+
if (changes.includes('listings')) {
|
|
323
|
+
mask.add('listings');
|
|
324
|
+
}
|
|
325
|
+
if (changes.some((change) => change === 'prices' || change === 'purchase-option' || change === 'multi-quantity')) {
|
|
326
|
+
mask.add('purchaseOptions');
|
|
327
|
+
}
|
|
328
|
+
if (changes.includes('tax-category')) {
|
|
329
|
+
mask.add('taxAndComplianceSettings');
|
|
330
|
+
}
|
|
331
|
+
const purchaseOptions = isNewOption
|
|
332
|
+
? [...remoteOptions.map(withoutState), desiredOption]
|
|
333
|
+
: remoteOptions.map((option) => option.purchaseOptionId === purchaseOptionId ? desiredOption : withoutState(option));
|
|
334
|
+
const { regionsVersion: _remoteVersion, ...remoteFields } = remote ?? {};
|
|
335
|
+
const product = {
|
|
336
|
+
...remoteFields,
|
|
337
|
+
packageName: input.packageName,
|
|
338
|
+
productId,
|
|
339
|
+
listings,
|
|
340
|
+
purchaseOptions,
|
|
341
|
+
...(taxCategory === undefined && remote?.taxAndComplianceSettings === undefined ? {} : {
|
|
342
|
+
taxAndComplianceSettings: {
|
|
343
|
+
...remote?.taxAndComplianceSettings,
|
|
344
|
+
...(taxCategory === undefined ? {} : { productTaxCategoryCode: taxCategory }),
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
};
|
|
348
|
+
// Prices written from the conversion go with its regions version; a patch
|
|
349
|
+
// that sends Play's own prices back keeps the version Play reported.
|
|
350
|
+
const regionsVersion = pricing !== undefined && !pricesHeld
|
|
351
|
+
&& (mask.has('purchaseOptions') || remote === undefined)
|
|
352
|
+
? pricing.regionsVersion
|
|
353
|
+
: (remote?.regionsVersion?.version ?? pricing?.regionsVersion);
|
|
354
|
+
if (mask.size > 0 && (regionsVersion === undefined || regionsVersion === null || regionsVersion === '')) {
|
|
355
|
+
throw new Error(`${label}: no regions version is known for the update; give googlePlay.price.`);
|
|
356
|
+
}
|
|
357
|
+
const write = {
|
|
358
|
+
...(mask.size === 0 ? {} : {
|
|
359
|
+
patch: {
|
|
360
|
+
product,
|
|
361
|
+
updateMask: [...mask].join(','),
|
|
362
|
+
allowMissing: remote === undefined,
|
|
363
|
+
regionsVersion: String(regionsVersion),
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
...(changes.includes('activate') ? { activatePurchaseOptionId: purchaseOptionId } : {}),
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
logicalId: input.logicalId,
|
|
370
|
+
productId,
|
|
371
|
+
action: remote === undefined ? 'create' : 'update',
|
|
372
|
+
changes,
|
|
373
|
+
purchaseOptionId,
|
|
374
|
+
live,
|
|
375
|
+
livePriceChange,
|
|
376
|
+
priceChanges,
|
|
377
|
+
notes,
|
|
378
|
+
remoteSha256,
|
|
379
|
+
desiredSha256: stableSha256({ changes, write }),
|
|
380
|
+
write,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export class PlayProductApplyError extends Error {
|
|
384
|
+
productId;
|
|
385
|
+
stage;
|
|
386
|
+
done;
|
|
387
|
+
constructor(productId, stage,
|
|
388
|
+
/** The products already written (created, updated or activated). */
|
|
389
|
+
done, cause) {
|
|
390
|
+
super(`Google Play ${stage} of ${productId} failed or its result is unknown (${cause instanceof Error ? cause.message : String(cause)}). Products already written: ${done.map((entry) => entry.productId).join(', ') || 'none'}. Run "mpgd store products plan" `
|
|
391
|
+
+ 'again: it reads Play and lists what is left; writing the same product again is safe.', { cause });
|
|
392
|
+
this.productId = productId;
|
|
393
|
+
this.stage = stage;
|
|
394
|
+
this.done = done;
|
|
395
|
+
this.name = 'PlayProductApplyError';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Apply a reviewed plan. `current` is a fresh plan from the same inputs:
|
|
400
|
+
* apply writes only when every product it would change still has the
|
|
401
|
+
* remote state and the planned write the reviewer saw. A product that is
|
|
402
|
+
* now unchanged (an earlier apply landed) is skipped, so a re-run is safe.
|
|
403
|
+
*/
|
|
404
|
+
export async function applyPlayProductPlan(input) {
|
|
405
|
+
const { saved, current } = input;
|
|
406
|
+
if (!input.approved) {
|
|
407
|
+
throw new Error('Writing Google Play products requires --approve.');
|
|
408
|
+
}
|
|
409
|
+
if (saved.packageName !== current.packageName || saved.catalogTarget !== current.catalogTarget
|
|
410
|
+
|| saved.keepLivePrices !== current.keepLivePrices) {
|
|
411
|
+
throw new StoreProductPlanStaleError('The saved plan was made for another app or other options.');
|
|
412
|
+
}
|
|
413
|
+
const savedById = new Map(saved.products.map((product) => [product.productId, product]));
|
|
414
|
+
const pending = current.products.filter((product) => product.action !== 'unchanged');
|
|
415
|
+
for (const product of pending) {
|
|
416
|
+
const reviewed = savedById.get(product.productId);
|
|
417
|
+
if (reviewed === undefined || reviewed.action !== product.action
|
|
418
|
+
|| reviewed.remoteSha256 !== product.remoteSha256
|
|
419
|
+
|| reviewed.desiredSha256 !== product.desiredSha256) {
|
|
420
|
+
throw new StoreProductPlanStaleError(`Google Play product ${product.productId} changed on Play or in the game files since the plan.`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const priced = pending.filter((product) => product.livePriceChange);
|
|
424
|
+
if (priced.length > 0 && input.confirmPriceChange !== true) {
|
|
425
|
+
throw new Error(`The plan changes the price of live Google Play products (${priced.map((product) => product.productId).join(', ')}); confirm with --confirm-price-change, `
|
|
426
|
+
+ 'or plan again with --keep-live-prices.');
|
|
427
|
+
}
|
|
428
|
+
const done = [];
|
|
429
|
+
for (const product of current.products) {
|
|
430
|
+
if (product.action === 'unchanged' || product.write === undefined) {
|
|
431
|
+
done.push({ productId: product.productId, status: 'unchanged' });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
input.signal?.throwIfAborted();
|
|
435
|
+
const { patch, activatePurchaseOptionId } = product.write;
|
|
436
|
+
if (patch !== undefined) {
|
|
437
|
+
try {
|
|
438
|
+
await input.port.patchOneTimeProduct({
|
|
439
|
+
packageName: current.packageName,
|
|
440
|
+
productId: product.productId,
|
|
441
|
+
product: patch.product,
|
|
442
|
+
updateMask: patch.updateMask,
|
|
443
|
+
allowMissing: patch.allowMissing,
|
|
444
|
+
regionsVersion: patch.regionsVersion,
|
|
445
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
throw new PlayProductApplyError(product.productId, 'patch', written(done), error);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (activatePurchaseOptionId !== undefined) {
|
|
453
|
+
try {
|
|
454
|
+
await input.port.activatePurchaseOption({
|
|
455
|
+
packageName: current.packageName,
|
|
456
|
+
productId: product.productId,
|
|
457
|
+
purchaseOptionId: activatePurchaseOptionId,
|
|
458
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
// The patch before it landed: report this product as written too.
|
|
463
|
+
const patched = patch === undefined
|
|
464
|
+
? done
|
|
465
|
+
: [
|
|
466
|
+
...done,
|
|
467
|
+
{
|
|
468
|
+
productId: product.productId,
|
|
469
|
+
status: product.action === 'create' ? 'created' : 'updated',
|
|
470
|
+
},
|
|
471
|
+
];
|
|
472
|
+
throw new PlayProductApplyError(product.productId, 'activate', written(patched), error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
done.push({
|
|
476
|
+
productId: product.productId,
|
|
477
|
+
status: product.action === 'create'
|
|
478
|
+
? 'created'
|
|
479
|
+
: patch === undefined
|
|
480
|
+
? 'activated'
|
|
481
|
+
: 'updated',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return { packageName: current.packageName, products: done };
|
|
485
|
+
}
|
|
486
|
+
/** Products an apply wrote (created, updated or activated), not the unchanged ones. */
|
|
487
|
+
function written(done) {
|
|
488
|
+
return done.filter((entry) => entry.status !== 'unchanged');
|
|
489
|
+
}
|
|
490
|
+
/** One line per product for the terminal. */
|
|
491
|
+
export function formatPlayProductPlan(plan) {
|
|
492
|
+
const lines = [`Google Play ${plan.packageName} (catalog target ${plan.catalogTarget})`];
|
|
493
|
+
for (const product of plan.products) {
|
|
494
|
+
const flags = [
|
|
495
|
+
product.live ? 'live' : undefined,
|
|
496
|
+
product.livePriceChange ? 'PRICE CHANGE ON LIVE PRODUCT' : undefined,
|
|
497
|
+
].filter((flag) => flag !== undefined);
|
|
498
|
+
lines.push(` ${product.action.padEnd(9)} ${product.productId} (${product.logicalId})${product.changes.length === 0 ? '' : `: ${product.changes.join(', ')}`}${flags.length === 0 ? '' : ` [${flags.join('; ')}]`}`);
|
|
499
|
+
const shown = product.priceChanges.slice(0, maxPrintedPriceChanges);
|
|
500
|
+
for (const change of shown) {
|
|
501
|
+
lines.push(` price ${change.region}: ${change.from ?? '-'} -> ${change.to ?? '-'}`);
|
|
502
|
+
}
|
|
503
|
+
if (product.priceChanges.length > shown.length) {
|
|
504
|
+
lines.push(` ... ${product.priceChanges.length - shown.length} more price changes in the plan file`);
|
|
505
|
+
}
|
|
506
|
+
for (const note of product.notes) {
|
|
507
|
+
lines.push(` note: ${note}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const entry of plan.skipped) {
|
|
511
|
+
lines.push(` skipped ${entry.productId ?? '-'} (${entry.logicalId}): ${entry.reason}`);
|
|
512
|
+
}
|
|
513
|
+
for (const productId of plan.remoteOnly) {
|
|
514
|
+
lines.push(` remote ${productId}: only on Play; not in the catalog (never deleted)`);
|
|
515
|
+
}
|
|
516
|
+
return lines.join('\n');
|
|
517
|
+
}
|
|
518
|
+
function toPlayMoney(money) {
|
|
519
|
+
return { currencyCode: money.currency, ...decimalToUnitsAndNanos(money.amount) };
|
|
520
|
+
}
|
|
521
|
+
function normalizeMoney(money) {
|
|
522
|
+
return {
|
|
523
|
+
currencyCode: money.currencyCode ?? undefined,
|
|
524
|
+
units: money.units === undefined || money.units === null || money.units === ''
|
|
525
|
+
? '0'
|
|
526
|
+
: BigInt(money.units).toString(),
|
|
527
|
+
nanos: money.nanos ?? 0,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function sameMoney(left, right) {
|
|
531
|
+
if (left === undefined || right === undefined) {
|
|
532
|
+
return left === right;
|
|
533
|
+
}
|
|
534
|
+
return stableJson(normalizeMoney(left)) === stableJson(normalizeMoney(right));
|
|
535
|
+
}
|
|
536
|
+
function samePricing(remote, desired) {
|
|
537
|
+
const index = (configs) => new Map((configs ?? []).map((config) => [String(config.regionCode), config]));
|
|
538
|
+
const left = index(remote.regionalPricingAndAvailabilityConfigs);
|
|
539
|
+
const right = index(desired.regionalPricingAndAvailabilityConfigs);
|
|
540
|
+
if (left.size !== right.size) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
for (const [region, config] of right) {
|
|
544
|
+
const other = left.get(region);
|
|
545
|
+
if (other === undefined || other.availability !== config.availability
|
|
546
|
+
|| !sameMoney(other.price, config.price)) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const a = remote.newRegionsConfig;
|
|
551
|
+
const b = desired.newRegionsConfig;
|
|
552
|
+
return a?.availability === b?.availability
|
|
553
|
+
&& sameMoney(a?.usdPrice, b?.usdPrice)
|
|
554
|
+
&& sameMoney(a?.eurPrice, b?.eurPrice);
|
|
555
|
+
}
|
|
556
|
+
function diffPrices(remote, desired) {
|
|
557
|
+
const describe = (config) => config === undefined
|
|
558
|
+
? undefined
|
|
559
|
+
: `${formatMoney(config.price)}${config.availability === 'AVAILABLE' ? '' : ` (${String(config.availability)})`}`;
|
|
560
|
+
const left = new Map((remote?.regionalPricingAndAvailabilityConfigs ?? []).map((config) => [
|
|
561
|
+
String(config.regionCode),
|
|
562
|
+
config,
|
|
563
|
+
]));
|
|
564
|
+
const right = new Map((desired.regionalPricingAndAvailabilityConfigs ?? []).map((config) => [
|
|
565
|
+
String(config.regionCode),
|
|
566
|
+
config,
|
|
567
|
+
]));
|
|
568
|
+
const changes = [];
|
|
569
|
+
for (const region of [...new Set([...left.keys(), ...right.keys()])].sort()) {
|
|
570
|
+
const from = describe(left.get(region));
|
|
571
|
+
const to = describe(right.get(region));
|
|
572
|
+
if (from !== to) {
|
|
573
|
+
changes.push({
|
|
574
|
+
region,
|
|
575
|
+
...(from === undefined ? {} : { from }),
|
|
576
|
+
...(to === undefined ? {} : { to }),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const newRegions = (config) => config === undefined
|
|
581
|
+
? undefined
|
|
582
|
+
: `${formatMoney(config.usdPrice)} / ${formatMoney(config.eurPrice)} (${String(config.availability)})`;
|
|
583
|
+
const fromNew = newRegions(remote?.newRegionsConfig);
|
|
584
|
+
const toNew = newRegions(desired.newRegionsConfig);
|
|
585
|
+
if (fromNew !== toNew) {
|
|
586
|
+
changes.push({
|
|
587
|
+
region: 'new-regions',
|
|
588
|
+
...(fromNew === undefined ? {} : { from: fromNew }),
|
|
589
|
+
...(toNew === undefined ? {} : { to: toNew }),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return changes;
|
|
593
|
+
}
|
|
594
|
+
function formatMoney(money) {
|
|
595
|
+
if (money === undefined) {
|
|
596
|
+
return '?';
|
|
597
|
+
}
|
|
598
|
+
const normal = normalizeMoney(money);
|
|
599
|
+
const nanos = String(normal.nanos ?? 0).padStart(9, '0').replace(/0+$/u, '');
|
|
600
|
+
return `${String(normal.units)}${nanos === '' ? '' : `.${nanos}`} ${String(normal.currencyCode)}`;
|
|
601
|
+
}
|
|
602
|
+
function sameListings(remote, desired) {
|
|
603
|
+
const normal = (listings) => stableJson(sortListings(listings).map((listing) => ({
|
|
604
|
+
languageCode: listing.languageCode,
|
|
605
|
+
title: listing.title,
|
|
606
|
+
description: listing.description,
|
|
607
|
+
})));
|
|
608
|
+
return normal(remote) === normal(desired);
|
|
609
|
+
}
|
|
610
|
+
function sortListings(listings) {
|
|
611
|
+
return [...listings].sort((left, right) => String(left.languageCode).localeCompare(String(right.languageCode)));
|
|
612
|
+
}
|
|
613
|
+
function sortRegions(configs) {
|
|
614
|
+
return [...configs].sort((left, right) => String(left.regionCode).localeCompare(String(right.regionCode)));
|
|
615
|
+
}
|
|
616
|
+
function withoutState(option) {
|
|
617
|
+
const { state: _state, ...rest } = option;
|
|
618
|
+
return rest;
|
|
619
|
+
}
|
|
620
|
+
function fold(value) {
|
|
621
|
+
return String(value).toLowerCase();
|
|
622
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ProductCatalog, type StoreListingCatalog } from '@mpgd/catalog';
|
|
2
|
+
/**
|
|
3
|
+
* Shared pieces of `mpgd store products plan|apply`: the game files the sync
|
|
4
|
+
* reads, the saved plan file, and the stable digests apply uses to refuse a
|
|
5
|
+
* plan the store or the files have moved past.
|
|
6
|
+
*/
|
|
7
|
+
export type StoreProductAction = 'create' | 'update' | 'unchanged';
|
|
8
|
+
export declare const defaultStoreListingFileName = "mpgd.store-listings.json";
|
|
9
|
+
export declare const defaultProductCatalogFileName = "mpgd.catalog.json";
|
|
10
|
+
export interface StoreProductSources {
|
|
11
|
+
readonly catalogFile: string;
|
|
12
|
+
readonly listingsFile: string;
|
|
13
|
+
readonly catalog: ProductCatalog;
|
|
14
|
+
readonly listings: StoreListingCatalog;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read and cross-check the game's product catalog and store listing file.
|
|
18
|
+
* Given paths resolve against the current directory, like every other mpgd
|
|
19
|
+
* file flag (`deploy --plan`, `--release-notes`); the defaults are the
|
|
20
|
+
* game's own files.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readStoreProductSources(input: {
|
|
23
|
+
readonly gameRoot: string;
|
|
24
|
+
readonly catalogFile?: string;
|
|
25
|
+
readonly listingsFile?: string;
|
|
26
|
+
}): StoreProductSources;
|
|
27
|
+
/** JSON with object keys sorted, so equal values always hash the same. */
|
|
28
|
+
export declare function stableJson(value: unknown): string;
|
|
29
|
+
export declare function stableSha256(value: unknown): string;
|
|
30
|
+
/** A new plan file, never overwriting one (the same rule as `deploy plan --out`). */
|
|
31
|
+
export declare function writeStoreProductPlan(file: string, plan: unknown): string;
|
|
32
|
+
export declare function readStoreProductPlanFile(file: string): Record<string, unknown>;
|
|
33
|
+
export declare class StoreProductPlanStaleError extends Error {
|
|
34
|
+
constructor(detail: string);
|
|
35
|
+
}
|
|
36
|
+
/** Decimal string (`"0.99"`) as whole units and nanos, without floating point. */
|
|
37
|
+
export declare function decimalToUnitsAndNanos(amount: string): {
|
|
38
|
+
units: string;
|
|
39
|
+
nanos: number;
|
|
40
|
+
};
|
|
41
|
+
/** Decimal strings compared by value: `0.99` equals `0.990`. */
|
|
42
|
+
export declare function sameDecimal(left: string, right: string): boolean;
|
|
43
|
+
/** BCP-47 casing: language lowercase, script title case, region uppercase. */
|
|
44
|
+
export declare function canonicalLanguageTag(tag: string): string;
|
|
45
|
+
/** `map` with at most `limit` calls in flight; results keep input order. */
|
|
46
|
+
export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, map: (item: T) => Promise<R>): Promise<R[]>;
|