← Public packages
@danj/audible-auth
Locked Audible credential gateway: Connect, token refresh, and narrow Audible calls for other Kody packages.
src/credits-core.ts
85 lines · 3.7 KB · TypeScript/** 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<string, number>
}
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<string, unknown> =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
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)!]),
),
}
}