Imported from washingtoneimae-dot/agent (
skills-library/devops/sacco-statement-automation/SKILL.md). Install upstream withnpx skills add washingtoneimae-dot/agent --skill sacco-statement-automation. Copyright stays with the author.
SACCO Statement Automation
Build a complete financial cooperative member statement system: SQLite transaction log, running-balance computation, HTML statement rendering, n8n email pipeline, Python API server, and HTML admin dashboard.
Architecture
┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ SQLite DB │────▶│ Python API │────▶│ n8n Email Pipeline │
│ • members │ │ Server (:9150) │ │ /webhook/sacco-send │
│ • tx log │ │ • compute_all │ │ HTTP Request nodes │
│ • stmts log │ │ • CRUD ops │ │ → Himalaya SMTP │
└─────────────┘ │ • generate_xlsx │ └──────────────────────┘
▲ │ • send_email │ ▲
│ └──────────────────┘ │
│ ▲ │
┌──────┴─────────────────┐ │ sends Excel │
│ HTML Dashboard │ │ attachments │
│ http://:9150/dashboard │────┘ │
│ • Add Transaction │ │
│ • Add Member │ POST action=send_all │
│ • Send Statements ─────┴──────────────────────────┘
└─────────────────────────┘
Core principle: The transaction log is the single source of truth. Balances are NEVER stored — they compute on every read. Fix a typo in a transaction → all future statements automatically correct.
Key design decision: A Python HTTP API server runs alongside n8n on port 9150. It handles all database operations, Excel generation, and email sending. The n8n workflows use HTTP Request nodes to call this server, bypassing the JS Task Runner that blocks child_process in Code nodes. The admin dashboard is served directly from the API server — no n8n webhook needed for the UI.
Database Schema
CREATE TABLE members (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
member_number TEXT UNIQUE,
joined_date TEXT,
dob TEXT, -- Date of birth (YYYY-MM-DD), shown on Excel statements
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
member_id TEXT REFERENCES members(id),
date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
type TEXT NOT NULL, -- contribution, loan_disbursement, loan_repayment,
-- interest_charge, share_purchase, registration, refund
amount REAL NOT NULL,
description TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE statements_sent (
id TEXT PRIMARY KEY,
member_id TEXT REFERENCES members(id),
sent_date TEXT DEFAULT (datetime('now')),
period_start TEXT,
period_end TEXT
);
Transaction Types
| Type | Increases | Decreases |
|---|---|---|
contribution |
Savings balance | — |
loan_disbursement |
Loan balance | — |
loan_repayment |
— | Loan balance |
interest_charge |
— | (tracked separately) |
share_purchase |
Shares | — |
registration |
— | (one-time fee) |
refund |
Savings balance | — |
total_pay |
(informational) | — |
Note: interest_charge has been REMOVED as a transaction type. Interest is now auto-calculated at statement generation time (rate% × loan balance) and added directly to the loan balance. See "Auto-Interest Calculation" below.
Python API Server Pattern
... (existing content) ...
System Shutdown Endpoint
The API server now includes a clean shutdown endpoint to stop the SACCO system without killing the process manually.
Endpoint
- Path:
/shutdown - Method:
POST - Purpose: Gracefully terminates the server process and releases the listening port.
- Response:
{'status': 'ok', 'message': 'Shutting down'}
Implementation Details
The handler runs the shutdown in a daemon thread using os._exit(0) after sending the JSON response. This ensures the client receives confirmation before the process terminates.
def _shutdown(self):
import threading, os
def _do_exit():
self._json({'status': 'ok', 'message': 'Shutting down'})
os._exit(0)
threading.Thread(target=_do_exit, daemon=True).start()
Dashboard Integration
A red Close System button is added to the top‑right of the dashboard UI:
<button onclick="shutdownSystem()" style="background:#e74c3c;">✕ Close System</button>
And the corresponding JavaScript:
function shutdownSystem(){
if(!confirm('Shut down the SACCO system?')) return;
fetch('/shutdown',{method:'POST'});
document.body.innerHTML = '<div>System shut down.</div>';
}
Usage
From the command line you can also stop the server via curl:
curl -X POST http://127.0.0.1:9150/shutdown
Or, if you prefer the batch file, the Windows SACCO.bat now detects a running process and offers a Stop option that calls the same endpoint.
Pitfalls
- Ensure any pending email sends have completed before invoking shutdown; the background email thread may be terminated abruptly.
- The endpoint does not perform any cleanup of temporary files; callers should remove generated Excel files if needed after shutdown.
The API server (api_server.py) is a single Python file using stdlib only (ThreadingHTTPServer, sqlite3, json). It:
- Serves the HTML admin dashboard at
GET /dashboard - Handles all CRUD operations via
POST /add_tx,/edit_member,/add_member,/delete_tx, etc. - Computes statements via
POST /compute_all(used by n8n pipeline) - Generates Excel files via
POST /generate_statement— creates Norken SACCO formatted.xlsxusing raw ZIP+XML (no openpyxl) - Sends emails via
POST /send_email(HTML body) orPOST /send_email_with_attachment(MIME multipart with.xlsxattachment) - Logs sent statements via
POST /log_statement
API Server Implementation Details
do_POST route pattern — use a dict lookup instead of if/elif chain for readability and speed:
def do_POST(self):
routes = {
'/compute_all': lambda: self._compute_all(),
'/add_tx': lambda: self._add_tx(body),
'/send_email': lambda: self._send_email(body),
}
handler = routes.get(self.path)
if handler: self._json(handler())
else: self._json({'error': 'not found'}, 404)
Body unwrapping — n8n wraps webhook payloads in an envelope {headers, params, query, body: {...}}. Always unwrap:
raw = self.rfile.read(cl)
raw_body = json.loads(raw) if raw else {}
body = raw_body.get('body', raw_body) if isinstance(raw_body, dict) else raw_body
READ CL ONCE — never call self.rfile.read(cl) twice. Read into a variable first, then use the variable. Double-reads consume the data on the first call, causing the second to hang:
# ✅ CORRECT:
raw = self.rfile.read(cl)
raw_body = json.loads(raw) if raw else {}
# ❌ WRONG — hangs on second read:
raw_body = json.loads(self.rfile.read(cl)) if self.rfile.read(cl) else {}
Error handling — wrap do_GET and do_POST in try/except with full traceback return:
def do_POST(self):
try:
...
except Exception as e:
self._json({'error': str(e), 'traceback': traceback.format_exc()}, 500)
Starting the API Server
python3 /home/someone/sacco/api_server.py &
# Runs on http://127.0.0.1:9150
Use background mode in terminal. Verify with curl http://127.0.0.1:9150/.
File Manifest
/home/<user>/sacco/
├── sacco.db # SQLite database
├── api_server.py # HTTP API server (port 9150)
├── compute.py # Running-balance computation
├── render.py # HTML email renderer
├── compute_all.py # JSON output for n8n (standalone)
├── generate_xlsx.py # Raw xlsx generator (no libs)
├── admin_handler.py # CLI-based DB ops (legacy)
├── database/
│ ├── schema.sql
│ └── seed_members.sql
└── scripts/
└── import_member_data.py
Critical Do'Ts
Always wrap do_GET and do_POST in try/except
def do_GET(self):
try:
...
except Exception as e:
import traceback
self._json({'error': str(e), 'traceback': traceback.format_exc()}, 500)
Without this, unhandled exceptions return empty responses (HTTP 000 from curl) instead of error messages. The traceback makes debugging possible without server stderr.
Always read self.rfile.read(cl) ONCE
# ✅ CORRECT:
raw = self.rfile.read(cl)
raw_body = json.loads(raw) if raw else {}
# ❌ WRONG — hangs forever:
raw_body = json.loads(self.rfile.read(cl)) if self.rfile.read(cl) else {}
The second form reads the stream twice — the first call (in the if) consumes the data, the second call blocks forever waiting for more data. The request times out with empty response.
Always unwrap the n8n webhook envelope
See references/api-server-route-pattern.md for the complete pattern.
Implementation Sequence
Phase 1: Database + Seed
- Create schema.sql with all three tables + indexes
- Initialize via Python's
sqlite3module - Seed members — use short IDs like
m001,m002 - Import legacy Excel data: parse with
zipfile+xml.etree.ElementTree(no openpyxl)
Phase 2: Compute + Render
compute.py — query all transactions, group by month, compute running balances (savings, loan, interest).
render.py — HTML email body with SACCO header, 6-month activity table, account summary.
compute_all.py — loop members, call compute_statement() + render_statement_html(), output JSON array.
Phase 3: Python API Server
Write api_server.py with endpoints for all CRUD, statement computation, Excel generation, and email sending. See "Python API Server Pattern" above.
Phase 4: n8n Email Pipeline
Uses HTTP Request nodes (NOT Code nodes — see pitfalls below) to call the API server:
Flow: Webhook → HTTP Request /compute_all → SplitInBatches → HTTP Request /send_email → HTTP Request /log_statement → Set (summary)
All HTTP Request nodes use: specifyBody: "json", jsonBody: "={{ $json }}", contentType: "json", sendBody: true.
Phase 5: HTML Admin Dashboard
The dashboard is served from the API server at http://127.0.0.1:9150/dashboard. It is a self-contained HTML page with:
- Add Transaction form — member dropdown, transaction type, amount, date, notes. Submits via AJAX to
POST /add_tx. - Add Member form — ID, name, email, phone, member number. Submits via AJAX to
POST /add_member. - Members table — rendered server-side from the DB with an Edit button per row that opens an inline modal for changing name/email/phone/member_number.
- Send Statements button — POSTs to the API server's
/send_all_statementsendpoint (not n8n), which generates Excel files and emails to all members. - Settings card — collapsible section for sender name, sender email, society name, account type. Uses a
settingstable in the database. - Setup Guide — collapsible card at the bottom with Gmail setup instructions and the Himalaya config template.
Critical CSS additions for the dashboard:
button.edit-btn {
background: #f39c12;
width: auto;
padding: 4px 12px;
font-size: 12px;
margin: 0;
}
Edit modal pattern — a fixed-position overlay with a card inside. CRITICAL: Every <input> in the edit modal must have a corresponding argument in the editMember() onclick call. Omitting even one field (like dob) will make it appear blank in the modal despite having data in the DB:
<!-- ✅ CORRECT — all 6 args including dob: -->
<button class="edit-btn" onclick="editMember('{id}','{name}','{email}','{phone}','{number}','{dob}')">Edit</button>
<!-- ❌ WRONG — missing dob, DOB field renders empty: -->
<button class="edit-btn" onclick="editMember('{id}','{name}','{email}','{phone}','{number}')">Edit</button>
The JS function signature must match:
function editMember(id,name,email,phone,number,dob){
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = name;
document.getElementById('edit-email').value = email;
document.getElementById('edit-phone').value = phone;
document.getElementById('edit-number').value = number;
document.getElementById('edit-dob').value = dob; // will be undefined if not passed!
document.getElementById('editModal').style.display = 'flex';
}
Rule: Map the edit modal's <input> IDs to onclick arguments and verify every field is represented. If you add a new field to the edit modal, update BOTH the HTML template AND the Python _dashboard_html() f-string that generates the onclick.
<div id="editModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);justify-content:center;align-items:center;z-index:1000;">
<div class="card" style="max-width:450px;margin:auto;margin-top:80px;">
<input type="hidden" id="edit-id">
<label>Name</label><input id="edit-name">
<button onclick="saveMember()">Save Changes</button>
<button onclick="closeEdit()" style="background:#95a5a6;">Cancel</button>
</div>
</div>
Async form handler fix — ALWAYS use event.preventDefault() instead of return submitForm(...) for async form handlers. An async function returns a Promise, not false, so return submitForm(...) does NOT prevent form submission:
<!-- ✅ CORRECT: -->
<form onsubmit="event.preventDefault(); submitForm(this, 'add_tx')">
<!-- ❌ WRONG — form navigates away: -->
<form onsubmit="return submitForm(this, 'add_tx')">
Inline Transaction Editing
Each member's transactions can be viewed by clicking their name in the members table. Each row has an Edit button that transforms the row into editable fields (date, type dropdown, amount). Click Save to update via POST /edit_tx.
API endpoint POST /edit_tx:
def _edit_tx(self, body):
tx_id = body['tx_id']
conn = self._connect()
updates = []
vals = []
for field in ['date', 'type', 'amount', 'description']:
if field in body:
updates.append(f'{field}=?')
vals.append(body[field])
if updates:
vals.append(tx_id)
conn.execute(f'UPDATE transactions SET {",".join(updates)} WHERE id=?', vals)
conn.commit()
conn.close()
return {'status': 'ok', 'tx_id': tx_id}
member_tx return format — each transaction row includes the id attribute and an Edit button:
for tx in txs:
rows += f'<tr id="txrow-{tx["id"]}">'
rows += f'<td>{tx["date"]}</td><td>{tx["type"]}</td><td>KES {tx["amount"]:,.0f}</td>'
rows += f'<td><button class="edit-btn" onclick="editTx(\'{tx["id"]}\',...)"}>Edit</button></td>'
rows += '</tr>'
editTx() JavaScript — replaces the row's innerHTML with inputs + a select dropdown for type:
function editTx(id, date, type, amount){
var row = document.getElementById('txrow-'+id);
row.innerHTML = "<td><input id='ed-"+id+"-date' value='"+date+"'></td>" +
"<td><select id='ed-"+id+"-type'>" + typeOptions + "</select></td>" +
"<td><input id='ed-"+id+"-amt' value='"+amount+"'></td>" +
"<td><button onclick='saveTx(\""+id+"\")'>Save</button></td>";
}
saveTx() JavaScript — reads values from the editable row, POSTs to /edit_tx, then refreshes the transaction list by calling viewTx() again. Uses dataset attributes on the tx-viewer div to remember which member is being viewed.
viewTx() stores context in data-* — avoids global variables:
function viewTx(mid, name){
var v = document.getElementById('tx-viewer');
v.dataset.mid = mid;
v.dataset.mname = name;
fetch('/member_tx', {method:'POST', body:JSON.stringify({member_id:mid})})
.then(r => r.json())
.then(j => { /* populate table */ });
}
Member Deletion
API endpoint POST /delete_member:
def _delete_member(self, body):
mid = body['member_id']
conn = self._connect()
conn.execute('DELETE FROM transactions WHERE member_id=?', (mid,))
conn.execute('DELETE FROM statements_sent WHERE member_id=?', (mid,))
conn.execute('DELETE FROM members WHERE id=?', (mid,))
conn.commit()
conn.close()
return {'status': 'ok', 'deleted': mid}
Always cascade-delete: member → their transactions → their statement logs.
Settings Table
A settings table in the database holds app-wide configuration that the admin can edit from the dashboard:
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT
);
Defaults inserted with INSERT OR IGNORE so they only set on first run. The dashboard pre-populates form fields from this table. See references/settings-table-pattern.md for the complete pattern.
API endpoints:
POST /get_settings— returns all settings as a JSON dictPOST /update_settings— accepts JSON body of key-value pairs, upserts each into the settings table
Key settings used by email sending:
| Key | Purpose | Default |
|---|---|---|
sender_name |
From: display name | Norke SACCO |
sender_email |
From: email address | name@gmail.com |
society_name |
Header on Excel statements | NORKEN SACCO SOCIETY LIMITED |
account_type |
Subtitle on Excel statements | MEMBER PERSONAL ACCOUNT |
gmail_app_password |
SMTP password (16-char) | '' |
email_subject |
Subject line template | "Your SACCO Account Statement - {month} {year}" |
email_footer |
Footer text in emails | "This is an automated statement..." |
Dynamic sender via _get_sender() — reads from DB every time so changes take effect without restart:
def _get_sender(self):
conn = self._connect()
name = conn.execute("SELECT value FROM settings WHERE key='sender_name'").fetchone()
email = conn.execute("SELECT value FROM settings WHERE key='sender_email'").fetchone()
conn.close()
return (name['value'] if name else 'SACCO', email['value'] if email else 'sacco@localhost')
Then use it in _send_email and _send_email_with_attachment:
sname, semail = self._get_sender()
raw = f'From: {sname} <{semail}>\r\n...'
Dashboard settings form — collapsible card with Save button that calls /update_settings:
<div class="card">
<h2 onclick="toggleSettings()">Email Settings ▼</h2>
<div id="settings-section" style="display:none;">
<div class="row">
<div><label>Sender Name</label><input id="set-sender_name" value="{settings['sender_name']}"></div>
<div><label>Sender Email</label><input id="set-sender_email" value="{settings['sender_email']}"></div>
</div>
<button onclick="saveSettings()">Save Settings</button>
</div>
</div>
HTML Template Extraction (Critical)
DO NOT embed the full dashboard HTML inside a Python f-string. The f'''...''' syntax requires {{ and }} escapes for every CSS brace, making the HTML unreadable and unmaintainable. Worse, a web designer cannot edit it without touching Python code — they will accidentally break the f-string or overwrite {placeholders}.
The fix is a two-step process:
Step 1: Extract to a separate file
Move the HTML template (everything from <!DOCTYPE html> to </html>) into a file like dashboard_template.html. Keep the {placeholders} exactly as they were — these are the slots that Python fills in.
Step 2: Replace with .replace() calls
In _dashboard_html(), read the template from file, then substitute each placeholder explicitly:
with open(os.path.join(os.path.dirname(__file__) or '.', 'dashboard_template.html'), encoding='utf-8') as f:
template = f.read()
template = template.replace('{member_opts}', member_opts)
template = template.replace('{member_table}', member_table)
template = template.replace('{type_opts}', type_opts)
template = template.replace('{today}', today)
template = template.replace("{settings.get('sender_name','Norke SACCO')}", settings.get('sender_name', 'Norke SACCO'))
template = template.replace("{settings.get('sender_email','')}", settings.get('sender_email', ''))
# ... repeat for each placeholder
return template
IMPORTANT: Do NOT use template.format() — the template contains { and } from CSS that will cause KeyError on any brace not in the format args.
Pitfall: When a designer edits dashboard_template.html, they must NOT modify:
{placeholders}— these are Python runtime substitutions- Form
idattributes — JavaScript references them onclickhandler names — they must match functions indashboard.js- The
<script src="/dashboard.js">tag
Designer will break viewTx onclick: A common designer mistake is adding their own "make member names clickable" script that overwrites innerHTML of table cells. Since the Python code already generates <a onclick=\"viewTx(...)\"> for each member name, the designer's script replaces the links with their own that do nothing. Remove any designer-added code that sets firstTd.innerHTML on table rows.
JavaScript File — MIME Type Gotcha
Serve JavaScript with application/javascript content type, NOT text/html:
# ✅ CORRECT — browser will execute it:
self.send_response(200)
self.send_header('Content-Type', 'application/javascript')
self.end_headers()
self.wfile.write(content.encode())
# ❌ WRONG — browser may refuse to execute:
self._html(content) # sets Content-Type: text/html
self.send_header('Content-Type', 'application/javascript') # too late, headers already sent
The _html() helper calls end_headers() internally. Any send_header() call after _html() is silently ignored. Always construct the response manually for non-HTML content.
Auto-Schema Creation on First Connect
For the system to work on a fresh install without manually creating tables, call _ensure_schema() on every _connect():
def _ensure_schema(conn):
conn.executescript('''
CREATE TABLE IF NOT EXISTS members (...);
CREATE TABLE IF NOT EXISTS transactions (...);
CREATE TABLE IF NOT EXISTS statements_sent (...);
CREATE TABLE IF NOT EXISTS settings (...);
''')
# Seed default settings (INSERT OR IGNORE preserves user changes)
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
defaults = [
('sender_name', 'Norken SACCO'),
('sender_email', 'name@gmail.com'),
('gmail_app_password', ''),
('society_name', 'NORKEN SACCO SOCIETY LIMITED'),
('account_type', 'MEMBER PERSONAL ACCOUNT'),
('interest_rate', '1'),
]
conn.executemany(
'INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
[(k, v, now) for k, v in defaults]
)
conn.commit()
class Handler(BaseHTTPRequestHandler):
def _connect(self):
conn = sqlite3.connect(DB, timeout=10)
_ensure_schema(conn)
return conn
This guarantees the database is ready on first access, even if sacco.db doesn't exist.
Separate JavaScript File
elif self.path == '/dashboard.js':
with open('dashboard.js') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', 'application/javascript')
self.end_headers()
self.wfile.write(content.encode())
Then in the dashboard HTML, just reference it:
<script src="/dashboard.js"></script>
The JS file lives at ~/sacco/dashboard.js alongside api_server.py.
Send All Statements Endpoint
POST /send_all_statements on the API server loops all members, generates Excel statements, and sends emails with attachments. No n8n involvement:
def _send_all_statements(self):
results = self._compute_all()
sent = []
for m in results:
gen = self._generate_statement({'member_id': m['member_id']})
body = {
'xlsx_path': gen['xlsx_path'],
'email': m['email'],
'html_body': m['html_body'],
'subject': m['subject'],
'member_id': m['member_id'],
}
email_result = self._send_email_with_attachment(body)
if email_result.get('sent'):
self._log_statement({'member_id': m['member_id']})
sent.append({'member_id': m['member_id'], 'status': 'ok'})
return {'sent': len(sent), 'results': sent}
The dashboard button fires this via AJAX and shows ""Sent to N member(s)"" as feedback.
Excel Statement Generation
The generate_xlsx.py script creates .xlsx files using pure Python stdlib (zipfile + XML templates — no openpyxl needed). The output matches the Norken SACCO format exactly — see references/norken-excel-format.md for the complete column layout, running total computation, and serial date handling.
Usage:
from generate_xlsx import generate_xlsx
generate_xlsx(member_name, member_number, rows, summary, output_path)
The API server endpoint POST /generate_statement returns JSON with the .xlsx file path:
{"status": "ok", "xlsx_path": "/tmp/sacco_statement_m001.xlsx", ...}
Email Sending
Primary: Himalaya SMTP with Gmail app password. The API server's /send_email endpoint constructs raw MIME messages and pipes them to himalaya message send.
With Excel attachment: Use /send_email_with_attachment instead — it constructs a MIME multipart message with the .xlsx file attached:
Content-Type: multipart/mixed; boundary="=_sacco_boundary_"
--=_sacco_boundary_
Content-Type: text/html; charset=UTF-8
<html>...</html>
--=_sacco_boundary_
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="SACCO_Statement_m001.xlsx"
Content-Transfer-Encoding: base64
<base64-encoded xlsx>
--=_sacco_boundary_--
Background Email Sending (Threading)
For large member lists (200+), the synchronous send blocks the HTTP response for minutes. Browser and proxy timeouts will fire before emails finish.
Fix: Run the send loop in a threading.Thread and return immediately:
def _send_all_statements(self):
import threading
results = self._compute_all()
total = len(results)
def _send():
for m in results:
if not m.get('email'):
continue
try:
gen = self._generate_statement({'member_id': m['member_id']})
body = {
'xlsx_path': gen['xlsx_path'], 'email': m['email'],
'html_body': m['html_body'], 'member_id': m['member_id'],
}
email_result = self._send_email_with_attachment(body)
if email_result.get('sent'):
self._log_statement({'member_id': m['member_id']})
except:
pass
t = threading.Thread(target=_send, daemon=True)
t.start()
return {'status': 'started', 'message': f'Sending to {total} members in background'}
The dashboard JS then shows "Sending to N members in background" and re-enables the button after 3 seconds.
Members Without Email
When compute_all() returns a member with no email address, skip them during send to avoid failed email attempts:
if not m.get('email'):
continue
Members Without Transactions
When compute_statement() finds zero transactions for a member, return None instead of an empty statement:
if not rows:
return None
Then the caller (compute_all or send_all_statements) naturally skips them via if not data: continue.
Download All Statements as ZIP
Add a GET /download_all_statements endpoint that zips all member .xlsx files into one download. The server returns application/zip with Content-Disposition: attachment. The dashboard shows a link: Download all statements as ZIP.
The endpoint loops _compute_all(), generates each member's statement via _generate_statement(), writes each xlsx into a ZipFile, and serves the buffer with _binary(). Clean up temporary xlsx files after adding to the zip.
Port Configuration via Environment Variable
Make the server port configurable via SACCO_PORT env var so WSL (9150) and Windows (9160) can run simultaneously on the same machine without conflict. Default to 9150 for backward compatibility. Windows SACCO.bat sets SACCO_PORT=9160 and opens the dashboard at the corresponding URL.
Port Conflict Detection
Before starting, check if the target port is already in use. On Windows: netstat -an | findstr ":%PORT% ". If occupied, show a clear message and exit — prevents silently running a server that can't bind.
Dashboard Auto-Refresh After CRUD
After Add Member or Delete Transaction, the dashboard should auto-refresh (1.2s delay) via location.reload() so the members table reflects the change immediately. Add Transaction and Edit Member do NOT trigger reload since they don't affect the visible table.
Cross-Platform Start Scripts (Windows .bat / .ps1)
The distribution includes:
SACCO.bat— double-click to start on Windows. Checks Python, downloads Himalaya automatically from GitHub if missing, starts server on port 9160, opens browser, stops when window closes.sacco.ps1/sacco-stop.ps1— PowerShell equivalents for background running.
Port separation: WSL uses port 9150, Windows uses 9160. Both from the same codebase — only the SACCO_PORT env var differs.
Excel Number Formatting (Commas)
To display numbers like 1,125,818.74 in Excel, add a styles.xml to the xlsx zip with a custom number format:
<numFmt numFmtId="164" formatCode="#,##0.00"/>
Then apply it to money cells via the s attribute:
def mcell(ref, v):
return f'<c r="{ref}" s="1"><v>{float(v):.2f}</v></c>'
And in cellXfs:
<cellXfs count="2">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
</cellXfs>
The format ID 164 (or any 164+) points to the custom #,##0.00 format. Cells with s="1" display with commas.
Excel Month Column (Human-Readable)
Instead of Excel serial date numbers (44531), show month names like "Dec 2021":
from datetime import datetime
y, m = month_str.split('-')
dt = datetime(int(y), int(m), 1)
month_display = dt.strftime('%b %Y') # "Dec 2021"
Store as a shared string and use t="s" in the cell XML, not a numeric value.
Cross-Platform Start Scripts
| Platform | Start Command | File |
|---|---|---|
| Linux/WSL | sacco |
~/.local/bin/sacco (bash) |
| Windows | Double-click SACCO.bat |
SACCO.bat |
| Windows (PowerShell) | .\sacco.ps1 |
sacco.ps1 |
The Windows SACCO.bat checks Python (tries py launcher first then python), downloads Himalaya automatically from GitHub if missing, starts the server on port 9160, opens the browser, and stops the server when the window closes.
Himalaya auto-install (Windows)
The batch file checks if himalaya is on PATH. If not, it downloads the latest Windows release from GitHub and installs to %APPDATA%\himalaya\bin:
[2/3] Checking Himalaya CLI...
Himalaya not found. Attempting auto-install...
Downloading latest Himalaya release...
Himalaya installed successfully [OK]
PowerShell download + extraction pattern:
powershell -Command "& {
$url = 'https://api.github.com/repos/soywod/himalaya/releases/latest'
$release = Invoke-RestMethod -Uri $url -Headers @{'User-Agent'='SACCO'}
$asset = $release.assets | Where-Object { $_.name -like '*windows*' }
$dl = $asset.browser_download_url
$zip = '%TEMP%\himalaya.zip'
Invoke-WebRequest -Uri $dl -OutFile $zip
Expand-Archive -Path $zip -DestinationPath '%APPDATA%\himalaya\bin' -Force
Remove-Item $zip
}"
After extraction, add the directory to the session PATH and permanently via [Environment]::SetEnvironmentVariable(..., 'User').
If the download fails or GitHub is unreachable, the batch file shows a clear fallback message with manual install options (GitHub releases, Scoop, Winget) but still starts the server — only email features are disabled.
Server error visibility + startup verification fix
Critical: Server output must go to a log file, not >nul. Verify with netstat port checks (not tasklist — see gotchas below):
REM Start server, save output to server.log
start "" /B %PYTHON_CMD% api_server.py >server.log 2>&1
REM Wait for port to open (up to 16 seconds)
set /a TRIES=0
:wait_start
timeout /t 2 /nobreak >nul
set /a TRIES+=1
REM Show progress so user doesn't think it froze
if %TRIES% gtr 1 (echo ... starting... %TRIES%/8)
netstat -an | findstr ":%SACCO_PORT% " >nul 2>&1
if %errorlevel% equ 0 goto server_running
if %TRIES% LSS 8 goto wait_start
REM 16 seconds and still not running — show error
echo ERROR: Server failed to start!
echo Check server.log:
type server.log
pause
exit /b 1
:server_running
echo Server is listening on port %SACCO_PORT% [OK]
Full .bat structure (production, port-checked)
@echo off
cd /d "%~dp0"
title SACCO System
set SACCO_PORT=9160
REM -- Port check --
netstat -an | findstr ":%SACCO_PORT% " >nul 2>&1
if %errorlevel% equ 0 ( echo Port in use & pause & exit /b 1 )
REM -- Step 1: Python check (try py then python) --
echo [1/3] Checking Python...
set PYTHON_CMD=python
py --version >nul 2>&1
if %errorlevel% equ 0 ( set PYTHON_CMD=py ) else (
python --version >nul 2>&1 || (
echo Python not found & pause & exit /b 1
)
)
REM -- Step 2: Himalaya check + auto-install --
echo [2/3] Checking Himalaya CLI...
where himalaya >nul 2>&1 || ( call :install_himalaya )
REM -- Step 3: Start server --
echo [3/3] Starting SACCO...
start "" /B %PYTHON_CMD% api_server.py >server.log 2>&1
REM Wait for port (up to ~16s)
set /a T=0
:waitport
timeout /t 2 /nobreak >nul
set /a T+=1
netstat -an | findstr ":%SACCO_PORT% " >nul 2>&1
if %errorlevel% equ 0 goto running
if %T% LSS 8 goto waitport
echo Server failed & type server.log & pause & exit /b 1
:running
start http://127.0.0.1:%SACCO_PORT%/dashboard
echo SACCO running on port %SACCO_PORT%
REM -- Waitloop (port, not process) --
:waitloop
timeout /t 3 /nobreak >nul
netstat -an | findstr ":%SACCO_PORT% " >nul 2>&1
if %errorlevel% equ 0 goto waitloop
echo SACCO stopped.
timeout /t 3 /nobreak >nul
exit /b 0
:install_himalaya
echo Himalaya not found — attempting auto-install...
set "HIM_DIR=%APPDATA%\himalaya\bin"
mkdir "%HIM_DIR%" 2>nul
powershell -Command "..." 2>&1 && (
set "PATH=%HIM_DIR%;%PATH%"
powershell "[Environment]::SetEnvironmentVariable('Path',...,'User')" >nul
goto :eof
)
echo Auto-install failed — email disabled.
goto :eof
Shutdown / Close System Button
Add a red Close System button in the top-right corner of the dashboard header:
<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>SACCO Admin Panel</h1>
<button onclick="shutdownSystem()" style="width:auto;padding:8px 16px;font-size:13px;background:#e74c3c;">✕ Close System</button>
</div>
API endpoint POST /shutdown:
def _shutdown(self):
import threading
t = threading.Thread(target=lambda: (
self._json({'status':'ok','message':'Shutting down'}),
os._exit(0)
), daemon=True)
t.start()
Uses os._exit(0) to force-terminate the whole process from within a request handler. Must run in a daemon thread so the JSON response is sent before the process exits.
Dashboard JavaScript:
function shutdownSystem(){
if(!confirm('Shut down the SACCO system?')) return;
fetch('/shutdown',{method:'POST'});
document.body.innerHTML = '<div>...System shut down...</div>';
}
Pitfalls
n8n task runner blocks child_process
This is the #1 blocker for n8n SACCO workflows. n8n v2.8.4's JS Task Runner sandboxes Code nodes in production mode. require('child_process') is disallowed, even though manual test execution from the editor works. executionOrder: "v0" does NOT help.
Fix: Use HTTP Request nodes calling the Python API server on port 9150. See n8n-api skill → references/python-api-server-workaround.md for the complete implementation, and references/http-request-body-config.md for the required body config.
HTTP Request node sends empty body (ECONNRESET)
When the HTTP Request node calls the API server with sendBody: true but no explicit body config, n8n sends {} (empty object) or the full webhook envelope as raw bytes, causing the Python server to hang up.
Fix: Always use these params on the HTTP Request node:
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ $json }}",
"options": {}
Never rely on auto-send. Never use specifyBody: "keypair" with empty parameters.
API server crashes on self.rfile.read(cl) double-read
Reading self.rfile.read(cl) twice in a conditional consumes the data on the first read. The second read hangs forever, causing the request to timeout.
Fix: Read once into a variable, then use the variable.
compute.py returns loan_repaid, not loan_repayment
compute_statement() groups transactions by month and stores loan repayments under the key loan_repaid — NOT loan_repayment. This catches everyone because the DB column is type = 'loan_repayment' but the compute function builds dicts with shortened keys.
# ✅ CORRECT:
r.get('loan_repaid', 0) # returns actual loan repayment amount
# ❌ WRONG — silently returns 0 for ALL rows:
r.get('loan_repayment', 0) # key does not exist in compute output
The same applies to loan_disbursed (not loan_disbursement) and interest_charged (not interest_charge). Always check the actual keys returned by compute_statement() before building downstream consumers like the xlsx generator or HTML renderer.
Excel date serial numbers
compute_statement() groups transactions by month and stores loan repayments under the key loan_repaid — NOT loan_repayment. This catches everyone because the DB column is type = 'loan_repayment' but the compute function builds dicts with shortened keys.
# ✅ CORRECT:
r.get('loan_repaid', 0) # returns actual loan repayment amount
# ❌ WRONG — silently returns 0 for ALL rows:
r.get('loan_repayment', 0) # key does not exist in compute output
The same applies to loan_disbursed (not loan_disbursement) and interest_charged (not interest_charge). Always check the actual keys returned by compute_statement() before building downstream consumers like the xlsx generator or HTML renderer.
Opening balance from "Balance b/f" row
When importing legacy data, the opening "Balance b/f" values must be inserted as initial transactions on a date BEFORE the first real transaction. If skipped, all computed running balances are off by the opening amounts.
Opening balance from "Balance b/f" row
When importing legacy data, the opening "
Email Test Button
Add a Test Email button alongside the settings form to verify email configuration without sending to all members:
def _test_email_settings(self, body):
sname, semail = self._get_sender()
to = body.get('to', semail)
test_body = f'<div><h2>Test Email</h2><p>Sender: {sname} <{semail}></p></div>'
raw = f'From: {sname} <{semail}>\r\nTo: {to}\r\nSubject: SACCO Email Test\r\n...'
r = subprocess.run([...], input=raw, ...)
if r.returncode == 0:
return {'status': 'ok', 'sent_to': to}
else:
return {'status': 'error', 'message': r.stderr}
Check returncode explicitly — subprocess.run() does NOT raise on non-zero exit codes without check=True. Always check r.returncode and surface r.stderr as the error message.
Dashboard test button — add a text input + button below the settings form:
<div style="display:flex;gap:8px;">
<input id="test-email-to" value="...">
<button onclick="testEmail()">Test Email</button>
</div>
<div id="test-msg"></div>
Automatic Himalaya Config Updates
When email settings are saved, the API server should update the Himalaya config file automatically. Add _write_himalaya_config() as a side-effect of _update_settings():
def _update_settings(self, body):
# ... save to DB ...
if any(k in body for k in ['sender_email', 'sender_name', 'gmail_app_password']):
self._write_himalaya_config(body)
return {'status': 'ok'}
The _write_himalaya_config() method reads all current settings from the DB (merged with any pending updates), constructs a TOML config string, and writes it to ~/.config/himalaya/config.toml:
def _write_himalaya_config(self, updates):
conn = self._connect()
rows = dict(conn.execute('SELECT key, value FROM settings').fetchall())
conn.close()
for k, v in updates.items():
rows[k] = str(v)
config = f'''[accounts.personal]
email = "{rows.get('sender_email')}"
display-name = "{rows.get('sender_name')}"
backend.auth.cmd = "echo {rows.get('gmail_app_password')}"
message.send.backend.auth.cmd = "echo {rows.get('gmail_app_password')}"
...'''
with open(os.path.expanduser('~/.config/himalaya/config.toml'), 'w') as f:
f.write(config)
This ensures changing the sender email or app password in the dashboard immediately updates Himalaya without manual file editing.
Starting the System (sacco command)
Create a startup script so the user just types sacco in their terminal:
#!/bin/bash
# ~/.local/bin/sacco
cd /home/someone/sacco
if curl -s -o /dev/null http://127.0.0.1:9150/ 2>/dev/null; then
echo "SACCO is already running!"
echo "Dashboard: http://127.0.0.1:9150/dashboard"
exit 0
fi
echo "Starting SACCO..."
python3 api_server.py &
sleep 3
if curl -s -o /dev/null http://127.0.0.1:9150/ 2>/dev/null; then
echo "SACCO is running!"
echo "Dashboard: http://127.0.0.1:9150/dashboard"
fi
And a stop script:
#!/bin/bash
# ~/.local/bin/sacco-stop
pkill -f 'api_server.py' 2>/dev/null
echo "SACCO stopped."
Install both to ~/.local/bin/ (already on PATH if Hermes set it up).
Related Skills
- n8n-api: Contains detailed n8n REST API guidance, the Python API server workaround reference, HTTP Request body config, Switch node format, webhook registration pitfalls, and xlsx generation with stdlib.
- himalaya: CLI email sending for the Himalaya fallback path.
- writing-plans: Use this before implementing to create a structured implementation plan.
- python-stdlib-admin (productivity): Overlaps significantly — covers the same Python stdlib patterns at a higher abstraction level. Consider consolidating.
Additional Patterns (from live session)
Activity Log
Add a collapsible card showing last system activity, fetched from a /get_activity endpoint:
def _get_activity(self):
conn = self._connect()
return {
'last_transaction': conn.execute('SELECT MAX(created_at) FROM transactions').fetchone()[0] or 'Never',
'last_member': conn.execute('SELECT MAX(created_at) FROM members').fetchone()[0] or 'Never',
'last_statement_sent': conn.execute('SELECT MAX(sent_date) FROM statements_sent').fetchone()[0] or 'Never',
'total_transactions': conn.execute('SELECT COUNT(*) FROM transactions').fetchone()[0],
'total_members': conn.execute('SELECT COUNT(*) FROM members').fetchone()[0],
}
Duplicate Registration Check
Warn when adding a second registration fee for a member who already has one:
API endpoint:
'/check_registration': lambda: self._check_registration(body),
def _check_registration(self, body):
count = conn.execute(
"SELECT COUNT(*) FROM transactions WHERE member_id=? AND type='registration'",
(mid,)).fetchone()[0]
return {'has_registration': count > 0}
JS in submitForm:
if(action === 'add_tx' && data.type === 'registration'){
fetch('/check_registration', {method:'POST', body:JSON.stringify({member_id:data.member_id})})
.then(r => r.json())
.then(j => {
if(j.has_registration && !confirm('Already has a registration fee. Add anyway?')) return;
doSubmit(action, data);
});
return false;
}
Windows .bat File Gotchas
CRITICAL: CRLF Line Endings (the #1 root cause of "flash and close")
Windows cmd.exe silently fails on batch files with Unix line endings (LF = 0a). Every .bat file written from WSL/Linux will use LF. The script runs up to the first if block or complex construct, then cmd.exe misparses and exits immediately — no error, no pause, just "flash and gone."
Detection: Run xxd file.bat | head -1. Line endings should be 0d 0a (CRLF), not 0a (LF).
Fix on WSL (safe — strips any existing CR first):
tr -d '\r' < SACCO.bat | sed 's/$/\r/' > SACCO.bat.tmp && mv SACCO.bat.tmp SACCO.bat
tr -d '\r' < sacco.ps1 | sed 's/$/\r/' > sacco.ps1.tmp && mv sacco.ps1.tmp sacco.ps1
tr -d '\r' < sacco-stop.ps1 | sed 's/$/\r/' > sacco-stop.ps1.tmp && mv sacco-stop.ps1.tmp sacco-stop.ps1
The tr -d '\r' step is essential — plain sed 's/$/\r/' on a file that already has CRLF produces \r\r\n (double CR), which cmd.exe also chokes on.
Prevention: Add .gitattributes to the repo so Git auto-converts on checkout:
*.bat text eol=crlf
*.ps1 text eol=crlf
This must be done for ALL Windows script files (.bat, .ps1, .cmd). Without it, every clone on Windows gets files that cmd.exe can't parse. This was the root cause of a 5-commit debugging session where every other fix appeared to "not work" because the underlying file was broken by line endings.
Never use tasklist /fi "IMAGENAME eq python.exe"
This only matches the exact process name python.exe. It misses:
py.exe(Python launcher — the batch prefers this)python3.exe/python3.12.exe(Python 3.12+ from python.org or Microsoft Store)
Never use tasklist /v /fi "STATUS eq running"
The status value "running" is LOCALIZED on non-English Windows: "En curso" (Spanish), "En cours" (French), "実行中" (Japanese), etc. The filter silently returns zero rows.
Prefer netstat port checks over tasklist process checks
The ONLY thing that matters is whether the server is listening on the port. netstat -an output format is identical on every locale. Process names vary; port states don't.
REM ✅ RELIABLE — locale-independent, works everywhere:
netstat -an | findstr ":%SACCO_PORT% " >nul 2>&1
if %errorlevel% equ 0 goto server_running
REM ❌ BROKEN — locale-dependent filter value:
tasklist /v /fi "STATUS eq running" 2>nul | findstr /i "python" >nul
Use this pattern for BOTH the startup verification loop and the waitloop keep-alive check.
Plain tasklist (no flags) is the fallback
If you must use tasklist, use it WITHOUT /v or /fi:
tasklist 2>nul | findstr /i "python py.exe" >nul
This is fast, locale-independent, and matches python.exe, python3.exe, python3.12.exe, py.exe — but it's still inferior to netstat port checks.
Other gotchas
- Avoid Unicode characters (
✓,⚠,✕) in.batfiles — Windows cmd.exe displays them as garbage (Γ£ô). Use[OK],[!],[x]instead. - Always set
SACCO_PORT=9160in the batch file so WSL (9150) and Windows can run simultaneously. - Add
pauseat end of batch (notpause >nul) as a safety net — if the waitloop exits prematurely for any reason, the window stays open with readable output. - Every error branch must end in
pausebeforeexit /b 1. The window must NEVER close without the user seeing why.
Edit modal: ALL fields must be passed in onclick
When the member table rows are generated server-side with edit buttons:
# ✅ CORRECT — pass EVERY modal field including dob:
onclick="editMember('{id}','{name}','{email}','{phone}','{number}','{dob}')"
# ❌ WRONG — missing dob makes it appear empty in edit modal:
onclick="editMember('{id}','{name}','{email}','{phone}','{number}')"
The JS function signature is editMember(id,name,email,phone,number,dob). If even one
parameter is omitted from the onclick call, document.getElementById('edit-dob').value = dob
receives undefined, and the DOB field displays blank — making it look like data was
never saved when it was.
Rule: Every <input> in the edit modal must have a corresponding argument in the
onclick call. Map the edit modal's input IDs to onclick arguments and verify every
field is represented.
Silent wait loops need progress indicators
The server startup wait loop uses timeout /t 2 /nobreak >nul — completely silent.
For up to 16 seconds the user sees a motionless terminal. Always add a progress
counter:
if %TRIES% gtr 1 (
set /a LEFT=8-%TRIES%
echo ... %LEFT% seconds remaining
)
Safety pause at the very end of batch files
Even when the script completes normally, the terminal closes silently after 3 seconds.
Add pause at the very end so the window never closes without user interaction:
echo SACCO stopped.
echo.
echo Press any key to close.
pause >nul
Error branches should use plain pause (not >nul) so the "Press any key..."
message is visible.
start "" /B NOT start /B ""
Windows CMD expects the optional title to come FIRST, before flags:
start "" /B python server.py # ✅ CORRECT
start /B "" python server.py # ❌ WRONG — some CMD versions misparse this
In the wrong form, cmd.exe treats "" as the command name (empty string),
executes nothing, and continues to the wait loop — causing the "flash and close"
in a different way than line endings do.
Windows PowerShell (.ps1) Gotchas
PowerShell scripts (sacco.ps1, sacco-stop.ps1) have the same py.exe blind spot as the batch file. Never filter by -Name "python*" — this matches python.exe and python3.exe but NOT py.exe (the Python launcher, which the batch prefers):
# WRONG — misses py.exe (Python launcher):
Get-Process -Name "python*" | Where-Object { $_.CommandLine -match "api_server" }
# CORRECT — matches python.exe, python3.exe, python3.12.exe, py.exe, etc.:
Get-Process -ErrorAction SilentlyContinue |
Where-Object { $_.ProcessName -match "python|py" -and $_.CommandLine -match "api_server" }
Apply this fix in both sacco.ps1 (the startup check for an already-running instance) and sacco-stop.ps1 (the stop command).
Additional fixes for sacco.ps1:
- Port: Use a variable (
$Port = 9160) instead of hardcoding 9150 (the WSL port) throughout the script - Fallback to py launcher: If
pythonfails to start the server, trypyas a fallback — on many Windows systems only the launcher is on PATH, not barepython - Server verification: Use
Invoke-WebRequestto verify the server is actually listening, rather than just checking the process exists (a started process does not guarantee a listening port)
Cross-Platform Database Path
DB = os.path.join(os.path.dirname(__file__) or '.', 'sacco.db')
Resolves correctly on both Linux (/home/user/sacco/sacco.db) and Windows (D:\Games\sacco-system\sacco.db).
n8n is No Longer Required
The system was originally built with n8n workflows, but the Python API server now handles everything directly — no n8n dependency. The latest version uses:
- A single
python3 api_server.pyprocess on port 9150 (WSL) / 9160 (Windows) - Background threading for batch email sending
- Settings-based configuration (no hardcoded credentials)
SACCO/sacco-stopcommands for start/stop
WebView2 GPU Performance (pywebview native app)
If the pywebview-packaged Windows app feels sluggish (typing lag, scroll friction) and Task Manager shows GPU at 100%, Chromium's GPU blocklist is likely forcing software rendering for backdrop-filter CSS effects. Fix the rendering pipeline — do NOT strip the glass/visual design.
Diagnostic approach: Test with a minimal HTML page (2 cards + backdrop-filter, zero JS) first. If that lags, the problem is GPU-level — not your CSS or JS. If the test page is smooth, the bottleneck is in your specific page's compositing.
Five-layer fix (items 1-3 are required, 4-5 improve compositing):
- GPU flags —
--ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copyviaWEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. Must reach WebView2 BEFORE initialisation: (a) Inno Setup[Registry]→HKCU\Environmentfor persistence, (b) PyInstallerruntime_hook.pyfor compiled.exelaunches, (c)os.environat module level inmain.pyfor dev runs. All three layers needed. - Compositor-layer promotion —
isolation: isolate+transform: translateZ(0)on.card. Removesoverflow: hiddenwhich breaks backdrop-filter in some WebView2 builds. - Static CSS background — Remove JS-driven parallax (
will-change: transform,--mx/--myvars onmousemove). Switch to pure CSS@keyframesanimation onbackground-position(runs on compositor thread). - Debounced MutationObservers — Use
requestAnimationFramegate instead of callingquerySelectorAllon every DOM mutation. - Remove
text_select=Falsefromcreate_window()— interferes with input field rendering in some pywebview versions.
Critical pitfall: os.environ set in main.py does NOT work for PyInstaller-compiled .exe builds — the bootloader runs first and WebView2 initialises before main.py executes. ONLY a runtime hook or registry key works for compiled builds. The user prefers compiled installers (setup.exe) over running raw .py files for security.
See references/pywebview-native-windows-packaging.md → "WebView2 Rendering Performance" for the full diagnostic checklist including compositor-layer promotion, isolation: isolate / translateZ(0), MutationObserver debouncing, JavaScript parallax removal, and text_select gotcha.
References
references/api-server-route-pattern.md— DO route pattern, body unwrapping, error handlingreferences/norken-excel-format.md— Exact column layout, running totals, serial date handlingreferences/background-email-sending.md— Threading pattern for large member listsreferences/cross-platform-distribution.md— HTML template extraction, auto-schema creation, DOB field, cross-platform ports, shutdown button, ZIP downloadreferences/interactive-scroll-pitfall.md— Designer scroll effect hides tx-viewer transactionsreferences/input-validation-and-interest.md— Auto-interest calculation, date validation, registration duplication checks, HTML escaping, distribution syncreferences/windows-batch-troubleshooting.md— flash and close debugging checklist, CRLF vs LF line endings, netstat vs tasklist reliability table, final working batch patternreferences/demo-script.md— End-to-end demo script exercising all API endpoints with colored pass/fail output