diff --git a/scripts/prices/.gitignore b/scripts/prices/.gitignore new file mode 100644 index 0000000000..c4457ced31 --- /dev/null +++ b/scripts/prices/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +*.p8 +*.json +!package.json +!package-lock.json +*.csv* +*.xlsx* +INFO.md diff --git a/scripts/prices/apple-prices.js b/scripts/prices/apple-prices.js new file mode 100644 index 0000000000..e9ebc620c3 --- /dev/null +++ b/scripts/prices/apple-prices.js @@ -0,0 +1,430 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const jwt = require('jsonwebtoken'); +const fetch = require('node-fetch'); +const { parse } = require('csv-parse/sync'); +const { stringify } = require('csv-stringify/sync'); + +// ============ HARDCODE THIS ============ +const APP_ID = '1605771084'; // your numeric app ID +// ======================================= +const CSV_FILE = 'apple-prices.csv'; +const POINTS_FILE = 'apple-price-points.csv'; + +function usage() { + console.log(`Usage: + node apple-prices.js export [-o ] + node apple-prices.js points [-o ] + node apple-prices.js import [--start-date YYYY-MM-DD] [--preserve true|false] [--dry-run] + +export writes ${CSV_FILE} and points writes ${POINTS_FILE} unless -o is given; neither overwrites an existing file. +import reads ${CSV_FILE} and changes the prices that differ from the current App Store prices. +`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const cmd = args[0]; + if (!cmd || !['export', 'points', 'import'].includes(cmd)) usage(); + + const issuerId = args[1]; + const p8Path = args[2]; + if (!issuerId || !p8Path) { + console.error('Missing issuer_id or key file'); + process.exit(1); + } + + const basename = path.basename(p8Path, '.p8'); + const keyId = basename.replace(/^AuthKey_/, ''); + if (!keyId || keyId === basename) { + console.error('Key file must be named AuthKey_XXXXXXXX.p8'); + process.exit(1); + } + + let output = { export: CSV_FILE, points: POINTS_FILE }[cmd]; + let startDate = null; + let preserveCurrent = true; + let dryRun = false; + + for (let i = 3; i < args.length; i++) { + if (args[i] === '-o' && cmd !== 'import' && args[i + 1]) output = args[++i]; + else if (args[i] === '--start-date' && /^\d{4}-\d{2}-\d{2}$/.test(args[i + 1])) startDate = args[++i]; + else if (args[i] === '--preserve' && ['true', 'false'].includes(args[i + 1])) preserveCurrent = args[++i] === 'true'; + else if (args[i] === '--dry-run') dryRun = true; + else usage(); + } + + return { cmd, issuerId, keyId, p8Path, output, startDate, preserveCurrent, dryRun }; +} + +const { cmd, issuerId, keyId, p8Path, output, startDate, preserveCurrent, dryRun } = parseArgs(); +const privateKey = fs.readFileSync(p8Path, 'utf8'); +const today = new Date().toISOString().slice(0, 10); +const RATE_LIMIT_RETRIES = 6; + +function getToken() { + return jwt.sign( + { iss: issuerId, aud: 'appstoreconnect-v1' }, + privateKey, + { algorithm: 'ES256', expiresIn: '5m', header: { kid: keyId, typ: 'JWT' } } + ); +} + +async function api(endpoint, options = {}, retry = 0) { + const url = new URL(endpoint, 'https://api.appstoreconnect.apple.com').href; + const res = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${getToken()}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + const text = await res.text(); + if (res.status === 429 && retry < RATE_LIMIT_RETRIES) { + const minutes = 2 ** retry; + console.warn(`Rate limit exceeded, retrying in ${minutes} min: ${url}`); + await sleep(minutes * 60 * 1000); + return api(endpoint, options, retry + 1); + } + if (!res.ok) { + throw new Error(`${res.status} ${url}: ${text}`); + } + return text ? JSON.parse(text) : {}; +} + +async function apiList(endpoint) { + const data = []; + const included = []; + let next = endpoint; + while (next) { + const res = await api(next); + data.push(...res.data); + included.push(...(res.included ?? [])); + next = res.links?.next; + } + return { data, included }; +} + +async function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +// ====================== EXPORT ====================== +async function readProducts() { + const products = []; + + const groups = await apiList(`/v1/apps/${APP_ID}/subscriptionGroups?limit=200`); + for (const group of groups.data) { + const subs = await apiList(`/v1/subscriptionGroups/${group.id}/subscriptions?limit=200`); + for (const sub of subs.data) { + console.log(`Reading subscription ${sub.attributes.productId}`); + products.push({ + type: 'subscription', + id: sub.id, + productId: sub.attributes.productId, + pricePointsPath: `/v1/subscriptions/${sub.id}/pricePoints`, + prices: await readSubscriptionPrices(sub), + }); + } + } + + const iaps = await apiList(`/v1/apps/${APP_ID}/inAppPurchasesV2?limit=200`); + for (const iap of iaps.data) { + console.log(`Reading IAP ${iap.attributes.productId}`); + products.push({ + type: 'iap', + id: iap.id, + productId: iap.attributes.productId, + pricePointsPath: `/v2/inAppPurchases/${iap.id}/pricePoints`, + prices: await readIapPrices(iap), + }); + } + + return products; +} + +async function readSubscriptionPrices(sub) { + const res = await apiList(`/v1/subscriptions/${sub.id}/prices?include=subscriptionPricePoint,territory&limit=200`); + const records = priceRecords(res, 'subscriptionPricePoint'); + const monthly = records + .filter(r => r.planType === 'MONTHLY') + .map(r => ` ${r.territory} ${r.startDate ?? '-'} ${r.point.customerPrice} ${r.currency}`); + if (monthly.length > 0) { + throw new Error(`${sub.attributes.productId} has MONTHLY plan prices:\n${monthly.join('\n')}`); + } + return territoryPrices(sub.attributes.productId, records); +} + +async function readIapPrices(iap) { + const records = []; + for (const kind of ['manualPrices', 'automaticPrices']) { + const res = await apiList(`/v1/inAppPurchasePriceSchedules/${iap.id}/${kind}?include=inAppPurchasePricePoint,territory&limit=200`); + records.push(...priceRecords(res, 'inAppPurchasePricePoint')); + } + return territoryPrices(iap.attributes.productId, records); +} + +function priceRecords({ data, included }, pricePointRelationship) { + const find = ({ type, id }) => included.find(i => i.type === type && i.id === id); + return data.map(({ attributes, relationships }) => { + const territory = find(relationships.territory.data); + const point = find(relationships[pricePointRelationship].data); + return { + territory: territory.id, + currency: territory.attributes.currency, + startDate: attributes.startDate, + endDate: attributes.endDate, + planType: attributes.planType, + point: { id: point.id, customerPrice: point.attributes.customerPrice }, + }; + }); +} + +function territoryPrices(productId, records) { + const territories = [...new Set(records.map(r => r.territory))]; + return territories.map(territory => { + const history = records + .filter(r => r.territory === territory) + .sort((a, b) => (a.startDate ?? '').localeCompare(b.startDate ?? '')); + const current = history.findLast(r => (r.startDate ?? '') <= today && (!r.endDate || r.endDate > today)); + if (!current) { + throw new Error(`${productId} ${territory}: no current price`); + } + return { + territory, + current, + scheduled: history.find(r => (r.startDate ?? '') > today), + }; + }); +} + +async function readPricePoints(product, territory) { + const { data } = await apiList(`${product.pricePointsPath}?filter[territory]=${territory}&limit=8000`); + return data + .map(p => ({ id: p.id, customerPrice: p.attributes.customerPrice })) + .sort((a, b) => Number(a.customerPrice) - Number(b.customerPrice)); +} + +async function exportPrices() { + if (fs.existsSync(output)) throw new Error(`${output} already exists`); + const products = await readProducts(); + const rows = products.flatMap(product => product.prices.map(({ territory, current, scheduled }) => ({ + type: product.type, + product_id: product.productId, + territory, + currency: current.currency, + current_price: current.point.customerPrice, + scheduled_price: scheduled?.point.customerPrice ?? '', + scheduled_date: scheduled?.startDate ?? '', + price: current.point.customerPrice, + }))); + + fs.writeFileSync(output, stringify(rows, { header: true }), { flag: 'wx' }); + console.log(`\nExported ${rows.length} rows → ${output}`); +} + +async function exportPricePoints() { + if (fs.existsSync(output)) throw new Error(`${output} already exists`); + const products = await readProducts(); + const rows = []; + for (const type of ['subscription', 'iap']) { + const product = products.find(p => p.type === type); + console.log(`Reading price points of ${type} ${product.productId}`); + for (const { territory, current } of product.prices) { + for (const point of await readPricePoints(product, territory)) { + rows.push({ type, territory, currency: current.currency, customer_price: point.customerPrice }); + } + } + } + + fs.writeFileSync(output, stringify(rows, { header: true }), { flag: 'wx' }); + console.log(`\nExported ${rows.length} price points → ${output}`); +} + +// ====================== IMPORT ====================== +async function findPricePoint(product, territory, value) { + if (Number.isNaN(value)) return {}; + const points = await readPricePoints(product, territory); + return { + point: points.find(p => Number(p.customerPrice) === value), + lower: points.findLast(p => Number(p.customerPrice) < value), + higher: points.find(p => Number(p.customerPrice) > value), + }; +} + +async function matchRows(products, rows) { + const problems = []; + const rowsByKey = new Map(); + for (const row of rows) { + const key = `${row.product_id} ${row.territory}`; + if (rowsByKey.has(key)) problems.push(`${key}: duplicate row`); + rowsByKey.set(key, row); + } + + const lines = []; + for (const product of products) { + console.log(`Checking prices of ${product.type} ${product.productId}`); + for (const price of product.prices) { + const key = `${product.productId} ${price.territory}`; + const row = rowsByKey.get(key); + rowsByKey.delete(key); + if (!row) { + problems.push(`${key}: missing row`); + continue; + } + const value = Number(row.price); + const changed = value !== Number(price.current.point.customerPrice); + const { point, lower, higher } = changed + ? await findPricePoint(product, price.territory, value) + : { point: price.current.point }; + if (!point) { + problems.push(`${key}: "${row.price}" ${price.current.currency} is not a price point, nearest are ${lower?.customerPrice ?? 'none'} and ${higher?.customerPrice ?? 'none'}`); + continue; + } + lines.push({ + product, + territory: price.territory, + currency: price.current.currency, + current: price.current.point, + point, + changed, + }); + } + } + for (const key of rowsByKey.keys()) problems.push(`${key}: not an App Store product territory`); + + if (problems.length > 0) { + throw new Error(`${problems.length} invalid rows, nothing was changed:\n${problems.join('\n')}`); + } + return lines; +} + +function describeLine({ territory, currency, current, point, changed }) { + return changed + ? `${territory} ${current.customerPrice} -> ${point.customerPrice} ${currency}` + : `${territory} ${point.customerPrice} ${currency}`; +} + +function subscriptionChanges(lines) { + return lines + .filter(line => line.product.type === 'subscription' && line.changed) + .map(line => { + const increase = Number(line.point.customerPrice) > Number(line.current.customerPrice); + return { + label: `subscription ${line.product.productId} ${describeLine(line)}${increase ? ', increase' : ''}`, + details: [], + path: '/v1/subscriptionPrices', + body: { + data: { + type: 'subscriptionPrices', + attributes: increase ? { startDate, preserveCurrentPrice: preserveCurrent } : { startDate }, + relationships: { + subscription: { data: { type: 'subscriptions', id: line.product.id } }, + subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: line.point.id } }, + }, + }, + }, + }; + }); +} + +function manualPrice(product, point, from, until) { + return { + type: 'inAppPurchasePrices', + attributes: { startDate: from, endDate: until }, + relationships: { + inAppPurchaseV2: { data: { type: 'inAppPurchases', id: product.id } }, + inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: point.id } }, + }, + }; +} + +async function iapScheduleChanges(products, lines) { + const changes = []; + for (const product of products.filter(p => p.type === 'iap')) { + const productLines = lines.filter(line => line.product === product); + if (!productLines.some(line => line.changed)) continue; + + const baseTerritory = (await api(`/v1/inAppPurchasePriceSchedules/${product.id}/baseTerritory`)).data.id; + const manualPrices = productLines + .flatMap(line => startDate && line.changed + ? [manualPrice(product, line.current, null, startDate), manualPrice(product, line.point, startDate, null)] + : [manualPrice(product, line.point, null, null)]) + .map((price, i) => ({ ...price, id: `\${price-${i}}` })); + + changes.push({ + label: `iap ${product.productId} price schedule, base territory ${baseTerritory}, ${productLines.length} territories`, + details: productLines.map(describeLine), + path: '/v1/inAppPurchasePriceSchedules', + body: { + data: { + type: 'inAppPurchasePriceSchedules', + relationships: { + inAppPurchase: { data: { type: 'inAppPurchases', id: product.id } }, + baseTerritory: { data: { type: 'territories', id: baseTerritory } }, + manualPrices: { data: manualPrices.map(({ type, id }) => ({ type, id })) }, + }, + }, + included: manualPrices, + }, + }); + } + return changes; +} + +function printChange({ label, details }) { + console.log(label); + for (const detail of details) console.log(` ${detail}`); +} + +async function applyChanges(changes) { + for (const [i, change] of changes.entries()) { + try { + await api(change.path, { method: 'POST', body: JSON.stringify(change.body) }); + } catch (e) { + throw new Error([ + `Failed: ${change.label}`, + e.message, + `Applied ${i} changes:`, + ...changes.slice(0, i).map(c => ` ${c.label}`), + `Not applied ${changes.length - i} changes:`, + ...changes.slice(i).map(c => ` ${c.label}`), + ].join('\n')); + } + printChange(change); + await sleep(300); + } +} + +async function importPrices() { + const rows = parse(fs.readFileSync(CSV_FILE, 'utf8'), { + columns: true, + skip_empty_lines: true, + bom: true, + }); + const products = await readProducts(); + const lines = await matchRows(products, rows); + const changes = [ + ...await iapScheduleChanges(products, lines), + ...subscriptionChanges(lines), + ]; + + console.log(`\n${changes.length} changes, start date ${startDate ?? 'immediate'}, preserve current price on increases ${preserveCurrent} (dry-run=${dryRun})`); + if (dryRun) { + changes.forEach(printChange); + return; + } + await applyChanges(changes); + console.log('Import finished'); +} + +// ====================== RUN ====================== +const commands = { export: exportPrices, points: exportPricePoints, import: importPrices }; +commands[cmd]().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/prices/package-lock.json b/scripts/prices/package-lock.json new file mode 100644 index 0000000000..ccd9270cd2 --- /dev/null +++ b/scripts/prices/package-lock.json @@ -0,0 +1,211 @@ +{ + "name": "prices", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prices", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "csv-parse": "^7.0.3", + "csv-stringify": "^6.9.0", + "jsonwebtoken": "^9.0.3", + "node-fetch": "^2.7.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/csv-parse": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", + "integrity": "sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==", + "license": "MIT" + }, + "node_modules/csv-stringify": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.9.0.tgz", + "integrity": "sha512-6b3e29CILFfSQ2nDRuZMRROzQOpPuJ+s8zPB5H7eK5ncXBbFOyVuRLU5Sx/zAUmVri3Ly49GyC8NEme4SVSAbA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/scripts/prices/package.json b/scripts/prices/package.json new file mode 100644 index 0000000000..f7d0a3f4c2 --- /dev/null +++ b/scripts/prices/package.json @@ -0,0 +1,19 @@ +{ + "name": "prices", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "csv-parse": "^7.0.3", + "csv-stringify": "^6.9.0", + "jsonwebtoken": "^9.0.3", + "node-fetch": "^2.7.0" + } +} diff --git a/scripts/prices/play-prices.js b/scripts/prices/play-prices.js new file mode 100644 index 0000000000..f9335a2010 --- /dev/null +++ b/scripts/prices/play-prices.js @@ -0,0 +1,288 @@ +#!/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 [-o ] + node play-prices.js import [--dry-run] [--migrate-decreases] +export writes ${CSV_FILE} or 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); +}); diff --git a/scripts/prices/price-levels.js b/scripts/prices/price-levels.js new file mode 100644 index 0000000000..783ba50701 --- /dev/null +++ b/scripts/prices/price-levels.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +const fs = require('fs'); + +const BIG_MAC_URL = 'https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-raw-index.csv'; +const EURO_AREA = ['AUT', 'BEL', 'BGR', 'CYP', 'DEU', 'ESP', 'EST', 'FIN', 'FRA', 'GRC', 'HRV', 'IRL', 'ITA', 'LTU', 'LUX', 'LVA', 'MLT', 'NLD', 'PRT', 'SVK', 'SVN']; +const APPLE_TERRITORIES = { XKX: 'XKS' }; +const ISO2 = { TWN: 'TW' }; +const COLUMNS = ['name', 'iso2', 'iso3', 'apple_territory', 'google_region', 'price_level', 'price_level_year', 'ppp_lcu_per_usd', 'atls_lcu_per_usd', 'big_mac_ratio', 'big_mac_date', 'big_mac_source']; + +async function download(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${url}`); + return res; +} + +async function worldBank(path, page = 1) { + const res = await download(`https://api.worldbank.org/v2/${path}?format=json&per_page=20000&page=${page}`); + const [header, records] = await res.json(); + if (!records) throw new Error(`World Bank ${path}: ${JSON.stringify(header)}`); + return page < header.pages ? records.concat(await worldBank(path, page + 1)) : records; +} + +async function economies() { + const countries = await worldBank('country'); + return countries + .filter(country => country.region.id !== 'NA') + .map(country => ({ name: country.name, iso2: country.iso2Code, iso3: country.id })); +} + +async function indicator(id) { + const records = await worldBank(`country/all/indicator/${id}`); + return records.filter(record => record.value !== null); +} + +async function priceLevels() { + const ppp = await indicator('PA.NUS.PPP'); + const rates = await indicator('PA.NUS.ATLS'); + const rateOf = new Map(rates.map(rate => [`${rate.countryiso3code} ${rate.date}`, rate.value])); + const levels = new Map(); + for (const { countryiso3code, date, value } of ppp.sort((a, b) => a.date.localeCompare(b.date))) { + const rate = rateOf.get(`${countryiso3code} ${date}`); + if (rate) levels.set(countryiso3code, { value: value / rate, year: date, ppp: value, atls: rate }); + } + return levels; +} + +async function bigMacRatios() { + const res = await download(BIG_MAC_URL); + const [header, ...lines] = (await res.text()).trim().split('\n'); + const columns = header.split(','); + const entries = lines.map(line => Object.fromEntries(line.split(',').map((field, i) => [columns[i], field]))); + const date = entries.map(entry => entry.date).sort().at(-1); + const latest = entries.filter(entry => entry.date === date); + const usaPrice = latest.find(entry => entry.iso_a3 === 'USA').dollar_price; + return new Map(latest.map(entry => [entry.iso_a3, { name: entry.name, ratio: entry.dollar_price / usaPrice, date }])); +} + +function bigMacOf(iso3, bigMacs) { + if (bigMacs.has(iso3)) return { ...bigMacs.get(iso3), source: 'own' }; + if (EURO_AREA.includes(iso3)) return { ...bigMacs.get('EUZ'), source: 'EUZ' }; + return {}; +} + +function priceLevelRow({ name, iso2, iso3 }, levels, bigMacs) { + const level = levels.get(iso3) ?? {}; + const bigMac = bigMacOf(iso3, bigMacs); + return { + name, + iso2, + iso3, + apple_territory: APPLE_TERRITORIES[iso3] ?? iso3, + google_region: iso2, + price_level: level.value, + price_level_year: level.year, + ppp_lcu_per_usd: level.ppp, + atls_lcu_per_usd: level.atls, + big_mac_ratio: bigMac.ratio, + big_mac_date: bigMac.date, + big_mac_source: bigMac.source, + }; +} + +function csvField(value) { + const text = String(value ?? ''); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function csv(rows) { + return [COLUMNS, ...rows.map(row => COLUMNS.map(column => row[column]))] + .map(fields => fields.map(csvField).join(',')) + .join('\n') + '\n'; +} + +async function main() { + const output = process.argv[2] ?? 'price-levels.csv'; + const [worldBankEconomies, levels, bigMacs] = await Promise.all([economies(), priceLevels(), bigMacRatios()]); + const worldBankIso3 = new Set(worldBankEconomies.map(economy => economy.iso3)); + const bigMacOnly = [...bigMacs] + .filter(([iso3]) => iso3 !== 'EUZ' && !worldBankIso3.has(iso3)) + .map(([iso3, { name }]) => ({ name, iso2: ISO2[iso3], iso3 })); + const rows = [...worldBankEconomies, ...bigMacOnly] + .sort((a, b) => a.iso3.localeCompare(b.iso3)) + .map(economy => priceLevelRow(economy, levels, bigMacs)); + fs.writeFileSync(output, csv(rows)); + console.log(`Wrote ${rows.length} rows to ${output}`); +} + +main().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/scripts/prices/prices-workbook.py b/scripts/prices/prices-workbook.py new file mode 100644 index 0000000000..bdc33d83a1 --- /dev/null +++ b/scripts/prices/prices-workbook.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +import csv +import sys +from collections import defaultdict +from pathlib import Path + +from openpyxl import Workbook +from openpyxl.formatting.rule import FormulaRule +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.workbook.properties import CalcProperties +from openpyxl.worksheet.datavalidation import DataValidation + +WORKBOOK_FILE = "prices.xlsx" +USAGE = f"""Usage: + python3 prices-workbook.py [-o ] +Reads apple-prices.csv, play-prices.csv, apple-price-points.csv, rates.csv and price-levels.csv from the current directory. +Writes {WORKBOOK_FILE} or and refuses to overwrite an existing file. Requires openpyxl (pip install openpyxl).""" + +FONT_NAME = "Arial" +INPUT_COLOR = "0000FF" +NOTE_COLOR = "595959" +UNUSED_COLOR = "A6A6A6" +HEADER_FILL = PatternFill("solid", fgColor="E7E6E6") +HIGHLIGHT_FILL = PatternFill("solid", fgColor="FCE4D6") + +TIERS = [ + (1, 0.55, 7), + (2, 0.40, 5), + (3, 0, 3), +] +TOLERANCE = 0.035 +STEPS = [ + (0, 1, 0.5, 0.5), + (10, 1, 1, 1), + *( + row + for unit in (1, 10, 100, 1000, 10000, 100000) + for row in ((35 * unit, 5 * unit, 5 * unit, 5 * unit), (75 * unit, 10 * unit, 10 * unit, 10 * unit)) + ), +] +FAMILIES = [ + ("Supporter", 1, "yes"), + ("Legend", 10, "no"), +] +ANNUAL_MONTHS = 6 +TIER_OVERRIDES = { + **dict.fromkeys( + [ + "ALB", "AND", "AUT", "BEL", "BGR", "BIH", "CHE", "CYP", "CZE", "DEU", "DNK", "ESP", "EST", "FIN", + "FRA", "FRO", "GBR", "GIB", "GRC", "HRV", "HUN", "IMN", "IRL", "ISL", "ITA", "LIE", "LTU", "LUX", + "LVA", "MCO", "MKD", "MLT", "MNE", "NLD", "NOR", "POL", "PRT", "ROU", "SMR", "SRB", "SVK", "SVN", + "SWE", "XKX", + ], + 1, + ), + "RUS": 2, + "CHN": 1, +} +AVOIDED_NUMBERS = [13, 66, 666] +AVOIDED_DIGITS = [ + ("CNY", 4, None), + ("HKD", 4, None), + ("MOP", 4, None), + ("TWD", 4, None), + ("JPY", 4, 9), + ("KRW", 4, None), + ("SGD", 4, None), + ("VND", 4, None), + ("MYR", 4, None), +] +MANUAL_PRICES = { + ("ARS", 1): 10000, + ("ETB", 1): 1150, + ("HUF", 1): 2350, + ("IDR", 1): 125000, + ("IQD", 1): 10500, + ("JPY", 1): 1050, + ("KRW", 1): 10000, + ("KZT", 3): 1350, + ("MNT", 3): 10500, + ("MXN", 1): 125, + ("MYR", 3): 12.5, + ("SGD", 1): 10, + ("THB", 1): 235, + ("TWD", 1): 215, + ("VND", 3): 85000, +} +PRODUCTS = [ + ("apple", "SUBSCR_BADGE_SUPPORTER_MONTH_01", "", "Supporter monthly"), + ("apple", "SUBSCR_BADGE_SUPPORTER_YEAR_01", "", "Supporter annual"), + ("apple", "SUBSCR_BADGE_LEGEND_MONTH_01", "", "Legend monthly"), + ("apple", "SUBSCR_BADGE_LEGEND_YEAR_01", "", "Legend annual"), + ("apple", "BADGE_SUPPORTER_01", "", "Supporter monthly"), + ("apple", "BADGE_LEGEND_01", "", "Legend monthly"), + ("play", "subscr_badge_supporter_01", "subscr-badge-supporter-month-01", "Supporter monthly"), + ("play", "subscr_badge_supporter_01", "subscr-badge-supporter-month-02", "Supporter monthly"), + ("play", "subscr_badge_supporter_01", "subscr-badge-supporter-year-01", "Supporter annual"), + ("play", "subscr_badge_legend_01", "subscr-badge-legend-month-01", "Legend monthly"), + ("play", "subscr_badge_legend_01", "subscr-badge-legend-year-01", "Legend annual"), + ("play", "badge_supporter_01", "", "Supporter monthly"), + ("play", "badge_legend_01", "", "Legend monthly"), +] +APPLE_DOWN_LIMIT = 0.02 +POINT_KEY_SCALE = 10**10 +APPLE_MANUAL_PRICES = { + ("RON", 1, "Supporter annual"): 200, + ("TRY", 2, "Supporter annual"): 1500, +} +PLAY_MANUAL_PRICES = { + ("SA", "subscr-badge-legend-year-01"): 1599, +} +APPLE_COLUMNS = ["type", "product_id", "territory", "currency", "current_price", "scheduled_price", "scheduled_date", "price"] +PLAY_COLUMNS = ["type", "product_id", "base_plan_id", "region", "currency", "current_price", "price"] +CHECK_COLUMNS = ["Country", "Tier", "Priced as", "Current, USD", "New, USD", "Change"] +COUNTRY_COLUMNS = [ + "Name", + "ISO3", + "ISO2", + "Apple territory", + "Play region", + "Apple currency", + "Play currency", + "Price level", + "Year", + "Tier override", + "Tier", + "PPP, LCU per USD", + "Atlas rate, LCU per USD", + "Big Mac ratio", + "Big Mac date", + "Big Mac source", +] + + +def read_csv(path): + with open(path, newline="", encoding="utf-8") as file: + return list(csv.DictReader(file)) + + +def number(text): + return float(text) if text else None + + +def currency_by_code(rows, code_column): + currencies = {} + for row in rows: + code, currency = row[code_column], row["currency"] + if currencies.setdefault(code, currency) != currency: + raise ValueError(f"{code} is priced in {currencies[code]} and {currency}") + return currencies + + +def put(ws, row, column, value, number_format=None, color=None, bold=False, italic=False): + cell = ws.cell(row, column, value) + cell.font = Font(name=FONT_NAME, size=10, color=color, bold=bold, italic=italic) + if number_format: + cell.number_format = number_format + return cell + + +def put_header(ws, row, headers, first_column=1): + for column, header in enumerate(headers, start=first_column): + put(ws, row, column, header, bold=True).fill = HEADER_FILL + + +def put_note(ws, row, text): + put(ws, row, 1, text, color=NOTE_COLOR, italic=True) + + +def set_widths(ws, widths): + for index, width in enumerate(widths, start=1): + ws.column_dimensions[get_column_letter(index)].width = width + + +def finish_table(ws, columns, last_row): + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:{get_column_letter(columns)}{last_row}" + + +def avoided_formula(settings, price, currency): + numbers = settings["avoided_numbers"] + digits = [f'IFERROR(INDEX({column},MATCH({currency},{settings["avoided_currencies"]},0))&"","")' for column in (settings["avoided_first"], settings["avoided_second"])] + contains_digit = ",".join(f'AND({digit}<>"",ISNUMBER(SEARCH({digit},{price}&"")))' for digit in digits) + return ( + f"OR({contains_digit}," + f"COUNTIF({numbers},{price}/10^(INT(LOG10({price}))-1))+COUNTIF({numbers},{price}/10^(INT(LOG10({price}))-2))" + f"+COUNTIF({numbers},MOD(INT({price}/1000),1000))+COUNTIF({numbers},MOD(INT({price}/10^6),1000))>0)" + ) + + +def add_validation(ws, cells, error, **rule): + validation = DataValidation(showErrorMessage=True, errorStyle="stop", error=error, **rule) + validation.add(cells) + ws.add_data_validation(validation) + + +def build_settings(ws, rate_date): + put(ws, 1, 1, "SimpleX badge prices", bold=True) + put_note(ws, 2, "Blue cells are inputs. Every price is computed from them.") + + put(ws, 4, 1, "Tiers", bold=True) + put_header(ws, 5, ["Tier", "Price level from", "Supporter monthly, USD"]) + tier_row = 6 + for offset, (tier, level, usd) in enumerate(TIERS): + row = tier_row + offset + put(ws, row, 1, tier) + put(ws, row, 2, level, "0.00", color=INPUT_COLOR if tier < len(TIERS) else None) + put(ws, row, 3, usd, color=INPUT_COLOR) + put_note(ws, tier_row + len(TIERS), "Price level: World Bank PPP factor ÷ Atlas rate, US = 1. Countries without a price level are in tier 1. A tier entered in Countries column J replaces the computed tier: 1 for European countries outside the former USSR, the Baltic states and China, 2 for Russia.") + + put(ws, 11, 1, "Rounding", bold=True) + tolerance_row = 12 + put(ws, tolerance_row, 1, "Tolerance") + put(ws, tolerance_row, 2, TOLERANCE, "0.0%", color=INPUT_COLOR) + put_note(ws, 13, "The exact amount is rounded down to the step when the result is within the tolerance, otherwise up.") + put_header(ws, 14, ["Amount from", "Step, tier 1", "Step, tier 2", "Step, tier 3"]) + step_row = 15 + for offset, values in enumerate(STEPS): + for column, value in enumerate(values, start=1): + put(ws, step_row + offset, column, value, color=INPUT_COLOR) + step_last = step_row + len(STEPS) - 1 + put_note(ws, step_last + 1, "Each step is used from its amount up to the next row's amount.") + + family_title = step_last + 3 + put(ws, family_title, 1, "Families", bold=True) + put_header(ws, family_title + 1, ["Family", "× supporter monthly", "Discounted"]) + family_row = family_title + 2 + for offset, (name, multiple, discounted) in enumerate(FAMILIES): + row = family_row + offset + put(ws, row, 1, name) + put(ws, row, 2, multiple, color=INPUT_COLOR) + put(ws, row, 3, discounted, color=INPUT_COLOR) + family_last = family_row + len(FAMILIES) - 1 + put_note(ws, family_last + 1, "Monthly price = multiple × the final supporter monthly price of the country's tier when discounted, of tier 1 otherwise.") + add_validation(ws, f"C{family_row}:C{family_last}", "Enter yes or no.", type="list", formula1='"yes,no"', allow_blank=False) + annual_row = family_last + 2 + put(ws, annual_row, 1, "Annual months") + put(ws, annual_row, 2, ANNUAL_MONTHS, color=INPUT_COLOR) + put_note(ws, annual_row + 1, "Annual price = annual months × monthly price.") + add_validation(ws, f"B{annual_row}", "Enter a whole number of months.", type="whole", operator="between", formula1="1", formula2="12", allow_blank=False) + + avoided_title = annual_row + 3 + put(ws, avoided_title, 1, "Avoided numbers", bold=True) + numbers_row = avoided_title + 1 + put(ws, numbers_row, 1, "Everywhere") + for column, avoided in enumerate(AVOIDED_NUMBERS, start=2): + put(ws, numbers_row, column, avoided, color=INPUT_COLOR) + put_note(ws, numbers_row + 1, "A price is flagged in the Warning columns of the Apple and Play tabs, and shaded orange on the Prices tab in Excel, when its significant digits, or its thousands or millions, equal one of these numbers.") + put_header(ws, numbers_row + 2, ["Currency", "Digit", "Second digit"]) + digit_row = numbers_row + 3 + for offset, (currency, digit, second_digit) in enumerate(AVOIDED_DIGITS): + put(ws, digit_row + offset, 1, currency, color=INPUT_COLOR) + put(ws, digit_row + offset, 2, digit, color=INPUT_COLOR) + put(ws, digit_row + offset, 3, second_digit, color=INPUT_COLOR) + digit_last = digit_row + len(AVOIDED_DIGITS) - 1 + put_note(ws, digit_last + 1, "A price in the currency is flagged when it contains either digit. A flagged price is replaced by a value in the Manual column of the Prices tab.") + + product_title = digit_last + 3 + put(ws, product_title, 1, "Store products", bold=True) + put_header(ws, product_title + 1, ["Store", "Product ID", "Base plan ID", "Priced as", "Key"]) + product_row = product_title + 2 + for offset, (store, product_id, base_plan_id, priced_as) in enumerate(PRODUCTS): + row = product_row + offset + put(ws, row, 1, store) + put(ws, row, 2, product_id) + put(ws, row, 3, base_plan_id or None) + put(ws, row, 4, priced_as, color=INPUT_COLOR) + put(ws, row, 5, f'=A{row}&"|"&B{row}&"|"&C{row}') + product_last = product_row + len(PRODUCTS) - 1 + + points_title = product_last + 2 + put(ws, points_title, 1, "Apple price points", bold=True) + apple_limit_row = points_title + 1 + put(ws, apple_limit_row, 1, "Round-down limit") + put(ws, apple_limit_row, 2, APPLE_DOWN_LIMIT, "0.0%", color=INPUT_COLOR) + put_note(ws, apple_limit_row + 1, "Apple prices are calculated on the Apple prices tab, one row per currency and tier. A price is the target when Apple offers it; otherwise the highest round price point at or below the target when that is within the limit, else the lowest round price point above it.") + put_note(ws, apple_limit_row + 2, "Round: a whole number, or a multiple of 0.5 below 10; the Apple points tab lists the round points and the others. Target: the workbook price; for annual prices, annual months × the Apple monthly price of the same row.") + put_note(ws, apple_limit_row + 3, "A value in the Manual columns of the Apple prices tab replaces the Apple price, and one in the Play manual column of the Play tab replaces the Play price. Points: apple-price-points.csv.") + + export_title = apple_limit_row + 5 + put(ws, export_title, 1, "Import", bold=True) + put_note(ws, export_title + 1, "Save sheet apple-prices.csv as CSV UTF-8 to scripts/prices/apple-prices.csv, then run: node apple-prices.js import --dry-run") + put_note(ws, export_title + 2, "Save sheet play-prices.csv as CSV UTF-8 to scripts/prices/play-prices.csv, then run: node play-prices.js import --dry-run") + put_note(ws, export_title + 3, "Both import scripts read a decimal point (3.5), so save from a region that writes 3.5, not 3,5.") + put_note(ws, export_title + 4, f"Rates: rates.csv, Frankfurter, {rate_date}. Price levels: price-levels.csv.") + + set_widths(ws, [22, 38, 34, 20, 44]) + return { + "tier1_level": f"Settings!$B${tier_row}", + "tier2_level": f"Settings!$B${tier_row + 1}", + "tier_usd": f"Settings!$C${tier_row}:$C${tier_row + len(TIERS) - 1}", + "tolerance": f"Settings!$B${tolerance_row}", + "step_from": f"Settings!$A${step_row}:$A${step_last}", + "steps": f"Settings!$B${step_row}:$D${step_last}", + "family_rows": [family_row + offset for offset in range(len(FAMILIES))], + "annual_months": f"Settings!$B${annual_row}", + "avoided_numbers": f"Settings!$B${numbers_row}:${get_column_letter(1 + len(AVOIDED_NUMBERS))}${numbers_row}", + "avoided_currencies": f"Settings!$A${digit_row}:$A${digit_last}", + "avoided_first": f"Settings!$B${digit_row}:$B${digit_last}", + "avoided_second": f"Settings!$C${digit_row}:$C${digit_last}", + "product_cells": f"D{product_row}:D{product_last}", + "product_priced_as": f"Settings!$D${product_row}:$D${product_last}", + "product_key": f"Settings!$E${product_row}:$E${product_last}", + "apple_down_limit": f"Settings!$B${apple_limit_row}", + } + + +def country_rows(levels, apple_currency, play_currency): + known_apple = {row["apple_territory"] for row in levels} + known_play = {row["google_region"] for row in levels} + added = [ + *({"name": code, "apple_territory": code} for code in sorted(apple_currency.keys() - known_apple)), + *({"name": code, "google_region": code} for code in sorted(play_currency.keys() - known_play)), + ] + return sorted([*levels, *added], key=lambda row: row["name"]) + + +def build_countries(ws, countries, apple_currency, play_currency, settings): + put_header(ws, 1, COUNTRY_COLUMNS) + last_row = len(countries) + 1 + for row, country in enumerate(countries, start=2): + apple = country.get("apple_territory", "") + play = country.get("google_region", "") + year = country.get("price_level_year", "") + put(ws, row, 1, country["name"]) + put(ws, row, 2, country.get("iso3") or None) + put(ws, row, 3, country.get("iso2") or None) + put(ws, row, 4, apple or None) + put(ws, row, 5, play or None) + put(ws, row, 6, apple_currency.get(apple)) + put(ws, row, 7, play_currency.get(play)) + put(ws, row, 8, number(country.get("price_level", "")), "0.0000") + put(ws, row, 9, int(year) if year else None) + put(ws, row, 10, TIER_OVERRIDES.get(country.get("iso3")), color=INPUT_COLOR) + put(ws, row, 11, f'=IF(J{row}<>"",J{row},IF(H{row}="",1,IF(H{row}>={settings["tier1_level"]},1,IF(H{row}>={settings["tier2_level"]},2,3))))') + put(ws, row, 12, number(country.get("ppp_lcu_per_usd", ""))) + put(ws, row, 13, number(country.get("atls_lcu_per_usd", ""))) + put(ws, row, 14, number(country.get("big_mac_ratio", "")), "0.000") + put(ws, row, 15, country.get("big_mac_date") or None) + put(ws, row, 16, country.get("big_mac_source") or None) + add_validation(ws, f"J2:J{last_row}", "Enter tier 1, 2 or 3, or leave empty.", type="whole", operator="between", formula1="1", formula2=str(len(TIERS)), allow_blank=True) + finish_table(ws, len(COUNTRY_COLUMNS), last_row) + set_widths(ws, [30, 7, 7, 10, 9, 10, 9, 11, 7, 10, 6, 14, 14, 11, 12, 11]) + return { + "name": f"Countries!$A$2:$A${last_row}", + "apple_code": f"Countries!$D$2:$D${last_row}", + "play_code": f"Countries!$E$2:$E${last_row}", + "apple_currency": f"Countries!$F$2:$F${last_row}", + "play_currency": f"Countries!$G$2:$G${last_row}", + "tier": f"Countries!$K$2:$K${last_row}", + } + + +def build_rates(ws, rates): + put_header(ws, 1, ["Currency", "Units per USD", "Date", "Source"]) + last_row = len(rates) + 1 + for row, rate in enumerate(rates, start=2): + put(ws, row, 1, rate["currency"]) + put(ws, row, 2, float(rate["units_per_usd"])) + put(ws, row, 3, rate["rate_date"]) + put(ws, row, 4, rate["source"]) + finish_table(ws, 4, last_row) + set_widths(ws, [10, 14, 12, 40]) + return { + "currency": f"Rates!$A$2:$A${last_row}", + "units": f"Rates!$B$2:$B${last_row}", + } + + +def build_prices(ws, currencies, settings, countries, rates): + headers = ["Key", "Currency", "Tier", "Apple territories", "Play regions", "Units per USD", "Exact", "Step", "Down", "Down vs exact", "Rounded", "Manual", "Final", "Final vs exact"] + first_price_column = len(headers) + 1 + last_price_column = len(headers) + 2 * len(FAMILIES) + put_header(ws, 1, headers) + for offset, family_row in enumerate(settings["family_rows"]): + monthly_column = first_price_column + 2 * offset + put(ws, 1, monthly_column, f'=Settings!$A${family_row}&" monthly"', bold=True).fill = HEADER_FILL + put(ws, 1, monthly_column + 1, f'=Settings!$A${family_row}&" annual"', bold=True).fill = HEADER_FILL + pairs = [(currency, tier) for currency in currencies for tier, _, _ in TIERS] + last_row = len(pairs) + 1 + keys = f"$A$2:$A${last_row}" + final = f"$M$2:$M${last_row}" + for row, (currency, tier) in enumerate(pairs, start=2): + tier1_final = f'INDEX({final},MATCH($B{row}&"|1",{keys},0))' + put(ws, row, 1, f'=B{row}&"|"&C{row}', color=NOTE_COLOR) + put(ws, row, 2, currency) + put(ws, row, 3, tier) + put(ws, row, 4, f"=COUNTIFS({countries['apple_currency']},$B{row},{countries['tier']},$C{row})") + put(ws, row, 5, f"=COUNTIFS({countries['play_currency']},$B{row},{countries['tier']},$C{row})") + put(ws, row, 6, f"=INDEX({rates['units']},MATCH($B{row},{rates['currency']},0))") + put(ws, row, 7, f"=INDEX({settings['tier_usd']},$C{row})*F{row}", "#,##0.00") + put(ws, row, 8, f"=INDEX({settings['steps']},MATCH(G{row},{settings['step_from']},1),$C{row})") + put(ws, row, 9, f"=FLOOR(G{row},H{row})") + put(ws, row, 10, f"=I{row}/G{row}-1", "0.0%") + put(ws, row, 11, f"=IF(I{row}>=G{row}*(1-{settings['tolerance']}),I{row},CEILING(G{row},H{row}))") + put(ws, row, 12, MANUAL_PRICES.get((currency, tier)), color=INPUT_COLOR) + put(ws, row, 13, f'=IF(L{row}="",K{row},L{row})') + put(ws, row, 14, f"=M{row}/G{row}-1", "0.0%") + for offset, family_row in enumerate(settings["family_rows"]): + monthly_column = first_price_column + 2 * offset + monthly = f"{get_column_letter(monthly_column)}{row}" + put(ws, row, monthly_column, f'=Settings!$B${family_row}*IF(Settings!$C${family_row}="yes",$M{row},{tier1_final})') + put(ws, row, monthly_column + 1, f"={settings['annual_months']}*{monthly}") + first_price, last_price = get_column_letter(first_price_column), get_column_letter(last_price_column) + add_validation(ws, f"L2:L{last_row}", "Enter a positive price or leave empty.", type="decimal", operator="greaterThan", formula1="0", allow_blank=True) + ws.conditional_formatting.add(f"A2:{last_price}{last_row}", FormulaRule(formula=["AND($C2<>1,$D2+$E2=0)"], font=Font(color=UNUSED_COLOR))) + ws.conditional_formatting.add(f"M2:M{last_row} {first_price}2:{last_price}{last_row}", FormulaRule(formula=[avoided_formula(settings, "M2", "$B2")], fill=HIGHLIGHT_FILL)) + finish_table(ws, last_price_column, last_row) + set_widths(ws, [9, 9, 5, 10, 8, 12, 12, 8, 10, 10, 10, 10, 10, 10, 18, 18, 18, 18]) + put_note(ws, last_row + 2, "Exact: the tier's supporter monthly USD price at the rate. Down: the exact amount rounded down to the step. Rounded: Down when it is within the tolerance, otherwise rounded up.") + put_note(ws, last_row + 3, "Final: Manual when entered, otherwise Rounded. In Excel, orange cells contain an avoided number from Settings; Numbers shows these only in the Warning columns of the Apple and Play tabs.") + put_note(ws, last_row + 4, "Grey rows: tiers 2 and 3 without territories. Tier-1 rows also set the legend prices of every tier.") + return { + "key": f"Prices!{keys}", + "names": f"Prices!${first_price}$1:${last_price}$1", + "prices": f"Prices!${first_price}$2:${last_price}${last_row}", + } + + +def build_store(ws, columns, rows, code_column, country_codes, product_key, settings, countries, rates, price, extra): + headers = [*columns, *CHECK_COLUMNS, *(header for header, _, _, _ in extra)] + put_header(ws, 1, headers) + letter = {name: get_column_letter(index) for index, name in enumerate(headers, start=1)} + code, currency, current, price_column = (letter[name] for name in (code_column, "currency", "current_price", "price")) + last_row = len(rows) + 1 + for row, data in enumerate(rows, start=2): + for index, name in enumerate(columns, start=1): + put(ws, row, index, price(row, letter) if name == "price" else data[name] or None) + rate = f"INDEX({rates['units']},MATCH(${currency}{row},{rates['currency']},0))" + put(ws, row, len(columns) + 1, f"=INDEX({countries['name']},MATCH(${code}{row},{country_codes},0))") + put(ws, row, len(columns) + 2, f"=INDEX({countries['tier']},MATCH(${code}{row},{country_codes},0))") + put(ws, row, len(columns) + 3, f"=INDEX({settings['product_priced_as']},MATCH({product_key(row)},{settings['product_key']},0))") + put(ws, row, len(columns) + 4, f"=VALUE({current}{row})/{rate}", "#,##0.00") + put(ws, row, len(columns) + 5, f"={price_column}{row}/{rate}", "#,##0.00") + put(ws, row, len(columns) + 6, f"={price_column}{row}/VALUE({current}{row})-1", "0.0%") + for offset, (_, value, number_format, color) in enumerate(extra, start=len(columns) + len(CHECK_COLUMNS) + 1): + put(ws, row, offset, value(row, letter), number_format, color=color) + finish_table(ws, len(headers), last_row) + return last_row, letter + + +def workbook_price(prices, row, letter): + return f'=INDEX({prices["prices"]},MATCH(${letter["currency"]}{row}&"|"&${letter["Tier"]}{row},{prices["key"]},0),MATCH(${letter["Priced as"]}{row},{prices["names"]},0))' + + +def points_by_currency(rows): + point_sets = defaultdict(set) + for row in rows: + point_sets[(row["type"], row["territory"], row["currency"])].add(float(row["customer_price"])) + by_currency = {} + for (kind, territory, currency), points in point_sets.items(): + if by_currency.setdefault(currency, points) != points: + raise ValueError(f"{kind} price points of {territory} differ from other {currency} territories") + return by_currency + + +def is_round(price): + return price == int(price) or (price < 10 and price * 2 == int(price * 2)) + + +def put_rows(ws, first_column, rows, number_formats): + for row, values in enumerate(rows, start=2): + for column, (value, number_format) in enumerate(zip(values, number_formats), start=first_column): + put(ws, row, column, value, number_format) + + +def build_points(ws, by_currency): + currencies = sorted(by_currency) + points = [(currency, point, (index + 1) * POINT_KEY_SCALE + point) for index, currency in enumerate(currencies) for point in sorted(by_currency[currency])] + round_points = [point for point in points if is_round(point[1])] + other_points = [point for point in points if not is_round(point[1])] + put_header(ws, 1, ["Round currency", "Round price", "Round key"]) + put_header(ws, 1, ["Other currency", "Other price", "Other key"], first_column=5) + put_header(ws, 1, ["Currencies"], first_column=9) + put_rows(ws, 1, round_points, [None, None, "0.00"]) + put_rows(ws, 5, other_points, [None, None, "0.00"]) + put_rows(ws, 9, [(currency,) for currency in currencies], [None]) + finish_table(ws, 3, len(round_points) + 1) + set_widths(ws, [15, 14, 18, 3, 15, 14, 18, 3, 12]) + put_note(ws, len(round_points) + 3, "Key: the currency's position in Currencies × 10^10 + price; both lists are sorted by key.") + return { + "currencies": f"'Apple points'!$I$2:$I${len(currencies) + 1}", + "round_currency": f"'Apple points'!$A$2:$A${len(round_points) + 1}", + "round_price": f"'Apple points'!$B$2:$B${len(round_points) + 1}", + "round_keys": f"'Apple points'!$C$2:$C${len(round_points) + 1}", + "other_keys": f"'Apple points'!$G$2:$G${len(other_points) + 1}", + } + + +def offered_formula(keys, currency_key, price): + return f"IFERROR(INDEX({keys},MATCH({currency_key}+{price},{keys},1))={currency_key}+{price},FALSE)" + + +def apple_price_formula(settings, points, currency, currency_key, target, found_row, manual): + below = f"IF({found_row}=0,0,IF(INDEX({points['round_currency']},{found_row})={currency},INDEX({points['round_price']},{found_row}),0))" + above = f"IFERROR(IF(INDEX({points['round_currency']},{found_row}+1)={currency},INDEX({points['round_price']},{found_row}+1),0),0)" + round_target = f"OR({target}=INT({target}),AND({target}<10,{target}*2=INT({target}*2)))" + offered = f"IF({round_target},{below}={target},{offered_formula(points['other_keys'], currency_key, target)})" + nearby = f"IF(OR({below}>={target}*(1-{settings['apple_down_limit']}),{above}=0),{below},{above})" + return f'=IF({manual}<>"",{manual},IF({offered},{target},{nearby}))' + + +def apple_warning_formula(settings, points, currency, currency_key, price, manual): + offered = f"OR({offered_formula(points['round_keys'], currency_key, price)},{offered_formula(points['other_keys'], currency_key, price)})" + avoided = avoided_formula(settings, price, currency) + return f'=IF({price}=0,"no round price point",IF(IF({manual}<>"",NOT({offered}),FALSE),"not an Apple price point",IF({avoided},"avoided number","")))' + + +def build_apple_prices(ws, currencies, settings, prices, points, countries): + price_count = 2 * len(FAMILIES) + price_names = [f"{name} {period}" for name, _, _ in FAMILIES for period in ("monthly", "annual")] + headers = ["Key", "Currency", "Tier", "Apple territories"] + blocks = ["Apple", "Warning", "Manual", "Workbook", "Target", "Row"] + block_start = {block: len(headers) + 1 + offset * price_count for offset, block in enumerate(blocks)} + key_column = len(headers) + len(blocks) * price_count + 1 + put_header(ws, 1, headers) + for block, start in block_start.items(): + for index in range(price_count): + name = f"INDEX({prices['names']},{index + 1})" + put(ws, 1, start + index, f"={name}" if block == "Apple" else f'="{block} "&{name}', bold=True).fill = HEADER_FILL + put(ws, 1, key_column, "Currency key", bold=True).fill = HEADER_FILL + pairs = [(currency, tier) for currency in currencies for tier, _, _ in TIERS] + last_row = len(pairs) + 1 + + def cell(block, index, row): + return f"${get_column_letter(block_start[block] + index)}{row}" + + for row, (currency, tier) in enumerate(pairs, start=2): + currency_key = f"${get_column_letter(key_column)}{row}" + put(ws, row, 1, f'=B{row}&"|"&C{row}', color=NOTE_COLOR) + put(ws, row, 2, currency) + put(ws, row, 3, tier) + put(ws, row, 4, f"=COUNTIFS({countries['apple_currency']},$B{row},{countries['tier']},$C{row})") + for index, name in enumerate(price_names): + target, found_row, manual, apple = (cell(block, index, row) for block in ("Target", "Row", "Manual", "Apple")) + target_formula = f"={cell('Workbook', index, row)}" if index % 2 == 0 else f"={settings['annual_months']}*{cell('Apple', index - 1, row)}" + put(ws, row, block_start["Workbook"] + index, f"=INDEX({prices['prices']},MATCH($A{row},{prices['key']},0),{index + 1})") + put(ws, row, block_start["Target"] + index, target_formula) + put(ws, row, block_start["Row"] + index, f"=IFERROR(MATCH({currency_key}+{target},{points['round_keys']},1),0)") + put(ws, row, block_start["Manual"] + index, APPLE_MANUAL_PRICES.get((currency, tier, name)), color=INPUT_COLOR) + put(ws, row, block_start["Apple"] + index, apple_price_formula(settings, points, f"$B{row}", currency_key, target, found_row, manual)) + put(ws, row, block_start["Warning"] + index, apple_warning_formula(settings, points, f"$B{row}", currency_key, apple, manual)) + put(ws, row, key_column, f"=MATCH($B{row},{points['currencies']},0)*{POINT_KEY_SCALE}", "0") + first_apple, last_apple = get_column_letter(block_start["Apple"]), get_column_letter(block_start["Apple"] + price_count - 1) + first_warning, last_warning = get_column_letter(block_start["Warning"]), get_column_letter(block_start["Warning"] + price_count - 1) + first_manual, last_manual = get_column_letter(block_start["Manual"]), get_column_letter(block_start["Manual"] + price_count - 1) + add_validation(ws, f"{first_manual}2:{last_manual}{last_row}", "Enter a positive Apple price point or leave empty.", type="decimal", operator="greaterThan", formula1="0", allow_blank=True) + ws.conditional_formatting.add(f"A2:{get_column_letter(key_column)}{last_row}", FormulaRule(formula=["$D2=0"], font=Font(color=UNUSED_COLOR))) + ws.conditional_formatting.add(f"{first_apple}2:{last_apple}{last_row}", FormulaRule(formula=[f'{first_warning}2<>""'], fill=HIGHLIGHT_FILL)) + finish_table(ws, key_column, last_row) + set_widths(ws, [9, 9, 5, 10, *[16] * (len(blocks) * price_count), 16]) + put_note(ws, last_row + 2, "One row per Apple currency and tier. Apple: the price sent to Apple. Manual: replaces the Apple price. Workbook: the price from the Prices tab.") + put_note(ws, last_row + 3, "Target: the workbook price; for annual prices, annual months × the Apple monthly price. Row: the position of the highest round price point at or below the target on the Apple points tab.") + put_note(ws, last_row + 4, "Grey rows: no Apple territory uses this currency in this tier.") + return { + "key": f"'Apple prices'!$A$2:$A${last_row}", + "names": f"'Apple prices'!${first_apple}$1:${last_apple}$1", + "prices": f"'Apple prices'!${first_apple}$2:${last_apple}${last_row}", + "warnings": f"'Apple prices'!${first_warning}$2:${last_warning}${last_row}", + } + + +def apple_columns(prices, apple_prices): + def position(row, letter): + return f'MATCH(${letter["currency"]}{row}&"|"&${letter["Tier"]}{row},{apple_prices["key"]},0),MATCH(${letter["Priced as"]}{row},{apple_prices["names"]},0)' + + def apple_price(row, letter): + return f"=INDEX({apple_prices['prices']},{position(row, letter)})" + + extra = [ + ("Workbook price", lambda row, letter: workbook_price(prices, row, letter), None, None), + ("Apple vs workbook", lambda row, letter: f"=${letter['price']}{row}/${letter['Workbook price']}{row}-1", "0.0%", None), + ("Warning", lambda row, letter: f'=INDEX({apple_prices["warnings"]},{position(row, letter)})&""', None, None), + ] + return apple_price, extra + + +def play_columns(settings, prices, play_rows): + rows_by_number = dict(enumerate(play_rows, start=2)) + + def play_price(row, letter): + manual, workbook = f"${letter['Play manual']}{row}", f"${letter['Workbook price']}{row}" + return f'=IF({manual}<>"",{manual},{workbook})' + + def play_manual(row, letter): + data = rows_by_number[row] + return PLAY_MANUAL_PRICES.get((data["region"], data["base_plan_id"] or data["product_id"])) + + def warning(row, letter): + return f'=IF({avoided_formula(settings, f"${letter["price"]}{row}", f"$E{row}")},"avoided number","")' + + extra = [ + ("Workbook price", lambda row, letter: workbook_price(prices, row, letter), None, None), + ("Play manual", play_manual, None, INPUT_COLOR), + ("Warning", warning, None, None), + ] + return play_price, extra + + +def build_export(ws, source, columns, last_row, price_formula): + put_header(ws, 1, columns) + for row in range(2, last_row + 1): + for index, name in enumerate(columns, start=1): + cell = f"{source}!{get_column_letter(index)}{row}" + put(ws, row, index, price_formula(row) if name == "price" else f'=IF({cell}="","",{cell})') + + +def output_path(args): + if not args: + return Path(WORKBOOK_FILE) + if len(args) == 2 and args[0] == "-o": + return Path(args[1]) + sys.exit(USAGE) + + +def main(): + output = output_path(sys.argv[1:]) + if output.exists(): + sys.exit(f"{output} already exists, nothing was written") + apple_rows = read_csv("apple-prices.csv") + play_rows = read_csv("play-prices.csv") + rates = read_csv("rates.csv") + levels = read_csv("price-levels.csv") + points = points_by_currency(read_csv("apple-price-points.csv")) + apple_currency = currency_by_code(apple_rows, "territory") + play_currency = currency_by_code(play_rows, "region") + currencies = sorted({*apple_currency.values(), *play_currency.values()}) + + wb = Workbook() + settings_ws = wb.active + settings_ws.title = "Settings" + prices_ws = wb.create_sheet("Prices") + apple_prices_ws = wb.create_sheet("Apple prices") + countries_ws = wb.create_sheet("Countries") + apple_ws = wb.create_sheet("Apple") + play_ws = wb.create_sheet("Play") + rates_ws = wb.create_sheet("Rates") + points_ws = wb.create_sheet("Apple points") + apple_export_ws = wb.create_sheet("apple-prices.csv") + play_export_ws = wb.create_sheet("play-prices.csv") + + settings = build_settings(settings_ws, max(rate["rate_date"] for rate in rates)) + countries = build_countries(countries_ws, country_rows(levels, apple_currency, play_currency), apple_currency, play_currency, settings) + rate_ranges = build_rates(rates_ws, rates) + prices = build_prices(prices_ws, currencies, settings, countries, rate_ranges) + add_validation(settings_ws, settings["product_cells"], "Choose a price column of the Prices sheet.", type="list", formula1=prices["names"], allow_blank=False) + point_ranges = build_points(points_ws, points) + apple_prices = build_apple_prices(apple_prices_ws, sorted(set(apple_currency.values())), settings, prices, point_ranges, countries) + apple_price, apple_extra = apple_columns(prices, apple_prices) + apple_last, apple_letter = build_store(apple_ws, APPLE_COLUMNS, apple_rows, "territory", countries["apple_code"], lambda row: f'"apple|"&$B{row}&"|"', settings, countries, rate_ranges, apple_price, apple_extra) + play_price, play_extra = play_columns(settings, prices, play_rows) + play_last, play_letter = build_store(play_ws, PLAY_COLUMNS, play_rows, "region", countries["play_code"], lambda row: f'"play|"&$B{row}&"|"&$C{row}', settings, countries, rate_ranges, play_price, play_extra) + play_manual_column = play_letter["Play manual"] + add_validation(play_ws, f"{play_manual_column}2:{play_manual_column}{play_last}", "Enter a positive price or leave empty.", type="decimal", operator="greaterThan", formula1="0", allow_blank=True) + for ws, letter, last_row in ((apple_ws, apple_letter, apple_last), (play_ws, play_letter, play_last)): + ws.conditional_formatting.add(f"{letter['price']}2:{letter['price']}{last_row}", FormulaRule(formula=[f'${letter["Warning"]}2<>""'], fill=HIGHLIGHT_FILL)) + set_widths(apple_ws, [12, 36, 9, 9, 12, 14, 14, 10, 30, 6, 18, 12, 10, 9, 14, 12, 24]) + set_widths(play_ws, [12, 26, 32, 7, 9, 12, 10, 30, 6, 18, 12, 10, 9, 14, 12, 18]) + apple_decimals = 'IF(ROUND(Apple!H{row}*10,6)=ROUND(Apple!H{row}*10,0),1,2)' + build_export(apple_export_ws, "Apple", APPLE_COLUMNS, apple_last, lambda row: f'=FIXED(Apple!H{row},IF(ISNUMBER(SEARCH(".",Apple!E{row})),{apple_decimals.format(row=row)},0),TRUE)') + play_decimals = 'IF(ROUND(Play!G{row},6)=ROUND(Play!G{row},0),0,IF(ROUND(Play!G{row}*10,6)=ROUND(Play!G{row}*10,0),1,2))' + build_export(play_export_ws, "Play", PLAY_COLUMNS, play_last, lambda row: f"=FIXED(Play!G{row},{play_decimals.format(row=row)},TRUE)") + + wb.calculation = CalcProperties(fullCalcOnLoad=True) + wb.save(output) + print(f"Wrote {output}: {len(currencies)} currencies, {len(apple_rows)} Apple rows, {len(play_rows)} Play rows") + + +main() diff --git a/scripts/prices/rates.js b/scripts/prices/rates.js new file mode 100644 index 0000000000..1fe0bbb1db --- /dev/null +++ b/scripts/prices/rates.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +const fs = require('fs'); +const { parseArgs } = require('util'); + +const RATES_URL = 'https://api.frankfurter.dev/v2/rates'; + +const USAGE = `Usage: + node rates.js fetch [output.csv] + node rates.js compare [--threshold 20] [--currencies ]...`; + +async function fetchRates(file) { + const url = `${RATES_URL}?base=USD`; + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${url}: ${await res.text()}`); + const rates = (await res.json()).filter(({ quote }) => quote !== 'USD'); + const usd = { quote: 'USD', rate: 1, date: new Date().toISOString().slice(0, 10) }; + const rows = [usd, ...rates] + .map(({ quote, rate, date }) => [quote, rate, date, RATES_URL]) + .sort(([a], [b]) => a.localeCompare(b)); + const lines = [['currency', 'units_per_usd', 'rate_date', 'source'], ...rows].map(row => row.join(',')); + fs.writeFileSync(file, lines.join('\n') + '\n'); + console.log(`Wrote ${rows.length} rows to ${file}`); +} + +function readColumns(file, names) { + const [header, ...rows] = fs.readFileSync(file, 'utf8').trim().split(/\r?\n/).map(line => line.split(',')); + const columns = names.map(name => header.indexOf(name)); + if (columns.includes(-1)) throw new Error(`${file}: header must include ${names.join(' and ')}`); + return rows.map(row => columns.map(column => row[column])); +} + +function readRates(file) { + return new Map(readColumns(file, ['currency', 'units_per_usd']).map(([currency, units]) => { + const unitsPerUsd = Number(units); + if (!(unitsPerUsd > 0)) throw new Error(`${file}: invalid units_per_usd for ${currency}`); + return [currency, unitsPerUsd]; + })); +} + +function readCurrencies(file) { + return readColumns(file, ['currency']).map(([currency]) => currency); +} + +function compareRates(baselineFile, currentFile, threshold, currencyFiles) { + const baseline = readRates(baselineFile); + const current = readRates(currentFile); + const currencies = currencyFiles.length > 0 ? currencyFiles.flatMap(file => readCurrencies(file)) : [...baseline.keys()]; + const compared = [...new Set(currencies)].sort(); + const changes = compared + .filter(currency => baseline.has(currency) && current.has(currency)) + .map(currency => { + const before = baseline.get(currency); + const after = current.get(currency); + return { currency, before, after, percent: (after / before - 1) * 100 }; + }) + .filter(({ percent }) => Math.abs(percent) > threshold); + if (changes.length > 0) { + console.log('currency,baseline,current,change_percent'); + changes.forEach(c => console.log(`${c.currency},${c.before},${c.after},${c.percent.toFixed(1)}`)); + } + const missingFromCurrent = compared.filter(currency => !current.has(currency)); + const missingFromBaseline = compared.filter(currency => !baseline.has(currency)); + if (missingFromCurrent.length > 0) console.error(`missing from ${currentFile}: ${missingFromCurrent.join(' ')}`); + if (missingFromBaseline.length > 0) console.error(`missing from ${baselineFile}: ${missingFromBaseline.join(' ')}`); + if (missingFromCurrent.length > 0 || missingFromBaseline.length > 0) return 1; + return changes.length > 0 ? 2 : 0; +} + +async function run([cmd, ...args]) { + if (cmd === 'fetch') { + await fetchRates(args[0] ?? 'rates.csv'); + return 0; + } + if (cmd === 'compare') { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + threshold: { type: 'string', default: '20' }, + currencies: { type: 'string', multiple: true, default: [] }, + }, + }); + const [baselineFile, currentFile] = positionals; + const threshold = Number(values.threshold); + if (positionals.length === 2 && threshold >= 0) return compareRates(baselineFile, currentFile, threshold, values.currencies); + } + console.log(USAGE); + return 1; +} + +run(process.argv.slice(2)) + .then(code => { + process.exitCode = code; + }) + .catch(e => { + console.error(e); + process.exitCode = 1; + });