Membangun Platform Exchange Crypto Enterprise: Business Logic, Fitur, dan Domain Knowledge

Bagaimana saya mengintegrasikan aplikasi nasabah langsung ke Core Banking System tanpa middleware — melayani jutaan nasabah mikro syariah dengan akurasi real-time, dan tetap zero downtime saat traffic melonjak 5x di musim Lebaran.
"The best technology disappears — the customer only sees trust." — Don Norman
Saya bertanggung jawab atas backend engineering aplikasi ini — merancang dan membangun API layer, Core Banking integration, notification engine, dan security middleware. Aplikasi ini adalah sisi customer-facing dari ekosistem Tepati — jika Tepati digunakan oleh petugas internal, SiTepat digunakan langsung oleh nasabah.
SiTepat adalah aplikasi mobile yang memberikan akses mandiri kepada nasabah (Undisclosed Enterprise Bank) untuk memantau status pembiayaan, tabungan, dan jadwal angsuran mereka — tanpa perlu mengunjungi kantor cabang atau menghubungi petugas.
Artikel ini membedah tantangan engineering dan keputusan arsitektur di balik pengembangan aplikasi customer-facing untuk sektor ultra-micro banking.
Sebelum SiTepat ada, nasabah hanya memiliki cara-cara tradisional untuk memantau status pembiayaan mereka:
Sebelum SiTepat, ~15% keterlambatan angsuran disebabkan oleh nasabah yang lupa tanggal jatuh tempo — bukan karena tidak mampu bayar. Setiap keterlambatan berpotensi meningkatkan rasio Non-Performing Loan (NPL) dan menurunkan credit score nasabah.
Membangun aplikasi untuk nasabah banking memiliki tantangan yang fundamental berbeda dari aplikasi internal (Tepati):
Berbeda dengan Tepati yang arsitekturnya offline-first dengan message broker, SiTepat menggunakan arsitektur API Gateway pattern yang mengabstraksi kompleksitas Core Banking System dari mobile client.
graph TD
A[Nasabah] -->|Open App| B[SiTepat Mobile App]
B -->|HTTPS + Cert Pinning| C[API Gateway - Golang]
C -->|Auth Check| D[Auth Service]
D -->|Validate| E[OAuth 2.0 + MPIN/Biometric]
C -->|Route| F[Account Service]
C -->|Route| G[Financing Service]
C -->|Route| H[Notification Service]
F -->|ISO 8583| I[Core Banking System]
G -->|ISO 8583| I
H -->|FCM| B
F -->|Cache| J[(Redis)]
G -->|Cache| J
I -->|Persist| K[(SQL Server)]
C -->|Audit| L[(MongoDB)]Key Architecture Decisions:
Problem: Core Banking System menggunakan protokol ISO 8583 — format binary fixed-length yang didesain tahun 1987 untuk transaksi ATM. Semua data saldo, mutasi, dan pembiayaan hanya bisa diakses melalui protokol ini. Mobile app butuh REST API.
Solution: Saya membangun ISO 8583 Bridge Service — sebuah Golang service yang bertindak sebagai translator antara REST API (JSON) dan ISO 8583 (binary).
graph LR
A[Mobile App] -->|REST JSON| B[API Gateway]
B -->|REST JSON| C[ISO 8583 Bridge]
C -->|Pack to ISO 8583| D[Core Banking]
D -->|ISO 8583 Response| C
C -->|Unpack to JSON| B
B -->|JSON Response| A// ISO 8583 Bridge - Simplified Balance Inquiry
type BalanceInquiry struct {
PAN string `iso8583:"2"` // Primary Account Number
ProcessCode string `iso8583:"3"` // 310000 = Balance Inquiry
Amount int64 `iso8583:"4"`
STAN string `iso8583:"11"` // System Trace Audit Number
LocalTime string `iso8583:"12"`
LocalDate string `iso8583:"13"`
MerchantID string `iso8583:"42"`
}
func (s *BridgeService) GetBalance(ctx context.Context, accountID string) (*BalanceResponse, error) {
// Build ISO 8583 message
msg := &BalanceInquiry{
PAN: accountID,
ProcessCode: "310000",
STAN: generateSTAN(),
LocalTime: time.Now().Format("150405"),
LocalDate: time.Now().Format("0102"),
}
packed, err := s.packer.Pack(msg)
if err != nil {
return nil, fmt.Errorf("failed to pack ISO message: %w", err)
}
// Send to Core Banking via TCP socket
resp, err := s.conn.SendWithTimeout(ctx, packed, 10*time.Second)
if err != nil {
// Circuit breaker: fallback to cached balance
cached, cacheErr := s.cache.Get(ctx, fmt.Sprintf("balance:%s", accountID))
if cacheErr == nil {
return cached.(*BalanceResponse), nil
}
return nil, fmt.Errorf("core banking unavailable: %w", err)
}
balance, err := s.packer.Unpack(resp)
if err != nil {
return nil, fmt.Errorf("failed to unpack response: %w", err)
}
// Cache the result
s.cache.Set(ctx, fmt.Sprintf("balance:%s", accountID), balance, 30*time.Second)
return balance, nil
}Key Engineering Decisions:
Results:
| Metric | Sebelum (Manual) | Sesudah (SiTepat) | Improvement |
|---|---|---|---|
| Balance inquiry response time | N/A (visit branch) | < 2 detik | — |
| Core Banking hit rate | 100% (no cache) | 35% (65% cache hit) | ↓ 65% load |
| API availability | N/A | 99.7% (with circuit breaker) | — |
| Daily active inquiries | 0 (manual only) | ~120,000 | — |
Problem: Nasabah tidak memiliki visibility atas status pembiayaan mereka. Informasi seperti sisa plafon, jadwal angsuran, dan riwayat pembayaran hanya bisa dilihat di buku angsuran fisik — yang sering hilang atau tidak diupdate.
Solution: Financing Dashboard yang menampilkan data pembiayaan secara komprehensif dan real-time.
// Financing Dashboard - Data Structure (React Native)
interface FinancingDetail {
contractId: string;
productName: string;
disbursementDate: string;
maturityDate: string;
plafond: number;
outstandingBalance: number;
marginRate: number;
tenor: number;
installment: {
monthly: number;
nextDueDate: string;
remainingInstallments: number;
paidInstallments: number;
};
paymentHistory: PaymentRecord[];
status: "active" | "completed" | "overdue" | "restructured";
}
interface PaymentRecord {
date: string;
amount: number;
method: "auto_debit" | "cash_to_officer" | "transfer";
status: "paid" | "late" | "partial";
lateDays: number;
}
// Fetch with caching strategy
async function fetchFinancingDetail(
contractId: string,
): Promise<FinancingDetail> {
// Try local cache first (stale-while-revalidate pattern)
const cached = await SecureStorage.get(`financing:${contractId}`);
if (cached && !isStale(cached, 60_000)) {
return cached.data;
}
try {
const response = await api.get(`/financing/${contractId}`, {
headers: { Authorization: `Bearer ${await getToken()}` },
});
await SecureStorage.set(`financing:${contractId}`, {
data: response.data,
timestamp: Date.now(),
});
return response.data;
} catch (error) {
// Offline fallback: return cached data even if stale
if (cached) return cached.data;
throw new FinancingFetchError(error);
}
}Key Design:
Results:
| Metric | Sebelum | Sesudah | Improvement |
|---|---|---|---|
| Customer inquiry calls (Call Center) | ~8,500/bulan | ~3,200/bulan | ↓ 62% |
| Avg time to check financing status | 15-30 menit (visit/call) | < 5 detik | ↓ 99% |
| Customer satisfaction (survey) | 3.2/5 | 4.5/5 | ↑ 41% |
| Buku angsuran replacement requests | ~2,100/bulan | ~400/bulan | ↓ 81% |
Problem: ~15% keterlambatan angsuran disebabkan karena lupa, bukan karena tidak mampu. Sistem SMS reminder sebelumnya tidak efektif karena sering masuk spam folder dan tidak bisa di-track delivery-nya.
Solution: Multi-channel smart reminder engine dengan scheduling yang configurable.
graph TD
A[Scheduler Service] -->|Scan H-3| B{Due Date Check}
B -->|H-3| C[Reminder: Gentle]
B -->|H-1| D[Reminder: Urgent]
B -->|H+0| E[Reminder: Due Today]
B -->|H+1| F[Reminder: Overdue]
C --> G[FCM Push Notification]
D --> G
E --> G
F --> G
G --> H[SiTepat App]
A --> I[(PostgreSQL - Schedule DB)]
G --> J[Notification Log]// Smart Reminder Scheduler (Golang)
type ReminderSchedule struct {
DaysBefore int
Template string
Priority string
Channel string
}
var reminderSchedules = []ReminderSchedule{
{DaysBefore: 3, Template: "reminder_gentle", Priority: "normal", Channel: "push"},
{DaysBefore: 1, Template: "reminder_urgent", Priority: "high", Channel: "push"},
{DaysBefore: 0, Template: "reminder_dueday", Priority: "high", Channel: "push"},
{DaysBefore: -1, Template: "reminder_overdue", Priority: "critical", Channel: "push+sms"},
}
func (s *ReminderService) ProcessDailyReminders(ctx context.Context) error {
today := time.Now().Truncate(24 * time.Hour)
for _, schedule := range reminderSchedules {
targetDate := today.AddDate(0, 0, schedule.DaysBefore)
customers, err := s.repo.FindByDueDate(ctx, targetDate)
if err != nil {
return fmt.Errorf("failed to fetch customers for %s: %w", targetDate, err)
}
for _, customer := range customers {
// Skip if already paid
if customer.IsPaidForCurrentPeriod() {
continue
}
notification := Notification{
CustomerID: customer.ID,
DeviceToken: customer.FCMToken,
Template: schedule.Template,
Priority: schedule.Priority,
Data: map[string]string{
"contract_id": customer.ContractID,
"amount": formatCurrency(customer.InstallmentAmount),
"due_date": targetDate.Format("2 January 2006"),
},
}
if err := s.notifier.Send(ctx, notification); err != nil {
s.logger.Error("failed to send reminder",
"customer_id", customer.ID,
"error", err,
)
// Don't fail entire batch for one customer
continue
}
s.repo.LogNotification(ctx, notification)
}
s.logger.Info("reminders processed",
"schedule", schedule.Template,
"target_date", targetDate,
"count", len(customers),
)
}
return nil
}Key Engineering:
customer_id + template + date.Results:
| Metric | Sebelum (SMS) | Sesudah (SiTepat) | Improvement |
|---|---|---|---|
| Late payment (karena lupa) | ~15% | ~4% | ↓ 73% |
| Notification delivery rate | 62% (SMS) | 94% (Push) | ↑ 52% |
| Nasabah bayar sebelum jatuh tempo | 58% | 81% | ↑ 40% |
| Cost per notification | Rp 350 (SMS) | Rp 0 (Push) | ↓ 100% |
Problem: Aplikasi banking terekspos ke publik — setiap vulnerability bisa berujung pada pencurian data finansial nasabah. Regulasi OJK dan PBI (Peraturan Bank Indonesia) mensyaratkan standar keamanan yang sangat tinggi.
Solution: Multi-layered security framework yang saya implementasikan di backend dan koordinasikan dengan tim mobile.
graph TD
A[User Login] --> B{Biometric / MPIN}
B -->|Valid| C[Generate JWT]
C --> D[API Request]
D --> E{Certificate Pinning Check}
E -->|Valid Cert| F{Rate Limiter}
F -->|Under Limit| G{JWT Validation}
G -->|Valid Token| H{Device Integrity Check}
H -->|Clean Device| I[Process Request]
H -->|Rooted Device| J[Block + Alert]
E -->|Invalid Cert| K[Reject - MITM Detected]
F -->|Over Limit| L[429 Too Many Requests]
G -->|Invalid Token| M[401 Unauthorized]// Security Middleware Stack (Golang)
func SecurityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Rate Limiting — per account, sliding window
accountID := extractAccountID(r)
if !rateLimiter.Allow(accountID, 30, time.Minute) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
auditLog(ctx, "rate_limit_exceeded", accountID)
return
}
// 2. JWT Validation with rotation support
claims, err := validateJWT(r.Header.Get("Authorization"))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
auditLog(ctx, "invalid_token", accountID)
return
}
// 3. Device Integrity Check
deviceFingerprint := r.Header.Get("X-Device-Fingerprint")
if isRootedDevice(deviceFingerprint) {
http.Error(w, "device not supported", http.StatusForbidden)
auditLog(ctx, "rooted_device_blocked", accountID)
alertSecurityTeam(ctx, accountID, "rooted_device_detected")
return
}
// 4. Session Binding — token harus dari device yang sama
if claims.DeviceID != deviceFingerprint {
http.Error(w, "session invalid", http.StatusUnauthorized)
auditLog(ctx, "device_mismatch", accountID)
return
}
// 5. Request Signing Verification
if !verifyRequestSignature(r) {
http.Error(w, "invalid signature", http.StatusBadRequest)
auditLog(ctx, "invalid_signature", accountID)
return
}
ctx = context.WithValue(ctx, "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Security Layers Implemented:
| Layer | Mekanisme | Tujuan |
|---|---|---|
| Transport | TLS 1.3 + Certificate Pinning | Mencegah MITM attack |
| Authentication | MPIN + Biometric + OAuth 2.0 | Multi-factor verification |
| Authorization | JWT dengan device binding | Token hanya valid di device asli |
| Integrity | Root/Jailbreak detection | Block device yang compromised |
| Rate Limiting | Sliding window per account | Mencegah brute force dan DDoS |
| Audit | Immutable audit log | Compliance dan forensic |
| Data at Rest | AES-256 encryption | Lindungi data lokal di device |
| Request Signing | HMAC-SHA256 | Pastikan request tidak di-tamper |
Results:
| Metric | Target (OJK/PBI) | Achieved | Status |
|---|---|---|---|
| Security incidents (data breach) | 0 | 0 | ✅ Compliant |
| Unauthorized access attempts blocked | — | ~2,400/bulan | Auto-blocked |
| Brute force MPIN attempts blocked | — | ~850/bulan | Auto-locked |
| OJK penetration test | Pass | Pass | ✅ Certified |
Problem: Nasabah sering memerlukan bukti transaksi untuk keperluan bisnis mereka — misalnya bukti bayar angsuran untuk mengajukan pinjaman di tempat lain, atau memastikan setoran dari petugas sudah masuk ke rekening.
Solution: Searchable transaction history dengan filter dan export capability.
// Transaction History Service (Golang)
type MutationFilter struct {
AccountID string `json:"account_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Type string `json:"type"` // "credit", "debit", "all"
MinAmount *int64 `json:"min_amount,omitempty"`
MaxAmount *int64 `json:"max_amount,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func (s *MutationService) GetMutations(ctx context.Context, filter MutationFilter) (*MutationResponse, error) {
// Validate date range — max 90 days per query (OJK requirement)
if filter.EndDate.Sub(filter.StartDate) > 90*24*time.Hour {
return nil, fmt.Errorf("date range exceeds 90 days maximum")
}
// Check cache for recent mutations (< 24 hours)
cacheKey := fmt.Sprintf("mutations:%s:%s:%s",
filter.AccountID,
filter.StartDate.Format("20060102"),
filter.EndDate.Format("20060102"),
)
if cached, err := s.cache.Get(ctx, cacheKey); err == nil {
return cached.(*MutationResponse), nil
}
// Fetch from Core Banking via ISO 8583
rawMutations, err := s.bridge.FetchMutations(ctx, filter.AccountID, filter.StartDate, filter.EndDate)
if err != nil {
return nil, fmt.Errorf("failed to fetch mutations: %w", err)
}
// Transform & enrich data
mutations := make([]Mutation, 0, len(rawMutations))
for _, raw := range rawMutations {
mutations = append(mutations, Mutation{
Date: raw.TransactionDate,
Description: enrichDescription(raw.Description),
Amount: raw.Amount,
Type: categorizeTransaction(raw.DebitCredit),
Balance: raw.RunningBalance,
Reference: raw.ReferenceNumber,
})
}
// Apply client-side filters
filtered := applyFilters(mutations, filter)
// Paginate
result := paginate(filtered, filter.Page, filter.PageSize)
// Cache for 5 minutes
s.cache.Set(ctx, cacheKey, result, 5*time.Minute)
return result, nil
}Results:
| Metric | Sebelum | Sesudah | Improvement |
|---|---|---|---|
| Waktu akses riwayat transaksi | Visit branch (1-2 jam) | < 3 detik | ↓ 99% |
| Cetak mutasi di branch | ~4,200 request/bulan | ~1,100 request/bulan | ↓ 74% |
| Dispute resolution time | 3-5 hari kerja | < 24 jam | ↓ 80% |
Implementasi SiTepat mengubah cara nasabah berinteraksi dengan bank:
Salah satu tantangan terbesar post-launch adalah fragmentasi device Android yang digunakan nasabah. Berbeda dengan Tepati yang perangkatnya di-standardisasi oleh bank, SiTepat berjalan di device pribadi nasabah:
| Segmen Device | Distribusi | Masalah |
|---|---|---|
| RAM < 2GB, Android 5-7 | ~35% | App sering di-kill oleh OS, push notification tidak sampai |
| RAM 2-3GB, Android 8-9 | ~40% | Relatif stabil, occasional ANR (Application Not Responding) |
| RAM > 3GB, Android 10+ | ~25% | Performa optimal, battery optimization kadang block background service |
Solusi yang diterapkan:
Tantangan unik yang tidak ditemukan di kebanyakan app banking: nasabah mikro banyak yang baru pertama kali menggunakan aplikasi perbankan. Kolaborasi antara engineering dan product yang kami lakukan:
Mempublikasikan aplikasi banking di Play Store memerlukan compliance tambahan:
Project SiTepat bukan sekadar tentang membangun aplikasi banking. Ini adalah pembuktian bahwa teknologi legacy (1980-an) bisa hidup berdampingan dengan ekspektasi modern (2020-an) jika dijembatani dengan arsitektur yang tepat.
Sebagai engineer, mudah bagi kita untuk terjebak mengejar hype teknologi terbaru. Namun, SiTepat mengajarkan saya bahwa nilai tertinggi seorang engineer bukan pada seberapa "bleeding edge" stack yang dipakai, melainkan pada kemampuan memecahkan masalah nyata dengan batasan yang ada.
"The best technology disappears."
Bagi manajemen, aplikasi ini hanyalah alat efisiensi. Bagi pedagang pasar yang menjadi nasabah kami, aplikasi ini adalah ketenangan pikiran.
Mampu membangun bridge yang aman, cepat, dan reliable di antara dua dunia yang berbeda (Legacy vs Modern, Tech-Savvy vs Awam) adalah kontribusi profesional yang paling saya banggakan dalam karir saya sejauh ini.



