Skip to content

src/credits-core.ts

85 lines · 3.7 KB · TypeScript
Raw
/** 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)!]),
		),
	}
}