Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 0 additions & 42 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,45 +154,3 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: dist/*.apk

ios:
# iOS builds need Apple's toolchain (Xcode), so this must run on a macOS
# runner. We build an UNSIGNED app bundle — no Apple Developer certificate or
# provisioning profile required. `fyne package` produces an unsigned .app
# (signing is a separate `fyne release` step, which we deliberately skip).
#
# The resulting .ipa is NOT installable as-is: iOS requires code signing to
# run on a device. Sideload it by re-signing with a free Apple ID using a
# tool like Sideloadly (https://sideloadly.io) or AltStore
# (https://altstore.io). Free-provisioning signatures expire after 7 days, so
# you'll need to re-sign/reinstall roughly weekly.
runs-on: macos-14
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Install the fyne CLI
run: go install fyne.io/tools/cmd/fyne@latest

- name: Build the unsigned iOS app bundle
shell: bash
run: |
export PATH="$PATH:$(go env GOPATH)/bin"
# Unsigned .app bundle for arm64 devices (no certificate needed).
fyne package -os ios -app-id app.financy -icon Icon.png

# Wrap the .app in the standard Payload/ layout and zip it into an
# .ipa so sideloading tools (Sideloadly, AltStore) can re-sign it.
mkdir -p Payload
mv Financy.app Payload/
mkdir -p dist
zip -r "dist/Financy-${GITHUB_REF_NAME}-ios-arm64-unsigned.ipa" Payload
echo "::warning::iOS .ipa is UNSIGNED. Re-sign it with a free Apple ID via Sideloadly or AltStore before installing on a device."

- name: Attach the unsigned .ipa to the GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*.ipa
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,86 @@ See `RELEASING.md`.

## [Unreleased]

### Added
- **Recurring, forward-looking.** The Recurring screen no longer just lists
templates — it leads with what your schedule actually means: **MONTHLY
BILLS / MONTHLY INCOME / NET PER MONTH / ACTIVE** stat cards (every
frequency normalized to a monthly figure) and a **Next 30 days timeline**
with occurrences bucketed into *Overdue & due* (amber, with an inline
Review & Post), *This week* and *Later this month*, each row wording its
distance ("today", "in 5 days", "3 days overdue"). The Accounts overview
gains an **Upcoming** card — due alert plus the next 14 days of
occurrences — that deep-links to the Recurring screen. Due entries are now
re-checked once a minute while the app runs (midnight rollover included),
not just when a file opens; the prompt only reappears when something *new*
becomes due, so "Later" means later. Review-before-post is unchanged:
nothing ever posts without your confirmation.

- **Cover overspending.** An overspent envelope can now be fixed in place:
the overspend banner's **Cover…** button and the category row's
right-click menu open a dialog that moves money from another envelope (or
from Ready to Assign) into the red one — source picker sorted by what each
envelope can give, amount prefilled with the shortfall, and validation so
you can't move more than a source has. Backed by a single-notification
`MoveAssigned` store operation.

### Changed
- **Desktop redesign — Recurring page.** Rebuilt around the forward-looking
layout above (stats → timeline → templates). The due nudge became a proper
amber alert banner with the amount due, the template list moved into a
titled **All templates** panel, and first-run shows an explainer ("Put your
bills on autopilot — with a veto") instead of a bare empty state. Row
actions (post early, edit, pause, delete) and the review dialog are
unchanged.
- **Desktop redesign — Budget page.** Spotting trouble no longer requires
reading numbers: every category row gains a traffic-light **usage bar**
(emerald while healthy, amber from 85% used, rose when overspent), overspent
envelopes show their Available as a filled red pill, and a banner above the
table counts them out loud. The Activity column became **Spent** — plain
figures, "—" for untouched envelopes, green "+" for refunds — and unassigned
categories show "—" instead of a wall of $ 0.00. The Ready-to-Assign hero
got the same padding/centering treatment as the Accounts hero. Assign
dialog, quick-assign, Auto-Assign and month locking are unchanged.
- **Desktop redesign — Transactions page.** The journal keeps its daily
grouping but each day is now its own card, headed by the date and the day's
net (shown only when there is one). The filter panel collapsed from two rows
to one slim line — month / type / account selects plus a full-width search —
and the summary cards read INCOME / EXPENSES / NET without the "(filtered)"
clutter. Transaction type chips are now soft circular badges. Selection
mode, row context menus and editing are unchanged.
- **Desktop redesign — Accounts page.** The card grid is now a grouped table,
matching the Debts page: Assets and Liabilities panels with one row per
account — name + institution (with a compact Off-budget chip), a
share-of-group bar, transaction count, and a right-aligned colored balance
column that's actually scannable. Row click still opens the register;
the ⋮ / right-click menu is unchanged. The net-worth hero stays.
- **Desktop redesign — modals.** Every dialog (setup wizard, transaction and
debt forms, reconcile, preferences, alerts, confirmations…) moved off Fyne's
boxy stock dialogs onto a custom modal: a dimming scrim with a centered
rounded card that matches the design system — soft shadow, hairline border,
title header and right-aligned footer actions. Delete confirmations now use
an explicit red **Delete** button instead of Yes/No. Native file open/save
pickers are unchanged.
- **Desktop redesign — a modern, slick shell.** The icon-only top toolbar and
bottom status bar are gone; navigation now lives in a labeled left sidebar
with the app brand, a prominent **New Transaction** button, clear icon+label
entries with an active highlight, quick access to Categories / Preferences /
theme toggle, and a live **net-worth summary card** (assets & liabilities)
pinned at the bottom. The design system was refreshed across both themes:
an indigo accent, calmer emerald/rose money colors (replacing raw
green/red), a deeper indigo-tinted dark palette, softer rounded corners,
slimmer pill progress bars, airier screen headers and stat cards. With no
document open the sidebar now hides entirely — the welcome screen has
nothing to navigate — and reappears when a file opens. Desktop only — the
mobile layout is unchanged.

### Removed
- **iOS release build.** The release workflow no longer produces the unsigned
`.ipa`: it wasn't installable without re-signing through Sideloadly/AltStore
(and expired weekly on free provisioning), so it added a macOS runner to
every release for an artifact almost nobody could use. Android, Linux,
Windows and macOS release assets are unchanged.

## [0.13.0] - 2026-07-11

### Added
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ build:
shot: build
go run . shot $(SHOTDIR)
@echo "Copying screenshots into docs/screenshots and website/static/img…"
@for n in accounts analytics categories data-summary recurring reports setup transactions; do \
@for n in accounts analytics budget categories data-summary recurring setup transactions; do \
cp "$(SHOTDIR)/$$n.png" "docs/screenshots/$$n.png"; \
done
@for n in accounts analytics categories reports transactions; do \
@for n in accounts analytics budget budget-assign categories transactions; do \
cp "$(SHOTDIR)/$$n.png" "website/static/img/$$n.png"; \
done
@cp "$(SHOTDIR)/recurring.png" website/static/img/recurring-screen.png
Expand Down
Binary file modified docs/screenshots/accounts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/analytics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/budget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/categories.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/data-summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/recurring.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/setup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/transactions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 32 additions & 4 deletions internal/core/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,27 +106,55 @@ func (s *Store) Assigned(month, catID string) int {
// SetAssigned records (or clears, when amount == 0) a category's assignment for
// a month and writes through to disk.
func (s *Store) SetAssigned(month, catID string, amount int) {
if s.setAssigned(month, catID, amount) {
s.notify()
}
}

// setAssigned is SetAssigned without the notification; it reports whether
// anything changed so callers batching several writes can notify once.
func (s *Store) setAssigned(month, catID string, amount int) bool {
if s.assignments == nil {
s.assignments = map[string]int{}
}
key := monthKey(month, catID)
if amount == 0 {
if _, ok := s.assignments[key]; !ok {
return
return false
}
delete(s.assignments, key)
if err := s.dbDeleteAssignment(month, catID); err != nil {
s.reportError(err)
return
return false
}
} else {
s.assignments[key] = amount
if err := s.dbUpsertAssignment(month, catID, amount); err != nil {
s.reportError(err)
return
return false
}
}
s.notify()
return true
}

// MoveAssigned shifts amount between two categories' assignments for a month —
// the "cover overspending" move. An empty fromID takes the money from Ready to
// Assign instead: assigning to the target alone is what reduces RTA, so no
// source needs decrementing. Subscribers are notified once for the whole move.
func (s *Store) MoveAssigned(month, fromID, toID string, amount int) {
if amount <= 0 || toID == "" || fromID == toID {
return
}
changed := false
if fromID != "" {
changed = s.setAssigned(month, fromID, s.Assigned(month, fromID)-amount)
}
if s.setAssigned(month, toID, s.Assigned(month, toID)+amount) {
changed = true
}
if changed {
s.notify()
}
}

// ---- derived flows ----
Expand Down
39 changes: 39 additions & 0 deletions internal/core/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,45 @@ func TestAssignedRoundTripAndClear(t *testing.T) {
}
}

// MoveAssigned shifts money between envelopes (the cover-overspending move),
// treats an empty source as Ready to Assign, and ignores no-op moves.
func TestMoveAssigned(t *testing.T) {
s := budgetStore()
s.SetAssigned("2026-07", "groceries", 500)

s.MoveAssigned("2026-07", "groceries", "housing", 200)
if got := s.Assigned("2026-07", "groceries"); got != 300 {
t.Fatalf("source Assigned = %d, want 300", got)
}
if got := s.Assigned("2026-07", "housing"); got != 200 {
t.Fatalf("target Assigned = %d, want 200", got)
}

// From Ready to Assign: only the target changes.
s.MoveAssigned("2026-07", "", "housing", 100)
if got := s.Assigned("2026-07", "housing"); got != 300 {
t.Fatalf("after RTA move target Assigned = %d, want 300", got)
}
if got := s.Assigned("2026-07", "groceries"); got != 300 {
t.Fatalf("RTA move touched the wrong envelope: groceries = %d", got)
}

// No-ops: zero/negative amounts, same source and target.
s.MoveAssigned("2026-07", "groceries", "housing", 0)
s.MoveAssigned("2026-07", "groceries", "housing", -50)
s.MoveAssigned("2026-07", "groceries", "groceries", 100)
if s.Assigned("2026-07", "groceries") != 300 || s.Assigned("2026-07", "housing") != 300 {
t.Fatal("no-op MoveAssigned changed assignments")
}

// A move can drive the source negative — that's the YNAB behavior when
// covering from an envelope whose Available came from rollover.
s.MoveAssigned("2026-07", "groceries", "housing", 400)
if got := s.Assigned("2026-07", "groceries"); got != -100 {
t.Fatalf("source Assigned = %d, want -100", got)
}
}

func TestPreviewAutoAssign(t *testing.T) {
s := budgetStore()
// Last month (June): groceries and housing assigned.
Expand Down
119 changes: 119 additions & 0 deletions internal/core/recurring.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,125 @@ func addMonths(t time.Time, months int) time.Time {
return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, time.UTC)
}

// ---- projection / summary ----

// Occurrence is one projected instance of a recurring template — used by the
// forward-looking UI (upcoming timeline, overview card). Unlike DueItem it
// carries no duplicate candidates and building it never advances a schedule.
type Occurrence struct {
RecurringID string
Kind string
Date int // Excel serial
Payee string
Memo string
Amount int
AcctA string
AcctB string
Freq string
Due bool // Date <= asOf: overdue or due today
}

// maxProjection bounds how many occurrences one template can contribute, so a
// years-stale weekly template can't expand into thousands of rows.
const maxProjection = 200

// UpcomingRecurring expands every enabled template from its NextDue through
// until (inclusive) without advancing any schedule. Occurrences on or before
// asOf are flagged Due — the same set PendingRecurring would prompt for.
// Sorted by date, then payee.
func (s *Store) UpcomingRecurring(asOf, until int) []Occurrence {
var out []Occurrence
for _, r := range s.recurring {
if !r.Enabled {
continue
}
n := 0
for d := r.NextDue; d <= until && n < maxProjection; d = nextDate(d, r.Freq) {
out = append(out, Occurrence{
RecurringID: r.ID,
Kind: r.Kind,
Date: d,
Payee: r.Payee,
Memo: r.Memo,
Amount: r.Amount,
AcctA: r.AcctA,
AcctB: r.AcctB,
Freq: r.Freq,
Due: d <= asOf,
})
n++
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Date != out[j].Date {
return out[i].Date < out[j].Date
}
return out[i].Payee < out[j].Payee
})
return out
}

// MonthlyEquivalent converts a per-period amount to its monthly figure:
// Weekly ×52/12, Biweekly ×26/12, Monthly ×1, Quarterly ÷3, Yearly ÷12,
// rounded half-up.
func MonthlyEquivalent(amount int, freq string) int {
switch freq {
case "Weekly":
return mulDivRound(amount, 52, 12)
case "Biweekly":
return mulDivRound(amount, 26, 12)
case "Quarterly":
return mulDivRound(amount, 1, 3)
case "Yearly":
return mulDivRound(amount, 1, 12)
default: // Monthly
return amount
}
}

func mulDivRound(a int, num, den int64) int {
v := int64(a) * num
return int((2*v + den) / (2 * den))
}

// RecurringSummary is the commitment overview shown on the Recurring screen
// header and the Accounts upcoming card.
type RecurringSummary struct {
MonthlyIncome int // monthly-equivalent of enabled Income templates
MonthlyExpense int // monthly-equivalent of enabled Expense templates
MonthlyNet int // MonthlyIncome − MonthlyExpense (transfers excluded)
Active int
Paused int
DueCount int // occurrences due as of asOf, catch-up included
DueTotal int // sum of |amount| across due occurrences
}

// RecurringSummaryFor computes the recurring commitment summary as of a date.
// Transfers count toward Active but not the monthly totals — they move money
// between accounts rather than in or out.
func (s *Store) RecurringSummaryFor(asOf int) RecurringSummary {
var sum RecurringSummary
for _, r := range s.recurring {
if !r.Enabled {
sum.Paused++
continue
}
sum.Active++
switch r.Kind {
case KindIncome:
sum.MonthlyIncome += MonthlyEquivalent(r.Amount, r.Freq)
case KindExpense:
sum.MonthlyExpense += MonthlyEquivalent(r.Amount, r.Freq)
}
}
sum.MonthlyNet = sum.MonthlyIncome - sum.MonthlyExpense
for _, o := range s.UpcomingRecurring(asOf, asOf) {
sum.DueCount++
sum.DueTotal += absInt(o.Amount)
}
return sum
}

// ---- due / post ----

// DueItem is one occurrence of a recurring template that's due. The user either
Expand Down
Loading
Loading