Skip to main content

Bilko Mobile Implementation Spec — Phase 1

Bilko Mobile Companion — Phase 1 Implementation Spec

Document type: Implementation Specification (Phase 1) MC task: #102483 Author: Paul Hudson / Skybound Date: 2026-05-28 Status: Direction approved; not dispatch-ready until Phase 0 auth/backend blockers are closed Updated: 2026-06-04 Depends on: docs/mobile/MOBILE-ARCHITECTURE.md, docs/mobile/MOBILE-PRD.md


1. Technology Decision

1.1 Framework — React Native with Expo (Managed Workflow)

Decision: React Native + Expo SDK (managed workflow first, bare only if forced)

Rationale grounded in the existing Bilko codebase:

  • The web app is Next.js 15 / TypeScript / React 19. React Native shares language, linting config (@alai/eslint-config, @alai/tsconfig), and design system primitives (Lucide icons, Tailwind-aligned token nomenclature). A Flutter build would require duplicating all of this in Dart with no reuse.
  • The Zustand store shapes in apps/web/lib/stores/ can be mirrored/adapted in React Native. Do not copy them verbatim: the web stores depend on a browser/cookie-oriented API client, while mobile uses native secure storage and OIDC/PKCE auth. Business logic in packages/domain-hr/, packages/domain-rs/, packages/domain-ba/ is plain TypeScript and portable where imports remain React Native-safe.
  • Native Swift + Kotlin is two separate codebases with two release tracks. It would double the build, test, and maintenance load for an early-phase product with no native computation requirements.
  • Expo Managed Workflow provides camera (expo-camera), secure storage (expo-secure-store), file system (expo-file-system), and push notifications (expo-notifications) without a native build machine dependency in early development. The team gets TestFlight/Play Console builds via EAS Build with no Mac required for CI runners.
  • Eject to bare workflow only if a capability unavailable in the managed SDK is required (e.g., a specific background processing extension or a native SE/eSIM integration). This is unlikely in Phase 1 or Phase 2 scope.

Not chosen: Flutter. Dart is a cold start for the team. No design system reuse. Separate CI pipeline. No business justification at current headcount. Not chosen: Native Swift/Kotlin. Two codebases. No shared types or API client. Onerous CI/code-signing setup for MVP.

1.2 Navigation — Expo Router v4 (file-based, built on React Navigation v7)

Expo Router is the canonical navigation layer in Expo SDK 52+. It is file-system-based (mirrors Next.js App Router conventions the team already knows), supports typed routes, and integrates deep links and universal links via its built-in linking config. It wraps React Navigation v7 internally.

Directory layout under apps/mobile/app/ maps directly to routes — (auth)/login.tsx, (tabs)/today/index.tsx, etc. No separate navigator configuration file is needed for the basic shell; the file tree is the configuration.

Do NOT use React Navigation v7 standalone (without Expo Router). The file-based approach is preferred.

1.3 State Management — Zustand (mirror web stores)

The web app already has Zustand 4.5.0 installed and uses typed stores in apps/web/lib/stores/. Mobile should mirror the same store shape for dashboard, invoices, expenses, and auth.

Pattern: one store file per domain slice — src/store/authStore.ts, src/store/dashboardStore.ts, src/store/invoiceStore.ts, src/store/expenseStore.ts, src/store/settingsStore.ts.

Stores hold: remote data state, loading/error flags, optimistic update helpers, and (Phase 2) sync queue references. They do NOT hold tokens — tokens live in Keychain only (see section 2.3).

Alternatives considered and rejected:

  • Redux Toolkit: more ceremony, overkill for the screen count in Phase 1.
  • TanStack Query: good fit for server-state, but adds another dependency and does not solve the auth/session store or the future offline queue. Adding it in Phase 2 for caching is fine, but Zustand is sufficient for Phase 1.
  • Jotai: atomic model is less predictable for the dashboard aggregation pattern.

1.4 API Client — Typed fetch wrapper (adapt apps/web/lib/api.ts)

The web app's api.ts is a useful reference for endpoint shape, error handling, and multipart upload, but mobile must not reuse browser-only cookie/session assumptions. The web client is a typed fetch wrapper around getApiBaseUrl() with:

  • In-memory access token (_accessToken module variable)
  • Automatic 401 → refresh → retry cycle
  • Typed error objects with status, code, details
  • multipart/form-data upload support (already used for expenses.uploadDocument and invoices.uploadReceipt)

Mobile should reproduce the endpoint/error/upload pattern in src/api/client.ts with these differences:

  1. Token/session material is loaded from expo-secure-store on app launch and stored back on refresh/session update.
  2. credentials: 'include' cookie refresh is not used in React Native.
  3. Customer login uses Microsoft Entra External ID via OIDC Authorization Code + PKCE (expo-auth-session) and then exchanges/validates the Entra token with Bilko backend for Bilko org/role context.

