// 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 { 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[] = []; 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 | null = null; const prices = async (oc: NonNullable[number]): Promise => { 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({ 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, 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`); }