diff --git a/cli/lib/onboard.ts b/cli/lib/onboard.ts index 6ef8a47..c233360 100644 --- a/cli/lib/onboard.ts +++ b/cli/lib/onboard.ts @@ -55,6 +55,8 @@ export function importCatalog(config: Pick g.subscriptions.map(x => [x.id, { group: g, sub: x }] as const))); const ids = [...new Set([...A.keys(), ...P.keys()])]; const groups = new Map(); + /** App Store groupLevel per subscription id (the only store that proves a level) */ + const appleLevel = new Map(); for (const id of ids) { const a = A.get(id), p = P.get(id); @@ -100,6 +102,7 @@ export function importCatalog(config: Pick = { 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): 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 }); diff --git a/tests/onboard.test.ts b/tests/onboard.test.ts index 70c2d1e..9879923 100644 --- a/tests/onboard.test.ts +++ b/tests/onboard.test.ts @@ -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; draft?: boolean } = {}): Call[] { +function liveStores(o: { review?: Record; 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();