API base URL resolution: always https://api.bilko.cloud/api/v1 (production) or an env-var override. The window.location hostname trick used in the web api-base.ts does not apply in React Native. Use EXPO_PUBLIC_API_URL instead.

Library: native fetch (available in React Native). No axios. No ky. These are unnecessary given the thin abstraction already proven in the web codebase.

1.5 Token Storage — expo-secure-store

Entra/Bilko access, ID, refresh/session tokens only in expo-secure-store.

Reasoning: expo-secure-store maps to iOS Keychain Services and Android Keystore under the hood. It satisfies the hard security requirement in MOBILE-ARCHITECTURE.md section 10: "tokens only in Keychain/Keystore-backed secure storage." AsyncStorage is plaintext on disk and is forbidden for token storage.

Key names:

  • bilko_access_token
  • bilko_id_token
  • bilko_refresh_or_session_token
  • bilko_token_expires_at (ISO string — used to proactively refresh before expiry)

On cold start the auth store reads from SecureStore; on logout it wipes all auth keys.

react-native-keychain is an alternative but it requires native code changes and does not work in the Expo managed workflow without a custom dev client build. expo-secure-store is the managed-workflow-safe choice.

1.6 Camera and OCR Library — expo-camera + expo-image-manipulator

expo-camera is the managed workflow camera primitive. It handles permission requests, preview, and photo capture on both iOS and Android. Phase 1 uses it only for still photo capture.

expo-image-manipulator (also managed) is used to resize images to max 1920px on the longest dimension and compress to JPEG ≤ 0.85 quality before upload. This is the only Phase 1 image processing step; no on-device OCR.

expo-document-picker handles the PDF/file import path from the share sheet (Phase 2, but the library is added to the Phase 1 scaffold so it does not trigger a native rebuild later).

1.7 Localization — expo-localization + i18n-js (mirror web message files)

The web app has five locale files: bs.json, en.json, hr.json, sr-Cyrl.json, sr-Latn.json. These files use next-intl conventions with namespaced keys.

Mobile should import the same message JSON files via a shared package (packages/i18n/) or by symlinking apps/web/messages/ for Phase 1. The locale is detected via expo-localization (device locale) and can be overridden in Settings.

Library: i18n-js (maintained, small, works in RN) or react-i18next (heavier but matches the pattern if the team wants parity with web). Recommendation: i18n-js for Phase 1 to keep the bundle lean.

Market detection for feature gating (HR-only travel orders): driven by organization.country from GET /auth/me, not by device locale.


2. Project Structure

2.1 apps/mobile/ Directory Layout

apps/mobile/
├── app/                              # Expo Router routes (file = route)
│   ├── (auth)/
│   │   ├── _layout.tsx              # Auth stack layout (no tab bar)
│   │   └── login.tsx                # Login screen
│   ├── (tabs)/
│   │   ├── _layout.tsx              # Tab bar layout (authenticated shell)
│   │   ├── today/
│   │   │   └── index.tsx            # Dashboard / Today screen
│   │   ├── invoices/
│   │   │   ├── index.tsx            # Invoice list
│   │   │   └── [id].tsx             # Invoice detail
│   │   ├── expenses/
│   │   │   ├── index.tsx            # Expense list / recent activity
│   │   │   └── scan.tsx             # Camera capture screen (modal)
│   │   └── more/
│   │       └── index.tsx            # Profile / Settings / Logout
│   ├── travel-order/
│   │   └── new.tsx                  # Travel order wizard (HR only — gated)
│   └── _layout.tsx                  # Root layout (font loading, splash guard)
├── src/
│   ├── api/
│   │   ├── client.ts                # Base fetch wrapper (mirrors web api-base + api.ts)
│   │   ├── auth.ts                  # /auth/* endpoint methods
│   │   ├── dashboard.ts             # /reports/dashboard + /mobile/dashboard
│   │   ├── invoices.ts              # /invoices/* endpoint methods
│   │   ├── expenses.ts              # /expenses/* endpoint methods
│   │   └── travel-orders.ts        # /travel-orders/* (HR only)
│   ├── components/
│   │   ├── ui/                      # Bilko design-system primitives (Button, Card, Badge, etc.)
│   │   ├── InvoiceCard.tsx
│   │   ├── ExpenseCard.tsx
│   │   ├── DashboardSummary.tsx
│   │   ├── CountryBadge.tsx         # Currency + country label display
│   │   └── SyncStatusBar.tsx        # Phase 2 — placeholder only in Phase 1
│   ├── hooks/
│   │   ├── useAuth.ts               # Read auth store + token helpers
│   │   ├── useDashboard.ts
│   │   ├── useInvoices.ts
│   │   ├── useExpenses.ts
│   │   └── useCountryConfig.ts      # Reads org.country → feature flags
│   ├── store/
│   │   ├── authStore.ts             # JWT tokens, user profile, org metadata
│   │   ├── dashboardStore.ts
│   │   ├── invoiceStore.ts
│   │   └── expenseStore.ts
│   ├── services/
│   │   ├── tokenService.ts          # SecureStore read/write/clear for tokens
│   │   ├── imageService.ts          # Resize + compress before upload
│   │   └── countryService.ts        # Country config → feature gate helpers
│   ├── i18n/
│   │   ├── index.ts                 # i18n-js setup + locale detection
│   │   └── messages/                # Symlink to apps/web/messages/ or copy
│   ├── types/
│   │   ├── api.ts                   # Shared API response types (copied from web where stable)
│   │   └── country.ts               # CountryMobileConfig (from MOBILE-ARCHITECTURE.md)
│   └── utils/
│       ├── currency.ts              # Format currency by country (EUR/RSD/BAM)
│       ├── date.ts                  # Date formatting helpers
│       └── validation.ts            # Form field validators
├── assets/
│   ├── images/
│   │   ├── icon.png                 # 1024x1024 app icon (Bilko "B" logo)
│   │   └── splash.png               # 1284x2778 splash screen
│   └── fonts/
│       ├── WorkSans-*.ttf           # Body font (mirrors web)
│       └── NationalPark-*.otf       # Heading font (mirrors web)
├── app.config.ts                    # Expo config (bundle IDs, env vars, plugins)
├── eas.json                         # EAS Build profiles (dev/staging/production)
├── package.json
└── tsconfig.json                    # Extends @alai/tsconfig

