Files
simplex-chat/scripts/prices/play-prices.js
T
EvgenyandEvgeny @ SimpleX Chat ea679e6e95 scripts: manage in-app prices (#7735)
* scripts: manage in-app prices

* better

* move scripts to one folder

* shorter token

* workbook generator script

* gitignore

* gitignore, package.json

* update script

* faster calculation

* fix workbook

* gitignore

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-25 06:03:52 +00:00

289 lines
11 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const { setTimeout: sleep } = require('timers/promises');
const PACKAGE_NAME = 'chat.simplex.app';
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE_NAME}`;
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const CSV_FILE = 'play-prices.csv';
const COLUMNS = ['type', 'product_id', 'base_plan_id', 'region', 'currency', 'current_price', 'price'];
const NANOS = 1_000_000_000n;
const MAX_ATTEMPTS = 5;
function parseArgs() {
const [cmd, keyFile, ...flags] = process.argv.slice(2);
if (cmd === 'export' && keyFile && (flags.length === 0 || (flags.length === 2 && flags[0] === '-o'))) {
return { cmd, keyFile, outFile: flags[1] ?? CSV_FILE };
}
if (cmd === 'import' && keyFile && flags.every(flag => ['--dry-run', '--migrate-decreases'].includes(flag))) {
return { cmd, keyFile, dryRun: flags.includes('--dry-run'), migrateDecreases: flags.includes('--migrate-decreases') };
}
console.log(`Usage:
node play-prices.js export <service-account.json> [-o <file>]
node play-prices.js import <service-account.json> [--dry-run] [--migrate-decreases]
export writes ${CSV_FILE} or <file> and refuses to overwrite an existing file; import reads ${CSV_FILE}.
`);
process.exit(1);
}
async function getAccessToken(keyFile) {
const key = JSON.parse(fs.readFileSync(keyFile, 'utf8'));
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT', kid: key.private_key_id };
const claims = {
iss: key.client_email,
scope: 'https://www.googleapis.com/auth/androidpublisher',
aud: TOKEN_URL,
iat: now,
exp: now + 3600,
};
const unsigned = [header, claims].map(part => Buffer.from(JSON.stringify(part)).toString('base64url')).join('.');
const signature = crypto.sign('sha256', Buffer.from(unsigned), key.private_key).toString('base64url');
const res = await fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${unsigned}.${signature}`,
}),
});
const text = await res.text();
if (!res.ok) throw new Error(`${res.status} ${TOKEN_URL}: ${text}`);
return JSON.parse(text).access_token;
}
async function api(token, method, path, body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${API}${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: body && JSON.stringify(body),
});
const text = await res.text();
if (res.ok) return text ? JSON.parse(text) : {};
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === MAX_ATTEMPTS) throw new Error(`${res.status} ${method} ${path}: ${text}`);
const delay = 2 ** attempt * 1000;
console.warn(` ${res.status} ${method} ${path}, retrying in ${delay / 1000}s`);
await sleep(delay);
}
}
async function listAll(token, collection) {
const items = [];
let query = '';
do {
const page = await api(token, 'GET', `/${collection}${query}`);
items.push(...(page[collection] || []));
query = page.nextPageToken ? `?pageToken=${encodeURIComponent(page.nextPageToken)}` : '';
} while (query);
return items;
}
async function fetchRegionPrices(token) {
const subscriptions = await listAll(token, 'subscriptions');
const oneTimeProducts = await listAll(token, 'oneTimeProducts');
return [
...subscriptions.flatMap(subscription => {
const product = {
type: 'subscription',
resource: subscription,
updatePath: `/subscriptions/${subscription.productId}`,
updateMask: 'basePlans',
};
return (subscription.basePlans || []).flatMap(plan => regionPricesOf(product, plan.basePlanId, plan.regionalConfigs));
}),
...oneTimeProducts.flatMap(oneTimeProduct => {
const product = {
type: 'onetime',
resource: oneTimeProduct,
updatePath: `/onetimeproducts/${oneTimeProduct.productId}`,
updateMask: 'purchaseOptions',
};
return regionPricesOf(product, '', purchaseOption(oneTimeProduct).regionalPricingAndAvailabilityConfigs);
}),
];
}
function purchaseOption(oneTimeProduct) {
const options = oneTimeProduct.purchaseOptions;
if (options.length !== 1) {
throw new Error(`one-time product ${oneTimeProduct.productId} has ${options.length} purchase options, the CSV supports one`);
}
return options[0];
}
function regionPricesOf(product, basePlanId, configs = []) {
return configs
.filter(config => config.price)
.map(config => ({
key: [product.type, product.resource.productId, basePlanId, config.regionCode].join(','),
product,
basePlanId,
config,
}));
}
function nanosOf(money) {
return BigInt(money.units ?? 0) * NANOS + BigInt(money.nanos ?? 0);
}
function toMoney(currencyCode, nanos) {
return { currencyCode, units: String(nanos / NANOS), nanos: Number(nanos % NANOS) };
}
function formatPrice(nanos) {
const fraction = String(nanos % NANOS).padStart(9, '0').replace(/0+$/, '');
return fraction ? `${nanos / NANOS}.${fraction}` : `${nanos / NANOS}`;
}
function parsePrice(text, currency) {
const match = /^(\d+)(?:\.(\d{1,9}))?$/.exec(text);
if (!match) throw new Error(`price "${text}" is not a decimal number`);
const nanos = BigInt(match[1]) * NANOS + BigInt((match[2] ?? '').padEnd(9, '0'));
if (nanos === 0n) throw new Error('price must be positive');
const digits = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits;
if (nanos % 10n ** BigInt(9 - digits) !== 0n) throw new Error(`price ${text}: ${currency} allows ${digits} fractional digits`);
return nanos;
}
async function exportPrices(keyFile, outFile) {
if (fs.existsSync(outFile)) throw new Error(`${outFile} already exists, nothing was written`);
const token = await getAccessToken(keyFile);
const rows = (await fetchRegionPrices(token)).map(({ key, config }) => {
const price = formatPrice(nanosOf(config.price));
return [key, config.price.currencyCode, price, price].join(',');
});
fs.writeFileSync(outFile, [COLUMNS.join(','), ...rows, ''].join('\n'), { flag: 'wx' });
console.log(`Exported ${rows.length} rows → ${outFile}`);
}
function readCsv() {
const [header, ...lines] = fs.readFileSync(CSV_FILE, 'utf8').replace(/^\u{FEFF}/u, '').split(/\r?\n/);
if (header !== COLUMNS.join(',')) throw new Error(`${CSV_FILE}: the header must be ${COLUMNS.join(',')}`);
return lines.flatMap((text, i) => (text ? [{ line: i + 2, fields: text.split(',') }] : []));
}
function matchRows(regionPrices, rows) {
const regionPricesByKey = new Map(regionPrices.map(regionPrice => [regionPrice.key, regionPrice]));
const rowKeys = new Set();
const changes = [];
const errors = [];
for (const { line, fields } of rows) {
const key = fields.slice(0, 4).join(',');
try {
if (rowKeys.has(key)) throw new Error(`duplicate row for ${key}`);
rowKeys.add(key);
changes.push(rowChange(key, regionPricesByKey.get(key), fields));
} catch (e) {
errors.push(`line ${line}: ${e.message}`);
}
}
for (const key of regionPricesByKey.keys()) {
if (!rowKeys.has(key)) errors.push(`missing row for ${key}`);
}
if (errors.length) throw new Error(`${CSV_FILE} is invalid, nothing was sent:\n ${errors.join('\n ')}`);
return changes;
}
function rowChange(key, regionPrice, fields) {
if (!regionPrice) throw new Error(`${key} has no price in Google Play`);
if (fields.length !== COLUMNS.length) throw new Error(`expected ${COLUMNS.length} columns, found ${fields.length}`);
const currency = fields[4];
const { currencyCode } = regionPrice.config.price;
if (currency !== currencyCode) throw new Error(`currency ${currency}, Google Play uses ${currencyCode}`);
return { ...regionPrice, current: nanosOf(regionPrice.config.price), price: parsePrice(fields[6], currencyCode) };
}
async function fetchRegionsVersion(token) {
const converted = await api(token, 'POST', '/pricing:convertRegionPrices', { price: { currencyCode: 'USD', units: '1' } });
return converted.regionVersion.version;
}
function planRequests(changes, regionsVersion, migrateDecreases) {
return [...Map.groupBy(changes, change => change.product)].flatMap(([product, productChanges]) => {
const query = new URLSearchParams({ updateMask: product.updateMask, 'regionsVersion.version': regionsVersion });
const update = {
label: `update ${product.type} ${product.resource.productId}`,
changes: productChanges,
method: 'PATCH',
path: `${product.updatePath}?${query}`,
body: product.resource,
};
const decreases = productChanges.filter(change => change.price < change.current);
return migrateDecreases && product.type === 'subscription' && decreases.length
? [update, migrationRequest(product.resource.productId, decreases, regionsVersion)]
: [update];
});
}
function migrationRequest(productId, decreases, regionsVersion) {
const oldestAllowedPriceVersionTime = new Date().toISOString();
return {
label: `migrate subscription ${productId} subscribers to lower prices`,
changes: decreases,
method: 'POST',
path: `/subscriptions/${productId}/basePlans:batchMigratePrices`,
body: {
requests: [...Map.groupBy(decreases, change => change.basePlanId)].map(([basePlanId, planDecreases]) => ({
packageName: PACKAGE_NAME,
productId,
basePlanId,
regionalPriceMigrations: planDecreases.map(change => ({
regionCode: change.config.regionCode,
oldestAllowedPriceVersionTime,
})),
regionsVersion: { version: regionsVersion },
})),
},
};
}
function printPlan(requests) {
for (const { label, changes } of requests) {
console.log(label);
for (const change of changes) {
const currency = change.config.price.currencyCode;
console.log(` ${change.key} ${currency} ${formatPrice(change.current)} → ${formatPrice(change.price)}`);
}
}
}
async function sendRequests(token, requests) {
for (const [i, request] of requests.entries()) {
try {
await api(token, request.method, request.path, request.body);
} catch (e) {
const applied = requests.slice(0, i).map(r => r.label).join('\n ') || 'none';
const remaining = requests.slice(i).map(r => r.label).join('\n ');
throw new Error(`${request.label} failed: ${e.message}\nApplied:\n ${applied}\nNot applied:\n ${remaining}`);
}
console.log(`Done: ${request.label}`);
}
}
async function importPrices(keyFile, dryRun, migrateDecreases) {
const token = await getAccessToken(keyFile);
const rows = readCsv();
const changes = matchRows(await fetchRegionPrices(token), rows).filter(change => change.price !== change.current);
for (const change of changes) change.config.price = toMoney(change.config.price.currencyCode, change.price);
const regionsVersion = await fetchRegionsVersion(token);
const requests = planRequests(changes, regionsVersion, migrateDecreases);
console.log(`Regions version ${regionsVersion}, ${requests.length} requests${dryRun ? ' (dry run, no updates are sent)' : ''}`);
printPlan(requests);
if (dryRun) return;
await sendRequests(token, requests);
console.log('Import finished');
}
async function main() {
const { cmd, keyFile, outFile, dryRun, migrateDecreases } = parseArgs();
if (cmd === 'export') await exportPrices(keyFile, outFile);
else await importPrices(keyFile, dryRun, migrateDecreases);
}
main().catch(e => {
console.error(e.message);
process.exit(1);
});