diff --git a/src/lib/components/subscriptions/Calendar.svelte b/src/lib/components/subscriptions/Calendar.svelte new file mode 100644 index 0000000..3c6b2b9 --- /dev/null +++ b/src/lib/components/subscriptions/Calendar.svelte @@ -0,0 +1,136 @@ + + +
+
+
+ {#each days as date, i (date)} +
+ {monthMark(date, i)} +
+ {/each} +
+
+ {#each days as date, i (date)} +
+ {Number(date.slice(8))} +
+ {/each} +
+ by {formatDate(days.at(-1), "short")} +
+ {#each rows as row (row.need.id)} +
+ {row.need.name} +
+ {#each row.cells as cell (cell.date)} +
+
+ {#if cell.count} + {cell.label} + {/if} +
+ {/each} +
+ {#each row.need.totals as total, i (total.code)} + {i > 0 ? "+" : ""} + {/each} +
+ {/each} +
+
diff --git a/src/lib/components/subscriptions/CardMenu.svelte b/src/lib/components/subscriptions/CardMenu.svelte new file mode 100644 index 0000000..0a8cdd3 --- /dev/null +++ b/src/lib/components/subscriptions/CardMenu.svelte @@ -0,0 +1,37 @@ + + + + Paying card + + {#each cards as card (card.id)} + manualCards.set(billId, card.id)}> + {card.name} + {card.code} + {#if chosen === card.id} + + {/if} + + {/each} + + manualCards.set(billId, null)} + >Card of the last payment + + diff --git a/src/lib/components/subscriptions/CardPicker.svelte b/src/lib/components/subscriptions/CardPicker.svelte new file mode 100644 index 0000000..812ee4d --- /dev/null +++ b/src/lib/components/subscriptions/CardPicker.svelte @@ -0,0 +1,52 @@ + + + { + manualCards.set(billId, item.insert === AUTO ? null : item.insert); + return true; + }} + onblur={cancel} + oncancel={cancel} + onsubmit={cancel} + placeholder="Paying card" + {suggestions} + bind:input + bind:value={text} +/> diff --git a/src/lib/components/subscriptions/Money.svelte b/src/lib/components/subscriptions/Money.svelte new file mode 100644 index 0000000..627141e --- /dev/null +++ b/src/lib/components/subscriptions/Money.svelte @@ -0,0 +1,23 @@ + + +{approx ? "~" : ""}{symbol ?? ""}{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)} diff --git a/src/lib/components/subscriptions/Runway.svelte b/src/lib/components/subscriptions/Runway.svelte new file mode 100644 index 0000000..ee2b2d9 --- /dev/null +++ b/src/lib/components/subscriptions/Runway.svelte @@ -0,0 +1,249 @@ + + +
+
+

+ Needed by {formatDate(end, "day")} +

+
+ Horizon + {#each HORIZONS as h (h.id)} + + {/each} +
+
+ Group by + + +
+
+ + {#if status} +

{status}

+ {:else if needs.length === 0} +

+ No subscription is due by {formatDate(end, "day")}. +

+ {:else} + + +
+ {#each needs as need (need.id)} +
+

+ {need.name} +

+ {#each need.totals as total (total.code)} +
+ +
+ {/each} + {#if need.short && need.currency} +
+ Short + + on {formatDate(need.short.date, "short")} +
+ {/if} + {#if need.balance !== null && need.currency} +
+ {byCurrency ? "On paying cards" : "Balance"} + +
+ {/if} +
    + {#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)} +
  • + {due.overdue + ? "overdue" + : formatDate(due.date, "short")} + {due.bill.attributes.name} + +
  • + {/each} +
+
+ {/each} +
+
+ + {#if menuFor} + {@const target = menuFor} + goto(`/subscriptions/show/${target.id}`)} + >Show + goto(`/subscriptions/edit/${target.id}`)} + >Edit + + + {/if} + +
+ {/if} + + {#if !status && renewals.length > 0} +

+ Annual renewals in {RENEWAL_DAYS} days: + {renewals.length}, + {#each renewalTotals as total, i (total.code)} + {i > 0 ? " + " : ""} + {/each} + {#each renewals as due (due.bill.id)} + {due.bill.attributes.name} + {formatDate(due.date, "short")} + {/each} +

+ {/if} + + {#if !status && strip.length > 0} +
+ +
+ {/if} +
diff --git a/src/lib/components/subscriptions/bills.ts b/src/lib/components/subscriptions/bills.ts index fe17fd8..5c8e699 100644 --- a/src/lib/components/subscriptions/bills.ts +++ b/src/lib/components/subscriptions/bills.ts @@ -1,5 +1,11 @@ import type { Bill, Item } from "#lib/api/types.js"; -import { addMonths, daysBetween, type Range, today } from "#lib/dates.js"; +import { + addDays, + addMonths, + daysBetween, + type Range, + today, +} from "#lib/dates.js"; import type { Money } from "#lib/format.js"; import { toNumber } from "#lib/format.js"; @@ -47,6 +53,23 @@ export const FREQUENCY_LABELS: Record = { const freq = (bill: Bill): Frequency => (bill.repeat_freq ?? "monthly") as Frequency; +const DAYS_PER_MONTH = 30.4; +const WEEK = 7; + +export const cycleDays = (bill: Bill) => + (MONTHS_PER[freq(bill)] * DAYS_PER_MONTH || WEEK) * ((bill.skip ?? 0) + 1); + +export function twoCyclesAfter(bill: Bill, from: string): string { + const cycles = 2 * ((bill.skip ?? 0) + 1); + const months = MONTHS_PER[freq(bill)]; + return months === 0 + ? addDays(from, WEEK * cycles) + : addMonths(from, months * cycles); +} + +export const isRange = (bill: Bill) => + toNumber(bill.amount_min) !== toNumber(bill.amount_max); + export const average = (bill: Bill) => (toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2; diff --git a/src/lib/subscriptions/manual.svelte.ts b/src/lib/subscriptions/manual.svelte.ts new file mode 100644 index 0000000..cee005c --- /dev/null +++ b/src/lib/subscriptions/manual.svelte.ts @@ -0,0 +1,40 @@ +const PREFIX = "ffui.bill-card."; + +function read(): Record { + const ids: Record = {}; + if (typeof localStorage === "undefined") { + return ids; + } + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key?.startsWith(PREFIX)) { + ids[key.slice(PREFIX.length)] = localStorage.getItem(key) ?? ""; + } + } + return ids; +} + +class ManualCards { + ids = $state>(read()); + picking = $state(""); + + get(billId: string): string | null { + return this.ids[billId] || null; + } + + set(billId: string, cardId: string | null) { + if (cardId) { + localStorage.setItem(PREFIX + billId, cardId); + } else { + localStorage.removeItem(PREFIX + billId); + } + this.ids = Object.fromEntries( + Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter( + ([, id]) => id + ) + ); + this.picking = ""; + } +} + +export const manualCards = new ManualCards(); diff --git a/src/lib/subscriptions/queries.ts b/src/lib/subscriptions/queries.ts new file mode 100644 index 0000000..1c32aa9 --- /dev/null +++ b/src/lib/subscriptions/queries.ts @@ -0,0 +1,46 @@ +import { api } from "#lib/api/client.js"; +import { all } from "#lib/api/lists.js"; +import type { Bill, Item, Page, TransactionGroup } from "#lib/api/types.js"; +import { addDays } from "#lib/dates.js"; +import { toPayments } from "./runway.js"; + +const SEARCH_LIMIT = 500; +const UNLINKED_DAYS = 92; + +async function search(query: string): Promise[]> { + const read = (page: number) => + api>("/v1/search/transactions", { + query: { limit: SEARCH_LIMIT, page, query }, + }); + const first = await read(1); + const pages = first.meta.pagination?.total_pages ?? 1; + const rest = await Promise.all( + Array.from({ length: Math.max(0, pages - 1) }, (_, i) => read(i + 2)) + ); + const seen = new Map>(); + for (const group of [first, ...rest].flatMap((page) => page.data)) { + seen.set(group.id, group); + } + return [...seen.values()]; +} + +export const runwayQueries = { + bills: (start: string, end: string) => ({ + queryFn: () => all("/v1/bills", { end, start }), + queryKey: ["subscriptions", "runway", start, end], + }), + paid: () => ({ + queryFn: async () => toPayments(await search("has_any_bill:true")), + queryKey: ["subscriptions", "payments"], + }), + unlinked: (day: string) => { + const after = addDays(day, -UNLINKED_DAYS); + return { + queryFn: async () => + toPayments( + await search(`has_no_bill:true type:withdrawal date_after:${after}`) + ).filter((payment) => !payment.billId), + queryKey: ["subscriptions", "unlinked", after], + }; + }, +}; diff --git a/src/lib/subscriptions/runway.ts b/src/lib/subscriptions/runway.ts new file mode 100644 index 0000000..663de11 --- /dev/null +++ b/src/lib/subscriptions/runway.ts @@ -0,0 +1,414 @@ +import type { Account, Bill, Item, TransactionGroup } from "#lib/api/types.js"; +import { + average, + cycleDays, + isRange, + twoCyclesAfter, +} from "#lib/components/subscriptions/bills.js"; +import { addDays, addMonths, daysBetween } from "#lib/dates.js"; +import { toNumber } from "#lib/format.js"; + +const EARLY_SHARE = 3; +const CENT = 0.005; + +export interface Currency { + code: string; + decimals: number; + symbol: string; +} + +export interface Sum extends Currency { + amount: number; +} + +export interface Payment extends Currency { + amount: number; + billId: string; + date: string; + description: string; + destinationId: string; + foreign: number; + foreignCode: string; + groupId: string; + journalId: string; + sourceId: string; + sourceName: string; +} + +export interface Card extends Currency { + balance: number | null; + id: string; + name: string; +} + +export interface Payer { + card: Card | null; + last: Payment | undefined; + manual: boolean; +} + +export interface Due { + approx: boolean; + bill: Item; + card: Card | null; + date: string; + native: Sum; + overdue: boolean; + pay: Sum; +} + +export interface Need { + balance: number | null; + currency: Currency | null; + dues: Due[]; + id: string; + name: string; + short: { amount: number; date: string } | null; + totals: Sum[]; +} + +export const HORIZONS = [ + { id: "7", key: "1", label: "7 days" }, + { id: "14", key: "2", label: "14 days" }, + { id: "month", key: "3", label: "Rest of month" }, + { id: "30", key: "4", label: "30 days" }, +] as const; +export type Horizon = (typeof HORIZONS)[number]["id"]; + +export const UNKNOWN = "unknown"; +export const STRIP_DAYS = 30; +export const RENEWAL_DAYS = 60; + +export function horizonEnd(horizon: Horizon, day: string): string { + if (horizon === "month") { + return addDays(addMonths(`${day.slice(0, 7)}-01`, 1), -1); + } + return addDays(day, Number(horizon)); +} + +export const billCurrency = (bill: Bill): Currency => ({ + code: bill.currency_code ?? "", + decimals: bill.currency_decimal_places ?? 2, + symbol: bill.currency_symbol ?? bill.currency_code ?? "", +}); + +export function toPayments(groups: Item[]): Payment[] { + return groups + .flatMap((group) => + group.attributes.transactions.map((split) => ({ + amount: Math.abs(toNumber(split.amount)), + billId: split.bill_id ?? "", + code: split.currency_code ?? "", + date: split.date.slice(0, 10), + decimals: split.currency_decimal_places ?? 2, + description: split.description, + destinationId: split.destination_id ?? "", + foreign: Math.abs(toNumber(split.foreign_amount)), + foreignCode: split.foreign_currency_code ?? "", + groupId: group.id, + journalId: split.transaction_journal_id ?? "", + sourceId: split.source_id ?? "", + sourceName: split.source_name ?? "", + symbol: split.currency_symbol ?? split.currency_code ?? "", + })) + ) + .sort((a, b) => b.date.localeCompare(a.date)); +} + +export function byBill(payments: Payment[]): Map { + const map = new Map(); + for (const payment of payments) { + map.set(payment.billId, [...(map.get(payment.billId) ?? []), payment]); + } + return map; +} + +export const toCards = (accounts: Item[]) => + new Map( + accounts.map(({ id, attributes: a }) => [ + id, + { + balance: toNumber(a.current_balance), + code: a.currency_code ?? "", + decimals: a.currency_decimal_places ?? 2, + id, + name: a.name, + symbol: a.currency_symbol ?? a.currency_code ?? "", + }, + ]) + ); + +export function payerOf( + last: Payment | undefined, + cards: Map, + manual: string | null +): Payer { + const chosen = manual ? cards.get(manual) : undefined; + if (chosen) { + return { card: chosen, last, manual: true }; + } + if (!last) { + return { card: null, last, manual: false }; + } + const card = cards.get(last.sourceId) ?? { + balance: null, + code: last.code, + decimals: last.decimals, + id: last.sourceId, + name: last.sourceName, + symbol: last.symbol, + }; + return { card, last, manual: false }; +} + +export function ownRates(payments: Payment[]): Map { + const rates = new Map(); + for (const p of payments) { + if (p.foreign > 0 && p.foreignCode && p.foreignCode !== p.code) { + const forward = `${p.foreignCode}>${p.code}`; + const back = `${p.code}>${p.foreignCode}`; + rates.set(forward, rates.get(forward) ?? p.amount / p.foreign); + rates.set(back, rates.get(back) ?? p.foreign / p.amount); + } + } + return rates; +} + +function rateFor( + from: string, + payer: Payer, + rates: Map +): number | null { + const to = payer.card?.code ?? from; + if (from === to) { + return 1; + } + const { last } = payer; + if (last && last.code === to && last.foreignCode === from && last.foreign) { + return last.amount / last.foreign; + } + return rates.get(`${from}>${to}`) ?? null; +} + +export function priceIn(payment: Payment, code: string): number | null { + if (payment.code === code) { + return payment.amount; + } + return payment.foreignCode === code && payment.foreign + ? payment.foreign + : null; +} + +function occurrences(bill: Bill, day: string) { + const paid = (bill.paid_dates ?? []).map((p) => (p.date ?? "").slice(0, 10)); + const early = cycleDays(bill) / EARLY_SHARE; + const end = bill.end_date?.slice(0, 10); + return (bill.pay_dates ?? []) + .map((raw) => raw.slice(0, 10)) + .filter((date) => !end || date <= end) + .filter( + (date) => + date < day || + !paid.some((p) => p <= date && daysBetween(p, date) < early) + ) + .map((date) => ({ date: date < day ? day : date, overdue: date < day })); +} + +export function dues( + bills: Item[], + day: string, + payer: (bill: Item) => Payer, + rates: Map +): Due[] { + return bills + .filter((bill) => bill.attributes.active) + .flatMap((bill) => { + const native: Sum = { + ...billCurrency(bill.attributes), + amount: average(bill.attributes), + }; + const who = payer(bill); + const rate = rateFor(native.code, who, rates); + const pay: Sum = + rate !== null && who.card + ? { ...who.card, amount: native.amount * rate } + : native; + return occurrences(bill.attributes, day).map(({ date, overdue }) => ({ + approx: isRange(bill.attributes) || (rate !== null && rate !== 1), + bill, + card: who.card, + date, + native, + overdue, + pay, + })); + }) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +export function sum(entries: Sum[]): Sum[] { + const totals = new Map(); + for (const entry of entries) { + const total = totals.get(entry.code) ?? { ...entry, amount: 0 }; + total.amount += entry.amount; + totals.set(entry.code, total); + } + return [...totals.values()]; +} + +function shortfall(list: Due[], balance: number | null, code: string) { + if (balance === null) { + return null; + } + let running = 0; + let date = ""; + for (const due of list) { + if (due.pay.code === code) { + running += due.pay.amount; + if (!date && running > balance + CENT) { + ({ date } = due); + } + } + } + return date ? { amount: running - balance, date } : null; +} + +function rank(a: Need, b: Need): number { + const order = (n: Need) => { + if (n.short) { + return 0; + } + return n.id === UNKNOWN ? 2 : 1; + }; + return order(a) - order(b) || a.name.localeCompare(b.name); +} + +function need( + id: string, + name: string, + list: Due[], + currency: Currency | null, + balance: number | null +): Need { + return { + balance, + currency, + dues: list, + id, + name, + short: currency ? shortfall(list, balance, currency.code) : null, + totals: sum(list.map((d) => d.pay)), + }; +} + +function grouped(list: Due[], key: (due: Due) => string) { + const map = new Map(); + for (const due of list) { + map.set(key(due), [...(map.get(key(due)) ?? []), due]); + } + return map; +} + +export function byCard(list: Due[]): Need[] { + return [...grouped(list, (d) => d.card?.id ?? UNKNOWN)] + .map(([id, items]) => { + const { card } = items[0]; + return card + ? need(id, card.name, items, card, card.balance) + : need(id, "Card unknown", items, null, null); + }) + .sort(rank); +} + +export function byCurrency(list: Due[]): Need[] { + return [...grouped(list, (d) => d.pay.code)] + .map(([code, items]) => { + const paying = new Map(); + for (const { card } of items) { + if (card?.code === code && card.balance !== null) { + paying.set(card.id, card.balance); + } + } + const balance = paying.size + ? [...paying.values()].reduce((total, b) => total + b, 0) + : null; + return need(code, code, items, items[0].pay, balance); + }) + .sort(rank); +} + +export interface Creep extends Currency { + from: number; + since: string; + to: number; +} + +export function creep(bill: Bill, history: Payment[]): Creep | null { + if (isRange(bill)) { + return null; + } + const currency = billCurrency(bill); + const prices = history + .map((p) => ({ amount: priceIn(p, currency.code), date: p.date })) + .filter((p): p is { amount: number; date: string } => p.amount !== null); + const [latest] = prices; + const change = prices.findIndex( + (p) => Math.abs(p.amount - (latest?.amount ?? 0)) > CENT + ); + if (!latest || change < 1) { + return null; + } + return { + ...currency, + from: prices[change].amount, + since: prices[change - 1].date, + to: latest.amount, + }; +} + +export function staleSince( + bill: Bill, + last: Payment | undefined, + day: string +): string | null { + const from = last?.date ?? bill.date?.slice(0, 10); + if (!(bill.active && from) || twoCyclesAfter(bill, from) >= day) { + return null; + } + return last?.date ?? ""; +} + +export function matches( + bills: Item[], + candidates: Payment[], + last: (bill: Item) => Payment | undefined, + day: string +): Map { + const found = new Map(); + const active = bills.filter((b) => b.attributes.active && last(b)); + for (const payment of candidates.filter((c) => c.date <= day)) { + const fits = active + .map((bill) => { + const a = bill.attributes; + const price = priceIn(payment, a.currency_code ?? ""); + const min = toNumber(a.amount_min); + const max = toNumber(a.amount_max); + const ok = + price !== null && + last(bill)?.destinationId === payment.destinationId && + price >= min - CENT && + price <= max + CENT && + payment.date >= (a.date ?? "").slice(0, 10); + return { bill, ok, width: max - min }; + }) + .filter((f) => f.ok) + .sort((x, y) => x.width - y.width); + const best = fits[0]?.bill; + if (best) { + found.set(best.id, [...(found.get(best.id) ?? []), payment]); + } + } + return found; +} + +export const isYearly = (bill: Bill) => bill.repeat_freq === "yearly"; diff --git a/src/routes/subscriptions/+page.svelte b/src/routes/subscriptions/+page.svelte index 07eb162..a8083b8 100644 --- a/src/routes/subscriptions/+page.svelte +++ b/src/routes/subscriptions/+page.svelte @@ -1,11 +1,11 @@ @@ -223,23 +372,27 @@ {/snippet} -
- {#each [ - { label: "Expected this period", totals: expected }, - { label: "Paid", totals: paid }, - { label: "Still to pay", totals: unpaid }, - ] as stat (stat.label)} - -
- -
-
+ + +{#snippet sums( + totals: Total[] +)} + {#each totals as total (total.code)} +
{/each} -
+{/snippet} {#snippet tools()} @@ -274,10 +427,12 @@ Name Amount + Price change Paid this period Next expected match - Repeats - + Card + Repeats + {#each months as month (month)} - + {section.title} {section.items.length} - - + + {@render sums(monthTotals)} + {monthTotals.length > 0 ? "a month" : ""} - + + - + {@render sums(perYear(section.items))} {#each section.items as item, j (item.id)} {@const i = offsets[s] + j} {@const bill = item.attributes} {@const ends = bill.end_date?.slice(0, 10)} + {@const last = lastOf(item)} + {@const who = payer(item)} + {@const change = creep(bill, payments.get(item.id) ?? [])} + {@const stale = staleSince(bill, last, day)} + {@const extra = found.get(item.id) ?? []} + {@const armedHere = arm.key?.endsWith(`:${item.id}`)} nav.click(i, e)} oncontextmenu={() => { @@ -334,9 +497,9 @@ nav.cursor = i; }} > - + {#if !bill.active} @@ -348,41 +511,90 @@ {bill.name} - - + {#if toNumber(bill.amount_max) !== + toNumber(bill.amount_min)} + {toNumber(bill.amount_min)}–{toNumber( + bill.amount_max + )} + {/if} + - {#if bill.amount_max !== bill.amount_min} - – - + + + {#if change} + {formatNumber(change.from, change.decimals)} + → {formatNumber(change.to, change.decimals)} + since {formatDate(change.since, "short")} {/if} - - + + {#if stale !== null} + {#if stale} + Unpaid since + {formatDate(stale, "short")} + {:else} + Never paid + {/if} + {:else} + + {/if} {bill.active ? formatDate(nextExpected(bill)) : ""} + {#if manualCards.picking === item.id} + + {:else if bill.active} + {#if who.card} + {who.card.name} + {#if who.manual} + + {/if} + {:else} + card unknown + {/if} + {/if} + + {repeats(bill)} {#if ends} , ends {formatDate(ends)} {/if} - + {#if bill.active} - + {/if} + {#if extra.length > 0 || stale !== null || armedHere} + + +
+ {#if extra.length > 0} + Not linked: + {#each extra as p (p.journalId)} + {formatDate(p.date, "short")} + {p.description} + + {/each} + + {#if linking[item.id]} + {linking[item.id]} + {/if} + {/if} + {#if stale !== null || armedHere} + + {#if inactiveGroup} + + {/if} + {/if} +
+ + + {/if} {/each} {/each} {#if sections.length > 1} - + Total, active subscriptions - - + + {@render sums(perMonth(rows))} + a month - + + - + {@render sums(perYear(rows))} @@ -426,6 +716,9 @@ {@const target = menuFor} show(target)}>Show edit(target)}>Edit + {#if target.attributes.active} + + {/if} Move to group @@ -447,6 +740,13 @@ > + + update(target, { active: !target.attributes.active })} + >{target.attributes.active + ? "Deactivate" + : "Activate"}