CodeKits.devNew developer offer

SwiftUI Expense Tracker with Node.js and MySQL

Build an expense tracker with a shared Node.js and MySQL API, precise money handling, secure accounts and predictable web and iPhone updates.

By Jonathan · 11 min read

Published

Expense tracker tutorial with a shared Node.js and MySQL backend
In this article

What you will build

Build an expense tracker with precise amounts, calendar-date filtering, authenticated accounts and a shared API. The SwiftUI companion uses the same stored expenses as the web dashboard.

This guide explains the architecture and implementation sequence. It is not a downloadable, production-ready application. The paired web and iPhone clients use the same authenticated API.

Define the shared product

Start with a deliberately small product. A user records an expense with an amount, a category, a description, and a spending date. The web dashboard and the iPhone application share the same backend, so a saved expense can appear on either device after refreshing. For this first release, use one currency per account. Do not add different currencies together or imply that a total includes currency conversion when no conversion system exists.

Trace one complete expense

Trace one expense through the whole system before building a large dashboard. The interface collects user input and sends a documented request. The server authenticates the caller, validates the values, and stores the record under that user's identity. The response returns the saved expense with its stable identifier. Only then should the interface report success. This end to end path gives you a useful baseline for testing both clients.

Store money without floating point errors

Choose a precise representation for money. In this example the account currency has two decimal places, and the API stores amounts as integer minor units. Twelve pounds and thirty four pence becomes twelve hundred and thirty four. Parse the input as decimal text rather than multiplying an arbitrary floating point value and hoping rounding fixes it. Define a maximum amount, reject extra precision, and document how refunds would be represented before adding them.

Separate spending dates from timestamps

A spending date and a creation timestamp answer different questions. Store the date selected by the user as a calendar date, while retaining a server timestamp for when the record was created. Validate real calendar dates rather than only checking the shape of a string. Filter a month using an inclusive start and an exclusive next-month boundary. This prevents a local date from moving into another month because a client converted it through midnight in a different timezone.

Design expenses and categories

Give expenses and categories explicit owners. Each expense refers to a category that belongs to the same user. A foreign key verifies existence but does not automatically establish that ownership rule, so enforce it during the server operation as well. Add indexes that match the user and month filters. Keep a version value if you want to detect edits from two devices, and use migrations rather than repeatedly resetting a database containing real records.

Agree on the API contract

Write down a small API contract that both clients can use. List the accepted fields, response shape, and error codes for each operation. The server derives the user identity from authentication, never from a user identifier supplied in the expense body. Keep list requests bounded and return a stable ordering. A separate summary endpoint can calculate the whole month's totals, which avoids accidentally presenting the total of only the current page as the monthly total.

Agree on the mobile contract

Define the authentication contract before drawing the login screen. The backend validates credentials and controls token lifetime and revocation. A mobile client can use a short-lived access token with a refresh session designed for rotation. This is a different contract from a browser-only cookie flow. Document error responses and decide how the application behaves when the refresh session is no longer valid.

Model responses explicitly

Create small decodable models that match the server response. Check the web status before decoding success data so an error response doesn't become a confusing model failure. Validate the expected content and never print tokens during debugging. If the backend returns a different naming convention, configure coding keys deliberately. Keep authentication transport concerns separate from the view's form state.

Submit over HTTPS

Send credentials over a trusted secure connection and keep the platform's certificate validation enabled. Set an appropriate timeout and handle cancellation. Don't disable transport security to fix a development hostname problem. Disable duplicate submission while the request is running, show a general authentication failure message, and clear the password from the form when it is no longer needed.

Store refresh credentials in Keychain

Use Keychain for the refresh credential rather than ordinary preferences or a plain file. Choose an accessibility policy that matches the app's foreground or background requirements. This excerpt shows insertion; the complete wrapper must handle existing items, update, lookup, deletion, and returned status codes. Keep the short-lived access token in memory where practical, and avoid exposing either token through diagnostic logs.

Coordinate authenticated state

Let a dedicated store coordinate authentication state on the main actor. Views can then render signed-out, loading, signed-in, and recovery states without each making independent token decisions. At startup, attempt the documented session restoration flow and load the authoritative profile. A cached email address isn't proof of a valid session. Handle an unavailable network differently from credentials that the server has explicitly rejected.

Refresh without a request storm

When several requests discover expiry together, coordinate one refresh operation rather than sending many competing rotations. Persist the newly rotated refresh credential safely before discarding the old one according to the protocol. Retry a failed authenticated request only within a bounded policy. For mutations, account for idempotency and uncertain completion. A rejected refresh session should return the interface to a clear signed-out state.

Revoke on logout

Logout clears the local credentials and asks the server to revoke the refresh session. Offline logout can remove local access immediately, but it can't guarantee remote revocation until the server is contacted. Design token lifetimes around that distinction. Also clear account-specific caches and pending requests so a second user doesn't see the first user's data. Test switching accounts as well as a simple sign-out screen.

Decode the expense contract

Make the Swift model match the documented API response exactly. This example uses string identifiers, integer minor units, and a calendar-date string. Configure explicit coding keys if the server uses different field names. Do not silently substitute zero for an amount that failed to decode, because that hides a contract error. Decode a saved response fixture before connecting the screen, then test invalid and missing required fields as explicit failures.

