/** Pure reading of Audible's account answer for credits. */ export type NextCharge = { /** Day of the next charge, `YYYY-MM-DD`. */ date: string /** Credits that charge adds. */ credits: number /** The charge, in cents. */ cents: number /** ISO currency of the charge, or null. */ currency: string | null } export type CreditsSummary = { /** Credits available to spend now, or null when Audible's answer holds no count. */ credits: number | null /** The plan's next charge and the credits it adds, or null without an active plan. */ nextCharge: NextCharge | null /** Key paths of Audible's answer, without values, to fix the readers above after a live check. */ fieldPaths: string[] /** Numbers under credit, price, amount, quantity, count, or balance keys, by key path, for the same purpose. */ numbers: Record } type Leaf = { path: string[]; value: unknown } function leaves(value: unknown, path: string[] = [], out: Leaf[] = []): Leaf[] { if (Array.isArray(value)) value.forEach((item) => leaves(item, [...path, '[]'], out)) else if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) leaves(v, [...path, k], out) else out.push({ path, value }) return out } function asNumber(value: unknown): number | null { const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN return Number.isFinite(n) ? n : null } const record = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} const lastKey = (leaf: Leaf) => leaf.path.filter((k) => k !== '[]').at(-1)?.toLowerCase() ?? '' /** A credit balance key: names credits and an available, balance, or remaining count. No live answer holds one yet. */ const BALANCE_KEY = /^(?=.*credit)(?=.*(available|balance|remaining)).*$/ const NUMBER_KEY = /credit|price|amount|quantity|count|balance/ /** * The next charge, from `customer_information`: the first subscription with a * next bill date and a positive amount, and the credits each period adds. */ function readNextCharge(body: unknown): NextCharge | null { const info = record(record(body).customer_information) const perPeriod = asNumber(record(info.credits_fulfillment_data).credits_to_be_received) const details = record(info.subscription).subscription_details if (!perPeriod || perPeriod <= 0 || !Array.isArray(details)) return null for (const raw of details) { const d = record(raw) const date = typeof d.next_bill_date === 'string' ? d.next_bill_date.match(/^\d{4}-\d{2}-\d{2}/)?.[0] : undefined const bill = record(d.next_bill_amount) const amount = asNumber(bill.amount) if (!date || amount === null || amount <= 0) continue const currency = typeof bill.currency === 'string' && /^[A-Z]{3}$/.test(bill.currency) ? bill.currency : null return { date, credits: Math.trunc(perPeriod), cents: Math.round(amount * 100), currency } } return null } /** * Read the credit balance and the plan's next charge from Audible's account * answer. Pure; returns numbers, a date, and key paths only, never values such * as names, ids, or payment details. */ export function readCredits(body: unknown): CreditsSummary { const all = leaves(body) const balance = all.find((l) => BALANCE_KEY.test(lastKey(l)) && asNumber(l.value) !== null) return { credits: balance ? Math.max(0, Math.trunc(asNumber(balance.value)!)) : null, nextCharge: readNextCharge(body), fieldPaths: [...new Set(all.map((l) => l.path.join('.')))].sort().slice(0, 300), numbers: Object.fromEntries( all.filter((l) => NUMBER_KEY.test(lastKey(l)) && asNumber(l.value) !== null).slice(0, 50).map((l) => [l.path.join('.'), asNumber(l.value)!]), ), } }