# 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/me` → `en` 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.ts` → `CountryMobileConfig`
- 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:

```yaml
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.ts` — `POST /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.