Stellar is built to kinda replace their Note or Journal app in beauty's pros phones.
The product promise was simple: your money data stays on your phone. No account required. No cloud database for your ledger.
But the twist is that we're adding an AI retirement advisor that reads user's numbers and make recommendation of how much they should set aside for retirement. This maybe contradict with the product's promise of privacy. You might be asking, If there's no backend, how do we call the LLMs ?
In this post, I want to walk you through that.
Origin story :
There are 2 main issues I observered over the years in this industry :
-The lack of retirement planning : most of my colleagues + people I know in the different branches are not really saving for retirement. Mostly come from their fluctuating paychecks and no offer from the employer. -The distrust wealth management app: most of them are just uncomfortable with the idea of sharing their financial data, especially knowing someone will be able to see it.
The mental 2 mental models I want to achieve :
- A private ledger that works offline ( not a bank sync . but rather a local database you can log your own numbers ).
- An AI feature that can reason about real totals, not made-up bank statements.
Those goals pull in opposite directions. Local storage says "keep everything on device." . To call the LLMs , the datas have to leave the device. So let me walk you through the process of how I built this system.
Why SQLite on the phone
SQLite solved the ledger side cleanly.
1. Go agaisnt with most of the income/expense tracker app out there, we will just store everything on the phone. This will be the first trust I need to gain from users. This was abstracted by NOT* making users create an account.
2. SQLite is wellknown, opensourced, and battle tested for years, It would quite a very trustworthy solution for our need.
3. The app live under a file named stellar.db in the app documents folder. Every app open runs the same boot sequence:
// Boot: enforce referential integrity, concurrent reads, schema version
await db.execAsync('PRAGMA foreign_keys = ON;');
await db.execAsync('PRAGMA journal_mode = WAL;');
await runMigrations(db);
await verifyDatabaseIntegrity(db);FK matter because SQLite turns them off by default. PRAGMA foreign_keys = ON helps with cascade delete. WAL mode lets the UI read dashboards while a write is inflight. Migrations are append-only files so we never rewrite history for users who already upgraded ( great for version control).
Money math lives in SQL, not in React state. Some of the earlier mistake I made was to load full tables into JavaScript and called .reduce(). That worked in demos and but will not when db exceeds > 10 of thousands of rows. Dashboard totals now come from queries like SELECT SUM(yourShare) WHERE date(date) BETWEEN ? AND ?. Lists paginate. The UI only reads what the screen needs.
We also split gross from take-home on every income row. Commission workers store amount (what the client paid) and yourShare (what actually counts toward balance). Net balance everywhere is:
// Net balance = take-home income minus expenses (not gross client payments)
const netBalance = sumYourShare - sumExpenses;I love SQLite 🤝 🥰
Trade-offs we accepted:
| W | L |
|---|---|
| Offline use, fast aggregates, privacy | No cross-device sync |
| No server bill for core features | No cloud backup |
| Simple mental model for users | Manually logging your own numbers |
For this audience, I think this was the right DB call.
The second problem: AI without a backend
To briefly describe what Polaris does, it generates multi-page retirement reports. Income pulse, savings pace, monthly plan, and similar editions. That requires a language model.
Two bad options showed up immediately.
Option A: Call the model from the phone. You would have to embed an API key in the build. Anyone can extract it from the app binary, you wake up with an API bill that slaps you in the face.
Option B: Build a full backend. Rails, Node, Postgres, auth, deploy pipelines. All of that just to proxy one HTTP call and check a subscription. For my use case I choose not to do this, more things to maintain. It's like reinventing the wheel.
What I learned Mobile apps cannot hold provider secrets. But "no backend" does not mean "put the key in .env and hope." You still need a small trusted place that holds the key, checks who paid, and returns structured JSON the UI can render.
Solution: A small cloud function (we used a Cloudflare Worker), great documentations for Agents to work with. One file, a few routes, no user database. The phone talks only to that function. The function holds your LLM provider API key and calls the model on the server.
We use the cloud func here to hold our secrets and subscription checks, not as a 2nd database.
Configure the client
The app reads public Expo env vars for the cloud function URL and a shared token. The real LLM API key never ships in the binary.
export function getPolarisWorkerConfig(): PolarisWorkerConfig {
const workerUrl = process.env.EXPO_PUBLIC_POLARIS_WORKER_URL ?? '';
const authToken = process.env.EXPO_PUBLIC_POLARIS_TOKEN ?? '';
return {
workerUrl,
authToken,
analyzeEndpoint: workerUrl ? `${workerUrl.replace(/\/$/, '')}/analyze` : '',
isConfigured: workerUrl.length > 0,
hasAuthToken: authToken.length > 0,
};
}Each analyze request also sends the billing app's anonymous user id in headers. That matters when there is no login.
export async function buildAnalyzeRequestHeaders(
authToken: string,
getAppUserId: () => Promise<string>,
): Promise<Record<string, string>> {
const appUserId = await getAppUserId();
return {
'Content-Type': 'application/json',
'X-Stellar-Token': authToken,
'X-App-User-Id': appUserId,
};
}Be honest here: the public token can be extracted from the IPA. It stops casual abuse. Paid access is enforced server-side, not by hiding a string in the client.
Why subscription checks matter when there is no account
Overview : You would have to pay money for the API calls. Anyone can extract the token from the IPA and abuse it since Polaris are made to generate multi-reports that related to the user's questions of their finance picture.
Why? "No account" applies to the ledger, not to billing. You still need to answer one question before calling the model: did this install ( device ) match the subscription ( monthly / annually ).
Traditional apps tie that to a login. Email signup, password, user row in DB, session token. We deliberately skipped that for money data.
Purchases still go through AppStore or Google Play. When someone subscribes, the store holds the receipt. RevenueCat sits between your app and those stores. It gives each install an anonymous app user id (a random string). That id links to an active subscription without you storing email, name, or ledger rows on a server.
Flow in plain terms:
- User Subscribe in the app. AppStore or Google Play processes payment.
- RevenueCat records that this app user id has an active plan.
- On interaction with the AI feature, the app sends that id in a header (
X-App-User-Id). - The cloud function calls RevenueCat with a secret server key (never in the app) and asks: is this id still subscribed?
- Only then does it call the LLM provider.
The in-app paywall is UX. It hides buttons and shows pricing. The cloud function is authority. A modified client can skip your UI. It cannot forge a valid subscription flag without paying the store.
Why not skip RevenueCat and trust the client? Because the shared token is the same for every copy of the app. Token plus "I promise I paid" in the request body would let anyone who extracts the token generate unlimited reports on your dime.
Why RevenueCat specifically? Once again I didn't want to reinvent the wheel so I can focus on iterate other things. If you don't know RevCat , the short description is : It handles all the little details of the Stores for you.
What the app user id isn't: It is not the user's name, email, or SQLite user row. It is a purchase handle. We send it in HTTP headers for billing checks only. It does not appear inside the prompt text sent to the model.
Trade-off: If RevenueCat's API is down, report generation stops. That is intentional. A paid feature should not become free because verification failed open.
Step 2: Assemble context from SQLite on the phone
When a user opens a Polaris report, the screen does not upload the database file. Hooks query SQLite for aggregates: monthly income, expense totals, streak signals, goal summaries, retirement setup from local store.
Those hooks feed buildReportContext, which produces a typed ReportContext object. From there, two prompt builders run on device:
buildSystemPrompt(type)sets persona, task, and the JSON shape the UI expects.buildUserPrompt(type, context)turns metrics into a text block the model must follow.
Financial models invent numbers if you let them. We fixed that with a DATA CONTRACT: dollar figures copied straight from SQL aggregates, labeled as the only figures the model may cite.
return `
DATA CONTRACT (authoritative, cite only these figures):
Calendar month (matches Money app Balance card, "Monthly" toggle):
- Income: $${t.monthIncomeTotal.toLocaleString()}
- Expenses: $${t.monthExpenseTotal.toLocaleString()}
- Net balance (income − expenses): $${t.monthNetBalance.toLocaleString()}
All-time (matches Money app Balance card, "All Time" toggle):
- Income: $${t.allTimeIncomeTotal.toLocaleString()}
- Expenses: $${t.allTimeExpenseTotal.toLocaleString()}
- Net balance: $${t.allTimeNetBalance.toLocaleString()}
Last 7 days (through today, recent activity):
- Income: $${t.lastSevenDaysIncomeTotal.toLocaleString()}
- Expenses: $${t.lastSevenDaysExpenseTotal.toLocaleString()}
`;Before prompts ship, buildUserDataSnapshot clamps ages, rounds money to integers, and strips goal objects down to name, amounts, and target date. No avatar paths. No display names. No raw income notes.
function sanitizeGoals(goals: GoalSummary[]): GoalSummary[] {
return goals.map((goal) => ({
name: goal.name,
targetAmount: toNonNegativeInteger(goal.targetAmount),
currentAmount: toNonNegativeInteger(goal.currentAmount),
targetDate: goal.targetDate,
}));
}Every edition also embeds a privacy line in the schema instructions:
export const EDITION_PRIVACY_NOTE =
'Built from your Stellar logs and setup answers. No personal identifiers are sent to Polaris.';The UI shows a "data used" footer so users can see what the edition claims it relied on. We have to be transparent with users.
Step 3: Call the cloud function from a hook
useReportGenerator is the single front door for network generation. It checks a 24-hour cache first (Zustand plus SQLite table polaris_advisor_reports). On cache miss, it POSTs prompts to the cloud function.
const headers = await buildAnalyzeRequestHeaders(
workerConfig.authToken,
() => polarisBilling.getAppUserId(),
);
const res = await fetch(workerConfig.analyzeEndpoint, {
method: 'POST',
signal: controller.signal,
headers,
body: JSON.stringify({
systemPrompt,
userPrompt,
templateId: type,
}),
});The request body is text prompts, not a SQL dump. The response is JSON the app parses into magazine pages. After validation, commitGeneration writes the brief back to SQLite so the next open is instant.
Quick Entry and the rest of the ledger never touch this path. You log $200 service plus $30 tip into SQLite through repositories. Polaris reads those rows later through aggregate queries. Same database, two different trust boundaries.
Step 4: What the cloud function actually does
The route POST /analyze is a straight pipeline. No ORM. No user table.
async function handleAnalyze(request: Request, env: Env): Promise<Response> {
if (!validateToken(request, env)) {
return errorResponse('Unauthorized', 401);
}
// validate body: templateId, systemPrompt, userPrompt
const appUserId = request.headers.get('X-App-User-Id');
if (!appUserId) {
return errorResponse('Missing X-App-User-Id', 401);
}
const hasSubscription = await checkActiveSubscription(appUserId, env);
if (!hasSubscription) {
return errorResponse('Paid subscription required', 403);
}
const result = await callLlmApi(body.templateId, body.systemPrompt, body.userPrompt, env);
if (!result.success) {
return errorResponse(result.error, 502);
}
return jsonResponse({
templateId: body.templateId,
...result.data,
generatedAt: new Date().toISOString(),
});
}Secrets live only on the server:
| Secret | Purpose |
|---|---|
LLM_API_KEY | Calls your LLM provider |
WORKER_SECRET | Matches app token header |
BILLING_SECRET_KEY | Server-side subscription check (e.g. RevenueCat) |
The model returns text. Don't trust it blindly. The cloud function parses JSON, validates a five-page magazine contract, retries with a stricter suffix prompt if fields are missing, then falls back to a second model. Only validated JSON reaches the phone.
const response = await fetch('https://api.your-llm-provider.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: options.model,
messages,
temperature: 0.3, // lower temperature = less creative number invention
max_tokens: options.maxTokens,
}),
});We also expose GET /api/projection-config from key-value storage on the same cloud function. IRA contribution limits and return assumptions can change yearly. Updating remote config beats shipping a new App Store build for a number Congress adjusts.
Privacy: what leaves the phone and what does not
This is the section recruiters and users both care about. We split the app into two paths on purpose.
Stays on device
| Data | Where |
|---|---|
| Full income and expense rows (dates, notes, per-entry detail) | stellar.db |
| User display name, avatar, work schedule | SQLite users table |
| Generated brief JSON after first fetch | SQLite polaris_advisor_reports |
| Onboarding income guess used only for first prompt | AsyncStorage, never written to SQLite |
| API keys | Cloud function secrets only |
Everyday tracking works in airplane mode ✈️.
Leaves the device (only when generating a report)
| Data | Why |
|---|---|
| Aggregated totals (month, all-time, 7-day, analysis window) | Model needs real dollars, not guesses |
| Derived metrics (volatility %, best/worst week amounts) | Pattern language in reports |
| Retirement setup (age, account type, EF target, contribution target) | Personalized guidance |
| Goal names, amounts, target dates | Goals analysis edition |
| Expense category names and totals | Monthly digest |
| RevenueCat app user id (HTTP header only, billing only) | Subscription enforcement |
We do not send email, legal name, device identifiers in the prompt body, or a full transaction export. The model sees summaries built from SQL, not a CSV of every row.
Honest caveat: if a user names a goal "Mom's surgery fund," that string can appear in a prompt. We say "minimized," not "anonymous." Custom labels are user-authored text. We still avoid shipping the whole ledger.
Protection layers in order:
- Minimize before send. Aggregates and counts, not raw narratives.
- Sanitize shapes. Round money, clamp ages, strip extra goal fields.
- Contract in the prompt. Instruct the model to cite only DATA CONTRACT figures for dollar amounts.
- Local planning engine for recommendations. Monthly contribution dollars come from deterministic code. The LLM narrates; it does not invent the math.
- HTTPS to the cloud function. Server logs focus on timings and token counts, not finance dumps.
- OS sandbox. The SQLite file lives in the app container. Optional biometric app lock adds another gate.
Architecture diagram
Trade-offs worth naming
Static app token is extractable. Real gate is the server-side subscription check.
No streaming. We wait for full JSON so we can validate before rendering. Simpler UI, slower perceived wait.
Client-built prompts. The cloud function stays thin, but the LLM provider still sees prompt text. Minimization is a product rule, not a crypto guarantee.
24-hour report cache. Fewer API calls and faster reopen. Advice can lag until cache expires or the data fingerprint changes.
Single LLM vendor with model fallback. One provider, two models in cascade. You can swap vendors behind the same cloud function interface later.
No cross-device sync for ledgers. Privacy win, backup burden on the user. Reminders nudge export.
Subscription check blocks on error. If the billing API is down, generate stops. Paid feature should not leak because verification failed open.
Each row is a product choice, not an accident.
Closing thought
- I hope this helps you to understand the overall architecture of why a local ledger for an income/expense tracker app in my use case, as well as how it should communicate with the LLMs. There still many other variety of choices out there, based on your use case and design goals, many approaches can be made.