PocketQuest

Plan-based budgeting with structured periods

MobileBudgetingReact Native
2026MobileBudgetingReact NativeRepo ↗

Demo

video

PocketQuest - Mobile Demo (React Native + Expo)

System Architecture

system-first

This project is designed around clear boundaries: shared contracts at the edge, domain rules in the service layer, and persistence isolated behind repositories.

Mobile (React Native) ↓ Shared Contracts (SSOT) ↓ API Route (Boundary) ↓ Service (Domain Rules) ↓ Repository (Prisma) ↓ PostgreSQL

Domain Rules

principles

PocketQuest is designed to be predictable and maintainable: policies live in the domain layer, boundaries are explicit, and infrastructure details don't leak into the UI.

Impact: This structure eliminates API drift between client and server, reduces duplicated validation logic, and prevents timezone-related reporting inconsistencies. Refactors remain localized to a single layer without cascading UI changes.

Trade-offs: Introducing explicit layers (route → service → repository) adds boilerplate compared to a monolithic handler, but improves long-term maintainability and makes domain policies testable in isolation.

Clear boundaries

Routes handle auth + parsing, services enforce domain policies, repositories isolate persistence. Each layer has one job.

Shared contracts (SSOT)

Client and server share enums + schemas so API shapes stay stable. This reduces drift and makes refactors safer.

Deterministic behavior

Reporting and summaries are computed on the server with timezone-aware boundaries, so the UI remains consistent across devices.

Stable error semantics

Infrastructure errors are mapped into HTTP semantics to keep client handling consistent and avoid leaking implementation details.

Example: These principles are concretely implemented in the Transactions module, where EXPENSE / INCOME / SAVING are treated as domain-level concepts rather than simple enums, reinforced by canonical category keys and ownership validation.

Selected Code

excerpts

Short excerpts that highlight the architectural boundaries and domain policies behind the Transactions system.

Shared contract (SSOT)

Client and server share the same enums and schemas to keep API boundaries stable.

// packages/shared/src/transactions/types.ts
export const RANGE_VALUES = ["THIS_MONTH","LAST_MONTH","THIS_YEAR","ALL"] as const;
export const rangeSchema = z.enum(RANGE_VALUES as any);

export const transactionCreateSchema = z.object({
  type: z.enum(["EXPENSE","INCOME","SAVING"] as any),
  amountMinor: z.number().int().nonnegative(),
  category: z.string().min(1),
  savingsGoalId: z.string().nullable().optional(),
  occurredAtISO: z.string().datetime(),
});

Canonical categories

Aliases fold into stable server keys. SAVING forces a canonical category="savings".

// packages/shared/src/transactions/categories.ts
const ALIASES: Record<string, string> = {
  restaurant: "dining",
  transportation: "transport",
};

export function canonicalCategoryKeyForServer(raw: string, type?: string) {
  const key = String(raw ?? "").trim().toLowerCase();
  const folded = ALIASES[key] ?? key;
  if (type === "SAVING") return "savings";
  return folded || "other";
}

Route boundary

Routes stay thin: auth, parse, validate, then delegate to services.

// apps/server/src/app/api/transactions/route.ts
const auth = await requireUserId(request);
if (!auth.ok) return auth.response;

const parsedRange = rangeSchema.safeParse(rawRange.toUpperCase());
if (!parsedRange.success) return json400();

const data = transactionCreateSchema.parse(body);
return NextResponse.json(
  await createTransactionForUser({ userId: auth.userId, data }),
  { status: 201 },
);

Domain rule: SAVING semantics

SAVING is a first-class type: requires savingsGoalId and enforces ownership validation.

// apps/server/src/domain/transaction/transaction.service.ts
if (type === "SAVING") {
  if (!savingsGoalId) {
    throw new HttpError(400, "savingsGoalId required");
  }
  await assertSavingsGoalOwnership({ userId, savingsGoalId });
  category = "savings";
}

Timezone-aware reporting

Period ranges are computed on the server with timezone boundaries for deterministic summaries.

// apps/server/src/domain/transaction/transaction.service.ts
const { fromISO, toISO } = computeOccurredAtFilter({
  range, // THIS_MONTH / LAST_MONTH / ...
  timezone: user.timeZone,
});

return repo.listTransactions({
  userId,
  occurredAtGte: fromISO,
  occurredAtLt: toISO,
});

Infrastructure semantics

Prisma errors are mapped into HTTP semantics to avoid leaking infrastructure details.

// apps/server/src/lib/prismaErrors.ts
return prismaHttpGuard(async () => {
  return await prisma.transaction.update({
    where: { id, userId },
    data,
  });
});

Client normalization

UI consumes one shape. Legacy fields and API drift are absorbed in the store.

// apps/mobile/src/app/store/transactionsStore.ts
const amountMinor =
  typeof tx.amountMinor === "number" ? tx.amountMinor :
  typeof tx.amountCents === "number" ? tx.amountCents : 0;

const occurredAtISO = tx.occurredAtISO ?? tx.occurredAt ?? "";
return { ...tx, amountMinor, occurredAtISO };

Transport rules (DTO normalize)

Canonical categories and partial-update safety are enforced before hitting the API.

// apps/mobile/src/app/api/transactionsApi.ts
function normalizeCreateDTO(input: any) {
  return {
    ...input,
    category: canonicalCategoryKeyForServer(input.category, input.type),
    savingsGoalId: input.type === "SAVING" ? input.savingsGoalId : undefined,
  };
}

Each excerpt points to a specific boundary: shared contract, category canonicalization, route, service policy, timezone filters, persistence semantics, client normalization, and transport rules.

These same architectural patterns are consistently applied across other modules such as Savings, Plans (period logic), and Dashboard aggregation, ensuring predictable behavior throughout the entire application.