Documentation Index Fetch the complete documentation index at: https://docs-mstore.faisalaffan.com/llms.txt
Use this file to discover all available pages before exploring further.
Offline-First POS System
Dokumentasi lengkap untuk implementasi offline-first architecture di MStore Backend, memungkinkan POS client beroperasi tanpa koneksi internet dan sync otomatis saat online.
๐ฏ Overview
Offline-first architecture memungkinkan:
โ
100% operasional tanpa internet untuk transaksi CASH
โ
Auto-sync background saat koneksi tersedia
โ
Conflict resolution otomatis untuk data collision
โ
Batch sync untuk efisiensi bandwidth
โ
Idempotent operations untuk retry safety
โ
Flutter + Isar DB untuk mobile client
โ
IndexedDB untuk web client
๐๏ธ Architecture Flow
๐ Database Schema
Offline Fields in transactions Table
ALTER TABLE transactions
ADD COLUMN offline_reference VARCHAR ( 255 ) NULL COMMENT 'Unique offline reference: DEVICE_ID-DATE-SEQ' ,
ADD COLUMN device_id VARCHAR ( 100 ) NULL COMMENT 'Device identifier' ,
ADD COLUMN created_at_device TIMESTAMP NULL COMMENT 'Created timestamp on device' ,
ADD COLUMN is_offline TINYINT ( 1 ) DEFAULT 0 COMMENT '1 if created offline' ,
ADD INDEX idx_offline_reference (offline_reference),
ADD INDEX idx_device_id (device_id);
Conflict Tracking Table
CREATE TABLE offline_conflicts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
conflict_id VARCHAR ( 36 ) NOT NULL UNIQUE COMMENT 'UUID for conflict' ,
offline_id VARCHAR ( 36 ) NOT NULL COMMENT 'Offline transaction ID' ,
offline_reference VARCHAR ( 255 ) NOT NULL ,
conflict_type ENUM( 'duplicate' , 'data_mismatch' , 'constraint_violation' ) NOT NULL ,
local_data JSON NOT NULL COMMENT 'Data from offline client' ,
server_data JSON NULL COMMENT 'Existing data on server' ,
resolution_strategy VARCHAR ( 50 ) NULL COMMENT 'keep_server, keep_local, merge' ,
resolved_at TIMESTAMP NULL ,
resolved_by BIGINT UNSIGNED NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_offline_id (offline_id),
INDEX idx_offline_reference (offline_reference),
INDEX idx_conflict_type (conflict_type),
INDEX idx_resolved_at (resolved_at)
);
๐ก API Endpoints
1. Batch Sync
Endpoint untuk sync batch transactions, payments, dan voids dari offline client.
POST /api/v1/pos/offline/batch-sync
Content-Type: application/json
Authorization: Bearer YOUR_TOKEN
{
"transactions" : [
{
"offline_id" : "uuid-1234",
"offline_reference" : "DEVICE001-20251013-001",
"device_id" : "DEVICE001",
"created_at_device" : "2025-10-13T10:30:00Z",
"branch_code" : "BRN-MST-OSK00001",
"payment_method" : "CASH",
"id_user_apps" : 5,
"items" : [
{
"product_id" : 1238,
"quantity" : 2,
"unit_price" : 15000,
"subtotal" : 30000
}
],
"subtotal" : 30000,
"grand_total" : 33000,
"status" : "paid"
}
],
"payments" : [],
"voids" : [],
"device_id" : "DEVICE001",
"synced_at" : "2025-10-13T11:00:00Z"
}
Response:
{
"code" : 200 ,
"data" : {
"success" : true ,
"total_items" : 1 ,
"success_count" : 1 ,
"failed_count" : 0 ,
"results" : [
{
"offline_id" : "uuid-1234" ,
"type" : "transaction" ,
"success" : true ,
"server_id" : 185 ,
"transaction_code" : "TRX-MC01-BRN-MST-OSK00001-251013-5UJW" ,
"conflict_detected" : false
}
],
"conflicts" : [],
"synced_at" : "2025-10-13T11:00:05Z"
}
}
2. Resolve Conflict
POST /api/v1/pos/offline/conflicts/:conflict_id/resolve
Content-Type: application/json
Authorization: Bearer YOUR_TOKEN
{
"strategy" : "keep_server" // or "keep_local", "merge"
}
Response:
{
"code" : 200 ,
"data" : {
"conflict_id" : "uuid-5678" ,
"status" : "resolved" ,
"message" : "Conflict resolved successfully"
}
}
๐ฑ Flutter + Isar DB Implementation
1. Setup Dependencies
# pubspec.yaml
dependencies :
isar : ^3.1.0+1
isar_flutter_libs : ^3.1.0+1
path_provider : ^2.1.1
uuid : ^4.2.1
dev_dependencies :
isar_generator : ^3.1.0+1
build_runner : ^2.4.7
2. Define Isar Collections
// lib/models/transaction.dart
import 'package:isar/isar.dart' ;
part 'transaction.g.dart' ;
@collection
class Transaction {
Id id = Isar .autoIncrement;
@Index (unique : true )
late String offlineId;
@Index (unique : true )
late String offlineReference;
late String deviceId;
late DateTime createdAtDevice;
late String branchCode;
late String paymentMethod;
late double subtotal;
late double discountTotal;
late double taxTotal;
late double grandTotal;
late String status;
late String paymentStatus;
@enumerated
late SyncStatus syncStatus;
String ? transactionCode;
int ? serverId;
DateTime ? syncedAt;
String ? syncError;
// Items as embedded objects
List < TransactionItem > items = [];
}
@embedded
class TransactionItem {
late int productId;
late int quantity;
late double unitPrice;
late double subtotal;
String ? productName;
}
enum SyncStatus {
pending,
syncing,
synced,
failed
}
3. Initialize Isar
// lib/services/database_service.dart
import 'package:isar/isar.dart' ;
import 'package:path_provider/path_provider.dart' ;
class DatabaseService {
static Isar ? _isar;
static Future < Isar > initialize () async {
if (_isar != null ) return _isar ! ;
final dir = await getApplicationDocumentsDirectory ();
_isar = await Isar . open (
[ TransactionSchema , SyncQueueSchema ],
directory : dir.path,
name : 'mstore_offline_db' ,
);
return _isar ! ;
}
static Isar get instance {
if (_isar == null ) {
throw Exception ( 'Database not initialized' );
}
return _isar ! ;
}
}
4. Create Transaction (Offline-First)
// lib/services/transaction_service.dart
import 'package:uuid/uuid.dart' ;
class TransactionService {
final Isar isar;
TransactionService ( this .isar);
Future < Transaction > createTransaction ( TransactionData data) async {
// 1. Generate offline identifiers
final offlineId = const Uuid (). v4 ();
final deviceId = await getDeviceId ();
final sequence = await getNextSequence ();
final offlineReference = ' $ deviceId - ${ DateTime . now (). format ( 'yyyyMMdd' )} - $ sequence ' ;
// 2. Create transaction object
final transaction = Transaction ()
..offlineId = offlineId
..offlineReference = offlineReference
..deviceId = deviceId
..createdAtDevice = DateTime . now ()
..branchCode = data.branchCode
..paymentMethod = data.paymentMethod
..subtotal = data.subtotal
..grandTotal = data.grandTotal
..status = 'paid'
..paymentStatus = 'paid'
..syncStatus = SyncStatus .pending
..items = data.items. map ((item) => TransactionItem ()
..productId = item.productId
..quantity = item.quantity
..unitPrice = item.unitPrice
..subtotal = item.subtotal
). toList ();
// 3. Save to Isar (OFFLINE FIRST)
await isar. writeTxn (() async {
await isar.transactions. put (transaction);
});
// 4. Add to sync queue
await addToSyncQueue (transaction);
// 5. Trigger background sync (non-blocking)
unawaited ( backgroundSync ());
// 6. Return immediately
return transaction;
}
}
5. Background Sync Worker
// lib/services/sync_service.dart
class SyncService {
final Isar isar;
final ApiClient api;
SyncService ( this .isar, this .api);
Future < void > backgroundSync () async {
// Check online status
if ( ! await isOnline ()) {
print ( '[Sync] Offline, skipping sync' );
return ;
}
// Get pending transactions
final pending = await isar.transactions
. filter ()
. syncStatusEqualTo ( SyncStatus .pending)
. findAll ();
if (pending.isEmpty) {
print ( '[Sync] No pending items' );
return ;
}
// Prepare batch sync request
final request = BatchSyncRequest (
transactions : pending. map ((tx) => OfflineTransaction . fromIsar (tx)). toList (),
payments : [],
voids : [],
deviceId : await getDeviceId (),
syncedAt : DateTime . now (),
);
try {
// Call batch sync API
final response = await api. batchSync (request);
// Process results
await isar. writeTxn (() async {
for ( final result in response.results) {
final tx = pending. firstWhere ((t) => t.offlineId == result.offlineId);
if (result.success) {
tx.syncStatus = SyncStatus .synced;
tx.serverId = result.serverId;
tx.transactionCode = result.transactionCode;
tx.syncedAt = DateTime . now ();
} else {
tx.syncStatus = SyncStatus .failed;
tx.syncError = result.error;
}
await isar.transactions. put (tx);
}
});
// Handle conflicts
if (response.conflicts.isNotEmpty) {
showConflictDialog (response.conflicts);
}
} catch (e) {
print ( '[Sync] Batch sync failed: $ e ' );
}
}
// Auto-sync every 30 seconds
void startAutoSync () {
Timer . periodic ( const Duration (seconds : 30 ), (_) {
backgroundSync ();
});
}
}
6. Conflict Resolution UI
// lib/widgets/conflict_resolver.dart
class ConflictResolver extends StatelessWidget {
final List < ConflictItem > conflicts;
final Function ( String conflictId, String strategy) onResolve;
@override
Widget build ( BuildContext context) {
return AlertDialog (
title : const Text ( 'โ ๏ธ Sync Conflicts Detected' ),
content : SingleChildScrollView (
child : Column (
children : conflicts. map ((conflict) => Card (
child : Column (
children : [
Text ( 'Transaction Conflict' ),
const SizedBox (height : 16 ),
// Local Data
ExpansionTile (
title : const Text ( 'Local Data' ),
children : [
Text ( jsonEncode (conflict.localData)),
],
),
// Server Data
ExpansionTile (
title : const Text ( 'Server Data' ),
children : [
Text ( jsonEncode (conflict.serverData)),
],
),
// Resolution Actions
Row (
mainAxisAlignment : MainAxisAlignment .spaceEvenly,
children : [
ElevatedButton (
onPressed : () => onResolve (conflict.offlineId, 'keep_server' ),
child : const Text ( 'Keep Server' ),
),
ElevatedButton (
onPressed : () => onResolve (conflict.offlineId, 'keep_local' ),
child : const Text ( 'Keep Local' ),
),
],
),
// Suggested Fix
Padding (
padding : const EdgeInsets . all ( 8.0 ),
child : Text (
'Suggested: ${ conflict . suggestedFix } ' ,
style : const TextStyle (fontStyle : FontStyle .italic),
),
),
],
),
)). toList (),
),
),
);
}
}
๐งช Testing Scenarios
Scenario 1: Offline Transaction (CASH)
test ( 'should create transaction offline and sync later' , () async {
// 1. Simulate offline
when (connectivity. checkConnectivity ()). thenReturn ( ConnectivityResult .none);
// 2. Create transaction
final tx = await transactionService. createTransaction (
TransactionData (
branchCode : 'BRN-MST-OSK00001' ,
paymentMethod : 'CASH' ,
items : [ TransactionItemData (productId : 1238 , quantity : 2 )],
grandTotal : 30000 ,
),
);
// 3. Verify saved to Isar
expect (tx.syncStatus, SyncStatus .pending);
final localTx = await isar.transactions. get (tx.id);
expect (localTx, isNotNull);
// 4. Simulate online
when (connectivity. checkConnectivity ()). thenReturn ( ConnectivityResult .wifi);
// 5. Trigger sync
await syncService. backgroundSync ();
// 6. Verify synced
final syncedTx = await isar.transactions. get (tx.id);
expect (syncedTx ! .syncStatus, SyncStatus .synced);
expect (syncedTx.transactionCode, isNotNull);
});
๐ Implementation Status
โ
Backend (100% Complete)
Component Status Service Layer โ
Complete Repository Layer โ
Complete Handler Layer โ
Complete Database Models โ
Complete Database Migration โ
Complete
โ
Documentation (100% Complete)
Document Status Architecture Guide โ
Complete Implementation Summary โ
Complete Flutter + Isar Guide โ
Complete REST Client Examples โ
Complete
๐ก Best Practices
Always save to local DB first before API call
Use unique offline_reference for idempotency
Implement retry logic with exponential backoff
Show sync status to users
Handle conflicts gracefully
Encrypt sensitive data in local DB
Monitor sync metrics
DONโT โ
Donโt block UI waiting for API response
Donโt sync on every transaction (batch instead)
Donโt ignore conflicts (must resolve)
Donโt store unencrypted payment data
Donโt retry infinitely (max 3-5 attempts)
Donโt sync when battery is low
๐ Troubleshooting
Problem: Sync Queue Growing
Symptoms : Sync queue has 100+ pending items
Solution :
// Implement batch size limit
const BATCH_SIZE = 50 ;
final pendingItems = await isar.transactions
. filter ()
. syncStatusEqualTo ( SyncStatus .pending)
. limit ( BATCH_SIZE )
. findAll ();
Problem: Isar Database Locked
Symptoms : โDatabase is lockedโ error
Solution :
// Ensure proper transaction handling
await isar. writeTxn (() async {
// All writes here
await isar.transactions. put (transaction);
});
Transaction Flow Complete transaction flow & state machine
Payment Gateway QRIS and payment integration
Inventory Flow Inventory management & stock movement