Key structural choices:

  • app/ is Expo Router routes. It contains only thin screen files that import from src/.
  • src/ contains all business logic, components, hooks, stores, and API calls. Screens import from src/. This makes screens testable and composable without touching the router.
  • No src/screens/ directory. Screens live in app/ as Expo Router requires. Components live in src/components/.

2.2 Configuration and Environment Variables

Expo uses EXPO_PUBLIC_* prefix for variables baked into the JS bundle at build time.

EXPO_PUBLIC_API_URL=https://api.bilko.cloud/api/v1
EXPO_PUBLIC_APP_ENV=production
EXPO_PUBLIC_SENTRY_DSN=...

All three market environments (HR/BA/RS) use the same API URL (api.bilko.cloud). Market behavior is driven by organization.country returned from /auth/me after login — not by build-time environment variables. There is no per-market build variant at the API URL level.

Build variants (from MOBILE-ARCHITECTURE.md section 12):

EAS Profile Bundle ID API URL Analytics
development no.alai.bilko.dev http://localhost:4000/api/v1 (or staging) off
staging no.alai.bilko.staging https://api-stage.bilko.cloud/api/v1 limited
production no.alai.bilko https://api.bilko.cloud/api/v1 consent-based

Bundle IDs are defined in app.config.ts via process.env.APP_ENV switching. Secrets are injected via EAS Secrets (never committed).

2.3 Localization File Strategy

The web app ships five message files under apps/web/messages/:

  • en.json — English (international fallback)
  • hr.json — Croatian
  • bs.json — Bosnian
  • sr-Latn.json — Serbian Latin
  • sr-Cyrl.json — Serbian Cyrillic

Phase 1 approach: copy these files into src/i18n/messages/ at scaffold time and commit them alongside the mobile app. They will diverge from the web copies as mobile-specific strings are added (tab labels, camera permission prompts, etc.). A shared packages/i18n/ extraction is planned for Phase 3 when the divergence cost justifies the shared package overhead.

Locale detection order: device locale via expo-localization → org language preference from /auth/meen fallback.


3. Phase 1 Screens (MVP)

Screen 1: Splash / Auto-login Check

Purpose: entry point, decides whether to route to Login or Today tab.

Behavior:

  1. App launches, splash screen is visible (native splash via expo-splash-screen).
  2. tokenService.ts reads Bilko/Entra token state from SecureStore.
  3. If no valid session/refresh token: hide splash, navigate to /(auth)/login.
  4. If a valid session/refresh token exists: refresh through the Entra/Bilko auth bridge. On success: store updated token state, hide splash, navigate to /(tabs)/today. On failure: clear all tokens, navigate to /(auth)/login.
  5. Maximum wait: 5 seconds; show error and navigate to login on timeout.

Implementation note: this logic lives in app/_layout.tsx as a root-level effect, not a dedicated screen component. expo-splash-screen is kept visible until the auth check resolves.

Screen 2: Login

Route: /(auth)/login

Fields:

  • "Continue with Microsoft" / localized primary button.
  • Optional environment label for staging builds only.

