package gnosplit import ( "encoding/base64" "html" "strconv" "strings" "time" "gno.land/p/nym-alexiscolin000/gnosplit/ledger/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // categories are an expense's kinds, in the order ByCat counts them. The last // one is the default. A form sends the name. var categories = []struct{ Name, Icon, Color string }{ {"Food", "🍽", "#4e79a7"}, {"Groceries", "🛒", "#f28e2b"}, {"Transport", "🚗", "#59a14f"}, {"Lodging", "🏠", "#e15759"}, {"Activities", "🎟", "#76b7b2"}, {"Shopping", "🛍", "#edc948"}, {"Bills", "🧾", "#b07aa1"}, {"Other", "📦", "#9c755f"}, } func categoryOf(name string) int { name = strings.ToLower(strings.TrimSpace(name)) if name == "" { return len(categories) - 1 } for i, c := range categories { if strings.ToLower(c.Name) == name { return i } } panic("gnosplit: no category " + name) } const ( pageSize = 25 // entries per history page latest = 8 // entries on a group's page ) // Render serves "" (home), "u/
" (a wallet's groups) and a group's // pages: "", then "/add", "/settle", "/settle///", // "/members", "/stats", "/history[/]", "/e/" and "/csv". func Render(path string) string { if path == "" { return renderHome() } if strings.HasPrefix(path, "u/") { return renderWallet(address(strings.TrimPrefix(path, "u/"))) } parts := strings.Split(path, "/") g := getGroup(parts[0]) if g == nil { return "# Not found\n\nNo group " + sanitize.InlineText(parts[0]) + ". [Back home](" + hub + ")\n" } page := "" if len(parts) > 1 { page = parts[1] } switch { case page == "" && len(parts) == 1: return renderGroup(g) case page == "add" && len(parts) == 2: return renderAdd(g) case page == "settle" && len(parts) == 2: return renderSettle(g, -1, -1, 0) case page == "settle" && len(parts) == 5: f, t, c := atoi(parts[2]), atoi(parts[3]), atoi(parts[4]) if f >= 0 && t >= 0 && f < len(g.Members) && t < len(g.Members) && c > 0 { return renderSettle(g, f, t, int64(c)) } case page == "members" && len(parts) == 2: return renderMembers(g) case page == "stats" && len(parts) == 2: return renderStats(g) case page == "history" && len(parts) <= 3: n := 1 if len(parts) == 3 { n = atoi(parts[2]) } if n >= 1 { return renderHistory(g, n) } case page == "e" && len(parts) == 3: if v := g.Entries.Get(key(atoi(parts[2]))); v != nil { return renderEntry(g, v.(*Entry)) } case page == "csv" && len(parts) == 2: return header(g, "Export") + "Every member and entry, as CSV.\n\n" + sanitize.CodeBlock(Export(parts[0])) } return "# Not found\n\n[Back to the group](" + link(g, "") + ")\n" } func renderHome() string { var b strings.Builder b.WriteString("# gnosplit\n\n") b.WriteString("**Share expenses with friends, then settle up in as few transfers as possible.** ") b.WriteString("Trips, flatshares, dinners: like Tricount, on gno.land, with no account and no ads.\n\n") b.WriteString("\n### 1. Create a group\nName it, pick a currency, list your friends. They don't need a wallet.\n") b.WriteString("\n### 2. Add expenses\nWho paid, for whom: split equally, by shares or by exact amounts. Foreign currencies too.\n") b.WriteString("\n### 3. Settle up\ngnosplit works out who pays whom, in the fewest transfers. Friends with a wallet get notified.\n") b.WriteString("\n\n") b.WriteString("## Create a group\n\n") b.WriteString("\n") b.WriteString(input("name", "Group name, like Ski trip", "", true)) b.WriteString(input("currency", "Currency, like € or CHF", "€", true)) b.WriteString(input("you", "Your name in the group", "", true)) b.WriteString(input("others", "Your friends, comma-separated: Alice, Bob", "", false)) b.WriteString("\n\n") b.WriteString("> [!NOTE] Everything is public\n> A group lives on gno.land: anyone with its link can read it. ") b.WriteString("Use first names, and keep private details out of titles.\n\n") b.WriteString("## Your groups\n\nOpen `" + hub + ":u/` to list the groups your wallet is in. ") b.WriteString(plural(groups.Size(), "group") + " so far.\n") return b.String() } func renderWallet(a address) string { if !a.IsValid() { return "# Not found\n\nNot an address. [Back home](" + hub + ")\n" } var b strings.Builder b.WriteString("# Your groups\n\n" + a.String() + "\n\n") n := 0 byAddr.Iterate(a.String()+"/", a.String()+"0", func(k string, _ any) bool { g := groups.Get(k[strings.LastIndex(k, "/")+1:]).(*Group) m := g.Members[g.editor(a)] if n == 0 { b.WriteString("| Group | You | Your balance |\n|---|---|---:|\n") } b.WriteString("| [" + cellText(g.Name) + "](" + link(g, "") + ") | " + sanitize.TableCell(m.Name) + " | " + ledger.Signed(m.Bal) + " " + g.Currency + " |\n") n++ return false }) if n == 0 { b.WriteString("This wallet is in no group yet. [Create one](" + hub + ")\n") } return b.String() } func renderGroup(g *Group) string { var b strings.Builder b.WriteString(header(g, "")) b.WriteString("\n## Balances\n\n| Member | | Balance |\n|---|---|---:|\n") var max int64 for _, m := range g.Members { if abs(m.Bal) > max { max = abs(m.Bal) } } for _, m := range g.Members { b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + balanceBar(m.Bal, max) + " | **" + g.money(m.Bal) + "** |\n") } b.WriteString("\n\n## Settle up\n\n") b.WriteString(transfers(g, true)) b.WriteString("\n\n## Latest\n\n") if g.Entries.Size() == 0 { b.WriteString("No expense yet. [Add the first one](" + link(g, "/add") + ")\n") return b.String() } b.WriteString(entryTable(g, 0, latest)) if g.Entries.Size() > latest { b.WriteString("\n[All " + plural(g.Entries.Size(), "entry") + " →](" + link(g, "/history") + ")\n") } return b.String() } // transfers lists the payments that settle the group, each with a link that // fills in the form to record it. func transfers(g *Group, short bool) string { bal := make([]int64, len(g.Members)) for i, m := range g.Members { bal[i] = m.Bal } ts := ledger.Settle(bal) if len(ts) == 0 { return "> [!SUCCESS] All settled up\n> Nobody owes anything.\n\n" } var b strings.Builder b.WriteString("| Who | Pays | Amount | |\n|---|---|---:|---|\n") for _, t := range ts { b.WriteString("| " + sanitize.TableCell(g.Members[t.From].Name) + " | " + sanitize.TableCell(g.Members[t.To].Name) + " | " + g.money(t.Amount) + " | [Record](" + link(g, "/settle/"+itoa(t.From)+"/"+itoa(t.To)+"/"+strconv.FormatInt(t.Amount, 10)) + ") |\n") } if short { b.WriteString("\n" + plural(len(ts), "transfer") + " settle everything. [Settle up →](" + link(g, "/settle") + ")\n") } return b.String() } func renderAdd(g *Group) string { var b strings.Builder b.WriteString(header(g, "Add")) if g.Moved { return b.String() } b.WriteString("Amounts are like `42.50`. Paid in another currency? Write `50 USD x0.92`: 50 dollars at 0.92 " + g.Currency + " each.\n\n") b.WriteString("## Split equally\n\n\n") b.WriteString(groupField(g) + input("title", "What, like Groceries", "", true) + input("amount", "Amount", "", true)) b.WriteString(memberSelect(g, "paidBy", "Paid by", -1)) b.WriteString(memberChecks(g, "forWhom", "For whom")) b.WriteString(categorySelect()) b.WriteString("\n\n") var shares, amounts []string for _, m := range g.Members { shares = append(shares, m.Name+":1") amounts = append(amounts, m.Name+":") } b.WriteString("## Split by shares\n\nGive each a number of shares: `Alice:2, Bob:1` makes Alice pay two thirds. Leave out who is not in.\n\n") b.WriteString("\n") b.WriteString(groupField(g) + input("title", "What", "", true) + input("amount", "Amount", "", true)) b.WriteString(memberSelect(g, "paidBy", "Paid by", -1)) b.WriteString(input("shares", "Shares", strings.Join(shares, ", "), true)) b.WriteString(categorySelect()) b.WriteString("\n\n") b.WriteString("## Split by amounts\n\nWrite each one's exact part: `Alice:12.50, Bob:7.50`. The expense is their sum.\n\n") b.WriteString("\n") b.WriteString(groupField(g) + input("title", "What", "", true)) b.WriteString(memberSelect(g, "paidBy", "Paid by", -1)) b.WriteString(input("amounts", "Amounts, like "+strings.Join(amounts, "12.50, ")+"12.50", "", true)) b.WriteString(categorySelect()) b.WriteString("\n\n") b.WriteString("## Money received\n\nA refund, a deposit returned: who received it, and for whom.\n\n") b.WriteString("\n") b.WriteString(groupField(g) + input("title", "What, like Deposit back", "", true) + input("amount", "Amount", "", true)) b.WriteString(memberSelect(g, "receivedBy", "Received by", -1)) b.WriteString(memberChecks(g, "forWhom", "For whom")) b.WriteString("\n") return b.String() } func renderSettle(g *Group, from, to int, amount int64) string { var b strings.Builder b.WriteString(header(g, "Settle up")) if from < 0 { b.WriteString(transfers(g, false) + "\n") } if g.Moved { return b.String() } b.WriteString("## Record a payment\n\nA payment made outside gnosplit, by bank transfer or in cash.\n\n") b.WriteString("\n" + groupField(g)) if to < 0 && len(g.Members) > 1 { to = 1 // a blank form starts on two different members } b.WriteString(memberSelect(g, "from", "From", from)) b.WriteString(memberSelect(g, "to", "To", to)) amt := "" if amount > 0 { amt = cents(amount) } b.WriteString(input("amount", "Amount", amt, true)) b.WriteString("\n\n") if from >= 0 { return b.String() } var debtors []int for i, m := range g.Members { if m.Bal < 0 && m.Addr != "" { debtors = append(debtors, i) } } if len(debtors) > 0 { b.WriteString("## Send a reminder\n\nA notification to a member who owes, at most one every 12 hours.\n\n") b.WriteString("\n" + groupField(g)) for k, i := range debtors { b.WriteString(selectOpt("member", g.Members[i].Name, first(k, "Who"), k == 0)) } b.WriteString("\n") } return b.String() } func renderMembers(g *Group) string { var b strings.Builder b.WriteString(header(g, "Members")) b.WriteString("| Member | Wallet | Paid | Share | Balance |\n|---|---|---:|---:|---:|\n") var free []int for i, m := range g.Members { w := "—" if m.Addr != "" { w = "[" + short(m.Addr) + "](" + hub + ":u/" + m.Addr.String() + ")" } else { free = append(free, i) } b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + w + " | " + g.money(m.Paid) + " | " + g.money(m.Share) + " | **" + g.money(m.Bal) + "** |\n") } b.WriteString("\nMembers with a wallet can add and change entries, and get notified. ") b.WriteString("To turn notifications on, open [gnotif](https://gnotif.xyz) with your address.\n\n") if g.Moved { return b.String() } b.WriteString("## Add a member\n\n\n" + groupField(g)) b.WriteString(input("name", "Name", "", true) + input("addr", "Their g1 address, if they have a wallet", "", false)) b.WriteString("\n\n") if len(free) > 0 && len(g.Members) > 0 { b.WriteString("## Link a wallet\n\nA friend got a wallet: link it to their name. A linked wallet is never replaced.\n\n") b.WriteString("\n" + groupField(g)) for k, i := range free { b.WriteString(selectOpt("name", g.Members[i].Name, first(k, "Member"), k == 0)) } b.WriteString(input("addr", "Their g1 address", "", true)) b.WriteString("\n") } return b.String() } func renderStats(g *Group) string { var b strings.Builder b.WriteString(header(g, "Stats")) var total int64 for _, c := range g.ByCat { total += c } if total <= 0 { b.WriteString("No expense yet.\n") return b.String() } b.WriteString("## By category\n\n" + categoryBar(g.ByCat, total) + "\n\n") b.WriteString("| | Category | Spent | Share |\n|---|---|---:|---:|\n") for i, c := range categories { if g.ByCat[i] == 0 { continue } b.WriteString("| " + swatch(c.Color) + " | " + c.Icon + " " + c.Name + " | " + g.money(g.ByCat[i]) + " | " + percent(g.ByCat[i], total) + " |\n") } b.WriteString("\n## By member\n\nWhat each one paid, and what their part of the expenses came to.\n\n") b.WriteString("| Member | Paid | Share | Of all paid |\n|---|---:|---:|---:|\n") for _, m := range g.Members { b.WriteString("| " + sanitize.TableCell(m.Name) + " | " + g.money(m.Paid) + " | " + g.money(m.Share) + " | " + percent(m.Paid, g.Spent) + " |\n") } return b.String() } func renderHistory(g *Group, page int) string { var b strings.Builder b.WriteString(header(g, "History")) n := g.Entries.Size() pages := (n + pageSize - 1) / pageSize if n == 0 || page > pages { b.WriteString("Nothing here.\n") return b.String() } b.WriteString(entryTable(g, (page-1)*pageSize, pageSize) + "\n") if page > 1 { b.WriteString("[← Newer](" + link(g, "/history/"+itoa(page-1)) + ") ") } if page < pages { b.WriteString("[Older →](" + link(g, "/history/"+itoa(page+1)) + ")") } b.WriteString("\n\nPage " + itoa(page) + " of " + itoa(pages) + " · [Export as CSV](" + link(g, "/csv") + ")\n") return b.String() } func renderEntry(g *Group, e *Entry) string { var b strings.Builder b.WriteString(header(g, "Entry")) b.WriteString("## " + entryIcon(e) + " " + sanitize.InlineText(e.Title) + "\n\n") total := abs(e.Total) b.WriteString("**" + g.money(total) + "**") if e.Note != "" { b.WriteString(" (" + e.Note + ")") } b.WriteString(" · " + verb(e) + " **" + sanitize.InlineText(g.Members[e.Payer].Name) + "** · " + date(e.At, "Mon Jan 2, 2006")) b.WriteString("\n\nRecorded by " + e.By.String() + "\n\n") if e.Kind != KindTransfer { b.WriteString("| Member | Part |\n|---|---:|\n") for i, m := range e.Who { b.WriteString("| " + sanitize.TableCell(g.Members[m].Name) + " | " + g.money(abs(e.Owe[i])) + " |\n") } b.WriteString("\n") } if g.Moved { return b.String() } b.WriteString("## Delete\n\nRemoves the entry and undoes it on every balance.\n\n") b.WriteString("\n" + groupField(g) + readonly("entry", "Entry", itoa(e.ID)) + "\n") return b.String() } // entryTable writes count entries, newest first, from offset. func entryTable(g *Group, offset, count int) string { var b strings.Builder b.WriteString("| Date | What | Paid by | For | Amount |\n|---|---|---|---|---:|\n") g.Entries.ReverseIterateByOffset(offset, count, func(_ string, v any) bool { e := v.(*Entry) title := "[" + cellText(e.Title) + "](" + link(g, "/e/"+itoa(e.ID)) + ")" amount := g.money(abs(e.Total)) if e.Kind == KindIncome { amount = "−" + amount } b.WriteString("| " + date(e.At, "Jan 2") + " | " + entryIcon(e) + " " + title + " | " + sanitize.TableCell(g.Members[e.Payer].Name) + " | " + forWhom(g, e) + " | " + amount + " |\n") return false }) return b.String() } func header(g *Group, page string) string { var b strings.Builder if page == "" { b.WriteString("# " + sanitize.InlineText(g.Name) + "\n\n") } else { b.WriteString("# [" + sanitize.InlineText(g.Name) + "](" + link(g, "") + ") · " + page + "\n\n") } b.WriteString(plural(len(g.Members), "member") + " · **" + g.money(g.Spent) + "** spent · ") b.WriteString("[Add](" + link(g, "/add") + ") · [Settle up](" + link(g, "/settle") + ") · [Members](" + link(g, "/members") + ") · [Stats](" + link(g, "/stats") + ") · [History](" + link(g, "/history") + ")\n\n") if g.Moved { b.WriteString("> [!WARNING] This group moved\n> It is kept read-only here. Its new home is [" + successor + "](" + strings.TrimPrefix(successor, "gno.land") + ":" + itoa(g.ID) + ").\n\n") } return b.String() } // Export writes a group as CSV: its members, then every entry, oldest first. // MoveOut hands it to the successor, and the /csv page shows it. func Export(group string) string { g := mustGroup(group) var b strings.Builder b.WriteString("member,address,balance,paid,share\n") for _, m := range g.Members { b.WriteString(csv(m.Name) + "," + m.Addr.String() + "," + cents(m.Bal) + "," + cents(m.Paid) + "," + cents(m.Share) + "\n") } b.WriteString("\nentry,kind,time,title,category,payer,total,note,split\n") g.Entries.Iterate("", "", func(_ string, v any) bool { e := v.(*Entry) split := make([]string, len(e.Who)) for i, m := range e.Who { split[i] = g.Members[m].Name + ":" + cents(e.Owe[i]) } b.WriteString(itoa(e.ID) + "," + [...]string{"expense", "income", "transfer"}[e.Kind] + "," + date(e.At, "2006-01-02T15:04:05Z") + "," + csv(e.Title) + "," + categories[e.Cat].Name + "," + csv(g.Members[e.Payer].Name) + "," + cents(e.Total) + "," + csv(e.Note) + "," + csv(strings.Join(split, ";")) + "\n") return false }) return b.String() } // Form fields. Every value is escaped for the attribute it lands in; a form's // fields are in the order of its function's parameters, since gnoweb passes // them by position. func input(name, placeholder, value string, required bool) string { s := `\n" } func readonly(name, placeholder, value string) string { return `` + "\n" } func groupField(g *Group) string { return readonly("group", "Group", itoa(g.ID)) } // selectOpt is one option of a select; gnoweb reads the description off // the first one, so only that one carries it. func selectOpt(name, value, description string, selected bool) string { s := `\n" } func memberSelect(g *Group, name, description string, selected int) string { var b strings.Builder if selected < 0 { selected = 0 } for i, m := range g.Members { b.WriteString(selectOpt(name, m.Name, first(i, description), i == selected)) } return b.String() } // first is s for the first item of a list, "" after. func first(i int, s string) string { if i == 0 { return s } return "" } func memberChecks(g *Group, name, description string) string { var b strings.Builder for i, m := range g.Members { s := `\n") } return b.String() } func categorySelect() string { var b strings.Builder for i, c := range categories { b.WriteString(selectOpt("category", c.Name, first(i, "Category"), i == len(categories)-1)) } return b.String() } // Pictures: small SVGs as data URIs, the one image source gnoweb always shows. // balanceBar draws a balance against the largest one: right and teal when the // group owes the member, left and orange when they owe. func balanceBar(v, max int64) string { w := 0 if max > 0 { w = int(abs(v) * 56 / max) if w == 0 && v != 0 { w = 1 } } x, color := 60, "#1a9e8f" if v < 0 { x, color = 60-w, "#e07b39" } return svg(120, 14, ``) } // categoryBar is one bar split by category, in the table's order. func categoryBar(byCat []int64, total int64) string { const w = 600 var s strings.Builder x := 0 for i, c := range byCat { if c <= 0 { continue } cw := int(c * w / total) if x+cw > w { cw = w - x } s.WriteString(``) x += cw } return svg(w, 24, s.String()) } func swatch(color string) string { return svg(12, 12, ``) } func svg(w, h int, body string) string { doc := `` + body + `` return "![](data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(doc)) + ")" } // Small helpers. func link(g *Group, page string) string { return hub + ":" + itoa(g.ID) + page } func entryIcon(e *Entry) string { switch e.Kind { case KindIncome: return "↩" case KindTransfer: return "⇄" } return categories[e.Cat].Icon } func verb(e *Entry) string { if e.Kind == KindIncome { return "received by" } return "paid by" } func forWhom(g *Group, e *Entry) string { if e.Kind == KindTransfer { return sanitize.TableCell(g.Members[e.Who[0]].Name) } if len(e.Who) == len(g.Members) { return "everyone" } var names []string for i, m := range e.Who { if i == 3 { return sanitize.TableCell(strings.Join(names, ", ")) + " +" + itoa(len(e.Who)-3) } names = append(names, g.Members[m].Name) } return sanitize.TableCell(strings.Join(names, ", ")) } func date(unix int64, layout string) string { return time.Unix(unix, 0).UTC().Format(layout) } func short(a address) string { s := a.String() if len(s) < 12 { return s } return s[:6] + "…" + s[len(s)-4:] } func percent(part, whole int64) string { if whole <= 0 { return "—" } return strconv.FormatInt((part*100+whole/2)/whole, 10) + "%" } func plural(n int, word string) string { if n == 1 { return "1 " + word } if strings.HasSuffix(word, "y") { return itoa(n) + " " + word[:len(word)-1] + "ies" } return itoa(n) + " " + word + "s" } // cents writes an amount for CSV: "42.50", no thousands separator. func cents(c int64) string { return strings.ReplaceAll(ledger.Format(c), ",", "") } func csv(s string) string { if !strings.ContainsAny(s, ",\"\n;") { return s } return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } func attr(s string) string { return html.EscapeString(s) } // cellText is link text inside a table cell: InlineText leaves "|" alone, // and a bare one would end the cell. func cellText(s string) string { return strings.ReplaceAll(sanitize.InlineText(s), "|", `\|`) } func abs(v int64) int64 { if v < 0 { return -v } return v } // atoi is a non-negative number, -1 for anything else. func atoi(s string) int { n, err := strconv.Atoi(s) if err != nil || n < 0 { return -1 } return n }