尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗A budget planner for 2+ people living together, with income-proportional expense splitting: if you pay rent, your housemate owes a share weighted by income, not a flat 50/50 — same idea as Splitwise, different math.
Next.js 16 (App Router) + TypeScript + PostgreSQL via Prisma 7 + Tailwind 4. Single repo, Postgres runs locally via docker-compose.
One deliberate deviation worth flagging: Prisma 7 removed inline
datasource url from schema.prisma entirely — the connection string now
lives in prisma.config.ts (for Migrate/Studio) and the runtime
PrismaClient takes an explicit @prisma/adapter-pg driver adapter (see
src/lib/db.ts). This isn't a stack swap, just what "Postgres via Prisma"
requires on the current stable release.
npm install
cp .env.example .env # defaults already match docker-compose.yml
npm run db:up # starts Postgres in Docker
npm run db:migrate # creates the schema
npm run db:seed # seeds one demo household
npm run dev # http://localhost:3000
The seed creates a household called "Demo Household" that reproduces the spec's worked example exactly — A earns €3000/mo, B earns €2000/mo (a 60/40 income split), A pays €1000 rent, B pays €150 groceries, both PROPORTIONAL. Open the Dashboard and you should see B owes A 310,00 € net. That number is also a passing test (see below) — if the UI and the test ever disagree, trust the test and file it as a bug.
npm test # pure-function tests, no database — always green
npm run test:db # real-database integration tests — needs db:up + db:migrate + a seeded/reachable Postgres first
npm run e2e # Playwright: drives the real screens in a browser against a real database
npm test is where the splitting engine itself lives (src/lib/splitting.ts,
src/lib/income-impact.ts, src/lib/recurring.ts, src/lib/money.ts), covering:
PRIOR_MONTH_FALLBACK/NO_INCOME_ON_RECORD warnings, and a separate usedEqualSplitFallback boolean); see belowsimplifySettlements' greedy debt-simplification, including a 3-party cycleformatCents/formatPercent producing real es-ES output ("310,00 €", "60,0 %")npm run e2e goes one level further out: it builds the app, serves it, and
drives the actual screens in Chromium against a real database — the only
layer that catches a query that is right and a page that renders it wrong.
It needs DATABASE_URL and a one-time npx playwright install chromium.
Each spec creates its own __e2e__ household and deletes it afterwards, so
it never touches data anyone is using.
npm run test:db needs a real, migrated Postgres and is NOT part of the
default npm test (kept separate on purpose — DB tests shouldn't make the
core suite flaky or slow). It covers: getDashboardData's income
resolution matching computeIncomeShares exactly against real rows, and
the IncomeRecord unique-constraint fix (a no-source duplicate is
genuinely rejected by Postgres, not just by application code).
Earlier drafts of this README said "no Docker in this sandbox, so this is
unverified beyond type-checking." That's no longer true for the backend
and the Expenses screen. Since neither Docker nor Homebrew exist in this
sandbox, verification used embedded-postgres (a real native Postgres
server binary, not a mock) as a stand-in for docker-compose up,
installed and removed again each time — it is not a project dependency,
and your actual docker-compose.yml/.env are untouched. Specifically,
for real, against a running Postgres:
npx prisma migrate dev / migrate deploy — both migrations apply cleanly, including the IncomeRecord.source NOT NULL migration, which was specifically tested against a database with a pre-existing NULL-source row already in it (dropped the schema, replayed the first migration, inserted a real NULL-source row via raw SQL, then replayed the second migration) — it backfills to 'General' instead of failing, which is what Prisma's autogenerated migration does NOT do on its own (it only adds NOT NULL + DEFAULT; the backfill UPDATE had to be added by hand — see that migration's .sql file).npm run db:seed — ran for real; its console output now prints real formatCents output ("rent 1.000,00 €... Expect: B owes A 310,00 €").getDashboardData/getBalances, called directly (not through curl) against the seeded data, returned balanceCents: 31000 / -31000 and a suggested transfer of 31000 — matching the spec exactly.next dev session against this real database, with actual Playwright browser automation (headless Chromium) driving the real rendered page — not just fetching HTML:
/h/[id]/expenses, confirmed 2 real rows render.<select> and clicked the real "Apply filters" submit button; confirmed the table actually re-rendered to show only B's expense.<Link>; confirmed the drawer opened with real content, including real EUR-formatted share amounts ("A 600,00 €", "B 400,00 €").<select>; confirmed two percentage <input> fields actually appeared (0 → 2) — this is useState-driven conditional rendering, not server HTML.ExpenseShare rows were exactly 2975/1275 cents (70%/30% of 4250) — the full round trip from a real click through the server action, the splitting engine, and back into the rendered UI.npm run test:db passed for real against this same database.What's still only type-checked/built, not click-tested: the Dashboard,
Recurring, Income, and Savings screens were confirmed to render via
server-side fetch (curl/direct function calls) in an earlier pass, but
have not had the same Playwright treatment as Expenses — in particular,
the Income screen's income-edit confirmation dialog (old-vs-new share
diff, "save only" vs "save and recalculate") has never been driven by an
actual click, only by its own unit test (findIncomeEditImpact) and a
real-DB resolution test. The "Generate this month" button on Recurring
and the savings contribution forms are in the same boat. If you only
trust one screen right now, trust Expenses; treat the other four as
"builds and type-checks cleanly, first real click-through still pending."
See prisma/schema.prisma for the full schema. Money is always an integer
number of minor units (cents) in the database — never a float — converted
to Decimal only inside the splitting engine's own math, and only
formatted to a decimal string at display time, always through
src/lib/money.ts's formatCents/formatPercent (no raw toFixed
anywhere in a component — every display path goes through one of these
two functions). Months are stored as a DateTime normalized to the 1st
of the month at UTC midnight (src/lib/month.ts), not an ad-hoc
"YYYY-MM" string.
Every amount renders as "310,00 €" — EUR, es-ES locale (comma decimal,
period thousands separator, symbol after the number) — unconditionally.
Household.baseCurrency is still a real schema column (defaults to
"EUR"), but nothing in the UI reads it for display anymore; an earlier
version threaded it through as a per-household override, which is how a
freshly-created household (schema default "USD", before this fix) ended
up rendering dollar amounts. If you actually want per-household
multi-currency support later, that's a real feature to build on top of
this, not a config flag to flip — right now it's hardcoded.
src/lib/splitting.ts)computeIncomeShares(members, incomeRecords, month) — each active
member's share of total household income for that month, plus their
resolved absolute income (resolvedIncome) so nothing downstream needs
its own copy of the fallback logic (getDashboardData used to have one;
it now just reads this). Two distinct fallback signals:
warnings, each tagged PRIOR_MONTH_FALLBACK (borrowed a
specific prior month — fallbackMonth says which) or
NO_INCOME_ON_RECORD (never had any income logged at all).usedEqualSplitFallback boolean: true only when
every active member resolved to zero income, so the whole
computation fell back to an equal split rather than being
income-weighted at all. One member individually hitting
NO_INCOME_ON_RECORD while others have real income does not set
this — that's an individual gap, not a household-wide fallback.computeExpenseShares(amount, paidBy, splitRule, ...) — dispatches on
the five split rules, always returning shares that sum exactly to
the expense amount via the largest-remainder (Hamilton) method: each
member gets floor(exact share), then leftover cents go one at a time
to whoever has the largest fractional remainder (tie-break: larger
share, then member ID). For PROPORTIONAL splits, also forwards
usedEqualSplitFallback and the fallback warnings, frozen alongside
the ratio snapshot via buildStoredIncomeRatioSnapshot — so a fallback
that happened when an expense was created is visible later on the
Expenses screen's drawer, not just in the moment on the Dashboard.computeBalances(expenses, settlements) — net pairwise balance per
member, from already-materialized ExpenseShare rows plus recorded
settlements. Never recomputes a split ratio; SOLO/personal expenses
(isShared: false) are skipped entirely.simplifySettlements(balances) — greedy largest-creditor/largest-debtor
matching for a minimal-transfer "settle up" suggestion (the same
heuristic Splitwise uses).Every PROPORTIONAL expense stores the income ratio that produced its
shares in Expense.incomeRatioSnapshot, frozen at write time. Editing a
past month's IncomeRecord does not silently rewrite old
ExpenseShare rows: src/lib/income-impact.ts recomputes every affected
PROPORTIONAL expense under the proposed new income and diffs it against
what's stored; the Income screen's form (src/components/income-form.tsx)
calls this before saving, and if anything would change, shows the old
vs. new breakdown and asks you to explicitly choose "save only" (income
changes, history stays settled) or "save and recalculate" (history is
rewritten).
generateRecurringForMonth (src/lib/actions.ts) checks for an existing
Expense by (recurringExpenseId, generatedForMonth) before creating one,
and that same pair is a database unique constraint as a safety net against
a concurrent double-click — clicking "Generate this month" twice can never
double-materialize a recurring expense.
"General")source used to be nullable, with a unique constraint on
(memberId, month, source) meant to stop duplicate income entries. It
didn't fully work: Postgres treats NULL as distinct from NULL in a
unique index, so two income records for the same member/month with no
source typed in both slipped through uncaught. Making source
non-nullable with a default closes that — two "no source" entries now
both resolve to "General" and collide for real. The migration
(prisma/migrations/20261008203100_.../migration.sql) backfills existing
NULL rows to 'General' before adding the NOT NULL constraint; this
had to be added by hand — Prisma's autogenerated migration only emits the
ALTER COLUMN ... SET NOT NULL, which fails outright against a database
that already has a NULL in that column, and the generated migration's own
comment says so. This was verified by hand against a database seeded with
a real pre-existing NULL-source row (see "What's been verified" above),
not just inferred from reading the generated SQL.
prisma/schema.prisma the data model
prisma.config.ts Prisma 7's new home for the DB connection string
src/lib/splitting.ts the splitting engine — pure functions, fully tested
src/lib/splitting.test.ts worked example, all 5 split rules, Hamilton allocation + 1000-trial property test
src/lib/income-impact.ts "don't silently rewrite settled balances"
src/lib/recurring.ts recurring → Expense materialization
src/lib/money.ts formatCents/formatPercent — the only place currency/percent formatting happens
src/lib/db.ts PrismaClient singleton (driver-adapter pattern)
src/lib/queries.ts reads, called directly from Server Components
src/lib/actions.ts writes, "use server" — called from client forms
src/lib/*.db.test.ts real-database integration tests — `npm run test:db`, not `npm test`
e2e/*.spec.ts Playwright browser tests — `npm run e2e`, not `npm test`
src/app/ the 5 screens (Dashboard, Expenses, Recurring, Income, Savings)
src/components/ client components (forms, the income-edit confirmation dialog, etc.)
legacy/ three prior versions of this project, kept for reference, not wired to anything
legacy/This project went through three iterations before this rewrite:
v1-html-python (static HTML + a Python stdlib server), v2-node-vanilla
(a Node.js rewrite of the same vanilla-JS frontend), and now this one. Both
are kept, untouched, in case anything in them is worth referencing — v2
in particular has a real household's data (v2-node-vanilla/data/households.db,
SQLite) that was never migrated into the new Postgres schema, since the
two data models don't map cleanly onto each other (no income-proportional
splitting, no per-expense split rules, flat savings/debt ledgers instead
of goal-based savings). If that household's history matters, it's there
to hand-port.