Skip to main content

Offline-First Backend Implementation

Dokumentasi lengkap implementasi backend untuk offline-first architecture di MStore Backend.

πŸ“Š Database Schema

Transactions Table (Modified)

Location: third_party/migrations/atlas/schema/05_transactional.my.hcl
// Offline-first fields (added 2025-10-13)
column "offline_reference" {
  null = true
  type = varchar(255)
  comment = "Unique reference: DEVICE_ID-YYYYMMDD-SEQ"
}

column "device_id" {
  null = true
  type = varchar(100)
  comment = "Device ID yang create transaction"
}

column "created_at_device" {
  null = true
  type = timestamp
  comment = "Timestamp saat dibuat di device"
}

column "is_offline" {
  null = false
  type = boolean
  default = false
  comment = "Flag offline transaction"
}

// Indexes
index "idx_transactions_offline_reference" {
  unique  = true
  columns = [column.offline_reference]
}

index "idx_transactions_device_id" {
  columns = [column.device_id]
}

index "idx_transactions_is_offline" {
  columns = [column.is_offline]
}

Offline Conflicts Table

Location: third_party/migrations/atlas/schema/09_offline_first.my.hcl
table "offline_conflicts" {
  schema = schema.mstore-monolith
  
  column "id" {
    null           = false
    type           = bigint
    unsigned       = true
    auto_increment = true
  }
  
  column "conflict_id" {
    null = false
    type = varchar(64)
    comment = "Unique conflict identifier"
  }
  
  column "offline_id" {
    null = false
    type = varchar(64)
    comment = "Offline transaction ID"
  }
  
  column "type" {
    null = false
    type = varchar(32)
    comment = "transaction, payment, void"
  }
  
  column "conflict_type" {
    null = false
    type = varchar(32)
    comment = "duplicate, data_mismatch, constraint_violation"
  }
  
  column "local_data" {
    null = true
    type = json
    comment = "Data dari offline client"
  }
  
  column "server_data" {
    null = true
    type = json
    comment = "Data dari server"
  }
  
  column "suggested_fix" {
    null = false
    type = varchar(32)
    comment = "keep_server, keep_local, merge"
  }
  
  column "resolution_url" {
    null = false
    type = varchar(255)
    comment = "URL untuk resolve conflict"
  }
  
  column "status" {
    null = false
    type = enum("pending", "resolved", "ignored")
    default = "pending"
  }
  
  column "resolved_at" {
    null = true
    type = timestamp
  }
  
  column "created_at" {
    null    = false
    type    = timestamp
    default = sql("CURRENT_TIMESTAMP")
  }
  
  column "updated_at" {
    null      = false
    type      = timestamp
    default   = sql("CURRENT_TIMESTAMP")
    on_update = sql("CURRENT_TIMESTAMP")
  }
  
  primary_key {
    columns = [column.id]
  }
  
  index "idx_offline_conflicts_conflict_id" {
    unique  = true
    columns = [column.conflict_id]
  }
  
  index "idx_offline_conflicts_offline_id" {
    columns = [column.offline_id]
  }
  
  index "idx_offline_conflicts_status" {
    columns = [column.status]
  }
}

πŸ—οΈ Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       HTTP Handler Layer            β”‚
β”‚  - BatchSync()                      β”‚
β”‚  - ResolveConflict()                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       Service Layer                 β”‚
β”‚  - BatchSync()                      β”‚
β”‚  - syncTransaction()                β”‚
β”‚  - syncPayment()                    β”‚
β”‚  - syncVoid()                       β”‚
β”‚  - ResolveConflict()                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     Repository Layer                β”‚
β”‚  - SaveConflict()                   β”‚
β”‚  - GetConflictByID()                β”‚
β”‚  - ListPendingConflicts()           β”‚
β”‚  - ResolveConflict()                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       Database (MySQL)              β”‚
β”‚  - transactions                     β”‚
β”‚  - offline_conflicts                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“¦ Go Models

Transaction Model