Behavior:

  • Starts Microsoft Entra External ID OIDC Authorization Code + PKCE flow via expo-auth-session.
  • Receives Entra authorization result, exchanges it per Entra config, then calls Bilko backend auth bridge to validate token and return Bilko user, organization, role, and API/session token state.
  • On success: store token state via tokenService.ts; store user and organization in authStore; navigate to /(tabs)/today.
  • On failure: display inline error (not alert), localized where possible.
  • No mobile registration flow in Phase 1 unless Entra self-service sign-up is explicitly enabled for Bilko.
  • No mobile password reset UI in Phase 1; use Entra hosted flow or deep link to web/help.

Design: full-screen, centered card. Bilko plum #8B6BBF primary button. Logo at top. Work Sans body font.

Screen 3: Dashboard (Today Tab)

Route: /(tabs)/today/index

Data sources:

  • GET /reports/dashboard — revenue, expenses, invoice counts (mirrors api.reports.dashboard() in web)
  • GET /auth/me — user name, org name, org country, org currency

Displayed sections:

  1. Greeting header ("Dobar dan, [name]" / localized)
  2. Revenue this month — formatted in org currency (EUR/RSD/BAM)
  3. Expenses this month — formatted in org currency
  4. Unpaid invoices — count + total amount
  5. Top 3 unpaid invoices by amount — InvoiceCard component (tap → invoice detail)
  6. "Capture expense" floating action button → navigates to /(tabs)/expenses/scan

Loading state: skeleton placeholders for all cards (no spinner). Error state: inline "Unable to load — tap to retry" per section. Offline state: show cached data with "Last updated [time]" indicator (Phase 1 cache is in-memory store only; full SQLite cache is Phase 2).

Country-specific behavior:

  • Currency symbol and format driven by countryService.tsCountryMobileConfig
  • VAT label ("PDV" for HR/RS/BA, same) from country config
  • No travel order card on this screen for BA/RS — only if supportsHRTravelOrders in Phase 1

Screen 4: Invoice Scan (Camera Capture)

Route: /(tabs)/expenses/scan (presented as a modal)

Flow:

  1. Request camera permission via expo-camera (permission rationale: "Bilko treba kameru za snimanje računa").
  2. Camera preview fills screen. Shutter button at bottom centre.
  3. On capture: preview captured image + confirm/retake buttons.
  4. On confirm: imageService.ts resizes to max 1920px longest side, JPEG quality 0.85.
  5. Show "Add expense details" form: description (required), amount (numeric), date (defaults today), category (select).
  6. "Save" calls POST /api/v1/expenses (JSON body) then POST /api/v1/expenses/{id}/documents (multipart with the compressed image).
  7. On success: show brief success toast, dismiss modal, refresh expense list.
  8. On upload failure: show error with "Retry" option. Do not discard the image or the draft expense ID.

Phase 1 note: this is capture-and-upload, not OCR extraction. The backend stores the image as scan_pending. Amount and description are entered manually by the user.

Screen 5: Travel Order Quick-Add (HR Only)

Route: /travel-order/new (presented as a modal, not a tab)

Gate: only reachable if useCountryConfig().country === 'HR'. The "Travel Order" entry point is hidden in the More tab for RS/BA users. No navigation guard is sufficient — the screen itself also checks the gate and shows an error if reached by URL manipulation.

Three-step wizard:

Step 1 — Basic details:

  • Destination (text, required)
  • Purpose of travel (text, required)
  • Departure date + return date (date pickers)

Step 2 — Allowances:

  • Daily allowance rate (pre-filled from org HR config, editable)
  • Number of days (auto-calculated from date range, user can override)
  • Advance payment requested (currency input, optional)

Step 3 — Review + Submit:

  • Summary of all fields
  • "Submit" button → POST /api/v1/travel-orders with the collected payload
  • On success: success screen with travel order number, "Done" closes the wizard

This mirrors the web "putni-nalozi" wizard reduced to 3 steps. Full expense attachment for receipts is Phase 2.

Screen 6: Recent Activity (Expenses Tab)

Route: /(tabs)/expenses/index

Displays last 10 invoices and expenses interleaved by date (most recent first).

Data:

  • GET /api/v1/invoices?limit=10&sort=created_desc via invoiceStore
  • GET /api/v1/expenses?limit=10&sort=created_desc via expenseStore

Each row shows: type icon, description/contact name, amount in org currency, date, status badge.

Tap on invoice row → /(tabs)/invoices/[id] (invoice detail view, read-only in Phase 1). Tap on expense row → expense detail (read-only in Phase 1 — full edit is Phase 2).

FAB at bottom right: "Scan expense" → navigates to camera capture screen.

Screen 7: Profile / Settings (More Tab)

Route: /(tabs)/more/index

Sections:

  1. User info: full name, email, organization name, country flag + name
  2. App info: app version (from expo-constants), environment (staging/production)
  3. Language override: select from [hr, bs, sr-Latn, sr-Cyrl, en]
  4. "Log out" button (destructive red)

