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 inpackages/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.
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 (
_accessTokenmodule variable) - Automatic 401 → refresh → retry cycle
- Typed error objects with
status,code,details multipart/form-dataupload support (already used forexpenses.uploadDocumentandinvoices.uploadReceipt)
Mobile should reproduce the endpoint/error/upload pattern in src/api/client.ts with these differences:
- Token/session material is loaded from
expo-secure-storeon app launch and stored back on refresh/session update. credentials: 'include'cookie refresh is not used in React Native.- 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_tokenbilko_id_tokenbilko_refresh_or_session_tokenbilko_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 fromsrc/.src/contains all business logic, components, hooks, stores, and API calls. Screens import fromsrc/. This makes screens testable and composable without touching the router.- No
src/screens/directory. Screens live inapp/as Expo Router requires. Components live insrc/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— Croatianbs.json— Bosniansr-Latn.json— Serbian Latinsr-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:
- App launches, splash screen is visible (native splash via
expo-splash-screen). tokenService.tsreads Bilko/Entra token state from SecureStore.- If no valid session/refresh token: hide splash, navigate to
/(auth)/login. - 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. - 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; storeuserandorganizationinauthStore; 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 (mirrorsapi.reports.dashboard()in web)GET /auth/me— user name, org name, org country, org currency
Displayed sections:
- Greeting header ("Dobar dan, [name]" / localized)
- Revenue this month — formatted in org currency (EUR/RSD/BAM)
- Expenses this month — formatted in org currency
- Unpaid invoices — count + total amount
- Top 3 unpaid invoices by amount — InvoiceCard component (tap → invoice detail)
- "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
supportsHRTravelOrdersin Phase 1
Screen 4: Invoice Scan (Camera Capture)
Route: /(tabs)/expenses/scan (presented as a modal)
Flow:
- Request camera permission via
expo-camera(permission rationale: "Bilko treba kameru za snimanje računa"). - Camera preview fills screen. Shutter button at bottom centre.
- On capture: preview captured image + confirm/retake buttons.
- On confirm:
imageService.tsresizes to max 1920px longest side, JPEG quality 0.85. - Show "Add expense details" form: description (required), amount (numeric), date (defaults today), category (select).
- "Save" calls
POST /api/v1/expenses(JSON body) thenPOST /api/v1/expenses/{id}/documents(multipart with the compressed image). - On success: show brief success toast, dismiss modal, refresh expense list.
- 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-orderswith 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_descviainvoiceStoreGET /api/v1/expenses?limit=10&sort=created_descviaexpenseStore
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:
- User info: full name, email, organization name, country flag + name
- App info: app version (from
expo-constants), environment (staging/production) - Language override: select from
[hr, bs, sr-Latn, sr-Cyrl, en] - "Log out" button (destructive red)
Logout behavior:
- Call
POST /api/v1/auth/logout(best effort — do not block logout on API failure). - Clear
bilko_access_token,bilko_refresh_token,bilko_token_expires_atfrom SecureStore. - Clear all Zustand stores.
- 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:
- Call
tokenService.getRefreshToken(). - If present: call
/auth/refresh. On success: update stored access token, retry original request once. - On refresh failure: call
authStore.logout()→ clears tokens + navigates to login. - Guard: prevent infinite loop on
/auth/refreshitself 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-cameracaptures a still photo.expo-image-manipulatorresizes 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
- EAS Build runs on Expo infrastructure (no local Mac required).
eas.jsondefines three profiles:development(simulator build),staging(ad-hoc distribution for internal testers),production(App Store / TestFlight).- Code signing: Distribution Certificate + Provisioning Profile stored in EAS credentials manager. Alem provides Apple Developer account credentials once.
- TestFlight submission:
eas submit --platform ios --profile stagingafter a successful staging build. - Phase 1 does not auto-submit to TestFlight from CI. The developer triggers
eas submitmanually after reviewing the build artifact.
Android — Internal Track via EAS Build + Play Console
- EAS Build produces an AAB (Android App Bundle).
- Upload artifact to Google Play Console Internal Testing track manually for Phase 1.
- Phase 1 does not use
eas submit --platform androidautomatically. Manual upload to Play Console. - 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.tswith 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-jssetup, 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-storeasbilko_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.jsonstaging profile wired.- GitHub Actions workflow:
mobile-check.yml(lint + type-check + unit tests on every PR). eas build --platform all --profile stagingproduces 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-sqliteinstallation 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:
- 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. - Which OCR provider for Phase 2? Requires product + legal + cost review (GDPR, BA data residency, per-page pricing).
- Local database encryption approach under Expo constraints (
expo-sqlite+ SQLCipher or plaintext with field-level encryption for sensitive columns). - Maximum local retention period for cached financial data and attachment files (legal/data retention policy).
- 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.