File: internal/models/transaction.go
type Transaction struct {
	ID                 int64      `json:"id" gorm:"primaryKey"`
	TransactionCode    string     `json:"transaction_code"`
	BranchID           int64      `json:"branch_id"`
	Status             string     `json:"status"`
	PaymentStatus      *string    `json:"payment_status"`
	SyncStatus         *string    `json:"sync_status"`
	TotalAmount        float64    `json:"total_amount"`
	PaymentMethod      string     `json:"payment_method"`
	CashierID          int64      `json:"cashier_id"`
	
	// Offline fields
	OfflineReference   *string    `json:"offline_reference" gorm:"uniqueIndex"`
	DeviceID           *string    `json:"device_id" gorm:"index"`
	CreatedAtDevice    *time.Time `json:"created_at_device"`
	IsOffline          bool       `json:"is_offline" gorm:"default:false"`
	
	CreatedAt          *time.Time `json:"created_at"`
	UpdatedAt          *time.Time `json:"updated_at"`
}

Conflict Record Model

File: internal/domains/pos/offline/offline_repository.go
type ConflictRecord struct {
	ID             uint       `gorm:"primaryKey"`
	ConflictID     string     `gorm:"uniqueIndex"`
	OfflineID      string     `gorm:"index"`
	Type           string     // transaction, payment, void
	ConflictType   string     // duplicate, data_mismatch, constraint_violation
	LocalData      string     `gorm:"type:json"`
	ServerData     string     `gorm:"type:json"`
	SuggestedFix   string     // keep_server, keep_local, merge
	ResolutionURL  string
	Status         string     `gorm:"default:pending"`
	ResolvedAt     *time.Time
	CreatedAt      time.Time
	UpdatedAt      time.Time
}

πŸ”§ Service Layer

Batch Sync Flow

func (s *offlineService) BatchSync(ctx context.Context, req BatchSyncRequest) (*BatchSyncResponse, error) {
    response := &BatchSyncResponse{
        Success:      true,
        TotalItems:   len(req.Transactions),
        Results:      []BatchSyncItemResult{},
        Conflicts:    []ConflictItem{},
        SyncedAt:     time.Now(),
    }

    // Start database transaction
    err := s.db.Transaction(func(tx *gorm.DB) error {
        // Process each transaction
        for _, offlineTx := range req.Transactions {
            result := s.syncTransaction(ctx, tx, offlineTx)
            response.Results = append(response.Results, result)
            
            if result.Success {
                response.SuccessCount++
            } else {
                response.FailedCount++
            }
        }
        return nil
    })

    return response, err
}

Duplicate Detection

func (s *offlineService) syncTransaction(ctx context.Context, tx *gorm.DB, offlineTx OfflineTransaction) BatchSyncItemResult {
    result := BatchSyncItemResult{
        OfflineID: offlineTx.OfflineID,
        Type:      "transaction",
    }

    // Check duplicate via offline_reference
    var existingTx models.Transaction
    err := tx.Where("transaction_code = ?", offlineTx.OfflineReference).First(&existingTx).Error

    if err == nil {
        // CONFLICT DETECTED!
        result.ConflictDetected = true
        result.Error = "Duplicate transaction"

        // Save conflict
        conflict := ConflictItem{
            ConflictID:    offlineTx.OfflineID,
            OfflineID:     offlineTx.OfflineID,
            Type:          "transaction",
            ConflictType:  "duplicate",
            LocalData:     offlineTx,
            ServerData:    existingTx,
            SuggestedFix:  "keep_server",
            ResolutionURL: fmt.Sprintf("/api/v1/pos/offline/conflicts/%s/resolve", offlineTx.OfflineID),
        }

        s.conflictRepo.SaveConflict(ctx, conflict)
        return result
    }

    // Create new transaction
    newTx := models.Transaction{
        TransactionCode:  offlineTx.OfflineReference,
        OfflineReference: &offlineTx.OfflineReference,
        DeviceID:         &offlineTx.DeviceID,
        CreatedAtDevice:  &offlineTx.CreatedAtDevice,
        IsOffline:        true,
        Status:           offlineTx.Status,
        TotalAmount:      offlineTx.GrandTotal,
        PaymentMethod:    offlineTx.PaymentMethod,
    }

    if err := tx.Create(&newTx).Error; err != nil {
        result.Error = fmt.Sprintf("Failed to create: %v", err)
        return result
    }

    result.Success = true
    serverID := uint(newTx.ID)
    result.ServerID = &serverID
    result.TransactionCode = newTx.TransactionCode

    return result
}

