onboard never writes duplicate subscription levels: the App Store groupLevel when it is set and unique in the group, else a stable order (App Store level, higher price per month = higher level, longer period, id) with a note per derived level; assignLevels in cli/lib/onboard.ts, tests with a live-shape App Store group (groupLevel 2 and 1) and Play-only / tied cases
This commit is contained in:
parent
346bf61166
commit
5ad3c0002d
2 changed files with 80 additions and 1 deletions
|
|
@ -55,6 +55,8 @@ export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products'
|
|||
const oldSubs = new Map((config.subscriptions ?? []).flatMap(g => g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const)));
|
||||
const ids = [...new Set([...A.keys(), ...P.keys()])];
|
||||
const groups = new Map<string, SubscriptionGroup>();
|
||||
/** App Store groupLevel per subscription id (the only store that proves a level) */
|
||||
const appleLevel = new Map<string, number>();
|
||||
|
||||
for (const id of ids) {
|
||||
const a = A.get(id), p = P.get(id);
|
||||
|
|
@ -100,6 +102,7 @@ export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products'
|
|||
if (!period || !PERIODS.includes(period)) { out.skipped.push({ id, why: `billing period ${period ?? 'unknown'} is not one the config supports (${PERIODS.join(', ')})` }); continue; }
|
||||
if (a?.period && p?.period && a.period !== p.period) note(id, `bills every ${a.period} on the App Store, ${p.period} on Google Play: period follows the App Store`, 'warn');
|
||||
const prev = oldSubs.get(id);
|
||||
if (typeof a?.level === 'number') appleLevel.set(id, a.level);
|
||||
const sub: Subscription = { ...(prev?.sub ?? {}), id, period, level: a?.level ?? prev?.sub.level ?? 1, usd: cents(usd), listings, ...platforms,
|
||||
...(overrides.length || prev?.sub.overrides ? { overrides: mergeOverrides(prev?.sub.overrides, overrides) } : {}) } as Subscription;
|
||||
if (!both || (a && p)) delete sub.platforms;
|
||||
|
|
@ -135,9 +138,39 @@ export function importCatalog(config: Pick<ShipConfig, 'languages' | 'products'
|
|||
note(x.id, 'in the config, on no store yet: kept');
|
||||
}
|
||||
out.subscriptions = [...groups.values()];
|
||||
for (const g of out.subscriptions) for (const n of assignLevels(g, appleLevel)) note(n.id, n.msg, n.level);
|
||||
return out;
|
||||
}
|
||||
|
||||
const PERIOD_MONTHS: Record<SubscriptionPeriod, number> = { P1W: 12 / 52, P1M: 1, P2M: 2, P3M: 3, P6M: 6, P1Y: 12 };
|
||||
|
||||
/**
|
||||
* Levels inside one group (1 = highest level of service), never duplicated (config validation refuses duplicates).
|
||||
* The App Store's groupLevel when it proves every level in the group without ties; otherwise a stable order: App
|
||||
* Store level first, then the higher price per month, then the longer period, then the id. Mutates the group.
|
||||
*/
|
||||
export function assignLevels(g: SubscriptionGroup, appleLevel: Map<string, number>): ImportNote[] {
|
||||
const subs = g.subscriptions;
|
||||
const levels = subs.map(x => appleLevel.get(x.id));
|
||||
if (levels.every(l => l !== undefined) && new Set(levels).size === subs.length) {
|
||||
for (const x of subs) x.level = appleLevel.get(x.id)!;
|
||||
return [];
|
||||
}
|
||||
const monthly = (x: Subscription) => x.usd / PERIOD_MONTHS[x.period];
|
||||
const order = [...subs].sort((x, y) => (appleLevel.get(x.id) ?? Infinity) - (appleLevel.get(y.id) ?? Infinity)
|
||||
|| monthly(y) - monthly(x) || PERIOD_MONTHS[y.period] - PERIOD_MONTHS[x.period] || x.id.localeCompare(y.id));
|
||||
const notes: ImportNote[] = [];
|
||||
order.forEach((x, i) => {
|
||||
const al = appleLevel.get(x.id);
|
||||
const how = al === undefined
|
||||
? 'no App Store groupLevel (Google Play has no levels): ordered by price per month (higher price = higher level, level 1 the highest), then the longer period'
|
||||
: `App Store groupLevel ${al} is shared with another subscription in the group: ties ordered by price per month (higher first), then the longer period`;
|
||||
if (al !== i + 1) notes.push({ id: x.id, level: 'warn', msg: `group "${g.name}": level ${i + 1}, ${how}; check it in App Store Connect` });
|
||||
x.level = i + 1;
|
||||
});
|
||||
return notes;
|
||||
}
|
||||
|
||||
function mergeOverrides(prev: { region: string; currency: string; price: number; territory?: string }[] | undefined, add: { region: string; currency: string; price: number }[]) {
|
||||
const m = new Map((prev ?? []).map(o => [o.region, o] as const));
|
||||
for (const o of add) m.set(o.region, { ...m.get(o.region), ...o });
|
||||
|
|
|
|||
|
|
@ -27,9 +27,23 @@ const money = (currencyCode: string, units: string, nanos = 0) => ({ currencyCod
|
|||
|
||||
/** App Store Connect + Google Play as they answered for a live app (sanitized): Pro has its price schedule's base
|
||||
* territory in Turkey, Tips is on the App Store only, Plus monthly is a Play-only subscription. */
|
||||
function liveStores(o: { review?: Record<string, unknown>; draft?: boolean } = {}): Call[] {
|
||||
function liveStores(o: { review?: Record<string, unknown>; draft?: boolean; subs?: boolean } = {}): Call[] {
|
||||
const P = '/androidpublisher/v3/applications/com.example.notes';
|
||||
// with `subs`: one App Store group "Premium" whose monthly (groupLevel 2) is listed before its yearly (groupLevel 1)
|
||||
const subPrice = (id: string, pt: string) => ({ type: 'subscriptionPrices', id, attributes: { startDate: null, preserved: false }, relationships: { subscriptionPricePoint: { data: { type: 'subscriptionPricePoints', id: pt } }, territory: { data: { type: 'territories', id: 'USA' } } } });
|
||||
const appleSubs = o.subs ? [
|
||||
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([{ type: 'subscriptionGroups', id: 'g1', attributes: { referenceName: 'Premium' } }]) },
|
||||
{ match: /^\/v1\/subscriptionGroups\/g1\/subscriptions/, answer: page([
|
||||
{ type: 'subscriptions', id: 's-m', attributes: { productId: 'com.example.notes.premium.monthly', name: 'Premium monthly', state: 'APPROVED', subscriptionPeriod: 'ONE_MONTH', groupLevel: 2, familySharable: false } },
|
||||
{ type: 'subscriptions', id: 's-y', attributes: { productId: 'com.example.notes.premium.yearly', name: 'Premium yearly', state: 'APPROVED', subscriptionPeriod: 'ONE_YEAR', groupLevel: 1, familySharable: false } }]) },
|
||||
{ match: /^\/v1\/subscriptions\/s-m\/subscriptionLocalizations/, answer: page([{ type: 'subscriptionLocalizations', id: 'sl1', attributes: { locale: 'en-US', name: 'Premium monthly', description: 'Everything, monthly' } }]) },
|
||||
{ match: /^\/v1\/subscriptions\/s-y\/subscriptionLocalizations/, answer: page([{ type: 'subscriptionLocalizations', id: 'sl2', attributes: { locale: 'en-US', name: 'Premium yearly', description: 'Everything, yearly' } }]) },
|
||||
{ match: /^\/v1\/subscriptions\/s-m\/prices/, answer: page([subPrice('sp1', 'spp-m')], [{ type: 'subscriptionPricePoints', id: 'spp-m', attributes: { customerPrice: '4.99', proceeds: '3.49' } }, terr('USA', 'USD')]) },
|
||||
{ match: /^\/v1\/subscriptions\/s-y\/prices/, answer: page([subPrice('sp2', 'spp-y')], [{ type: 'subscriptionPricePoints', id: 'spp-y', attributes: { customerPrice: '39.99', proceeds: '27.99' } }, terr('USA', 'USD')]) },
|
||||
{ match: /^\/v1\/subscriptions\/s-[my]\/(introductoryOffers|promotionalOffers|offerCodes)/, answer: page([]) },
|
||||
] : [];
|
||||
return mockFetch([
|
||||
...appleSubs,
|
||||
// ---- App Store Connect: catalog
|
||||
{ match: /^\/v1\/apps\/6400000101\/inAppPurchasesV2\?limit=200/, answer: page([
|
||||
{ type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'Pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'APPROVED' } },
|
||||
|
|
@ -164,6 +178,38 @@ describe('contact, sign-in, deletion page (pure)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('subscription levels', () => {
|
||||
test('App Store groupLevel is imported per subscription (live shape), the Play-only one gets the next free level', async () => {
|
||||
ascEnv(); playEnv();
|
||||
const p = liveProject();
|
||||
liveStores({ subs: true });
|
||||
const r = await capture(() => onboard(p.ctx(['--write'], ['write'])));
|
||||
expect(r.exit).toBeNull();
|
||||
const c = (await loadConfig(p.loaded.path)).config;
|
||||
const g = c.subscriptions!.find(x => x.name === 'Premium')!;
|
||||
expect(g.subscriptions.map(x => `${x.id} ${x.level}`)).toEqual(['com.example.notes.premium.monthly 2', 'com.example.notes.premium.yearly 1']);
|
||||
});
|
||||
test('Play-only and tied levels: price per month (higher = level 1), then the longer period; never duplicated, with a note', () => {
|
||||
const play: StoreItem[] = [
|
||||
{ id: 'a.basic', kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 2.99 }] },
|
||||
{ id: 'a.pro', kind: 'subscription', period: 'P1M', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 9.99 }] },
|
||||
{ id: 'a.pro.year', kind: 'subscription', period: 'P1Y', locales: ['en-US'], prices: [{ region: 'US', currency: 'USD', price: 119.88 }] },
|
||||
];
|
||||
const playTexts = Object.fromEntries(play.map(x => [x.id, { 'en-US': { title: x.id, description: 'd' } }]));
|
||||
const cfg = { ...BASE, apple: undefined, subscriptions: [{ name: 'Subscriptions', listings: { en: { name: 'Subscriptions' } }, subscriptions: [
|
||||
{ id: 'a.basic', period: 'P1M' as const, level: 1, usd: 2.99, listings: { en: { title: 'a.basic', description: 'd' } } },
|
||||
{ id: 'a.pro', period: 'P1M' as const, level: 1, usd: 9.99, listings: { en: { title: 'a.pro', description: 'd' } } }] }] };
|
||||
const r = importCatalog(cfg, { apple: null, play, playTexts });
|
||||
expect(r.subscriptions[0]!.subscriptions.map(x => `${x.id} ${x.level}`).sort()).toEqual(['a.basic 3', 'a.pro 2', 'a.pro.year 1']);
|
||||
expect(r.notes.filter(n => n.msg.includes('ordered by price per month')).length).toBe(3);
|
||||
// ties on the App Store are broken the same way
|
||||
const apple: StoreItem[] = play.map(x => ({ ...x, level: 1, group: 'Subscriptions' }));
|
||||
const t = importCatalog({ ...cfg, apple: BASE.apple, play: undefined }, { apple, play: null, appleTexts: playTexts });
|
||||
expect(t.subscriptions[0]!.subscriptions.map(x => `${x.id} ${x.level}`).sort()).toEqual(['a.basic 3', 'a.pro 2', 'a.pro.year 1']);
|
||||
expect(t.notes.some(n => n.msg.includes('groupLevel 1 is shared'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storebound onboard (recorded live shapes)', () => {
|
||||
test('--write: catalog with prices, contact, review contact and demo account (never printed), sign-in, deletion page; catalog diff clean', async () => {
|
||||
ascEnv(); playEnv();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue