A RESTful API for managing personal expenses and funds.
- User authentication and authorization
- CRUD operations for expenses
- CRUD operations for categories
- CRUD operations for funds
- Fund transactions tracking
- Linking expenses with funds
- Default fund support
- Fund balance management
- Fund transfers between accounts
- An MCP server for Claude (claude.ai, Desktop, mobile, Claude Code), behind OAuth
Data routes take the caller's identity from credentials, never from a userId
in the query or body (it is ignored if sent):
- web:
Authorization: Bearer <accessToken>, from signin, signup or refresh - Telegram bot:
X-Telegram-Bot-Secret(equal toTELEGRAM_BOT_SECRET) andX-Telegram-User-Id; the request acts as the user linked to that Telegram account. The bot may only callGET /api/category,GET /api/funds,POST /api/cost,GET /api/tags,GET /api/meandPUT /api/me/active-tag(plus the two bot routes under/api/telegram)
No credentials, an invalid or expired token, or an unlinked Telegram id is a 401; a bot call to any other route is a 403; a bearer and bot headers together are a 400.
While AUTH_ENFORCE is not true, clients deployed before this scheme keep
working: the refreshToken cookie identifies the web user, and the bot secret
with a userId (no Telegram id) identifies the bot's user. Each such request
logs [auth-legacy]. With AUTH_ENFORCE=true both are a 401.
Routes that take an id only find the caller's own records: anybody else's id, or a malformed one, is a 404.
POST /api/auth/signup- Register a new user; starts a sessionPOST /api/auth/signin- Sign in; starts a session and sets therefreshTokencookiePOST /api/auth/refresh- Exchange therefreshTokencookie for a new access token; rotates the cookieGET /api/auth/- Older silent-auth endpoint, kept for web bundles that predate/refresh; the same exchangePOST /api/auth/signout- End the cookie's session and clear the cookie; needs no access token
Signup, signin and refresh return the user
(id, username, email, defaultCurrency, telegramId, verified, activeTag) and an accessToken.
Every web sign-in is a session in the database. The refreshToken cookie
(httpOnly, secure, sameSite=lax, path /api/auth, so data routes never see
it) holds a random secret; only its sha256 is stored. Every refresh replaces the
secret and moves the session's end to 90 days later, never past 180 days after
sign-in. The secret just replaced still works for 5 minutes (two tabs refreshing
at once, or a phone retrying after a lost response), without a new cookie; after
that, presenting it revokes the session.
The access token lives 15 minutes and names its session (sid).
A refresh JWT cookie from before sessions (path /) becomes a session on its
first refresh; that migration goes in the post-trip cleanup.
GET /api/sessions- The caller's active sessions:[{ id, current, device, createdAt, lastUsedAt, expiresAt }],devicesuch as"iPhone · Safari"; the current one first, then bylastUsedAtDELETE /api/sessions/:id- Sign that session out (204); the current one clears the cookie too. Anybody else's id, or a malformed one, is a 404POST /api/sessions/revoke-others- Sign out every session but the current one:{ revoked: <count> }; 409 for an access token that names no session
GET /api/me- The caller, in the same shape as signinPUT /api/me/active-tag-{ "tag": "japan-2026" }sets the tag every new cost gets unless it says otherwise;null, or a tag that normalises to nothing, clears it. Returns the user
GET /api/category- Get the global categories and the caller's ownPOST /api/category- Create a category owned by the caller
GET /api/costs- Get the caller's costs, grouped by category (dateFrom,dateTo). Withtag, only costs carrying it, and the dates become optional (absent: all time). Each cost carriestags,currencyandfundAmount(null without a fund)POST /api/cost- Create a cost; the fund, if given, must be the caller's, the category must be global or the caller's, andamountmust be a positive numberGET /api/cost/:id- One of the caller's costs with its category, its fund (_id,name,currency),tagsandfundAmount(null without a fund)PATCH /api/cost/:id- Edit one of the caller's costs; every field is optional:amount,currency,fundId(nulltakes it off any fund),category,date,comment,tags(replaced, normalised). Other fields are ignored. Returns the cost asGETdoes. Whenamount,currencyor the fund change, the old fund is credited back (arefundtransaction) and the new one debited (anexpense), both describedEdited: …; in the same fund the old debit counts as available.rateis recomputed only when the currency changes. Nothing is written when any check failsDELETE /api/cost/:id- Delete one of the caller's costs; its fund, if it still exists, is credited back with arefundtransactionDeleted: …. 204GET /api/tags- The caller's tags as{ tag, count, total, currency, firstDate, lastDate },totalin the default currency, most recently used first
Tags are normalised wherever they enter: trimmed, lowercased, inner whitespace
turned into -, only letters (any script), digits, - and _ kept, at most 32
characters and 10 tags per cost, duplicates removed. A tag left empty is dropped,
not rejected.
GET /api/funds- The active funds and theirtotal(archived funds are left out of both).?archived=truereturns only the archived funds, newest archived first, without a total; web only, the bot gets 403POST /api/funds- Create a new fund; takeskindandgoal(below)PUT /api/funds/:id- Update a fund (400 forisDefault: trueon an archived one); takeskindandgoalPOST /api/funds/:id/archive- Archive a fund with any balance: it leaves the list and the total, keeps its history and cannot take new expenses or transfers (400); an archived default fund stops being the default. Returns the fund; archiving twice changes nothing. Web onlyPOST /api/funds/:id/restore- Bring an archived fund back; returns the fund. Web onlyDELETE /api/funds/:id- Delete a fund that has no history (no cost on it, no transaction, which includes adjustments); otherwise 409{ error, code: 'fund_has_history' }, archive it insteadGET /api/funds/:id- Get fund details (also an archived fund, witharchivedAt)GET /api/funds/:id/transactions- Get fund transactions (also of an archived fund)GET /api/funds/total- Total of the active funds in the caller's default currencyPOST /api/funds/transfer- Transfer money between funds (400 when either is archived)
Savings: a fund has a kind, account (default; funds saved before have none and read as accounts) or savings,
a separate real balance. A savings fund may have a goal: { amount, date } (amount > 0 in the fund's currency,
date optional; goal: null clears it); a goal on an account, a bad kind, amount or date, and isDefault: true
on a savings fund are 400. Making a fund savings clears isDefault; turning it back into an account clears the goal.
Every fund in GET /api/funds, ?archived=true and GET /api/funds/:id carries kind, goal and, with a goal,
goalProgress: { percent, remaining, monthsLeft, perMonth, reached } (monthsLeft counts Asia/Bangkok calendar
months from the current one to the target date's month, at least 1 — a past date gives 1; perMonth is
remaining / monthsLeft rounded up; both are null without a date). The total of GET /api/funds and
GET /api/funds/total also carry available (accounts) and saved (savings); amount is exactly their sum.
Savings are put aside with a transfer and may be spent from; budgets and analytics ignore the kind.
An expense already on an archived fund can still be edited and deleted there (the refund goes back to the
fund); POST /api/cost and moving an expense onto an archived fund answer 400.
Web only. Every amount is in the caller's default currency, converted exactly as
GET /api/costs converts it (amount × rate rounded per cost; a cost without
currency/rate counts at rate 1).
GET /api/analytics/summary?dateFrom&dateTo- The period[dateFrom, dateTo):total,count,avgPerDay,projection(to the period's end at the pace so far, only while today is inside it, elsenull),previous(the previous calendar month when the period is exactly one Asia/Bangkok calendar month, ending at the next month's start or one millisecond before it; otherwise the same number of days right beforedateFrom),categorieswithshare(0–1) andpreviousTotal,funds(no fund:id: null,"No account"),tags(untagged left out) and the 10 largest expenses intop. Both dates are required; a period over 400 days is a 400 While today is inside the period,avgPerDaydivides by the days started so far (at least 1) instead of all its days, andpreviouscovers only the same elapsed span from its start, capped at its end:previous.dateFrom/dateTodescribe that span, andprevious.total,previous.countand everypreviousTotalcount only it.previous.fullTotalis always the whole previous period. A finished period is compared with the whole previous one.GET /api/analytics/monthly?months=12- Totals per calendar month, with categories, for the lastmonths(1–24) months including the current one, oldest first; a month without expenses has total 0. Months are counted in Asia/Bangkok
A monthly limit per category, in the caller's default currency when it was set. One limit applies to every month; there is no history and no rollover. A limit never blocks an expense: going over is shown, not prevented. A limit may be on a global category; it is still the caller's own. Spent is computed on read with the aggregation analytics uses (every expense of the category counts, converted, in the Asia/Bangkok calendar month).
GET /api/budgets?month=YYYY-MM- User and bot.monthdefaults to the current Bangkok month, a malformed one is 400.{ currency, month, dateFrom, dateTo, daysInMonth, elapsedDays, pacePercent, budgets }, each budget{ id, category, limit, currency, spent, remaining, percent, state, currencyMismatch }, most used first.elapsedDayscounts today; all of a past month, none of a future one.state:overwhen spent > limit; elseaheadwhenpercentis overpacePercent+ 10 or at least 80; elseon_track. A limit set in another currency than the current default one hascurrencyMismatch: trueandnullfigures and state.PUT /api/budgets/:categoryId{ amount }- User only. Create or replace; returns the budget for the current month. Bad amount 400, a category the caller cannot use 404DELETE /api/budgets/:categoryId- User only. 204, 404 when there is no limitPOST /api/costalso returnsbudget: the item above for the month of the expense's date plusmonth, ornullwithout a limit (or if the lookup failed: the expense is saved)
GET /api/telegram/telegram-binding-link- Link that binds the caller's Telegram accountPOST /api/telegram/telegram-unbind- Unbind the caller's Telegram accountPOST /api/telegram/telegram-bind,GET /api/telegram/user-by-telegram/:telegramId- bot only,X-Telegram-Bot-Secret
GET /api/exchange-rates/current,GET /api/exchange-rates/currencies- PublicPOST /api/exchange-rates/update- Fetch fresh rates from the paid API by hand; needsX-Admin-Secretequal toADMIN_SECRET, and does not exist (404) whileADMIN_SECRETis unset. A daily cron updates the rates without it.
Claude connects as a custom connector: in claude.ai → Settings → Connectors → Add
custom connector, URL https://api.wannatrack.shataev.com/mcp (Claude Code:
claude mcp add --transport http wanna-track https://api.wannatrack.shataev.com/mcp).
Claude registers itself, the user signs in on the API's own page and decides whether
Claude may add expenses.
POST /mcp- MCP over Streamable HTTP, stateless. Only an OAuth access token from/oauth/tokenis accepted (no JWT, no bot secret); without one: 401 withWWW-Authenticate: Bearer resource_metadata=…, scope=…. Tools:get_profile,list_categories,list_funds,list_tags,list_expenses,get_summary,get_budgets,get_monthly_trend(scopeexpenses.read), andadd_expense,set_active_tag,set_budget(amount 0 or null removes the limit) (scopeexpenses.write, not even listed without it). They use the same services as the REST routes, so amounts and totals match the web app.GET /.well-known/oauth-protected-resource[/mcp],GET /.well-known/oauth-authorization-server- DiscoveryPOST /oauth/register- Dynamic client registration; public clients only, and only withhttps://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callbackorhttp://localhost|127.0.0.1:<any port>/callback. Body at most 16 KB; only the id, the name (100 characters) and the redirect URIs are kept. A client nobody allows within 24 hours is deleted, at most 200 such clients exist at a time (then 429), and addresses outside Anthropic's160.79.104.0/21may register 100 an hourGET/POST /oauth/authorize- Sign in (10 failed attempts per IP or email in 15 minutes lock the form) and consent; PKCE S256 requiredPOST /oauth/token- Form-encodedauthorization_codeandrefresh_tokengrants. Access tokens last 1 hour, refresh tokens 30 days and work once: each refresh returns a new one, and presenting a used one revokes the whole grantPOST /oauth/revoke- RFC 7009GET /api/oauth/grants,DELETE /api/oauth/grants/:id- The caller's connected apps and disconnecting one (all its tokens stop working); bearer JWT. Allowing an app again replaces the user's grant of the same app (same name and redirect URIs, so claude.ai and Claude Code stay apart); a grant unused for 60 days expires and leaves the list
PUBLIC_API_URL (default https://api.wannatrack.shataev.com) sets the issuer and the
resource URL for a local run.
POST /api/cost
{
"amount": 1500,
"currency": "JPY", // Optional: default is the fund's currency, else the user's
"category": "categoryId",
"comment": "Ramen",
"date": "2026-10-25T10:00:00Z",
"fundId": "fundId", // Optional: link cost to a specific fund
"tags": ["japan-2026"] // Optional: absent means the user's active tag; [] means none
}amount and currency are what was spent. When the fund holds another currency
the fund is debited fundAmount: the amount converted at the stored rate and
rounded to the fund currency's precision; the balance check uses it too, and
the fund transaction's description names the original (Ramen (1500 JPY)).
rate (currency to the user's default currency) is always computed by the
server; a rate in the body is ignored. A currency without a stored rate is a
400 and nothing is debited.
{
"amount": 1500,
"currency": "JPY",
"fundAmount": 366.71,
"rate": 0.2444742,
"tags": ["japan-2026"],
...
}POST /api/funds
{
"name": "Main Account",
"icon": "💰",
"description": "Primary spending account",
"initialBalance": 1000,
"isDefault": true
}POST /api/funds/transfer
{
"fromFundId": "sourceFundId",
"toFundId": "targetFundId",
"amount": 500,
"description": "Transfer to savings"
}amount is in the currency of the source fund. When the two funds hold
different currencies the amount is converted at the stored exchange rate and
rounded to the precision of the target currency, so the response reports both
sides of the operation:
{
"message": "Transferred successfully!",
"withdrawn": { "amount": 1000000, "currency": "VND" },
"credited": { "amount": 1270.95, "currency": "THB" },
"rate": 0.0012709542237442923
}Both of the below touch how fund balances are written. They are known, they predate the multi-currency work, and they are meant to be picked up together as a separate feature — the same handlers have to be rewritten for either one, so doing them in one pass is cheaper than twice.
POST /api/funds/transfer performs four independent writes: two
FundTransaction documents and two $inc updates, one per fund. Each write is
atomic on its own, the sequence is not. If the process dies between the debit
and the credit, the money is withdrawn and never arrives. It cannot be
recovered afterwards: a balance is stored on the fund as a number rather than
derived from the transaction history, so nothing recomputes it.
POST /api/cost has the same gap on a smaller scale — the fund balance is
saved first and the Cost document after it, so a crash in between leaves
money spent with no expense recorded. Editing and deleting a cost write the
refund first, then the new debit, then the cost: an interrupted request leaves
the money with the user, but a retried delete or two concurrent edits can
credit a fund twice.
The realistic trigger is not exotic: a CapRover redeploy, an OOM kill or a dropped Atlas connection while a request is in flight.
Multi-document transactions need a replica set, and every Atlas cluster (including the free M0 tier) is one, so the fix is available without changing infrastructure:
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
await outgoingTransaction.save({ session });
await incomingTransaction.save({ session });
await Fund.findByIdAndUpdate(fromFundId, { $inc: { currentBalance: -withdrawnAmount } }, { session });
await Fund.findByIdAndUpdate(toFundId, { $inc: { currentBalance: creditedAmount } }, { session });
});
} finally {
await session.endSession();
}Worth knowing before starting:
- every operation inside the block needs
{ session }; one that is missing it silently escapes the transaction and survives a rollback withTransactionmay run the callback again after a transient error such as a primary re-election, so it must hold no side effects outside the database- a standalone local
mongodrejects transactions withTransaction numbers are only allowed on a replica set member; run the test container with--replSetto reproduce the production setup
The balance check and the balance update are separate steps. In
POST /api/funds/transfer the handler reads the fund, compares
currentBalance against the requested amount, and only later applies $inc.
Two requests that arrive together both pass the check and both go through,
leaving the fund negative.
POST /api/cost is worse, because it does a read-modify-write
(fund.currentBalance -= amount; await fund.save()): of two concurrent
expenses, one decrement can be lost entirely and the balance ends up higher
than it should be.
A transaction alone does not fix this — the stale read happens inside it just as well. The check has to move into the update itself, so the database evaluates the condition:
const updated = await Fund.findOneAndUpdate(
{ _id: fromFundId, currentBalance: { $gte: withdrawnAmount } },
{ $inc: { currentBalance: -withdrawnAmount } },
{ new: true, session }
);
if (!updated) {
return res.status(400).json({ error: 'Insufficient amount of money in source fund' });
}- Node.js
- Express.js
- MongoDB
- Mongoose
- JWT for authentication
- Clone the repository
- Install dependencies:
npm install
- Create a
.envfile with the following variables:MONGODB_URI=your_mongodb_uri JWT_SECRET=your_jwt_secret PORT=3000 - Start the server:
npm start
To run the server in development mode with auto-reload:
npm run devTo run tests:
npm test