Logout behavior:

  1. Call POST /api/v1/auth/logout (best effort — do not block logout on API failure).
  2. Clear bilko_access_token, bilko_refresh_token, bilko_token_expires_at from SecureStore.
  3. Clear all Zustand stores.
  4. Navigate to /(auth)/login.

4. API Integration

4.1 Auth Flow

Microsoft Entra External ID OIDC Authorization Code + PKCE
  app:     expo-auth-session starts hosted login
  success: Entra authorization code/token response

POST /api/v1/auth/entra/session   (Phase 0 backend bridge; see `docs/backend/MOBILE-ENTRA-AUTH-BRIDGE-SPEC.md`)
  body:    { idToken/accessToken or authorizationCode exchange result }
  success: { user, organization, tokens/session: { accessToken, refreshOrSessionToken?, expiresIn } }
  → tokenService.store(...)
  → authStore.setUser(user), authStore.setOrg(organization)

POST /api/v1/auth/entra/refresh or Entra SDK/session refresh
  body:    implementation-specific; no httpOnly browser cookie dependency
  success: { accessToken, expiresIn }
  → tokenService.updateAccessToken(accessToken)

POST /api/v1/auth/logout
  body:    {}  (access token in Authorization header)
  → tokenService.clear()
  → optionally revoke Bilko session / Entra session where supported

The current Kotlin/Ktor web auth flow is cookie-oriented: login/register set an httpOnly refreshToken cookie and /auth/refresh reads call.request.cookies["refreshToken"]. Mobile must not depend on that browser cookie path. Phase 0 must add/confirm the Entra External ID bridge and Bilko user/org mapping before Slice A is dispatched. Backend contract: docs/backend/MOBILE-ENTRA-AUTH-BRIDGE-SPEC.md.

4.2 Automatic 401 Retry

client.ts intercepts 401 responses identically to the web api.ts:

  1. Call tokenService.getRefreshToken().
  2. If present: call /auth/refresh. On success: update stored access token, retry original request once.
  3. On refresh failure: call authStore.logout() → clears tokens + navigates to login.
  4. Guard: prevent infinite loop on /auth/refresh itself responding 401.

4.3 User Profile and Org Info

GET /api/v1/auth/me
  Authorization: Bearer <accessToken>
  returns: {
    user: { id, email, fullName, role },
    organization: { id, name, country, baseCurrency, language, vatNumber }
  }

Called once after login and stored in authStore. organization.country drives all country-gating logic (HR-only features, currency display, PDV/VAT labels).

4.4 Invoice List

GET /api/v1/invoices?limit=10&status=&sort=created_desc
  Authorization: Bearer <accessToken>
  returns: { data: Invoice[], total: number, page: number }

Phase 1 uses the existing /api/v1/invoices endpoint (same as web). A dedicated /mobile/invoices BFF endpoint (listed in MOBILE-ARCHITECTURE.md section 6) is desirable for Phase 2 to return a mobile-optimized projection. Phase 1 maps the existing response shape in src/types/api.ts.

4.5 Expense Upload (Multipart)

Step 1: POST /api/v1/expenses
  body (JSON): { description, amount, date, category, currency }
  returns: { id, ... }

Step 2: POST /api/v1/expenses/{id}/documents
  body (multipart/form-data): file = <compressed JPEG>
  returns: { uploaded, documentId, url, fileName, message }

The web expenses.uploadDocument() already implements this exact two-step pattern. Mobile src/api/expenses.ts replicates it using FormData with { uri, name, type } format for React Native's fetch multipart encoding.

Image size constraint: imageService.ts enforces max 1920px before upload. If the resulting JPEG exceeds 5 MB (unusual), a second compression pass at 0.7 quality is applied. The backend imposes a 10 MB limit on the documents endpoint.

4.6 Travel Orders (HR Only)

POST /api/v1/travel-orders
  Authorization: Bearer <accessToken>
  body: {
    destination: string,
    purpose: string,
    departureDate: string,   // ISO date
    returnDate: string,      // ISO date
    dailyAllowanceRate: number,
    numberOfDays: number,
    advancePayment?: number,
    currency: "EUR"           // HR always EUR
  }
  returns: { id, orderNumber, status }

If the /travel-orders endpoint does not yet exist in the Kotlin/Ktor backend, this is a backend dependency for Slice D. The slice cannot be completed until the backend endpoint is available. Backend team must confirm endpoint availability or create a stub before Slice D dispatch.


5. Camera and OCR Plan

Phase 1 (this spec — Slices A–F)

  • expo-camera captures a still photo.
  • expo-image-manipulator resizes to max 1920px, JPEG 0.85.
  • The compressed image is uploaded as a multipart document attachment to the expense record via POST /api/v1/expenses/{id}/documents.
  • Backend sets scanStatus: "scan_pending" on the document record.
  • No extraction data is returned to the mobile client in Phase 1. The user manually enters amount, description, and date.
  • User-facing copy: "Slikajte račun — naš tim ili AI će ga procesirati" (localized per market). No claim of live OCR.

