Restaurant POS is a Kotlin Multiplatform point-of-sale app for restaurant cashier workflows. The project focuses on a practical offline-first flow: cashiers can log in, browse a synced menu, create orders, take payments, print receipts, and keep working even when the network is temporarily unavailable.
The same business logic, UI, navigation, local database, dependency injection, and tests are shared across Android and iOS. Platform-specific code is used only where it is needed, such as Firebase, SQLDelight drivers, WorkManager, SmartPOS printing, and iOS Firestore REST calls.
- Cashier login using locally synced users.
- Menu browsing from local SQLDelight storage.
- Category filtering and menu search.
- Cart building with quantity changes and item removal.
- Dine-in, takeaway, and delivery orders.
- Promo code validation and discount calculation.
- Cash and card payment flows.
- Receipt printing on Android SmartPOS devices.
- Order history with filters, sorting, and order details.
- Refund domain logic for cash and card refunds.
- Manual and automatic sync for users, menu items, discounts, orders, payments, and refunds.
- Offline outbox for actions that must be uploaded later.
In a restaurant, the cashier should not stop taking orders just because the internet drops for a few minutes. This project handles that by making local storage the source of truth for cashier actions.
The basic idea is:
- The app downloads reference data such as users, menu items, and discounts.
- The cashier creates orders and payments locally.
- Every important local action writes an outbox row in the same database transaction.
- The app uploads those outbox rows when the network is available.
- If upload fails, the row stays in the outbox and is retried later.
- The UI can show what is synced, pending, failed, or conflicted.
This means the cashier flow stays fast and reliable, while remote sync happens in the background.
- Kotlin Multiplatform for shared Android and iOS code.
- Compose Multiplatform and Material 3 for shared UI.
- Compose Navigation for screen routing.
- Koin for dependency injection.
- Kotlin Coroutines and Flow for async work and UI state.
- SQLDelight for local users, menu items, discounts, orders, payments, refunds, and sync outbox storage.
- AndroidX DataStore Preferences for current-user and time-sync metadata.
- Firebase Firestore on Android.
- Ktor client on iOS for Firestore REST access.
- Android WorkManager for background outbox sync.
- SmartPOS printer AIDL integration on Android.
- kotlin.test and kotlinx-coroutines-test for tests.
The project uses clean architecture with an MVI-style UI layer.
composeApp -> domain
composeApp -> data -> domain
The domain module contains the business rules. It does not depend on Compose, Firebase, SQLDelight, WorkManager, or platform APIs.
Important examples:
CreateOrderUseCasevalidates the cart, reloads menu items from local storage, calculates totals, applies discounts, and creates the order.PayOrderByCashUseCasecreates a paid cash payment, marks the order as paid, starts receipt printing, and schedules sync.PayOrderByCardUseCasestores a processing payment, calls the payment gateway, saves success or failure, and schedules sync only when payment succeeds.CreateRefundUseCase,RefundByCashUseCase, andRefundByCardUseCasehandle refund rules and refund status updates.SyncRetryPolicydefines which sync errors should be retried and how long to wait before retrying.
The data module connects the business rules to local storage, remote APIs, sync, and printing.
Important examples:
OrderRepoImplsaves orders, payments, refunds, and outbox rows transactionally.SyncDataRepoImplreads remote reference data, uploads outbox rows, marks aggregates as synced, and checks upload dependencies.SqlDelightLocalDataSourceImplowns SQLDelight queries and sync state transitions.SyncOutboxProcessorImplprocesses pending outbox rows safely.FirebaseRemoteDataSourceuploads Android outbox rows to Firestore.
The Compose app module contains screens, ViewModels, navigation, theme, and reusable UI components.
Important examples:
LoginViewModelvalidates login input and checks if sync needs attention.HomeViewModelloads menu items, manages the cart, applies discounts, creates orders, and starts payment flows.OrdersViewModelshows order history and order details.SyncViewModelshows sync counts, last sync times, and manual sync actions.SettingsViewModelloads the current cashier and handles logout.
Cashiers log in with phone and password. Users are synced into the local database, so login can work without a live network request after the first sync.
Phone numbers are normalized by removing spaces and converting Arabic/Persian digits to Latin digits.
Menu items are loaded from SQLDelight. The cashier can filter by category, search by name, add items to the cart, change quantities, and choose the order type.
When the cart changes, the existing created order id is cleared. This prevents the app from paying an old order after the cashier changes the cart.
Order creation is local-first.
CreateOrderUseCase revalidates the cart before saving:
- The cart cannot be empty.
- Quantities must be positive.
- The cashier must exist.
- Menu items are reloaded from local storage.
- Missing or inactive menu items are rejected.
- Promo code rules are checked again.
- Totals are calculated in the domain layer.
OrderRepoImpl then saves the order, order items, and CREATE_ORDER outbox row in one transaction.
Cash payment is completed locally and can be uploaded later.
Card payment stores a processing payment first, then calls the payment gateway:
- On success, payment details are saved, the order becomes paid, receipt printing starts, and sync is scheduled.
- On failure, the failure reason is saved and the order stays available for retry.
- A failed or processing card payment can be reused on retry instead of creating duplicate payment rows.
Receipt printing is separate from payment success. If the printer fails, the payment is still saved as successful and the print status becomes Failed. The cashier can retry printing from order details.
Android uses the SmartPOS printer service through AIDL. iOS currently uses a fake receipt printer.
Refund logic is implemented in the domain layer:
- Only paid or partially refunded orders are refundable.
- Refund quantities cannot exceed the remaining refundable quantity.
- Pending and processing refunds reserve quantity so the same items are not refunded twice.
- Cash refunds complete locally.
- Card refunds require the original card transaction id.
- Failed card refunds are saved without marking the order as refunded.
Local actions create outbox rows with stable idempotency keys:
CREATE_ORDER:<orderId>
CREATE_PAYMENT:<paymentId>
CREATE_REFUND:<refundId>
The processor handles the queue like this:
- Reset stale
IN_PROGRESSrows after 10 minutes. - Select
PENDINGrows andRETRY_WAITINGrows whose retry time has arrived. - Process rows in creation order.
- Check dependencies before upload.
- Mark the row
IN_PROGRESS. - Upload to remote.
- Mark the row as
SUCCEEDED,RETRY_WAITING,FAILED, orCONFLICT.
Dependency checks are important:
- Orders can upload directly.
- Payments wait until their order has synced.
- Refunds wait until their order and payment have synced.
This keeps remote data in a valid order even if the cashier created everything offline.
The app treats failures differently depending on the reason.
Examples:
- No internet.
- Timeout.
- Temporary server error.
- Rate limit.
The row becomes RETRY_WAITING, keeps the error details, increases its retry count, and gets a next_attempt_at time. When that time arrives, WorkManager or manual sync can upload it again.
Examples:
- Unauthorized cashier.
- Invalid payload rejected by remote.
The row becomes FAILED. This is not retried automatically because sending the same payload again will probably fail again until the data or permissions are fixed.
Conflicts become CONFLICT. This keeps the row visible for manual handling instead of hiding a data problem.
If the remote already has the same idempotency key, the app treats it as success. This protects the system from duplicate orders/payments when the app retries after a timeout.
SQLDelight stores:
users: synced cashier records.menu_items: synced menu snapshot.discounts: synced promo code rules.orders: order header, totals, statuses, cashier snapshot, and sync timestamp.order_items: immutable order item snapshots.payments: cash/card payment attempts and gateway metadata.refunds: refund header, method, amount, status, and sync timestamp.refund_items: refund item details.sync_outbox: upload queue with idempotency key, retry state, lock time, and last error.
Users and menu items that disappear from the remote snapshot are marked inactive instead of deleted. This keeps old orders readable while preventing inactive records from being used for new actions.
- Uses Firebase Firestore SDK.
- Uses SQLDelight Android driver.
- Uses AndroidX DataStore Preferences.
- Uses WorkManager for background outbox upload.
- Uses SmartPOS AIDL printer service from
data/libs/SmartPos_1.3.6_R201217.jar. - Uses
composeApp/google-services.jsonfor Firebase configuration.
- Uses Ktor/Darwin HTTP client for Firestore REST calls.
- Uses SQLDelight native driver.
- Uses shared Compose UI through
ComposeUIViewController. - Uses a fake receipt printer.
- Automatic background scheduling is not implemented yet, but manual/shared sync logic is available.
Build the Android debug app:
./gradlew :composeApp:assembleDebugOr open the project in Android Studio and run the composeApp Android target.
For iOS, open:
iosApp/iosApp.xcodeproj
Then run the iOS app target from Xcode.
Run all checks:
./gradlew checkRun focused module tests:
./gradlew :domain:allTests
./gradlew :data:allTests
./gradlew :composeApp:allTestsCurrent tests cover:
- Login validation and current-user behavior.
- User, menu item, and discount sync.
- Phone/server time validation.
- Discount rules.
- Order creation, payment, refund, and receipt retry use cases.
- Sync idempotency keys and retry policy.
- Sync outbox processing.
- SQLDelight local behavior for users, menu items, discounts, orders, and outbox rows.
- Home, login, orders, and sync ViewModel behavior.
- Android WorkManager sync scheduling.
.
├── composeApp
│ └── src
│ ├── commonMain/kotlin/com/sahm/pos
│ │ ├── App.kt
│ │ ├── navigation
│ │ ├── screens
│ │ ├── components
│ │ ├── di
│ │ ├── theme
│ │ └── utils
│ ├── androidMain
│ └── iosMain
├── domain
│ └── src/commonMain/kotlin/com/sahm/pos/domain
│ ├── entity
│ ├── repository
│ ├── results
│ ├── sync
│ └── usecase
├── data
│ └── src
│ ├── commonMain/kotlin/com/sahm/pos/data
│ │ ├── local
│ │ ├── mapper
│ │ ├── model
│ │ ├── printing
│ │ ├── remote
│ │ ├── repo
│ │ └── sync
│ ├── commonMain/sqldelight
│ ├── androidMain
│ └── iosMain
└── iosApp