128 lines
10 KiB
TypeScript
128 lines
10 KiB
TypeScript
// Offer codes for one-time purchases (consumable, non-consumable, non-renewing) from products[].offerCodes.
|
|
// storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]
|
|
// Creates the missing campaigns (by name) with their prices: free, or the USA price point of `usd` and its
|
|
// equalizations (every territory, or `territories`). A campaign alone lets nobody redeem anything. Codes are made only
|
|
// with --codes, after a confirmation: custom codes (anyone who has the code) and one-time-use batches; the one-time
|
|
// codes are saved to .storebound/offer-codes/ (gitignored; treat them like vouchers). Each app may have 10 active
|
|
// offers and 1 million codes per quarter (App Store Connect Help). Subscription offer codes: apple subscriptions.
|
|
// Pure parts and sources: cli/lib/offers.ts.
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { sellsOn } from '../lib/config';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { iapCustomCodeReq, iapOfferCodeBody, iapOneTimeCodesReq, type TerritoryPoint, validateIapOfferCodes } from '../lib/offers';
|
|
import { applePoints, pickPoint } from '../lib/subscriptions';
|
|
import { pages } from '../prices';
|
|
import { ascClient, type J } from './asc';
|
|
import { describeReq, execute, type Req } from './jsonapi';
|
|
import { findOrCreate, getById } from '../lib/upsert';
|
|
|
|
const TAG = 'offer-codes';
|
|
|
|
/** Differences between an existing offer code campaign and the config (Apple does not allow editing a campaign). Pure. */
|
|
export function campaignDiff(cur: { eligibility: string[]; prices: { territory: string; pointId?: string }[] },
|
|
want: { eligibility: string[]; free: boolean; prices: { territory: string; pointId?: string }[] }): string[] {
|
|
const out: string[] = [];
|
|
const set = (x: string[]) => [...new Set(x)].sort();
|
|
const a = set(cur.eligibility), b = set(want.eligibility);
|
|
if (a.join() !== b.join()) out.push(`eligibility ${a.join('+') || '(none)'} -> ${b.join('+') || '(none)'}`);
|
|
const curFree = cur.prices.length > 0 && cur.prices.every(x => !x.pointId);
|
|
if (cur.prices.length && curFree !== want.free) out.push(`mode ${curFree ? 'free' : 'paid'} -> ${want.free ? 'free' : 'paid'}`);
|
|
const ct = set(cur.prices.map(x => x.territory)), wt = set(want.prices.map(x => x.territory));
|
|
const gone = ct.filter(t => !wt.includes(t)), added = wt.filter(t => !ct.includes(t));
|
|
if (gone.length || added.length) out.push(`territories ${added.length ? `+${added.length} (${added.slice(0, 5).join(',')}${added.length > 5 ? ',...' : ''})` : ''}${gone.length ? ` -${gone.length} (${gone.slice(0, 5).join(',')}${gone.length > 5 ? ',...' : ''})` : ''}`.replace('territories ', 'territories '));
|
|
if (!want.free && !curFree) {
|
|
const have = new Map(cur.prices.map(x => [x.territory, x.pointId] as const));
|
|
const diff = want.prices.filter(x => have.has(x.territory) && have.get(x.territory) !== x.pointId).map(x => x.territory);
|
|
if (diff.length) out.push(`prices differ in ${diff.length} territor${diff.length === 1 ? 'y' : 'ies'} (${diff.slice(0, 5).join(',')}${diff.length > 5 ? ',...' : ''})`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root, resolve } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const dry = a.has('dry');
|
|
const only = a.list('only');
|
|
const products = (config.products ?? []).filter(p => p.offerCodes?.length && sellsOn(config, p, 'apple') && (!only.length || only.includes(p.id)));
|
|
if (!products.length) throw new Error('no products with offerCodes for the App Store (products[].offerCodes in storebound.config.ts)');
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const problems = products.flatMap(p => validateIapOfferCodes(p.id, p.offerCodes, p.type, today));
|
|
if (problems.length) throw new Error(problems.join('; '));
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const report: Record<string, unknown>[] = [];
|
|
const codeReqs: { req: Req; product: string; campaign: string; oneTime: boolean }[] = [];
|
|
let active = 0;
|
|
|
|
for (const p of products) {
|
|
const iap = (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data?.[0];
|
|
if (!iap) { warn(TAG, `${p.id}: not on App Store Connect (storebound apple iap)`); report.push({ product: p.id, missing: true }); continue; }
|
|
const existing = await c.all(`/v2/inAppPurchases/${iap.id}/offerCodes?include=customCodes,oneTimeUseCodes&limit=200`);
|
|
active += existing.filter((x: J) => x.attributes.active).length;
|
|
let usaPoints: ReturnType<typeof applePoints> | null = null;
|
|
const prices = async (oc: NonNullable<typeof p.offerCodes>[number]): Promise<TerritoryPoint[]> => {
|
|
if (oc.free) {
|
|
const all = oc.territories ?? (await c.all('/v1/territories?limit=200')).map((t: J) => t.id as string);
|
|
return all.map(territory => ({ territory }));
|
|
}
|
|
usaPoints ??= applePoints(await pages(c, `/v2/inAppPurchases/${iap.id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
|
|
const usa = pickPoint(usaPoints, 'USA', oc.usd!);
|
|
const eq = applePoints(await pages(c, `/v1/inAppPurchasePricePoints/${usa.id}/equalizations?include=territory&limit=200`));
|
|
const all = [{ territory: 'USA', pointId: usa.id }, ...eq.filter(x => x.territory !== 'USA').map(x => ({ territory: x.territory, pointId: x.id }))];
|
|
return oc.territories ? all.filter(x => oc.territories!.includes(x.territory)) : all;
|
|
};
|
|
for (const oc of p.offerCodes!) {
|
|
const cur = existing.find((x: J) => x.attributes.name === oc.name);
|
|
let id: string | undefined = cur?.id;
|
|
if (!cur) {
|
|
const body = iapOfferCodeBody(iap.id, oc, await prices(oc));
|
|
const req: Req = { method: 'POST', path: '/v1/inAppPurchaseOfferCodes', body, note: `${p.id}: campaign "${oc.name}" (${oc.free ? 'free' : `USD ${oc.usd}`}, ${body.included.length} territories)` };
|
|
if (dry) log(TAG, `would ${describeReq(req)}`);
|
|
else {
|
|
// campaign names are not known to be unique: looked up right before the create, never sent twice
|
|
const made = await findOrCreate<J>({ what: `${p.id} campaign "${oc.name}"`, tag: TAG,
|
|
find: async () => (await c.all(`/v2/inAppPurchases/${iap.id}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name) ?? null,
|
|
journal: { root, key: `apple:${appId}:iapOfferCode:${p.id}:${oc.name}`, byId: x => getById(c.asc, `/v1/inAppPurchaseOfferCodes/${x}`) as Promise<J | null>, idOf: v => v.id },
|
|
create: async () => (await c.asc('POST', req.path, req.body)).data });
|
|
id = made.value.id;
|
|
log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`);
|
|
if (made.created) active++;
|
|
}
|
|
} else {
|
|
log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
|
|
const curPrices = (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/prices?include=territory,pricePoint&limit=200`))
|
|
.map((x: J) => ({ territory: x.relationships?.territory?.data?.id as string, pointId: x.relationships?.pricePoint?.data?.id as string | undefined })).filter((x: { territory?: string }) => !!x.territory);
|
|
const diffs = campaignDiff({ eligibility: cur.attributes.customerEligibilities ?? [], prices: curPrices }, { eligibility: oc.eligibility, free: !!oc.free, prices: await prices(oc) });
|
|
if (diffs.length) warn(TAG, `${p.id}: campaign ${oc.name} differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new campaign with another name)`);
|
|
}
|
|
const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []);
|
|
for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false });
|
|
// one-time batches are not matched one by one: a batch is created when the campaign has fewer batches than configured
|
|
const batches = cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/oneTimeUseCodes?limit=200`)).length : 0;
|
|
for (const b of (oc.oneTimeCodes ?? []).slice(batches)) codeReqs.push({ req: iapOneTimeCodesReq(id ?? '${new campaign}', b), product: p.id, campaign: oc.name, oneTime: true });
|
|
report.push({ product: p.id, campaign: oc.name, exists: !!cur, codesToCreate: codeReqs.filter(x => x.campaign === oc.name && x.product === p.id).map(x => x.req.note) });
|
|
}
|
|
}
|
|
if (active > 10) warn(TAG, `${active} active offer code campaigns: App Store Connect allows 10 active offers per app at a time`);
|
|
if (a.has('json')) printJson({ dryRun: dry, campaigns: report, codes: codeReqs.map(x => describeReq(x.req)) });
|
|
if (!codeReqs.length) { done(TAG, dry ? 'offer codes checked (dry run)' : 'offer codes done'); return; }
|
|
if (!a.has('codes')) { for (const x of codeReqs) log(TAG, `not created without --codes: ${x.req.note}`); done(TAG, `${codeReqs.length} code request(s) left; run with --codes (asks first)`); return; }
|
|
if (dry) { await execute(c.asc, codeReqs.map(x => x.req), { dry, tag: TAG }); done(TAG, 'dry run: no codes created'); return; }
|
|
if (!(await confirm(ctx, `Create ${codeReqs.length} code request(s) (${codeReqs.map(x => x.req.note).join('; ')})? Codes count against the 1 million per quarter and can be redeemed by anyone who gets them.`))) return;
|
|
const dir = resolve('.storebound/offer-codes');
|
|
for (const x of codeReqs) {
|
|
const out = (await execute(c.asc, [x.req], { dry: false, tag: TAG }))[0] as J | null;
|
|
if (!x.oneTime || !out) continue;
|
|
// the codes of a one-time-use batch (text/csv); saved locally, never printed
|
|
const values = await c.bytes(`/v1/inAppPurchaseOfferCodeOneTimeUseCodes/${out.data.id}/values`, 'text/csv');
|
|
if (!values) { warn(TAG, `${x.product} "${x.campaign}": codes not ready yet; download them in App Store Connect`); continue; }
|
|
mkdirSync(dir, { recursive: true });
|
|
const file = join(dir, `${x.product}-${x.campaign.replace(/[^A-Za-z0-9._-]+/g, '_')}-${out.data.id}.csv`);
|
|
writeFileSync(file, values);
|
|
log(TAG, `${x.product} "${x.campaign}": one-time codes saved to ${file}`);
|
|
}
|
|
done(TAG, `${codeReqs.length} code request(s) done`);
|
|
}
|