Pemrograman Dart
Proyek Mini: Membangun Aplikasi CLI Sistem Manajemen Perpustakaan Berbasis OOP
Rangkum seluruh materi Dart Dasar hingga OOP Lanjutan dengan membangun aplikasi CLI Sistem Manajemen Perpustakaan multi-file yang menerapkan encapsulation, inheritance, polymorphism, sealed class, exception handling, dan async/await.

Selamat! Anda telah menyelesaikan 17 materi perjalanan belajar Dart dari dasar variabel di DartPad hingga konsep Object-Oriented Programming (OOP) lanjutan, Generics, Exception Handling, dan pemrograman asinkron di Dart 3. Namun, memahami konsep secara terpisah belumlah lengkap sebelum kita merangkainya menjadi sebuah aplikasi utuh. Pada materi penutup ini, kita akan membangun Aplikasi CLI Sistem Manajemen Perpustakaan berbasis multi-file di VS Code yang memisahkan lapisan model data (models), kontrak penyimpanan (repositories), penanganan kesalahan (exceptions), dan antarmuka pengguna terminal (bin/).
Yang akan dipelajari
- Merancang struktur folder proyek Dart profesional yang terpisah antara
lib/danbin/. - Menerapkan Encapsulation (
_stokTersedia), Inheritance (ItemKoleksi→Buku,JurnalIlmiah), dan Polymorphism (hitungDendaPerHari()). - Menggunakan
abstract interface classsebagai kontrak Repository sertasealed classuntuk memodelkan hasil operasi secara exhaustive. - Menangani kesalahan bisnis dengan Custom Exception dan operasi penyimpanan asinkron (
Future,async/await). - Membuat menu interaktif di terminal menggunakan pustaka bawaan
dart:io.
Arsitektur dan Struktur Folder Proyek
Buat proyek baru di terminal menggunakan perintah dart create perpustakaan_cli, lalu buka foldernya di VS Code. Kita akan menyusun struktur file proyek seperti berikut:
perpustakaan_cli/
├── pubspec.yaml
├── lib/
│ ├── exceptions/
│ │ └── perpustakaan_exception.dart
│ ├── models/
│ │ ├── hasil_operasi.dart
│ │ └── item_koleksi.dart
│ └── repositories/
│ └── perpustakaan_repository.dart
└── bin/
└── perpustakaan.dart
Berikut pemetaan konsep OOP yang bekerja di balik setiap file:
| File di dalam Proyek | Konsep Dart 3 & OOP yang Diterapkan | Tanggung Jawab Utama |
|---|---|---|
lib/exceptions/perpustakaan_exception.dart |
implements Exception, @override toString() |
Mendefinisikan Custom Exception untuk kesalahan pencarian ID dan stok habis |
lib/models/item_koleksi.dart |
abstract class, Encapsulation (_stok), extends, @override (Polimorfisme) |
Mewakili entitas ItemKoleksi beserta turunan Buku dan JurnalIlmiah |
lib/models/hasil_operasi.dart |
sealed class, Generics (<T>) |
Membungkus hasil transaksi (OperasiSukses<T> atau OperasiGagal<T>) |
lib/repositories/perpustakaan_repository.dart |
abstract interface class, Future<T>, async/await |
Menyimpan, mencari, meminjam, dan mengembalikan koleksi secara asinkron |
bin/perpustakaan.dart |
dart:io, switch pattern matching, try-catch |
Menyajikan menu CLI interaktif kepada pustakawan |
Implementasi Kode per File
Mari kita buat masing-masing file secara bertahap.
1. Membuat Custom Exception (lib/exceptions/perpustakaan_exception.dart)
File ini menampung kelas exception khusus agar kegagalan bisnis di perpustakaan memiliki pesan yang jelas:
// File: lib/exceptions/perpustakaan_exception.dart
class KoleksiTidakDitemukanException implements Exception {
final String kode;
const KoleksiTidakDitemukanException(this.kode);
@override
String toString() =>
'Koleksi dengan kode "$kode" tidak ditemukan di katalog.';
}
class StokKosongException implements Exception {
final String judul;
const StokKosongException(this.judul);
@override
String toString() =>
'Maaf, seluruh eksemplar "$judul" sedang dipinjam.';
}
2. Membuat Model Entitas & Polimorfisme (lib/models/item_koleksi.dart)
Di sini kita menerapkan Encapsulation pada field _stokTersedia (agar stok tidak bisa diubah menjadi negatif dari luar file), Abstraction pada ItemKoleksi, serta Inheritance & Polymorphism pada Buku dan JurnalIlmiah:
// File: lib/models/item_koleksi.dart
import '../exceptions/perpustakaan_exception.dart';
abstract class ItemKoleksi {
final String kode;
final String judul;
final int tahunTerbit;
int _stokTersedia;
ItemKoleksi({
required this.kode,
required this.judul,
required this.tahunTerbit,
required int stokAwal,
}) : _stokTersedia = stokAwal < 0 ? 0 : stokAwal;
int get stokTersedia => _stokTersedia;
bool get apakahTersedia => _stokTersedia > 0;
// Abstract method: setiap jenis koleksi punya aturan tersendiri
String get kategoriLabel;
int get batasHariPinjam;
int hitungDendaPerHari();
void kurangiStokSaatPinjam() {
if (_stokTersedia <= 0) {
throw StokKosongException(judul);
}
_stokTersedia--;
}
void tambahStokSaatKembali() {
_stokTersedia++;
}
int hitungTotalDenda(int hariTerlambat) {
if (hariTerlambat <= 0) return 0;
return hariTerlambat * hitungDendaPerHari();
}
String ringkasanKatalog() {
final status = apakahTersedia ? 'Tersedia ($_stokTersedia eks)' : 'Habis Dipinjam';
return '[$kode] ($kategoriLabel) "$judul" ($tahunTerbit) - $status';
}
}
class Buku extends ItemKoleksi {
final String penulis;
final int jumlahHalaman;
Buku({
required super.kode,
required super.judul,
required super.tahunTerbit,
required super.stokAwal,
required this.penulis,
required this.jumlahHalaman,
});
@override
String get kategoriLabel => 'Buku';
@override
int get batasHariPinjam => 7;
@override
int hitungDendaPerHari() => 2000;
@override
String ringkasanKatalog() {
return '${super.ringkasanKatalog()} | Penulis: $penulis ($jumlahHalaman hlm)';
}
}
class JurnalIlmiah extends ItemKoleksi {
final int volume;
final String bidangStudi;
JurnalIlmiah({
required super.kode,
required super.judul,
required super.tahunTerbit,
required super.stokAwal,
required this.volume,
required this.bidangStudi,
});
@override
String get kategoriLabel => 'Jurnal Vol.$volume';
@override
int get batasHariPinjam => 3;
@override
int hitungDendaPerHari() => 5000;
@override
String ringkasanKatalog() {
return '${super.ringkasanKatalog()} | Bidang: $bidangStudi';
}
}
3. Membuat Sealed Class Hasil Operasi (lib/models/hasil_operasi.dart)
Kita menggabungkan sealed class dan Generics <T> agar setiap transaksi mengembalikan status sukses atau gagal yang wajib diperiksa secara tuntas oleh pemanggilnya:
// File: lib/models/hasil_operasi.dart
sealed class HasilOperasi<T> {
const HasilOperasi();
}
final class OperasiSukses<T> extends HasilOperasi<T> {
final T data;
final String pesan;
const OperasiSukses({required this.data, required this.pesan});
}
final class OperasiGagal<T> extends HasilOperasi<T> {
final String pesanError;
const OperasiGagal(this.pesanError);
}
4. Membuat Kontrak dan Implementasi Repository (lib/repositories/perpustakaan_repository.dart)
Lapisan repository menggunakan abstract interface class dan mensimulasikan komunikasi database asinkron menggunakan Future:
// File: lib/repositories/perpustakaan_repository.dart
import '../exceptions/perpustakaan_exception.dart';
import '../models/hasil_operasi.dart';
import '../models/item_koleksi.dart';
abstract interface class PerpustakaanRepository {
Future<List<ItemKoleksi>> ambilSemuaKoleksi();
Future<void> tambahKoleksi(ItemKoleksi item);
Future<HasilOperasi<ItemKoleksi>> pinjamKoleksi(String kode, String namaPeminjam);
Future<HasilOperasi<int>> kembalikanKoleksi(String kode, int hariTerlambat);
}
class MemoriPerpustakaanRepository implements PerpustakaanRepository {
final Map<String, ItemKoleksi> _katalog = {};
MemoriPerpustakaanRepository() {
// Data awal katalog perpustakaan
_isiDataAwal();
}
void _isiDataAwal() {
final daftarAwal = <ItemKoleksi>[
Buku(
kode: 'BK-01',
judul: 'Arsitektur Aplikasi dengan Dart 3',
tahunTerbit: 2026,
stokAwal: 2,
penulis: 'Widia Rohman',
jumlahHalaman: 320,
),
Buku(
kode: 'BK-02',
judul: 'Struktur Data & Algoritma Modern',
tahunTerbit: 2025,
stokAwal: 1,
penulis: 'Rina Permata',
jumlahHalaman: 280,
),
JurnalIlmiah(
kode: 'JR-01',
judul: 'Jurnal Komputasi Awan Nusantara',
tahunTerbit: 2026,
stokAwal: 1,
volume: 12,
bidangStudi: 'Teknik Informatika',
),
];
for (final item in daftarAwal) {
_katalog[item.kode.toUpperCase()] = item;
}
}
ItemKoleksi _cariBerdasarkanKode(String kode) {
final kodeNormal = kode.trim().toUpperCase();
final item = _katalog[kodeNormal];
if (item == null) {
throw KoleksiTidakDitemukanException(kodeNormal);
}
return item;
}
@override
Future<List<ItemKoleksi>> ambilSemuaKoleksi() async {
await Future.delayed(const Duration(milliseconds: 200));
return _katalog.values.toList();
}
@override
Future<void> tambahKoleksi(ItemKoleksi item) async {
await Future.delayed(const Duration(milliseconds: 200));
_katalog[item.kode.toUpperCase()] = item;
}
@override
Future<HasilOperasi<ItemKoleksi>> pinjamKoleksi(
String kode,
String namaPeminjam,
) async {
await Future.delayed(const Duration(milliseconds: 250));
try {
final item = _cariBerdasarkanKode(kode);
item.kurangiStokSaatPinjam();
return OperasiSukses(
data: item,
pesan:
'Peminjaman oleh "$namaPeminjam" berhasil! '
'Batas pinjam: ${item.batasHariPinjam} hari '
'(Denda terlambat: Rp${item.hitungDendaPerHari()}/hari).',
);
} on KoleksiTidakDitemukanException catch (e) {
return OperasiGagal(e.toString());
} on StokKosongException catch (e) {
return OperasiGagal(e.toString());
}
}
@override
Future<HasilOperasi<int>> kembalikanKoleksi(
String kode,
int hariTerlambat,
) async {
await Future.delayed(const Duration(milliseconds: 250));
try {
final item = _cariBerdasarkanKode(kode);
item.tambahStokSaatKembali();
final totalDenda = item.hitungTotalDenda(hariTerlambat);
final infoDenda = totalDenda > 0
? 'Terlambat $hariTerlambat hari. Total denda: Rp$totalDenda.'
: 'Dikembalikan tepat waktu (Tanpa denda).';
return OperasiSukses(
data: totalDenda,
pesan: 'Koleksi "${item.judul}" telah diterima kembali. $infoDenda',
);
} on KoleksiTidakDitemukanException catch (e) {
return OperasiGagal(e.toString());
}
}
}
5. Membuat Titik Masuk CLI Interaktif (bin/perpustakaan.dart)
Sekarang kita rangkai seluruh komponen di dalam bin/perpustakaan.dart menggunakan stdin.readLineSync() dan exhaustive switch untuk menampilkan hasilnya:
// File: bin/perpustakaan.dart
import 'dart:io';
import 'package:perpustakaan_cli/models/hasil_operasi.dart';
import 'package:perpustakaan_cli/models/item_koleksi.dart';
import 'package:perpustakaan_cli/repositories/perpustakaan_repository.dart';
String bacaInput(String label) {
stdout.write(label);
return stdin.readLineSync()?.trim() ?? '';
}
Future<void> tampilkanKatalog(PerpustakaanRepository repo) async {
print('\nMemuat katalog perpustakaan...');
final daftar = await repo.ambilSemuaKoleksi();
print('================ KATALOG PERPUSTAKAAN ================');
for (final item in daftar) {
print(item.ringkasanKatalog());
}
print('======================================================');
}
Future<void> menuPinjam(PerpustakaanRepository repo) async {
final kode = bacaInput('Masukkan kode koleksi (mis. BK-01): ');
final peminjam = bacaInput('Nama peminjam: ');
if (kode.isEmpty || peminjam.isEmpty) {
print('[!] Kode koleksi dan nama peminjam wajib diisi.');
return;
}
final hasil = await repo.pinjamKoleksi(kode, peminjam);
// Exhaustive switch pada sealed class HasilOperasi
final pesanLayar = switch (hasil) {
OperasiSukses(data: final item, pesan: final info) =>
'[SUKSES] ${item.judul}\n $info',
OperasiGagal(pesanError: final err) => '[GAGAL] $err',
};
print(pesanLayar);
}
Future<void> menuKembalikan(PerpustakaanRepository repo) async {
final kode = bacaInput('Masukkan kode koleksi yang dikembalikan: ');
final inputHari = bacaInput('Jumlah hari keterlambatan (isi 0 jika tepat waktu): ');
final hariTerlambat = int.tryParse(inputHari);
if (hariTerlambat == null || hariTerlambat < 0) {
print('[!] Jumlah hari terlambat harus berupa angka bulat >= 0.');
return;
}
final hasil = await repo.kembalikanKoleksi(kode, hariTerlambat);
switch (hasil) {
case OperasiSukses(pesan: final info):
print('[SUKSES] $info');
case OperasiGagal(pesanError: final err):
print('[GAGAL] $err');
}
}
Future<void> main() async {
// Polimorfisme: bertipe interface PerpustakaanRepository
final PerpustakaanRepository repo = MemoriPerpustakaanRepository();
var berjalan = true;
print('Selamat Datang di Sistem Manajemen Perpustakaan CLI (Dart 3)');
while (berjalan) {
print('\nMenu Utama:');
print('1. Lihat Katalog Koleksi');
print('2. Pinjam Koleksi');
print('3. Kembalikan Koleksi & Hitung Denda');
print('0. Keluar');
final pilihan = bacaInput('Pilih menu (0-3): ');
switch (pilihan) {
case '1':
await tampilkanKatalog(repo);
case '2':
await menuPinjam(repo);
case '3':
await menuKembalikan(repo);
case '0':
berjalan = false;
print('Terima kasih! Program dihentikan.');
default:
print('[!] Pilihan tidak dikenali, silakan pilih 0, 1, 2, atau 3.');
}
}
}
Contoh Transkrip Interaksi di Terminal
Ketika dijalankan menggunakan dart run bin/perpustakaan.dart, berikut contoh interaksi yang terjadi:
Selamat Datang di Sistem Manajemen Perpustakaan CLI (Dart 3)
Menu Utama:
1. Lihat Katalog Koleksi
2. Pinjam Koleksi
3. Kembalikan Koleksi & Hitung Denda
0. Keluar
Pilih menu (0-3): 1
Memuat katalog perpustakaan...
================ KATALOG PERPUSTAKAAN ================
[BK-01] (Buku) "Arsitektur Aplikasi dengan Dart 3" (2026) - Tersedia (2 eks) | Penulis: Widia Rohman (320 hlm)
[BK-02] (Buku) "Struktur Data & Algoritma Modern" (2025) - Tersedia (1 eks) | Penulis: Rina Permata (280 hlm)
[JR-01] (Jurnal Vol.12) "Jurnal Komputasi Awan Nusantara" (2026) - Tersedia (1 eks) | Bidang: Teknik Informatika
======================================================
Menu Utama:
1. Lihat Katalog Koleksi
2. Pinjam Koleksi
3. Kembalikan Koleksi & Hitung Denda
0. Keluar
Pilih menu (0-3): 2
Masukkan kode koleksi (mis. BK-01): JR-01
Nama peminjam: Dimas Anggara
[SUKSES] Jurnal Komputasi Awan Nusantara
Peminjaman oleh "Dimas Anggara" berhasil! Batas pinjam: 3 hari (Denda terlambat: Rp5000/hari).
Menu Utama:
1. Lihat Katalog Koleksi
2. Pinjam Koleksi
3. Kembalikan Koleksi & Hitung Denda
0. Keluar
Pilih menu (0-3): 3
Masukkan kode koleksi yang dikembalikan: JR-01
Jumlah hari keterlambatan (isi 0 jika tepat waktu): 2
[SUKSES] Koleksi "Jurnal Komputasi Awan Nusantara" telah diterima kembali. Terlambat 2 hari. Total denda: Rp10000.
Menu Utama:
1. Lihat Katalog Koleksi
2. Pinjam Koleksi
3. Kembalikan Koleksi & Hitung Denda
0. Keluar
Pilih menu (0-3): 0
Terima kasih! Program dihentikan.
Coba sendiri
Setelah kelima file di atas berhasil Anda jalankan dengan dart run bin/perpustakaan.dart, lakukan eksperimen berikut:
- Pilih menu
2(Pinjam Koleksi) dan pinjam kodeBK-02(yang stok awalnya hanya1). - Pilih menu
1(Lihat Katalog) dan perhatikan statusBK-02berubah menjadiHabis Dipinjam. - Pilih menu
2sekali lagi dan coba pinjamBK-02untuk kedua kalinya. Amati bagaimanaStokKosongExceptiondilempar oleh methodkurangiStokSaatPinjam(), ditangkap oleh repository, diubah menjadiOperasiGagal, dan ditampilkan dengan rapi tanpa membuat aplikasi CLI berhenti mendadak!
Kesalahan yang sering terjadi
| Kesalahan Pemula | Mengapa Bermasalah? | Solusi yang Benar |
|---|---|---|
Menulis import '../lib/models/...' dari dalam folder bin/ |
Melanggar konvensi paket Dart dan dapat membuat analyzer mengenali satu kelas sebagai dua tipe berbeda | Dari dalam bin/, selalu gunakan jalur paket: import 'package:nama_proyek/models/...'; |
Mengubah nilai _stokTersedia secara langsung dari kode menu di bin/ |
Merusak enkapsulasi sehingga stok bisa menjadi negatif tanpa melewati validasi | Ubah stok hanya melalui method publik kurangiStokSaatPinjam() dan tambahStokSaatKembali() |
Menggunakan int.parse() langsung pada input terminal pengguna tanpa try-catch atau int.tryParse() |
Jika pengguna tidak sengaja menekan Enter kosong atau mengetik huruf, aplikasi langsung crash | Gunakan int.tryParse(input) dan periksa apakah hasilnya null sebelum memprosesnya |
Menaruh seluruh logika bisnis di dalam bin/perpustakaan.dart |
Kode menjadi sulit diuji (test) dan tidak bisa dipakai ulang jika nanti antarmukanya diganti ke Flutter | Simpan model dan logika bisnis di dalam folder lib/, biarkan bin/ hanya mengurus input/output terminal |
Kaitannya dengan Flutter / Dunia Nyata: Tahukah Anda bahwa seluruh isi folder
lib/(models/,exceptions/, danrepositories/) yang baru saja kita tulis 100% dapat langsung disalin ke dalam proyek aplikasi Flutter tanpa mengubah satu baris kode pun? Itulah kekuatan utama pemisahan tanggung jawab (Separation of Concerns) dan OOP di Dart. Ketika Anda beralih dari aplikasi CLI ke aplikasi mobile atau web Flutter, Anda hanya perlu mengganti kode dibin/perpustakaan.dart(yang memakaistdin/stdout) menjadi komponen antarmuka Flutter (TextField,ElevatedButton, danListView), sementara seluruh aturan bisnis dan repository tetap bekerja sama persis!
Latihan
- Menambah Subclass Baru (
SkripsiMahasiswa): Di dalamlib/models/item_koleksi.dart, tambahkan kelas turunan baruclass SkripsiMahasiswa extends ItemKoleksidengan propertipenulisdanuniversitas. TetapkankategoriLabelmenjadi'Skripsi',batasHariPinjammenjadi5hari, danhitungDendaPerHari()sebesar3000. Tambahkan 1 data skripsi di_isiDataAwal()pada repository. - Fitur Pencarian Kata Kunci di Repository: Tambahkan kontrak
Future<List<ItemKoleksi>> cariBerdasarkanJudul(String kataKunci)padaPerpustakaanRepositorydan implementasikan diMemoriPerpustakaanRepository(gunakan.where(...)dan.toLowerCase().contains(...)). Tambahkan pilihan menu4. Cari Koleksi Berdasarkan Juduldibin/perpustakaan.dart. - Menampilkan Riwayat Peminjaman dengan Mixin: Buat
mixin PencatatTransaksiyang menyimpanList<String> riwayatTransaksidan methodvoid catatTransaksi(String deskripsi). Tempelkan mixin ini padaMemoriPerpustakaanRepositorymenggunakanwith, lalu catat setiap peminjaman dan pengembalian yang berhasil beserta cap waktunya.
Kunci jawaban dan pembahasan
Latihan 1
Tambahkan kelas berikut di bagian bawah lib/models/item_koleksi.dart:
class SkripsiMahasiswa extends ItemKoleksi {
final String penulis;
final String universitas;
SkripsiMahasiswa({
required super.kode,
required super.judul,
required super.tahunTerbit,
required super.stokAwal,
required this.penulis,
required this.universitas,
});
@override
String get kategoriLabel => 'Skripsi';
@override
int get batasHariPinjam => 5;
@override
int hitungDendaPerHari() => 3000;
@override
String ringkasanKatalog() {
return '${super.ringkasanKatalog()} | Oleh: $penulis ($universitas)';
}
}
Pembahasan: Berkat polimorfisme, setelah SkripsiMahasiswa dibuat dan dimasukkan ke dalam _katalog, seluruh fitur peminjaman, pengembalian, dan perhitungan denda di dalam MemoriPerpustakaanRepository langsung bekerja otomatis untuk skripsi tanpa perlu mengubah kode logika repository sedikit pun!
Latihan 2
Tambahkan kontrak pada PerpustakaanRepository dan implementasinya di MemoriPerpustakaanRepository:
// Di dalam abstract interface class PerpustakaanRepository:
Future<List<ItemKoleksi>> cariBerdasarkanJudul(String kataKunci);
// Di dalam class MemoriPerpustakaanRepository:
@override
Future<List<ItemKoleksi>> cariBerdasarkanJudul(String kataKunci) async {
await Future.delayed(const Duration(milliseconds: 150));
final kueri = kataKunci.trim().toLowerCase();
if (kueri.isEmpty) return [];
return _katalog.values
.where((item) => item.judul.toLowerCase().contains(kueri))
.toList();
}
Lalu tambahkan fungsi pembantu di bin/perpustakaan.dart dan panggil pada case '4'::
Future<void> menuCariJudul(PerpustakaanRepository repo) async {
final kataKunci = bacaInput('Masukkan kata kunci judul: ');
final hasil = await repo.cariBerdasarkanJudul(kataKunci);
if (hasil.isEmpty) {
print('Tidak ada koleksi yang cocok dengan kata kunci "$kataKunci".');
return;
}
print('Ditemukan ${hasil.length} koleksi:');
for (final item in hasil) {
print('- ${item.ringkasanKatalog()}');
}
}
Pembahasan: Pencarian dilakukan secara case-insensitive (tidak membedakan huruf besar dan kecil) dengan mengubah baik judul koleksi maupun kata kunci pencarian ke huruf kecil terlebih dahulu menggunakan .toLowerCase().
Latihan 3
Tambahkan mixin di lib/repositories/perpustakaan_repository.dart dan tempelkan ke MemoriPerpustakaanRepository:
mixin PencatatTransaksi {
final List<String> _logTransaksi = [];
List<String> get logTransaksi => List.unmodifiable(_logTransaksi);
void catatTransaksi(String deskripsi) {
final jam = DateTime.now().toIso8601String().substring(11, 19);
_logTransaksi.add('[$jam] $deskripsi');
}
}
class MemoriPerpustakaanRepository
with PencatatTransaksi
implements PerpustakaanRepository {
// ... di dalam pinjamKoleksi setelah item.kurangiStokSaatPinjam():
// catatTransaksi('PINJAM: ${item.kode} (${item.judul}) oleh $namaPeminjam');
// ... di dalam kembalikanKoleksi setelah item.tambahStokSaatKembali():
// catatTransaksi('KEMBALI: ${item.kode} (${item.judul}) - Denda: Rp$totalDenda');
}
Pembahasan: Perhatikan urutan penulisan kata kunci pada deklarasi kelas di Dart: with NamaMixin selalu ditulis sebelum implements NamaInterface. Selain itu, kita menggunakan List.unmodifiable(_logTransaksi) pada getter agar kode dari luar tidak bisa menghapus atau memalsukan catatan riwayat transaksi.
Discussion (0)
We store your optional name, your comment, and technical hashes (not your raw IP address) to prevent spam. A functional cookie lets you edit or delete your own comments from this browser. To request deletion, use the contact page.
No comments yet. Start the discussion.