Skip to main content

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)

ComponentStatus
Service Layerβœ… Complete
Repository Layerβœ… Complete
Handler Layerβœ… Complete
Database Modelsβœ… Complete
Database Migrationβœ… Complete

βœ… Documentation (100% Complete)

DocumentStatus
Architecture Guideβœ… Complete
Implementation Summaryβœ… Complete
Flutter + Isar Guideβœ… Complete
REST Client Examplesβœ… Complete

πŸ’‘ Best Practices

DO βœ…

  • 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

Need Help? Contact backend team atau check GitHub Issues