storebound/cli/lib/offers.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

301 lines
22 KiB
TypeScript

// App Store offers beyond introductory / promotional offers, as pure parts: offer codes for one-time purchases,
// win-back offers for subscriptions, promoted in-app purchases and app pre-orders. The commands (cli/apple/offers.ts)
// read, plan, print the dry run and send; everything that becomes visible to customers asks first and none of it is
// an MCP tool.
// Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026):
// In-app purchase offer codes: POST /v1/inAppPurchaseOfferCodes (name, customerEligibilities NON_SPENDER |
// ACTIVE_SPENDER | CHURNED_SPENDER, inAppPurchase, prices = inAppPurchaseOfferPrices: territory + pricePoint),
// POST /v1/inAppPurchaseOfferCodeCustomCodes (customCode, numberOfCodes, expirationDate?),
// POST /v1/inAppPurchaseOfferCodeOneTimeUseCodes (numberOfCodes, expirationDate, environment PRODUCTION | SANDBOX),
// GET /v1/inAppPurchaseOfferCodeOneTimeUseCodes/{id}/values (the codes), GET /v2/inAppPurchases/{id}/offerCodes.
// Help "Create offer codes for In-App Purchases": up to 10 active offers per app, 1 million codes per app per
// quarter, one-time-use batches of 500-25,000 valid at most 6 months, custom codes up to 64 characters without
// special characters and up to 25,000 redemptions, paid or free offers. Consumables, non-consumables and
// non-renewing subscriptions (iOS 16.3+).
// https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/create-offer-codes-for-in-app-purchases
// Win-back offers: POST /v1/winBackOffers (referenceName, offerId, duration, offerMode, periodCount,
// customerEligibilityPaidSubscriptionDurationInMonths, customerEligibilityTimeSinceLastSubscribedInMonths {minimum,
// maximum}, customerEligibilityWaitBetweenOffersInMonths?, startDate, endDate?, priority HIGH | NORMAL,
// promotionIntent NOT_PROMOTED | USE_AUTO_GENERATED_ASSETS; subscription; prices = winBackOfferPrices with a
// subscriptionPricePoint), GET /v1/subscriptions/{id}/winBackOffers.
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-winbackoffers
// Help "Set up win-back offers": the subscription must be approved first; paid duration 1-24 months or 3, 4, 5
// years; time since last subscribed 1-24 months (min and max required); wait between offers 2-24 months; start at
// the earliest tomorrow, run at least 3 days; at most 350 per subscription and 5 at a time per storefront; reference
// name, offer id and price cannot be changed later; an offer id must not be reused in the group.
// https://developer.apple.com/help/app-store-connect/manage-subscriptions/set-up-win-back-offers
// Promoted purchases: POST /v1/promotedPurchases (visibleForAllUsers, enabled?, app, inAppPurchaseV2 | subscription),
// PATCH /v1/promotedPurchases/{id} (visibleForAllUsers, enabled), GET /v1/apps/{id}/promotedPurchases,
// PATCH /v1/apps/{id}/relationships/promotedPurchases (the order). Help "Promote In-App Purchases": up to 20
// promoted at a time, each needs its promotional image, the app must handle purchases started on the App Store
// (PurchaseIntent). https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/promote-in-app-purchases
// Pre-orders: GET /v1/apps/{id}/appAvailabilityV2, GET /v2/appAvailabilities/{id}/territoryAvailabilities
// (available, releaseDate, preOrderEnabled, preOrderPublishDate, contentStatuses), PATCH
// /v1/territoryAvailabilities/{id} (available, releaseDate, preOrderEnabled), POST /v1/endAppAvailabilityPreOrders
// ("End the pre-order for your app and release to store immediately"). Help "Publish for pre-order": a version
// must be approved first; release date 2-180 days ahead (Apple's pre-orders page: up to 365 days for an existing
// app in a new region); not possible where the app is already released.
// https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/publish-for-pre-order
// Google Play pre-registration has no API (androidpublisher v3 lists no such resource): Play Console only.
import type { Req } from '../apple/jsonapi';
import { many, one } from '../apple/jsonapi';
import { APPLE_OFFER_DURATION, APPLE_OFFER_MODE } from './subscriptions';
// ---- config ----
/** Offer code campaign for a one-time purchase (consumable, non-consumable, non-renewing). */
export interface IapOfferCodeDef {
/** Reference name of the campaign (unique per product). */
name: string;
/** NON_SPENDER = never purchased in the app, ACTIVE_SPENDER = purchased in the last 30 days, CHURNED_SPENDER =
* purchased more than 30 days ago. All three = everyone. */
eligibility: ('NON_SPENDER' | 'ACTIVE_SPENDER' | 'CHURNED_SPENDER')[];
/** Free offer (no price) or a paid one in USD (the USA price point and its equalizations). */
free?: boolean;
usd?: number;
/** Limit the offer to these App Store territories (alpha-3). Default: every territory. */
territories?: string[];
/** Custom codes (anyone with the code; created only from a terminal run with --codes that confirms). */
customCodes?: { code: string; count: number; expires?: string }[];
/** One-time-use code batches (500-25,000, expiry at most 6 months; --codes, confirms; the codes are saved to
* .storebound/offer-codes/). */
oneTimeCodes?: { count: number; expires: string; sandbox?: boolean }[];
}
/** Win-back offer for lapsed subscribers of one subscription. */
export interface WinBackDef {
/** Offer identifier (unique in the subscription group, never reused; cannot change later). */
id: string;
/** Reference name (default: the id; cannot change later). */
name?: string;
type: 'free' | 'pay-as-you-go' | 'pay-up-front';
/** free / pay-up-front: P3D ... P1Y; pay-as-you-go: the subscription period (default). */
duration?: string;
/** pay-as-you-go: number of periods (default 1). */
periods?: number;
/** USD price (per period for pay-as-you-go, total for pay-up-front); fixed after creation. */
usd?: number;
/** Customers who paid at least this many months: 1-24, 36, 48 or 60. */
paidMonths: number;
/** Lapsed between min and max months ago (1-24). */
lapsedMonths: { min: number; max: number };
/** Months between two win-back offers for one customer (2-24, optional). */
waitMonths?: number;
/** YYYY-MM-DD, at the earliest tomorrow. */
start: string;
/** YYYY-MM-DD, at least 3 days after start (optional). */
end?: string;
priority?: 'high' | 'normal';
/** Promote on the App Store with auto-generated assets (needs an approved subscription image). */
promote?: boolean;
}
export interface PromotedConfig {
/** Product / subscription ids in display order (at most 20). */
products: string[];
/** Shown to every user (true) or only to users your app makes it visible for (false). Default true. */
visibleForAllUsers?: boolean;
}
const DAY = 86_400_000;
const isDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
const ELIG = ['NON_SPENDER', 'ACTIVE_SPENDER', 'CHURNED_SPENDER'];
const PAID_MONTHS = new Set([...Array.from({ length: 24 }, (_, i) => i + 1), 36, 48, 60]);
/** Problems with products[].offerCodes. With `today` (when codes are about to be created) also the 6-month expiry
* rule; the config check leaves dates relative to today out, so an old campaign never breaks the config. Pure. */
export function validateIapOfferCodes(at: string, codes: IapOfferCodeDef[] | undefined, type: string, today?: string): string[] {
const e: string[] = [];
const names = new Set<string>();
const max = today ? new Date(Date.parse(today) + 183 * DAY).toISOString().slice(0, 10) : '9999-12-31';
for (const [i, c] of (codes ?? []).entries()) {
const p = `${at}.offerCodes[${i}]`;
if (!c.name || [...c.name].length > 64) e.push(`${p}.name: 1-64 characters`);
if (names.has(c.name)) e.push(`${p}.name: duplicate "${c.name}"`);
names.add(c.name);
if (!Array.isArray(c.eligibility) || !c.eligibility.length || !c.eligibility.every(x => ELIG.includes(x))) e.push(`${p}.eligibility: some of ${ELIG.join(', ')}`);
if (!!c.free === (c.usd !== undefined)) e.push(`${p}: free: true or usd (one of them)`);
if (c.usd !== undefined && !(c.usd > 0)) e.push(`${p}.usd: positive`);
if (c.territories && !c.territories.every(t => /^[A-Z]{3}$/.test(t))) e.push(`${p}.territories: App Store territories, alpha-3 (USA)`);
for (const [k, cc] of (c.customCodes ?? []).entries()) {
if (!/^[A-Za-z0-9]{1,64}$/.test(cc.code ?? '')) e.push(`${p}.customCodes[${k}].code: letters and digits, up to 64`);
if (!(Number.isInteger(cc.count) && cc.count >= 1 && cc.count <= 25000)) e.push(`${p}.customCodes[${k}].count: 1-25000`);
if (cc.expires !== undefined && (!isDate(cc.expires) || cc.expires > max)) e.push(`${p}.customCodes[${k}].expires: YYYY-MM-DD within 6 months`);
}
for (const [k, b] of (c.oneTimeCodes ?? []).entries()) {
const lo = b.sandbox ? 10 : 500, hi = b.sandbox ? 10000 : 25000;
if (!(Number.isInteger(b.count) && b.count >= lo && b.count <= hi)) e.push(`${p}.oneTimeCodes[${k}].count: ${lo}-${hi}`);
if (!isDate(b.expires) || b.expires > max) e.push(`${p}.oneTimeCodes[${k}].expires: YYYY-MM-DD within 6 months (required)`);
}
}
if ((codes ?? []).length && !['consumable', 'non_consumable', 'non_renewing'].includes(type)) e.push(`${at}.offerCodes: one-time purchases only`);
return e;
}
/** Problems with subscriptions[].winBack. With `today` (right before creating) also "start at the earliest
* tomorrow". Pure. */
export function validateWinBack(at: string, offers: WinBackDef[] | undefined, today?: string): string[] {
const e: string[] = [];
const ids = new Set<string>();
for (const [i, o] of (offers ?? []).entries()) {
const p = `${at}.winBack[${i}]`;
if (!/^[A-Za-z0-9._-]{1,64}$/.test(o.id ?? '')) e.push(`${p}.id: letters, digits, ".", "_", "-"`);
if (ids.has(o.id)) e.push(`${p}.id: duplicate "${o.id}"`);
ids.add(o.id);
if (!(o.type in APPLE_OFFER_MODE)) e.push(`${p}.type: free | pay-as-you-go | pay-up-front`);
if (o.type !== 'pay-as-you-go' && !(o.duration && o.duration in APPLE_OFFER_DURATION)) e.push(`${p}.duration: ${Object.keys(APPLE_OFFER_DURATION).join(', ')}`);
if (o.type !== 'free' && !(typeof o.usd === 'number' && o.usd > 0)) e.push(`${p}.usd: positive (a paid offer)`);
if (o.type === 'free' && o.usd !== undefined) e.push(`${p}.usd: not for a free offer`);
if (o.periods !== undefined && !(Number.isInteger(o.periods) && o.periods >= 1 && o.periods <= 12)) e.push(`${p}.periods: 1-12`);
if (!PAID_MONTHS.has(o.paidMonths)) e.push(`${p}.paidMonths: 1-24, 36, 48 or 60`);
const l = o.lapsedMonths;
if (!l || !(Number.isInteger(l.min) && Number.isInteger(l.max) && l.min >= 1 && l.max <= 24 && l.min <= l.max)) e.push(`${p}.lapsedMonths: { min, max } within 1-24`);
if (o.waitMonths !== undefined && !(Number.isInteger(o.waitMonths) && o.waitMonths >= 2 && o.waitMonths <= 24)) e.push(`${p}.waitMonths: 2-24`);
if (!isDate(o.start)) e.push(`${p}.start: YYYY-MM-DD`);
else if (today && o.start <= today) e.push(`${p}.start: at the earliest tomorrow`);
if (o.end !== undefined && (!isDate(o.end) || (isDate(o.start) && Date.parse(o.end) - Date.parse(o.start) < 3 * DAY))) e.push(`${p}.end: YYYY-MM-DD, at least 3 days after start`);
if (o.priority !== undefined && o.priority !== 'high' && o.priority !== 'normal') e.push(`${p}.priority: high | normal`);
}
return e;
}
export function validatePromoted(p: PromotedConfig | undefined, ids: Set<string>): string[] {
if (!p) return [];
const e: string[] = [];
if (!Array.isArray(p.products) || !p.products.length) e.push('apple.promoted.products: at least one product id');
if ((p.products ?? []).length > 20) e.push('apple.promoted.products: at most 20 (App Store limit)');
for (const id of p.products ?? []) if (!ids.has(id)) e.push(`apple.promoted.products: "${id}" is not a product or subscription of the config`);
if (new Set(p.products).size !== (p.products ?? []).length) e.push('apple.promoted.products: duplicates');
return e;
}
// ---- request bodies ----
export interface TerritoryPoint { territory: string; pointId?: string }
/** POST /v1/inAppPurchaseOfferCodes. A free offer sends the territories without a price point (the schema marks the
* price point optional; not verified against a live account). */
export function iapOfferCodeBody(iapId: string, c: IapOfferCodeDef, prices: TerritoryPoint[]) {
const ids = prices.map((_, i) => `\${iapcode-${i}}`);
return {
data: { type: 'inAppPurchaseOfferCodes', attributes: { name: c.name, customerEligibilities: c.eligibility },
relationships: { inAppPurchase: one('inAppPurchases', iapId), prices: { data: ids.map(id => ({ type: 'inAppPurchaseOfferPrices', id })) } } },
included: prices.map((p, i) => {
if (!c.free && !p.pointId) throw new Error(`${c.name}: paid offer in ${p.territory} needs a price point`);
return { type: 'inAppPurchaseOfferPrices', id: ids[i], relationships: { territory: one('territories', p.territory), ...(p.pointId ? { pricePoint: one('inAppPurchasePricePoints', p.pointId) } : {}) } };
}),
};
}
export const iapCustomCodeReq = (offerCodeId: string, cc: { code: string; count: number; expires?: string }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeCustomCodes',
note: `custom code ${cc.code} (${cc.count} redemptions${cc.expires ? `, until ${cc.expires}` : ''})`,
body: { data: { type: 'inAppPurchaseOfferCodeCustomCodes', attributes: { customCode: cc.code, numberOfCodes: cc.count, ...(cc.expires ? { expirationDate: cc.expires } : {}) },
relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } });
export const iapOneTimeCodesReq = (offerCodeId: string, b: { count: number; expires: string; sandbox?: boolean }): Req => ({ method: 'POST', path: '/v1/inAppPurchaseOfferCodeOneTimeUseCodes',
note: `${b.count} one-time-use ${b.sandbox ? 'sandbox ' : ''}codes until ${b.expires}`,
body: { data: { type: 'inAppPurchaseOfferCodeOneTimeUseCodes', attributes: { numberOfCodes: b.count, expirationDate: b.expires, environment: b.sandbox ? 'SANDBOX' : 'PRODUCTION' },
relationships: { offerCode: one('inAppPurchaseOfferCodes', offerCodeId) } } } });
/** POST /v1/winBackOffers. `subPeriod` = the subscription's period (pay-as-you-go duration). Paid prices: one
* subscriptionPricePoint per territory (the point carries its territory, as in Apple's example request). A free offer
* sends the territories without a price point (not verified against a live account). */
export function winBackBody(subId: string, subPeriod: string, o: WinBackDef, prices: TerritoryPoint[]) {
const ids = prices.map((_, i) => `\${winback-${i}}`);
const duration = o.type === 'pay-as-you-go' ? (o.duration ?? subPeriod) : o.duration!;
return {
data: { type: 'winBackOffers', attributes: {
referenceName: o.name ?? o.id, offerId: o.id, duration: APPLE_OFFER_DURATION[duration], offerMode: APPLE_OFFER_MODE[o.type],
periodCount: o.type === 'pay-as-you-go' ? (o.periods ?? 1) : 1,
customerEligibilityPaidSubscriptionDurationInMonths: o.paidMonths,
customerEligibilityTimeSinceLastSubscribedInMonths: { minimum: o.lapsedMonths.min, maximum: o.lapsedMonths.max },
...(o.waitMonths !== undefined ? { customerEligibilityWaitBetweenOffersInMonths: o.waitMonths } : {}),
startDate: o.start, ...(o.end ? { endDate: o.end } : {}), priority: (o.priority ?? 'normal').toUpperCase(),
promotionIntent: o.promote ? 'USE_AUTO_GENERATED_ASSETS' : 'NOT_PROMOTED',
}, relationships: { subscription: one('subscriptions', subId), prices: { data: ids.map(id => ({ type: 'winBackOfferPrices', id })) } } },
included: prices.map((p, i) => {
if (o.type !== 'free' && !p.pointId) throw new Error(`${o.id}: paid win-back offer in ${p.territory} needs a price point`);
return { type: 'winBackOfferPrices', id: ids[i], relationships: p.pointId ? { subscriptionPricePoint: one('subscriptionPricePoints', p.pointId) } : { territory: one('territories', p.territory) } };
}),
};
}
// ---- promoted purchases ----
export interface PromotedNow { id: string; productId: string; enabled: boolean; visibleForAllUsers: boolean; state?: string }
export interface PromotedPlan { create: Req[]; update: Req[]; order: Req | null; unknown: string[]; extra: string[] }
/** Wanted promoted purchases vs the current ones. New ones are created disabled unless enable; `enable` also turns on
* existing ones. The order request lists the configured ones first, then the others as they are. Pure. */
export function planPromoted(appId: string, cfg: PromotedConfig, now: PromotedNow[], products: Map<string, { apiId: string; kind: 'iap' | 'sub' }>, o: { enable?: boolean } = {}): PromotedPlan {
const plan: PromotedPlan = { create: [], update: [], order: null, unknown: [], extra: [] };
const visible = cfg.visibleForAllUsers ?? true;
const have = new Map(now.map(p => [p.productId, p] as const));
for (const id of cfg.products) {
const cur = have.get(id);
if (cur) {
const attrs: Record<string, boolean> = {};
if (cur.visibleForAllUsers !== visible) attrs.visibleForAllUsers = visible;
if (o.enable && !cur.enabled) attrs.enabled = true;
if (Object.keys(attrs).length) plan.update.push({ method: 'PATCH', path: `/v1/promotedPurchases/${cur.id}`, note: `${id}: ${Object.entries(attrs).map(([k, v]) => `${k}=${v}`).join(', ')}`,
body: { data: { type: 'promotedPurchases', id: cur.id, attributes: attrs } } });
continue;
}
const p = products.get(id);
if (!p) { plan.unknown.push(id); continue; }
plan.create.push({ method: 'POST', path: '/v1/promotedPurchases', note: `promote ${id}${o.enable ? ' (enabled)' : ' (not enabled yet)'}`,
body: { data: { type: 'promotedPurchases', attributes: { visibleForAllUsers: visible, enabled: !!o.enable },
relationships: { app: one('apps', appId), ...(p.kind === 'iap' ? { inAppPurchaseV2: one('inAppPurchases', p.apiId) } : { subscription: one('subscriptions', p.apiId) }) } } } });
}
plan.extra = now.filter(p => !cfg.products.includes(p.productId)).map(p => p.productId);
return plan;
}
/** PATCH /v1/apps/{id}/relationships/promotedPurchases: the display order (configured first, then the rest). */
export function promotedOrderReq(appId: string, cfg: PromotedConfig, now: PromotedNow[]): Req | null {
const byProduct = new Map(now.map(p => [p.productId, p.id] as const));
const ordered = [...cfg.products.map(id => byProduct.get(id)).filter((x): x is string => !!x), ...now.filter(p => !cfg.products.includes(p.productId)).map(p => p.id)];
if (!ordered.length || ordered.join() === now.map(p => p.id).join()) return null;
return { method: 'PATCH', path: `/v1/apps/${appId}/relationships/promotedPurchases`, note: 'promoted purchase order', body: many('promotedPurchases', ordered) };
}
// ---- pre-orders ----
export interface TerritoryAvail { id: string; territory: string; available?: boolean; releaseDate?: string | null; preOrderEnabled?: boolean; preOrderPublishDate?: string | null; contentStatuses?: string[] }
/** Territory availabilities response(s) (include=territory) -> rows. Pure. */
export function territoryRows(pages: { data?: { id: string; attributes?: Record<string, unknown>; relationships?: { territory?: { data?: { id: string } } } }[] }[]): TerritoryAvail[] {
return pages.flatMap(p => (p.data ?? []).map(t => ({ id: t.id, territory: t.relationships?.territory?.data?.id ?? '?', available: t.attributes?.available as boolean | undefined,
releaseDate: (t.attributes?.releaseDate as string | null | undefined) ?? null, preOrderEnabled: t.attributes?.preOrderEnabled as boolean | undefined,
preOrderPublishDate: (t.attributes?.preOrderPublishDate as string | null | undefined) ?? null, contentStatuses: t.attributes?.contentStatuses as string[] | undefined })));
}
/** PATCH requests that put the chosen territories on pre-order with this release date. Territories where the app is
* already out (AVAILABLE) cannot go on pre-order. Pure. */
export function planPreOrder(rows: TerritoryAvail[], releaseDate: string, territories: string[] | null, today = new Date().toISOString().slice(0, 10)): { reqs: Req[]; problems: string[]; warnings: string[] } {
const problems: string[] = [], warnings: string[] = [];
if (!isDate(releaseDate)) problems.push('--release-date: YYYY-MM-DD');
const days = (Date.parse(releaseDate) - Date.parse(today)) / DAY;
if (isDate(releaseDate) && days < 2) problems.push('--release-date: at least 2 days ahead');
if (isDate(releaseDate) && days > 365) problems.push('--release-date: at most 365 days ahead (180 for a new app)');
else if (isDate(releaseDate) && days > 180) warnings.push('more than 180 days ahead: only allowed for an existing app in a new region');
const pick = territories ? rows.filter(r => territories.includes(r.territory)) : rows;
if (territories) for (const t of territories) if (!rows.some(r => r.territory === t)) problems.push(`territory ${t}: not in the app's availability`);
const reqs: Req[] = [];
for (const r of pick) {
if (r.contentStatuses?.includes('AVAILABLE')) { warnings.push(`${r.territory}: already released there, no pre-order possible`); continue; }
if (r.preOrderEnabled && r.releaseDate === releaseDate && r.available) continue;
reqs.push({ method: 'PATCH', path: `/v1/territoryAvailabilities/${r.id}`, note: `${r.territory}: pre-order, release ${releaseDate}`,
body: { data: { type: 'territoryAvailabilities', id: r.id, attributes: { available: true, preOrderEnabled: true, releaseDate } } } });
}
return { reqs, problems, warnings };
}
/** POST /v1/endAppAvailabilityPreOrders: end the pre-order in these territories and release there now. */
export function endPreOrderReq(rows: TerritoryAvail[], territories: string[] | null): Req | null {
const pick = rows.filter(r => r.preOrderEnabled && (!territories || territories.includes(r.territory)));
if (!pick.length) return null;
return { method: 'POST', path: '/v1/endAppAvailabilityPreOrders', note: `end the pre-order and release now in ${pick.map(r => r.territory).join(', ')}`,
body: { data: { type: 'endAppAvailabilityPreOrders', relationships: { territoryAvailabilities: many('territoryAvailabilities', pick.map(r => r.id)) } } } };
}