Build the SwiftUI form and summary

Build the monthly list and an add-expense sheet around a view model that owns request state. Keep networking out of the view body and update visible state on the appropriate actor. Use a suitable numeric keyboard but still validate the decimal text, including the user's locale. Display the backend's category totals with accessible labels. A successful save closes the sheet only after confirmation, while a failure preserves the entered values and offers a deliberate retry.

Refresh both clients without false promises

Sharing a database does not automatically push changes into an open screen. In this version, refresh after saving and support pull to refresh when changes may have happened on another device. Explain that behaviour rather than promising real time synchronization. Cancel or ignore stale requests when the selected month or account changes. Keep the list and summary associated with the same requested month, so a slow response cannot overwrite the screen after the user has moved elsewhere.

Make retries idempotent

Generate one request identifier for a particular intended creation and reuse it when retrying that same operation. Store the identifier with a uniqueness rule scoped to the authenticated user. If the request is repeated, return the original outcome rather than inserting a second expense. Compare the payload when resolving duplicates so the same identifier cannot silently refer to different content. Disabling the button improves usability, but database-backed idempotency protects against retries outside the interface.

Filter and paginate predictably

Apply the authenticated user and date range in the database query itself. Use a stable secondary identifier when multiple expenses share a spending date. Limit the page size and validate pagination inputs on the server. Keep the month definition identical between the list and summary endpoint. A chart and a list that disagree because they use different timezone or filtering rules will confuse users even when each individual query appears reasonable in isolation.

Calculate the full monthly summary

Calculate monthly category totals from all matching records, not just the visible page. Keep the user and date filters in the aggregate query. Database drivers may return large integer sums as strings, so define the response contract deliberately and avoid silently losing precision during conversion. Format the amount only at the presentation boundary. An empty month is a valid result with zero totals, and the chart should provide a readable text equivalent rather than relying on colour alone.

Handle edits from two devices

A web browser and an iPhone can edit the same expense. Use a version check when accepting a change so an old screen cannot silently overwrite a newer value. Keep ownership in the update predicate as well. If the version no longer matches, return a conflict and let the user reload or deliberately reapply their edit. Do not label every zero-row update as a permission failure; distinguish the relevant conditions through your documented server behaviour.

Keep category changes consistent

Category names may change without changing the identity of historical expenses. Decide what deleting a category means before adding the button. A simple first policy rejects deletion while expenses still reference it, or requires an explicit reassignment. Avoid an accidental cascade that removes financial history when someone cleans up a category list. Apply the same ownership checks to category reads and mutations, because an expense application can leak private information through supporting endpoints as well as through the main list.

Test isolation and calculation boundaries

Use two accounts to test the privacy boundary, including guessed expense and category identifiers. Verify calculations with small decimal amounts, large allowed totals, and an empty month. Check calendar boundaries and a leap day. Repeat the same creation request and confirm there is only one stored expense. Edit from two clients and verify that an outdated version produces the intended conflict. Inspect database state after rejected mutations instead of checking only the error message.

Choose an honest offline scope

Start with read-only offline access. A cached catalogue can remain useful when the network disappears without pretending that every server feature still works. Define which records are safe to keep on the device, how long they remain valid, and what must be removed on logout. Show the last successful refresh time so users can distinguish current data from an older local copy.

Version the stored envelope

Store metadata alongside the cached records. A schema version lets future app releases reject or migrate incompatible files. An account identifier prevents one person's data from being loaded for another account. Save the successful refresh time rather than the last attempted request. Validate the envelope when reading it, and have a clear fallback for corruption instead of crashing during application startup.

Write atomically

Write a complete new cache atomically so interruption doesn't leave a partially written file as the only copy. Keep the path inside an application-controlled storage location and choose a directory appropriate to whether the data is disposable or essential. Apply suitable platform data protection for sensitive records. This file is a cache, not a replacement for the authoritative server database.

Replace only after successful decoding

Treat a successful, validated server response as the point at which new data becomes authoritative. Decide how to handle a disk-write failure: the interface may still display fresh data while warning that offline storage wasn't updated. Don't turn a server error page into an empty catalogue. Preserve the previous snapshot when the request or decoding fails, and make that stale state visible.

Protect account boundaries

Offline storage extends the lifetime of data on the device. Apply the same account boundaries as the online application. Clear or segregate cached records when users switch accounts, and consider whether some sensitive data should never be cached at all. If you later add offline edits, introduce identifiers, an outbox, retry rules, and conflict resolution deliberately. A read cache alone doesn't solve synchronization.

Deploy and verify the shared backend

Deploy the backend behind HTTPS and keep database access private. Use separate development and production configuration, protect secrets, and apply migrations with a recovery plan. Confirm that both clients point to the intended environment before testing synchronization. Create an expense on one client and refresh the other, then reverse the direction. Monitor failures without logging passwords, tokens, or unnecessary expense descriptions. Keep backups and practise restoring them into an isolated test environment.

Continue the project

For implementation help with the web client, native app or shared backend, see CodeKits development services.

Reference documentation