Phase 2 (separate MC — not in this spec)

Backend-side OCR candidates:

  • Google Document AI (Croatia: strong Latin script + EUR currency)
  • Mindee (pre-built receipt extractor, SaaS, per-page pricing)
  • Tesseract 5 + custom post-processing (self-hosted, lower cost, lower accuracy)
  • Google ML Kit on-device (free, no data leaves device, but limited to common receipt formats)

The choice depends on data residency requirements (GDPR, BA data sovereignty), per-page cost budget, and accuracy threshold for HR/RS/BA merchant receipt formats. This decision requires a separate product + legal review. Defer to Phase 2 MC.

Fiken pattern alignment

Fiken's "bare ta bilde med appen, vi foreslår hvordan det skal registreres" pattern is the target UX for Phase 2+. In Phase 1 the equivalent copy is: "Snimite račun odmah, unesite iznos, sinkroniziramo s računovođom" (capture now, enter amount, syncs to accountant).


6. Native Features

Biometric Login (Phase 2)

expo-local-authentication provides LocalAuthentication.authenticateAsync() for FaceID / TouchID on iOS and BiometricPrompt on Android.

Phase 1: not implemented. The setting does not appear in the More tab. Phase 2: add "Enable biometric login" toggle in Settings. On enable: store a biometric-protected flag in SecureStore. On app foreground after lock timeout: prompt biometric before showing sensitive screens. Token is not re-issued — biometric unlocks the in-memory auth state only.

Push Notifications (Deferred — Phase 3)

Phase 1 does not request notification permission, register push tokens, or wire notification handlers. There is no confirmed backend device-token endpoint yet, and prompting users before useful notifications exist is bad UX.

Phase 3 may use expo-notifications or direct FCM/APNs after product/security confirms provider choice and backend device-token storage.

Offline Queue (Phase 2)

Phase 1 has no offline queue. If the user is offline:

  • Dashboard shows the last in-memory cached data.
  • Capture screen shows an error if the network call fails ("Nema veze — pokušajte ponovo kad budete online").

Phase 2 adds SQLite via expo-sqlite and the sync queue described in MOBILE-ARCHITECTURE.md sections 7 and 8.


7. Deployment

iOS — TestFlight via EAS Build

  1. EAS Build runs on Expo infrastructure (no local Mac required).
  2. eas.json defines three profiles: development (simulator build), staging (ad-hoc distribution for internal testers), production (App Store / TestFlight).
  3. Code signing: Distribution Certificate + Provisioning Profile stored in EAS credentials manager. Alem provides Apple Developer account credentials once.
  4. TestFlight submission: eas submit --platform ios --profile staging after a successful staging build.
  5. Phase 1 does not auto-submit to TestFlight from CI. The developer triggers eas submit manually after reviewing the build artifact.

Android — Internal Track via EAS Build + Play Console

  1. EAS Build produces an AAB (Android App Bundle).
  2. Upload artifact to Google Play Console Internal Testing track manually for Phase 1.
  3. Phase 1 does not use eas submit --platform android automatically. Manual upload to Play Console.
  4. Signing key: generated by EAS and stored in EAS credentials (not in the repo).

CI — GitHub Actions

Phase 1 CI runs on every push to feature/bilko-mobile-* and main branches:

jobs:
  mobile-check:
    runs-on: ubuntu-latest
    steps:
      - TypeScript type check (tsc --noEmit)
      - ESLint
      - Vitest unit tests (src/services/, src/store/, src/utils/)
      - EAS Build trigger for staging profile (manual workflow_dispatch only in Phase 1)

No auto-submit to TestFlight or Play Console in Phase 1. CI produces a build artifact URL. Full auto-submit pipeline is Phase 3 (after E2E tests are in place).


8. Implementation Slices

All slices are independent dispatch units. Each has a clear done-state, measurable acceptance test, and a named API dependency that must be confirmed green before the slice starts.

Slice A — Expo Scaffold + Login + JWT Auth

Scope:

  • apps/mobile/ directory created with Expo managed SDK 52+.
  • app.config.ts with three build profiles.
  • src/api/client.ts — base fetch wrapper with 401 auto-refresh.
  • src/services/tokenService.ts — SecureStore read/write/clear.
  • src/store/authStore.ts — user/org/session state; no raw tokens in Zustand.
  • /(auth)/login.tsx — Entra "Continue with Microsoft" login, error display, loading state.
  • app/_layout.tsx — splash guard (auto-login check on cold start).
  • Localization scaffold: i18n-js setup, English + Croatian strings for login screen.

