Skip to content

Instantly share code, notes, and snippets.

@astashov
Last active August 29, 2024 18:07
Show Gist options
  • Save astashov/79dd4ef4e91ea012710145623bfe0984 to your computer and use it in GitHub Desktop.
Save astashov/79dd4ef4e91ea012710145623bfe0984 to your computer and use it in GitHub Desktop.
const fetch = require("node-fetch");
const fs = require("fs");
const jwt = require("jsonwebtoken");
const util = require("util");
// To get the private key, go to App Store Connect (appstoreconnect.apple.com), then "Users and Access"
// at the top. Then go to "Integrations" -> "App Store Connect API", under "Team Keys" create a new key,
// and you'll be able to download the private key for it.
const PRIVATE_KEY = fs.readFileSync("AuthKey_F2BLAHBLAH.p8", "utf8");
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find "Issuer ID"
const ISSUER_ID = "4b8896ba-0ab0-a7c5-acde-543b969a6c5c";
// Id of your app
const APP_ID = "com.liftosaur.www";
// On the App Store Connect -> Users and Access -> Integrations -> App Store Connect API, you can find Key ID for your
// private key you created following instructions above.
const KEY_ID = "F2BLAHBLAH";
// You can find subscription ids on the subscriptions page under your app in App Store Connect
const SUBSCRIPTION_IDS = {
"6445212345": "monthly",
"6445312346": "yearly",
};
// You can find in-app purchase ids on the in-app purchases page under your app in App Store Connect
const PRODUCT_ID = "645012347";
const API_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
const API_BASE_URL_V2 = "https://api.appstoreconnect.apple.com/v2";
function formatYYYYMMDD(date, separator = "-") {
const d = new Date(date);
let month = `${d.getMonth() + 1}`;
let day = `${d.getDate()}`;
const year = `${d.getFullYear()}`;
if (month.length < 2) {
month = `0${month}`;
}
if (day.length < 2) {
day = `0${day}`;
}
return [year, month, day].join(separator);
}
// Generate JWT token for authentication
function generateJWT() {
const token = jwt.sign(
{
iss: ISSUER_ID,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 20 * 60, // 20 minutes expiration
bid: APP_ID,
aud: "appstoreconnect-v1",
},
PRIVATE_KEY,
{
algorithm: "ES256",
header: {
alg: "ES256",
kid: KEY_ID,
typ: "JWT",
},
}
);
return token;
}
function getNewPrices() {
return fs
.readFileSync("apple_prices.csv", "utf8")
.split("\n")
.slice(1)
.map((line) => {
const [
,
code,
currencyCode,
,
,
,
,
,
,
,
,
,
,
monthlyMarketing,
yearlyMarketing,
lifetimeMarketing,
] = line.split(",").map((v) => v.trim());
return {
countryCode: code.trim(),
currencyCode: currencyCode.trim(),
monthly: parseFloat(monthlyMarketing.trim()),
yearly: parseFloat(yearlyMarketing.trim()),
lifetime: parseFloat(lifetimeMarketing.trim()),
};
});
}
const subscriptionToTerritoryToPricePoints = {};
const productTerritoryToPricePoints = {};
async function getFromApple(url) {
const token = generateJWT();
console.log("Fetching page", url);
let results = [];
let json;
do {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
json = await response.json();
results = results.concat(json.data);
url = json.links.next;
console.log("Fetching next page", url);
} while (json.links.next);
return results;
}
function findClosestPricePoint(pricePoints, targetPrice) {
return pricePoints.reduce((prev, curr) => {
return Math.abs(curr.price - targetPrice) < Math.abs(prev.price - targetPrice) ? curr : prev;
});
}
async function getSubscriptionPricePoints(subscriptionId, territory) {
const pricePoint = subscriptionToTerritoryToPricePoints[subscriptionId]?.[territory];
if (pricePoint) {
return pricePoint;
}
let pp;
fs.mkdirSync(`pricepoints`, { recursive: true });
if (fs.existsSync(`pricepoints/${subscriptionId}-${territory}.json`)) {
const pricePoints = fs.readFileSync(`pricepoints/${subscriptionId}-${territory}.json`, "utf8");
pp = JSON.parse(pricePoints);
} else {
const url = `${API_BASE_URL}/subscriptions/${subscriptionId}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`;
const json = await getFromApple(url);
pp = json.map((d) => {
return {
id: d.id,
price: d.attributes.customerPrice,
};
});
pp.sort((a, b) => b.price - a.price);
fs.writeFileSync(`pricepoints/${subscriptionId}-${territory}.json`, JSON.stringify(pp, null, 2));
}
subscriptionToTerritoryToPricePoints[subscriptionId] = subscriptionToTerritoryToPricePoints[subscriptionId] || {};
subscriptionToTerritoryToPricePoints[subscriptionId][territory] = pp;
return pp;
}
async function getProductPricePoints(territory) {
const pricePoint = productTerritoryToPricePoints[territory];
if (pricePoint) {
return pricePoint;
}
let pp;
fs.mkdirSync(`pricepoints`, { recursive: true });
if (fs.existsSync(`pricepoints/${PRODUCT_ID}-${territory}.json`)) {
const pricePoints = fs.readFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, "utf8");
pp = JSON.parse(pricePoints);
} else {
const url = `${API_BASE_URL_V2}/inAppPurchases/${PRODUCT_ID}/pricePoints?include=territory&filter%5Bterritory%5D=${territory}`;
const json = await getFromApple(url);
pp = json.map((d) => {
return {
id: d.id,
price: d.attributes.customerPrice,
};
});
pp.sort((a, b) => b.price - a.price);
fs.writeFileSync(`pricepoints/${PRODUCT_ID}-${territory}.json`, JSON.stringify(pp, null, 2));
}
productTerritoryToPricePoints[territory] = pp;
return pp;
}
// Function to update subscription price for a country
async function updateSubscriptionPrices(subscriptionId, key) {
const token = generateJWT();
const newPrices = getNewPrices();
for (const price of newPrices) {
const pricePoints = await getSubscriptionPricePoints(subscriptionId, price.countryCode);
if (pricePoints.length === 0) {
console.log("No price point found for", subscriptionId, price.countryCode, price[key]);
return;
}
const pricePoint = findClosestPricePoint(pricePoints, price[key]);
const data = {
data: {
type: "subscriptionPrices",
attributes: {
preserveCurrentPrice: false,
startDate: formatYYYYMMDD(Date.now()),
},
relationships: {
subscription: {
data: {
type: "subscriptions",
id: subscriptionId,
},
},
subscriptionPricePoint: {
data: {
type: "subscriptionPricePoints",
id: pricePoint.id,
},
},
territory: {
data: {
type: "territories",
id: price.countryCode,
},
},
},
},
};
console.log("Updating price for", key, price.countryCode, price[key], pricePoint.price);
const url = `${API_BASE_URL}/subscriptionPrices`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const json = await response.json();
console.log(
util.inspect(json, {
showHidden: false,
maxArrayLength: null,
depth: null,
})
);
console.log("\n");
}
}
async function updateProductPrices() {
const token = generateJWT();
const newPrices = getNewPrices();
const key = "lifetime";
const manualPrices = await Promise.all(
newPrices.map(async (price) => {
const pricePoints = await getProductPricePoints(price.countryCode);
const pricePoint = findClosestPricePoint(pricePoints, price[key]);
return {
pricePoint,
price,
};
})
);
const data = {
data: {
type: "inAppPurchasePriceSchedules",
relationships: {
baseTerritory: {
data: {
type: "territories",
id: "USA",
},
},
inAppPurchase: {
data: {
type: "inAppPurchases",
id: PRODUCT_ID,
},
},
manualPrices: {
data: manualPrices.map(({ pricePoint }) => {
return {
type: "inAppPurchasePrices",
id: `pp-${pricePoint.id}`,
};
}),
},
},
},
included: manualPrices.map(({ pricePoint, price }) => {
return {
attributes: {
startDate: null,
endDate: null,
},
id: `pp-${pricePoint.id}`,
relationships: {
inAppPurchasePricePoint: {
data: {
id: pricePoint.id,
type: "inAppPurchasePricePoints",
},
},
inAppPurchaseV2: {
data: {
id: PRODUCT_ID,
type: "inAppPurchases",
},
},
},
type: "inAppPurchasePrices",
};
}),
};
console.log("Updating prices of lifetime");
const url = `${API_BASE_URL}/inAppPurchasePriceSchedules`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const json = await response.json();
console.log(
util.inspect(json, {
showHidden: false,
maxArrayLength: null,
depth: null,
})
);
console.log("\n");
}
async function main() {
for (const [subscriptionId, key] of Object.entries(SUBSCRIPTION_IDS)) {
await updateSubscriptionPrices(subscriptionId, key);
}
await updateProductPrices();
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment