Add an 80s-style green-on-black bank terminal shell ("banking") using
charmbracelet/bubbletea for full-screen TUI rendering over SSH.
Screens: login, main menu, account summary, account detail with
transactions, wire transfer wizard (6-step form capturing routing
number, destination, beneficiary, amount, memo, auth code), transaction
history with pagination, secure messages with breadcrumb content (fake
internal IPs, vault codes), change PIN, and hidden admin access (99)
that locks after 3 failed attempts with COBOL-style error output.
All key actions (login, navigation, wire transfers, admin attempts) are
logged to the session store. Wire transfer data is the honeypot gold.
Configurable via [shell.banking] in TOML: bank_name, terminal_id, region.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
5.2 KiB
Go
214 lines
5.2 KiB
Go
package banking
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// --- Account Summary ---
|
|
|
|
type accountSummaryModel struct {
|
|
accounts []Account
|
|
}
|
|
|
|
func newAccountSummaryModel(accounts []Account) accountSummaryModel {
|
|
return accountSummaryModel{accounts: accounts}
|
|
}
|
|
|
|
func (m accountSummaryModel) Update(_ tea.Msg) (accountSummaryModel, tea.Cmd) {
|
|
// Any key returns to menu.
|
|
return m, nil
|
|
}
|
|
|
|
func (m accountSummaryModel) View() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(centerText("ACCOUNT SUMMARY"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(thinDivider())
|
|
b.WriteString("\n\n")
|
|
|
|
// Header.
|
|
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "ACCOUNT", "TYPE", "BALANCE")))
|
|
b.WriteString("\n")
|
|
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50)))
|
|
b.WriteString("\n")
|
|
|
|
total := int64(0)
|
|
for _, acct := range m.accounts {
|
|
total += acct.Balance
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-18s %18s",
|
|
acct.Number, acct.Type, formatCurrency(acct.Balance))))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50)))
|
|
b.WriteString("\n")
|
|
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "", "TOTAL", formatCurrency(total))))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(thinDivider())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
|
|
b.WriteString("\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// --- Account Detail (with transactions) ---
|
|
|
|
type accountDetailModel struct {
|
|
accounts []Account
|
|
transactions map[string][]Transaction
|
|
selected int
|
|
page int
|
|
pageSize int
|
|
choosing bool
|
|
choice string
|
|
}
|
|
|
|
func newAccountDetailModel(accounts []Account, transactions map[string][]Transaction) accountDetailModel {
|
|
return accountDetailModel{
|
|
accounts: accounts,
|
|
transactions: transactions,
|
|
choosing: true,
|
|
pageSize: 10,
|
|
}
|
|
}
|
|
|
|
func (m accountDetailModel) Update(msg tea.Msg) (accountDetailModel, tea.Cmd) {
|
|
keyMsg, ok := msg.(tea.KeyMsg)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
|
|
if m.choosing {
|
|
switch keyMsg.Type {
|
|
case tea.KeyEnter:
|
|
for i := range m.accounts {
|
|
if m.choice == fmt.Sprintf("%d", i+1) {
|
|
m.selected = i
|
|
m.choosing = false
|
|
m.page = 0
|
|
break
|
|
}
|
|
}
|
|
if m.choice == "0" {
|
|
m.choice = "back"
|
|
}
|
|
return m, nil
|
|
case tea.KeyBackspace:
|
|
if len(m.choice) > 0 {
|
|
m.choice = m.choice[:len(m.choice)-1]
|
|
}
|
|
default:
|
|
ch := keyMsg.String()
|
|
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' {
|
|
m.choice += ch
|
|
}
|
|
}
|
|
} else {
|
|
switch keyMsg.String() {
|
|
case "n", "N":
|
|
acctNum := m.accounts[m.selected].Number
|
|
txns := m.transactions[acctNum]
|
|
maxPage := (len(txns) - 1) / m.pageSize
|
|
if m.page < maxPage {
|
|
m.page++
|
|
}
|
|
case "p", "P":
|
|
if m.page > 0 {
|
|
m.page--
|
|
}
|
|
case "b", "B":
|
|
m.choosing = true
|
|
m.choice = ""
|
|
default:
|
|
m.choice = "back"
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m accountDetailModel) View() string {
|
|
if m.choosing {
|
|
return m.viewChooseAccount()
|
|
}
|
|
return m.viewDetail()
|
|
}
|
|
|
|
func (m accountDetailModel) viewChooseAccount() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(centerText("ACCOUNT DETAIL"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(thinDivider())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(titleStyle.Render(" SELECT ACCOUNT:"))
|
|
b.WriteString("\n\n")
|
|
|
|
for i, acct := range m.accounts {
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d] %s - %s %s",
|
|
i+1, acct.Number, acct.Type, formatCurrency(acct.Balance))))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(thinDivider())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
|
|
b.WriteString(inputStyle.Render(m.choice))
|
|
b.WriteString(inputStyle.Render("_"))
|
|
b.WriteString("\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m accountDetailModel) viewDetail() string {
|
|
var b strings.Builder
|
|
|
|
acct := m.accounts[m.selected]
|
|
txns := m.transactions[acct.Number]
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(centerText(fmt.Sprintf("ACCOUNT DETAIL - %s", acct.Number)))
|
|
b.WriteString("\n\n")
|
|
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" TYPE: %s", acct.Type)))
|
|
b.WriteString("\n")
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" BALANCE: %s", formatCurrency(acct.Balance))))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(thinDivider())
|
|
b.WriteString("\n\n")
|
|
|
|
// Header.
|
|
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-34s %12s %12s", "DATE", "DESCRIPTION", "AMOUNT", "BALANCE")))
|
|
b.WriteString("\n")
|
|
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 72)))
|
|
b.WriteString("\n")
|
|
|
|
// Paginate.
|
|
start := m.page * m.pageSize
|
|
end := min(start+m.pageSize, len(txns))
|
|
|
|
for _, txn := range txns[start:end] {
|
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-34s %12s %12s",
|
|
txn.Date, txn.Description, formatCurrency(txn.Amount), formatCurrency(txn.Balance))))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
totalPages := (len(txns) + m.pageSize - 1) / m.pageSize
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf(" PAGE %d OF %d", m.page+1, totalPages)))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(dimStyle.Render(" [N]EXT PAGE [P]REV PAGE [B]ACK ANY OTHER KEY = MAIN MENU"))
|
|
b.WriteString("\n")
|
|
|
|
return b.String()
|
|
}
|