Done when: a developer can run the app on iOS Simulator, complete Entra External ID login with a real Bilko staging account, be taken to a blank /(tabs)/today placeholder screen, and log out successfully. Tokens/session material confirmed in SecureStore (not AsyncStorage/Zustand).

API dependencies: Entra tenant/app registration + Bilko backend auth bridge for token validation, user/org provisioning, role mapping, and session/API token issuance.

Slice B — Dashboard + Invoice/Expense Read-Only Lists

Scope:

  • /(tabs)/today/index.tsx — Dashboard screen (revenue, expenses, unpaid invoices, top 3 invoices).
  • /(tabs)/invoices/index.tsx — Invoice list (last 10 by date).
  • /(tabs)/invoices/[id].tsx — Invoice detail (read-only: contact, line items, total, status).
  • /(tabs)/expenses/index.tsx — Expense list (last 10 by date, no scan button yet).
  • src/store/dashboardStore.ts, invoiceStore.ts, expenseStore.ts.
  • src/components/DashboardSummary.tsx, InvoiceCard.tsx, ExpenseCard.tsx, CountryBadge.tsx.
  • Currency formatting by org country (EUR/RSD/BAM) in src/utils/currency.ts.

Done when: authenticated user sees live data from staging API on the dashboard and can scroll the invoice/expense lists. HR company shows EUR, RS shows RSD, BA shows BAM.

API dependencies: GET /api/v1/reports/dashboard, GET /api/v1/invoices, GET /api/v1/expenses, GET /api/v1/auth/me.

Slice C — Invoice Scan (Camera + Upload as Expense)

Scope:

  • /(tabs)/expenses/scan.tsx — camera capture modal.
  • src/services/imageService.ts — resize + compress.
  • FAB on expenses list and dashboard to trigger scan.
  • Two-step: create expense (POST /expenses), then upload document (POST /expenses/{id}/documents).
  • Error handling: retry on upload failure, preserve draft expense ID.
  • Success toast + expense list refresh.

Done when: user can point camera at a paper invoice, confirm the photo, enter description + amount + date, save, and see the expense appear in the list with a document attachment. Verified on both iOS Simulator and Android Emulator.

API dependencies: POST /api/v1/expenses, POST /api/v1/expenses/{id}/documents (multipart). Both are live in the existing backend.

Slice D — Travel Order Quick-Add (HR Only)

Scope:

  • /travel-order/new.tsx — 3-step wizard.
  • src/api/travel-orders.tsPOST /api/v1/travel-orders.
  • src/hooks/useCountryConfig.ts — gates the entry point to HR only.
  • Entry point: "Putni nalog" menu item in the More tab, visible only when org.country === "HR".

Done when: HR-country test account can complete the 3-step wizard and receive a travel order number. RS/BA accounts do not see the entry point. Verified that navigating directly to the route on a non-HR account shows a clear "not available" message.

API dependencies: POST /api/v1/travel-orders — backend must confirm this endpoint exists or create a stub. This is the highest-risk dependency for Phase 1; flag to backend team immediately.

Slice E — i18n + Market Detection + Settings Screen

Scope:

  • /(tabs)/more/index.tsx — Profile, language selector, app version, logout.
  • Complete i18n coverage for all Phase 1 screens in five locales: hr, bs, sr-Latn, sr-Cyrl, en.
  • src/i18n/ populated with mobile-specific strings not in the web messages (camera permission prompts, upload error copy, travel order wizard steps).
  • Language override persisted in expo-secure-store as bilko_locale_override.
  • Locale applied to number formatting (Intl.NumberFormat) and date formatting (Intl.DateTimeFormat) via country config.

Done when: switching language in Settings immediately re-renders all visible text without app restart. All five locales render without missing key warnings. Currency format is correct per org country independent of device locale.

API dependencies: none beyond GET /api/v1/auth/me (already in Slice B).

Slice F — TestFlight Build + Internal Share

Scope:

  • eas.json staging profile wired.
  • GitHub Actions workflow: mobile-check.yml (lint + type-check + unit tests on every PR).
  • eas build --platform all --profile staging produces valid IPA + AAB artifacts.
  • IPA submitted to TestFlight internal group. AAB uploaded to Play Console internal track.
  • Build metadata: bundle version, build number, changelog entry.

Done when: at least two internal testers (Alem + one developer) have installed the app via TestFlight on iPhone and via Play Console on Android, completed the login flow, and confirmed the dashboard loads.

API dependencies: staging API must be reachable from TestFlight/emulator devices.


9. Out of Scope — Phase 1