πŸ“‚ Repository Layer

Save Conflict

func (r *conflictRepository) SaveConflict(ctx context.Context, conflict ConflictItem) error {
    // Marshal to JSON
    localDataJSON, _ := json.Marshal(conflict.LocalData)
    serverDataJSON, _ := json.Marshal(conflict.ServerData)

    record := ConflictRecord{
        ConflictID:    conflict.ConflictID,
        OfflineID:     conflict.OfflineID,
        Type:          conflict.Type,
        ConflictType:  conflict.ConflictType,
        LocalData:     string(localDataJSON),
        ServerData:    string(serverDataJSON),
        SuggestedFix:  conflict.SuggestedFix,
        ResolutionURL: conflict.ResolutionURL,
        Status:        "pending",
    }

    return r.db.WithContext(ctx).Create(&record).Error
}

Get Conflict

func (r *conflictRepository) GetConflictByID(ctx context.Context, conflictID string) (*ConflictItem, error) {
    var record ConflictRecord
    err := r.db.WithContext(ctx).
        Where("conflict_id = ?", conflictID).
        First(&record).Error

    if err != nil {
        return nil, err
    }

    // Unmarshal JSON
    var localData, serverData interface{}
    json.Unmarshal([]byte(record.LocalData), &localData)
    json.Unmarshal([]byte(record.ServerData), &serverData)

    return &ConflictItem{
        ConflictID:    record.ConflictID,
        OfflineID:     record.OfflineID,
        Type:          record.Type,
        ConflictType:  record.ConflictType,
        LocalData:     localData,
        ServerData:    serverData,
        SuggestedFix:  record.SuggestedFix,
        ResolutionURL: record.ResolutionURL,
    }, nil
}

🌐 API Endpoints

1. Batch Sync

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",
      "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"
}
Response:
{
  "code": 200,
  "data": {
    "conflict_id": "uuid-5678",
    "status": "resolved",
    "message": "Conflict resolved successfully"
  }
}

πŸ§ͺ Testing

Unit Test Example

func TestBatchSync_Success(t *testing.T) {
    // Setup
    db := setupTestDB()
    service := NewOfflineService(db, nil, nil)

    // Test data
    req := BatchSyncRequest{
        Transactions: []OfflineTransaction{
            {
                OfflineID:        "uuid-1234",
                OfflineReference: "DEVICE001-20251013-001",
                DeviceID:         "DEVICE001",
                GrandTotal:       33000,
                Status:           "paid",
            },
        },
        DeviceID:  "DEVICE001",
        SyncedAt:  time.Now(),
    }

    // Execute
    response, err := service.BatchSync(context.Background(), req)

    // Assert
    assert.NoError(t, err)
    assert.True(t, response.Success)
    assert.Equal(t, 1, response.SuccessCount)
    assert.Equal(t, 0, response.FailedCount)
}

πŸ’‘ Best Practices

DO βœ…

  • Use database transactions untuk atomic operations
  • Implement proper error handling dengan context
  • Log semua sync operations untuk audit
  • Use unique index pada offline_reference
  • Validate input data sebelum processing
  • Return detailed error messages

DON’T ❌

  • Jangan skip duplicate detection
  • Jangan ignore conflicts
  • Jangan hardcode device IDs
  • Jangan lupa rollback on error
  • Jangan expose sensitive data di logs


πŸ†˜ Troubleshooting

Duplicate Key Error

Symptom: Duplicate entry for key 'idx_transactions_offline_reference' Solution: Conflict detection working correctly. Check conflict table.

Transaction Rollback

Symptom: Some transactions synced, some not Solution: Ensure all operations in single DB transaction.

JSON Marshal Error

Symptom: json: unsupported type Solution: Ensure all struct fields are exportable (capitalized).
Implementation Complete! Backend sudah 100% ready untuk production. Test dengan REST Client di api/pos/offline-sync.http