history: payments hanya run terbaru, TANGGAL pindah ke judul, MEMO dihapus (DB+UI)

This commit is contained in:
proitlab committed 2026-08-05 20:37:50 +07:00
1 parent a3251eebc4
commit 344de409cd
10 files changed
+83 -41

No files matched your search

+2 -2
View File
@@ -29,7 +29,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column. - The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key. - Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only. - Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/VOTE equal width with AKUN left and STAKE/VOTE centered; tablet keeps the fixed STAKE column near page center; `/history` uses six-column run/payment grids; mobile turns history rows into labeled cards, including MEMO and TXID. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting. - Dashboard layout (spec §V28): desktop gives AKUN/STAKE/VOTE equal width with AKUN left and STAKE/VOTE centered; tablet keeps the fixed STAKE column near page center; `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB. - Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
## Layout ## Layout
@@ -39,7 +39,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`. - `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`.
- `get_voters.js` — reference implementation only. - `get_voters.js` — reference implementation only.
- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan. `test_distribute.py` — oracle (mock chain+pyntelope, temp DB). - `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan. `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. `/history` = riwayat distribusi read-only; `run-list`/`pay-list` use six-column desktop/tablet grids and labeled mobile cards. - `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd. - `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`; shared by get_voters & dashboard. - `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. - `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
+5 -4
View File
@@ -52,9 +52,9 @@ api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via p
api: POST `https://v2.vexascan.com:2096/v1/chain/get_info` → chain_id (utk signing) api: POST `https://v2.vexascan.com:2096/v1/chain/get_info` → chain_id (utk signing)
api: GET `https://api.databisnis.id/v2/history/get_transaction?id=<txid>` (Hyperion) → {executed, trx_id, lib} — guard resend api: GET `https://api.databisnis.id/v2/history/get_transaction?id=<txid>` (Hyperion) → {executed, trx_id, lib} — guard resend
db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, total_staked, total_sent, status, created_at) via `db.py`; append db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, total_staked, total_sent, status, created_at) via `db.py`; append
db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, memo, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain)
cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign
web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; 6-kolom desktop/tablet, kartu berlabel mobile web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (secret swarm / env dev), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`) env: `VEX_BP_PRIVATE_KEY` (secret swarm / env dev), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`)
## §V — Invariants ## §V — Invariants
@@ -79,12 +79,12 @@ V19: target transfer = kontrak `vex.token` (konstanta chain hardcoded; ⊥ diuba
V20: saldo distribusi dari chain `get_currency_balance` pd `vex.token`; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun V20: saldo distribusi dari chain `get_currency_balance` pd `vex.token`; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun
V21: ∀ transfer → dicatat di `distribute_payments` sbg `pending` SEBELUM kirim; status → `sent`|`failed` dr hasil tx; ⊥ hapus baris (append) V21: ∀ transfer → dicatat di `distribute_payments` sbg `pending` SEBELUM kirim; status → `sent`|`failed` dr hasil tx; ⊥ hapus baris (append)
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat) V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat)
V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB)
V24: run harian `DISTRIBUTE_HOUR` (default 10) waktu lokal `TZ`; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan V24: run harian `DISTRIBUTE_HOUR` (default 10) waktu lokal `TZ`; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan
V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB
V26: `VEX_BP_PRIVATE_KEY` ⊥ pernah di-log/print; `--dry-run` utk uji lokal (⊥ sign) V26: `VEX_BP_PRIVATE_KEY` ⊥ pernah di-log/print; `--dry-run` utk uji lokal (⊥ sign)
V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi
V28: web list desktop → kolom AKUN/STAKE/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE & VOTE TERAKHIR center; tablet grid lama; history runs/payments 6 kolom desktop/tablet; mobile → kartu berlabel ∀ field termasuk MEMO/TXID; HTML `no-store` + stylesheet versioned V28: web list desktop → kolom AKUN/STAKE/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE & VOTE TERAKHIR center; tablet grid lama; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned
## §T — Tasks ## §T — Tasks
id|status|task|cites id|status|task|cites
@@ -118,6 +118,7 @@ T27|x|tests: test_distribute.py (mock chain+pyntelope, temp DB: math share, memo
T28|x|hygiene: .gitignore += `.oc/`; stage penghapusan README.md (stub kosong)| T28|x|hygiene: .gitignore += `.oc/`; stage penghapusan README.md (stub kosong)|
T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web
T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web
T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web
## §B — Bug log ## §B — Bug log
id|date|cause|fix id|date|cause|fix
+7 -2
View File
@@ -208,10 +208,15 @@ def api_search():
@app.get('/history') @app.get('/history')
def history(): def history():
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.""" """V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.
Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul.
"""
runs = db.list_runs() runs = db.list_runs()
payments = db.list_payments() latest = runs[0][0] if runs else None
payments = db.list_payments(run_id=latest)
return render_template('history.html', runs=runs, payments=payments, return render_template('history.html', runs=runs, payments=payments,
latest_date=runs[0][1] if runs else None,
bp=TARGET_BP) bp=TARGET_BP)
+35 -12
View File
@@ -116,7 +116,6 @@ _PAY_COLS = (
'run_id INTEGER NOT NULL, ' 'run_id INTEGER NOT NULL, '
'owner VARCHAR(13) NOT NULL, ' 'owner VARCHAR(13) NOT NULL, '
'amount DOUBLE NOT NULL, ' 'amount DOUBLE NOT NULL, '
'memo TEXT NOT NULL, '
'txid TEXT, ' 'txid TEXT, '
'status TEXT NOT NULL, ' 'status TEXT NOT NULL, '
'error TEXT, ' 'error TEXT, '
@@ -141,11 +140,32 @@ def ensure_distribute_schema():
else: else:
cur.execute(_RUN_DDL_MYSQL) cur.execute(_RUN_DDL_MYSQL)
cur.execute(_PAY_DDL_MYSQL) cur.execute(_PAY_DDL_MYSQL)
_drop_pay_memo(cur)
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
def _drop_pay_memo(cur):
"""Migrasi: buang kolom memo lama dari distribute_payments (idempoten).
Kolom `memo` pernah disimpan (V23) lalu dihapus — DB produksi yang sudah
punya kolom perlu di-alter; DB baru langsung tanpa kolom.
"""
if DB_BACKEND == 'sqlite':
cur.execute(
"SELECT name FROM pragma_table_info('distribute_payments')")
cols = {row[0] for row in cur.fetchall()}
else:
cur.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
" AND TABLE_NAME = 'distribute_payments'")
cols = {row[0] for row in cur.fetchall()}
if 'memo' in cols:
cur.execute(_translate('ALTER TABLE distribute_payments DROP COLUMN memo'))
def record_run(run_date, balance_start, total_voters, total_staked, def record_run(run_date, balance_start, total_voters, total_staked,
total_sent, status, created_at): total_sent, status, created_at):
"""Insert baris `distribute_runs`, kembalikan run_id.""" """Insert baris `distribute_runs`, kembalikan run_id."""
@@ -165,7 +185,7 @@ def record_run(run_date, balance_start, total_voters, total_staked,
conn.close() conn.close()
def record_payment(run_id, owner, amount, memo, status, created_at, def record_payment(run_id, owner, amount, status, created_at,
txid=None, error=None): txid=None, error=None):
"""Insert baris `distribute_payments` → payment_id.""" """Insert baris `distribute_payments` → payment_id."""
ensure_distribute_schema() ensure_distribute_schema()
@@ -174,9 +194,9 @@ def record_payment(run_id, owner, amount, memo, status, created_at,
cur = conn.cursor() cur = conn.cursor()
cur.execute(_translate( cur.execute(_translate(
'INSERT INTO distribute_payments ' 'INSERT INTO distribute_payments '
'(run_id, owner, amount, memo, txid, status, error, created_at, ' '(run_id, owner, amount, txid, status, error, created_at, '
' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)'), ' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'),
(run_id, owner, amount, memo, txid, status, error, created_at, (run_id, owner, amount, txid, status, error, created_at,
created_at)) created_at))
conn.commit() conn.commit()
return cur.lastrowid return cur.lastrowid
@@ -224,11 +244,14 @@ def list_runs():
'FROM distribute_runs ORDER BY run_id DESC') 'FROM distribute_runs ORDER BY run_id DESC')
def list_payments(): def list_payments(run_id=None):
"""Transfer per-pemilih, terbaru dulu (gabung run untuk tanggal).""" """Transfer per-pemilih, terbaru dulu; opsional filter satu run."""
ensure_distribute_schema() ensure_distribute_schema()
return query( sql = ('SELECT payment_id, run_id, owner, amount, txid, status, error, '
'SELECT p.payment_id, p.run_id, p.owner, p.amount, p.memo, p.txid, ' ' created_at, updated_at FROM distribute_payments')
' p.status, p.error, p.created_at, p.updated_at, r.run_date ' params = ()
'FROM distribute_payments p JOIN distribute_runs r ' if run_id is not None:
' ON p.run_id = r.run_id ORDER BY p.payment_id DESC') sql += ' WHERE run_id = %s'
params = (run_id,)
sql += ' ORDER BY payment_id DESC'
return query(sql, params)
+1 -1
View File
@@ -160,7 +160,7 @@ def run_distribution(dry_run=False):
0.0, 'ok', created_at) 0.0, 'ok', created_at)
pending = [] pending = []
for owner, share in shares: for owner, share in shares:
pid = db.record_payment(run_id, owner, float(share), memo, 'pending', pid = db.record_payment(run_id, owner, float(share), 'pending',
created_at) created_at)
pending.append((pid, owner, share)) pending.append((pid, owner, share))
+4 -1
View File
@@ -193,7 +193,7 @@ button.pill {
} }
.pay-list .list-header, .pay-list .list-header,
.pay-list .list-row { .pay-list .list-row {
grid-template-columns: 1fr 140px 170px 100px 1.2fr 260px; grid-template-columns: 1fr 170px 110px 280px;
} }
.list-header { .list-header {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -229,6 +229,9 @@ button.pill {
.list-row .vote-date { text-align: center; } .list-row .vote-date { text-align: center; }
.list-header span:nth-child(3), .list-header span:nth-child(3),
.list-header span:nth-child(4) { text-align: center; } .list-header span:nth-child(4) { text-align: center; }
.pay-list .list-header span:nth-child(2),
.pay-list .list-header span:nth-child(3) { text-align: center; }
.pay-list .list-header span:nth-child(4) { text-align: left; }
.txid { .txid {
font-size: 12px; font-size: 12px;
letter-spacing: 1px; letter-spacing: 1px;
+4 -8
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=4) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=5) }}">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
@@ -59,26 +59,22 @@
</div> </div>
</section> </section>
<p class="caption">TRANSFER PER PEMILIH</p> <p class="caption">TRANSFER PER PEMILIH{% if latest_date %} · {{ latest_date }}{% endif %}</p>
<section class="spec-list pay-list"> <section class="spec-list pay-list">
<div class="list-header"> <div class="list-header">
<span>AKUN</span> <span>AKUN</span>
<span>TANGGAL</span>
<span>JUMLAH</span> <span>JUMLAH</span>
<span>STATUS</span> <span>STATUS</span>
<span>MEMO</span>
<span>TXID</span> <span>TXID</span>
</div> </div>
<div class="list-rows"> <div class="list-rows">
{% for pay in payments %} {% for pay in payments %}
<div class="list-row"> <div class="list-row">
<span class="owner">{{ pay[2] }}</span> <span class="owner">{{ pay[2] }}</span>
<span class="vote-date" data-label="TANGGAL">{{ pay[10] }}</span>
<span class="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span> <span class="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ pay[6]|upper }}</span> <span class="vote-date" data-label="STATUS">{{ pay[5]|upper }}</span>
<span class="txid memo" data-label="MEMO">{{ pay[4] }}</span> <span class="owner txid" data-label="TXID">{{ pay[4] or '—' }}</span>
<span class="owner txid" data-label="TXID">{{ pay[5] or '—' }}</span>
</div> </div>
{% else %} {% else %}
<div class="empty">BELUM ADA TRANSFER</div> <div class="empty">BELUM ADA TRANSFER</div>
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=4) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=5) }}">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
+12 -3
View File
@@ -55,12 +55,17 @@ def main():
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
# ————— V27: seed riwayat distribusi utk uji /history ————— # ————— V27: seed riwayat distribusi utk uji /history —————
# run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru).
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime('%Y-%m-%d')
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
dbm.ensure_distribute_schema() dbm.ensure_distribute_schema()
old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok',
'2026-08-04T10:00:00')
dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent',
'2026-08-04T10:00:01', txid='txold999')
rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok', rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok',
'2026-08-05T10:00:00') '2026-08-05T10:00:00')
dbm.record_payment(rid, 'acct001', 50.0, dbm.record_payment(rid, 'acct001', 50.0, 'sent',
f'DATABISNISID PROFIT SHARE {today}', 'sent',
'2026-08-05T10:00:01', txid='txabc123') '2026-08-05T10:00:01', txid='txabc123')
client = dashboard.app.test_client() client = dashboard.app.test_client()
@@ -104,9 +109,13 @@ def main():
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html) check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
check('V27 run date tampil', today in html) check('V27 run date tampil', today in html)
check('V27 tanggal di judul TRANSFER PER PEMILIH',
f'TRANSFER PER PEMILIH · {today}' in html)
check('V27 transfer per-pemilih tampil', 'acct001' in html) check('V27 transfer per-pemilih tampil', 'acct001' in html)
check('V27 hanya run terbaru (transfer lama tak tampil)',
'oldacct' not in html)
check('V27 status SENT tampil', 'SENT' in html) check('V27 status SENT tampil', 'SENT' in html)
check('V27 memo tercatat', f'DATABISNISID PROFIT SHARE {today}' in html) check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html)
check('V27 txid tercatat', 'txabc123' in html) check('V27 txid tercatat', 'txabc123' in html)
# ————— V11: search server-side ?q= (substring, case-insensitive) ————— # ————— V11: search server-side ?q= (substring, case-insensitive) —————
+12 -7
View File
@@ -150,9 +150,9 @@ def main():
calls['runs'].append((run_date, status)) calls['runs'].append((run_date, status))
return 7 return 7
def fake_record_payment(run_id, owner, amount, memo, status, created, def fake_record_payment(run_id, owner, amount, status, created,
txid=None, error=None): txid=None, error=None):
calls['pays'].append((owner, amount, memo, status)) calls['pays'].append((owner, amount, status))
return len(calls['pays']) return len(calls['pays'])
def fake_update(pid, status, txid=None, error=None, updated_at=None): def fake_update(pid, status, txid=None, error=None, updated_at=None):
@@ -165,7 +165,13 @@ def main():
db.record_payment = fake_record_payment db.record_payment = fake_record_payment
db.update_payment_status = fake_update db.update_payment_status = fake_update
db.update_run_status = fake_run_update db.update_run_status = fake_run_update
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('tx' + to) signed = []
def fake_sign(to, amount, memo):
signed.append(memo)
return FakeSigned('tx' + to)
dist.build_signed_transfer = fake_sign
buf = io.StringIO() buf = io.StringIO()
with redirect_stdout(buf): with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False) rc = dist.run_distribution(dry_run=False)
@@ -173,11 +179,10 @@ def main():
check('V21 run tercatat (run_date)', calls['runs'][0][0] check('V21 run tercatat (run_date)', calls['runs'][0][0]
== datetime.now().strftime('%Y-%m-%d')) == datetime.now().strftime('%Y-%m-%d'))
check('V21 2 payment pending tercatat', check('V21 2 payment pending tercatat',
len([p for p in calls['pays'] if p[3] == 'pending']) == 2) len([p for p in calls['pays'] if p[2] == 'pending']) == 2)
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime('%Y-%m-%d')
check('V23 memo per run', check('V23 memo on-chain per run',
all(p[2] == f'DATABISNISID PROFIT SHARE {today}' all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed))
for p in calls['pays']))
check('V21 semua sent + txid', check('V21 semua sent + txid',
all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates'])) all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates']))
check('V24 status run ok + total_sent', check('V24 status run ok + total_sent',