The following are explicitly deferred. They must not be partially implemented in Phase 1 slices:

  • On-device OCR. No ML Kit, no Tesseract, no cloud OCR call from mobile. Upload raw image + manual entry only.
  • Push notifications, permission prompts, device-token registration, handlers, and deep link routing. Entire push scope is Phase 3.
  • Offline SQLite queue. No expo-sqlite installation in Phase 1. In-memory state only. Phase 2.
  • Biometric auth (FaceID / TouchID). Not in Settings in Phase 1. Phase 2.
  • Apple Pay / Google Pay. Not applicable to Phase 1 scope. Future.
  • Calculator widget / home screen widgets. Not applicable.
  • Invoice draft creation from mobile (simple invoice). Phase 4 per PRD.
  • Approval actions (approve/reject expense or invoice). Phase 3 per PRD.
  • SEF status views (RS). Phase 5 per PRD.
  • eRačun/HR-FISK status views (HR). Phase 5 per PRD.
  • Company switcher (multi-org accounts). Phase 2 (after single-org login is stable).
  • Bank feed / Tok integration. Not in mobile scope for any Phase.

10. Risks and Mitigations

Risk Likelihood Impact Mitigation
Entra External ID bridge is not implemented in Bilko backend High Blocker for Slice A Phase 0 backend task: Entra app registration, token validation, user/org mapping, role mapping, session/API token issuance, logout/revocation behavior.
/travel-orders endpoint does not exist in Kotlin/Ktor backend High Blocks Slice D File backend ticket immediately. Slice D cannot start until endpoint is confirmed or stubbed.
Token accidentally written to AsyncStorage by a library (e.g., React Native MMKV default) Low Critical (security) Audit all third-party library storage usage. No AsyncStorage import in src/services/tokenService.ts. CI lint rule to flag AsyncStorage imports outside explicitly allowed files.
Image upload exceeds backend/documented size limit on a high-res device Medium User-facing error imageService.ts enforces max 1920px + second-pass compression if result > 5 MB. Align backend/docs limit before Slice C; current backend evidence showed 20 MB while older docs say 10 MB.
Travel order screen reachable by RS/BA users via direct URL Low UX confusion Screen-level guard in /travel-order/new.tsx checks org.country and renders "not available" fallback. Navigation entry point also gated.
Expo managed workflow missing a required native capability Low Forces bare ejection Maintain a list of required capabilities before Slice A scaffold. Current Phase 1 requirements (camera, secure store, image manipulator, localization) are all available in managed SDK 52.
expo-secure-store value size limit (2 KB on some Android versions) Low Truncated token Access tokens (JWTs) are typically 600–900 bytes. Refresh tokens are similar. Both fit within the 2 KB limit. Monitor token size from backend.
Bilko design tokens (colors, fonts) not available as an npm-importable package Medium Visual inconsistency Phase 1: copy tokens from apps/web/tailwind.config.ts into src/components/ui/tokens.ts manually. Phase 2: extract packages/design-tokens/ shared package.
HR/RS/BA locale text not reviewed by native speakers Medium Credibility risk in demo Dževad Jahić (Lexicon) must sign off on BS strings before Slice F / TestFlight build. HR strings reviewed by HR market advisor.

11. Backend Dependencies (Pre-Dispatch Checklist)

Before any slice is dispatched, the backend team must confirm the following:

# Dependency Required for Status
B1 Microsoft Entra External ID tenant/app registration for Bilko mobile/web customer login Slice A Required Phase 0
B2 Bilko backend auth bridge validates Entra token and maps/creates Bilko user + organization + role Slice A Required Phase 0
B3 Bilko API/session token issuance works without browser httpOnly refresh-cookie dependency for React Native Slice A Required Phase 0
B4 GET /auth/me returns organization.country and organization.baseCurrency Slice B Likely exists — confirm field names
B5 GET /reports/dashboard field names match mobile dashboard mapping (cashBalance, revenueMTD, etc.) Slice B Existing backend uses web field names — adapt mobile
B6 POST /expenses/{id}/documents accepts multipart { uri, name, type } format from React Native fetch Slice C Likely exists (web uses it) — confirm RN FormData compat
B7 POST /travel-orders endpoint exists with the field schema in section 4.6 Slice D Exists in Kotlin/Ktor; confirm mobile payload shape

12. Open Architecture Questions (Carry Forward to Phase 2)

These are not blockers for Phase 1 but must be resolved before Phase 2 dispatch:

  1. Should the mobile app consume the existing /api/v1/* endpoints or new /mobile/* BFF endpoints? The architecture doc recommends /mobile/* for projection efficiency. Phase 1 uses existing endpoints as a pragmatic shortcut. A decision is needed before Phase 2 to avoid building on the wrong base.
  2. Which OCR provider for Phase 2? Requires product + legal + cost review (GDPR, BA data residency, per-page pricing).
  3. Local database encryption approach under Expo constraints (expo-sqlite + SQLCipher or plaintext with field-level encryption for sensitive columns).
  4. Maximum local retention period for cached financial data and attachment files (legal/data retention policy).
  5. Expo Notifications vs direct FCM/APNs for Phase 3. Expo Notifications is faster but less flexible for compliance markets that may require local notification routing.