gnosplit.gno
14.41 Kb · 491 lines
1// Package gnosplit shares a group's expenses, like Tricount: who paid what,
2// who owes whom, and the fewest transfers that settle everything.
3//
4// A group has members, each a name with or without a wallet. Members with a
5// wallet linked are the group's editors: they record expenses, incomes and
6// transfers, and get a gnotif notification when one concerns them. Members
7// without one are just names: a friend with no wallet still has a balance.
8//
9// Amounts are cents of the group's currency, a label such as "€" or "CHF":
10// no coin moves here, a transfer records a payment made elsewhere. Every
11// balance is kept up to date on each write, so reading a group never adds
12// up its history.
13package gnosplit
14
15import (
16 "chain"
17 "chain/runtime"
18 "chain/runtime/unsafe"
19 "strconv"
20 "strings"
21 "time"
22
23 "gno.land/p/nym-alexiscolin000/gnosplit/ledger/v0"
24 "gno.land/p/nt/avl/v0"
25)
26
27// Limits keep every page and every write small.
28const (
29 maxMembers = 30
30 maxEntries = 5000 // per group
31 maxName = 24 // a member's name, in bytes
32 maxGroup = 40 // a group's name
33 maxTitle = 60 // an entry's title
34 maxCurrency = 8
35 remindEvery = 12 * 60 * 60 // seconds between two reminders to one member
36)
37
38// Kind is what an entry records.
39type Kind uint8
40
41const (
42 KindExpense Kind = iota // Payer paid Total for Who
43 KindIncome // Payer received Total that belongs to Who
44 KindTransfer // Payer paid Total to Who[0]
45)
46
47// Member is one person in a group. Bal is what they paid minus what they owe,
48// in cents: > 0 the group owes them, < 0 they owe the group.
49type Member struct {
50 Name string
51 Addr address // "" while no wallet is linked
52 Bal int64
53 Paid int64 // total of the expenses they paid, net of incomes received
54 Share int64 // total of their shares of expenses, net of incomes
55 Nudged int64 // when they were last reminded, unix seconds
56}
57
58// Entry is one expense, income or transfer. Owe[i] is the part of Who[i]; the
59// parts sum to Total. An income stores Total and Owe as negative amounts, so
60// one rule applies every kind: Payer gains Total, each of Who loses its part.
61type Entry struct {
62 ID int
63 Kind Kind
64 Title string
65 Cat int
66 Payer int
67 Total int64
68 Note string // the foreign amount, "" if none
69 Who []int
70 Owe []int64
71 At int64
72 By address
73}
74
75// Group is one shared account.
76type Group struct {
77 ID int
78 Name string
79 Currency string
80 Members []Member
81 Entries avl.Tree // key(ID) → *Entry
82 Next int // the next entry's ID
83 Spent int64 // expenses minus incomes, for the header
84 ByCat []int64 // expenses per category, incomes left out
85 Created int64
86 Moved bool // taken over by the successor realm: read-only here
87}
88
89var (
90 groups avl.Tree // key(ID) → *Group
91 byAddr avl.Tree // addr + "/" + key(ID) → true, the groups a wallet belongs to
92 nextID = 1
93)
94
95// self is this realm's pkgpath and hub its gnoweb path, read at init while
96// this realm is current: every link follows from them.
97var (
98 self = unsafe.CurrentRealm().PkgPath()
99 hub = strings.TrimPrefix(self, "gno.land")
100)
101
102func init(cur realm) {
103 setUp(unsafe.OriginCaller(), runtime.ChainID())
104 declareTriggers(cur)
105}
106
107// CreateGroup opens a group named name, counting in currency, with the
108// caller as member you (wallet linked) and others, comma-separated names,
109// without wallets. It returns the group's ID.
110func CreateGroup(cur realm, name, currency, you, others string) int {
111 caller := cur.Previous().Address()
112 name, currency = strings.TrimSpace(name), strings.TrimSpace(currency)
113 if !validText(name, maxGroup) {
114 panic("gnosplit: a group name is 1 to 40 bytes on one line")
115 }
116 if !validText(currency, maxCurrency) || strings.ContainsAny(currency, " ,<>[]()|*_`\\&\"'") {
117 panic("gnosplit: a currency is 1 to 8 bytes with no space, like € or CHF")
118 }
119 g := &Group{ID: nextID, Name: name, Currency: currency, Created: time.Now().Unix(), ByCat: make([]int64, len(categories))}
120 g.addMember(you, caller)
121 for _, n := range splitList(others) {
122 g.addMember(n, "")
123 }
124 nextID++
125 groups.Set(key(g.ID), g)
126 chain.Emit(EventGroupCreated, "group", itoa(g.ID), "by", caller.String())
127 return g.ID
128}
129
130// AddMember adds name to the group, with a wallet if addr is set: that member
131// becomes an editor and is told by gnotif.
132func AddMember(cur realm, group, name, addr string) {
133 g, by := editable(cur, group)
134 a := parseAddr(addr)
135 i := g.addMember(name, a)
136 chain.Emit(EventMemberAdded, "group", itoa(g.ID), "member", g.Members[i].Name)
137 if a != "" {
138 notifyJoined(g, a, g.Members[by].Name)
139 }
140}
141
142// LinkMember links a wallet to a member who has none yet, making them an
143// editor. A linked wallet is never replaced, so no editor can take over
144// another's place.
145func LinkMember(cur realm, group, name, addr string) {
146 g, by := editable(cur, group)
147 i := g.member(name)
148 if g.Members[i].Addr != "" {
149 panic("gnosplit: " + g.Members[i].Name + " already has a wallet")
150 }
151 a := parseAddr(addr)
152 if a == "" {
153 panic("gnosplit: an address is required")
154 }
155 g.link(i, a)
156 chain.Emit(EventMemberLinked, "group", itoa(g.ID), "member", g.Members[i].Name, "addr", a.String())
157 notifyJoined(g, a, g.Members[by].Name)
158}
159
160// AddExpense records that paidBy paid amount for forWhom (comma-separated
161// names, "" for everyone), split equally. amount is "42.50", or a foreign
162// amount with its rate, "50 USD x0.92". It returns the entry's ID.
163func AddExpense(cur realm, group, title, amount, paidBy, forWhom, category string) int {
164 g, by := editable(cur, group)
165 total, note := mustAmount(amount)
166 who := g.members(forWhom)
167 return g.record(by, KindExpense, title, category, g.member(paidBy), total, note, who, ledger.Equal(total, len(who)))
168}
169
170// AddByShares records an expense split by shares: shares is "Alice:2, Bob:1",
171// so Alice owes two thirds.
172func AddByShares(cur realm, group, title, amount, paidBy, shares, category string) int {
173 g, by := editable(cur, group)
174 total, note := mustAmount(amount)
175 who, w := g.pairs(shares, func(s string) (int64, bool) {
176 n, err := strconv.Atoi(s)
177 return int64(n), err == nil && n > 0 && n <= 1000
178 }, "a share is a whole number from 1 to 1000")
179 return g.record(by, KindExpense, title, category, g.member(paidBy), total, note, who, ledger.Weighted(total, w))
180}
181
182// AddByAmounts records an expense split by exact amounts: amounts is
183// "Alice:12.50, Bob:7.50", and the expense is their sum.
184func AddByAmounts(cur realm, group, title, paidBy, amounts, category string) int {
185 g, by := editable(cur, group)
186 who, owe := g.pairs(amounts, ledger.ParseCents, "an amount is like 12.50")
187 var total int64
188 for _, o := range owe {
189 total += o
190 }
191 if total > ledger.MaxCents {
192 panic("gnosplit: the amounts add up to too much")
193 }
194 return g.record(by, KindExpense, title, category, g.member(paidBy), total, "", who, owe)
195}
196
197// AddIncome records money receivedBy got on behalf of forWhom (a refund, a
198// deposit returned), split equally: it lowers what each of them paid.
199func AddIncome(cur realm, group, title, amount, receivedBy, forWhom string) int {
200 g, by := editable(cur, group)
201 total, note := mustAmount(amount)
202 who := g.members(forWhom)
203 return g.record(by, KindIncome, title, "", g.member(receivedBy), -total, note, who, ledger.Equal(-total, len(who)))
204}
205
206// AddTransfer records that from paid amount to to, outside the chain.
207func AddTransfer(cur realm, group, from, to, amount string) int {
208 g, by := editable(cur, group)
209 total, note := mustAmount(amount)
210 f, t := g.member(from), g.member(to)
211 if f == t {
212 panic("gnosplit: a transfer goes to someone else")
213 }
214 title := g.Members[f].Name + " → " + g.Members[t].Name
215 return g.record(by, KindTransfer, title, "", f, total, note, []int{t}, []int64{total})
216}
217
218// Delete removes an entry and undoes its effect on every balance.
219func Delete(cur realm, group, entry string) {
220 g, _ := editable(cur, group)
221 id, err := strconv.Atoi(strings.TrimSpace(entry))
222 if err != nil {
223 panic("gnosplit: no entry " + entry)
224 }
225 v, ok := g.Entries.Remove(key(id))
226 if !ok {
227 panic("gnosplit: no entry " + entry)
228 }
229 g.apply(v.(*Entry), -1)
230 chain.Emit(EventEntryDeleted, "group", itoa(g.ID), "entry", itoa(id), "by", cur.Previous().Address().String())
231}
232
233// Remind sends member, who owes the group, a gnotif reminder: at most one
234// every 12 hours, and only to a member with a wallet.
235func Remind(cur realm, group, member string) {
236 g, by := editable(cur, group)
237 i := g.member(member)
238 m := &g.Members[i]
239 now := time.Now().Unix()
240 switch {
241 case m.Bal >= 0:
242 panic("gnosplit: " + m.Name + " owes nothing")
243 case m.Addr == "":
244 panic("gnosplit: " + m.Name + " has no wallet to remind")
245 case i == by:
246 panic("gnosplit: you cannot remind yourself")
247 case now-m.Nudged < remindEvery:
248 panic("gnosplit: " + m.Name + " was reminded less than 12 hours ago")
249 }
250 m.Nudged = now
251 notifyReminder(g, m, g.Members[by].Name)
252}
253
254// record validates and stores an entry, then applies it to the balances.
255func (g *Group) record(by int, kind Kind, title, category string, payer int, total int64, note string, who []int, owe []int64) int {
256 title = strings.TrimSpace(title)
257 if !validText(title, maxTitle) {
258 panic("gnosplit: a title is 1 to 60 bytes on one line")
259 }
260 if g.Entries.Size() >= maxEntries {
261 panic("gnosplit: this group is full (5000 entries)")
262 }
263 e := &Entry{
264 ID: g.Next, Kind: kind, Title: title, Cat: categoryOf(category), Payer: payer,
265 Total: total, Note: note, Who: who, Owe: owe,
266 At: time.Now().Unix(), By: g.Members[by].Addr,
267 }
268 g.Next++
269 g.Entries.Set(key(e.ID), e)
270 g.apply(e, 1)
271 chain.Emit(EventEntryAdded, "group", itoa(g.ID), "entry", itoa(e.ID), "by", e.By.String())
272 notifyEntry(g, e, by)
273 return e.ID
274}
275
276// apply adds an entry to the balances (sign 1) or takes it back out (-1).
277func (g *Group) apply(e *Entry, sign int64) {
278 g.Members[e.Payer].Bal += sign * e.Total
279 for i, m := range e.Who {
280 g.Members[m].Bal -= sign * e.Owe[i]
281 }
282 if e.Kind == KindTransfer {
283 return
284 }
285 g.Spent += sign * e.Total
286 if e.Kind == KindExpense {
287 g.ByCat[e.Cat] += sign * e.Total
288 }
289 g.Members[e.Payer].Paid += sign * e.Total
290 for i, m := range e.Who {
291 g.Members[m].Share += sign * e.Owe[i]
292 }
293}
294
295// addMember appends a member and returns its index.
296func (g *Group) addMember(name string, a address) int {
297 name = strings.TrimSpace(name)
298 if !validName(name) {
299 panic("gnosplit: a name is 1 to 24 bytes, without , : ; or =")
300 }
301 if g.find(name) >= 0 {
302 panic("gnosplit: there is already a " + name)
303 }
304 if len(g.Members) >= maxMembers {
305 panic("gnosplit: a group has at most 30 members")
306 }
307 g.Members = append(g.Members, Member{Name: name})
308 i := len(g.Members) - 1
309 if a != "" {
310 g.link(i, a)
311 }
312 return i
313}
314
315func (g *Group) link(i int, a address) {
316 if g.editor(a) >= 0 {
317 panic("gnosplit: this wallet is already in the group")
318 }
319 g.Members[i].Addr = a
320 byAddr.Set(a.String()+"/"+key(g.ID), true)
321}
322
323// find is the index of the member called name, any case, or -1.
324func (g *Group) find(name string) int {
325 name = strings.ToLower(strings.TrimSpace(name))
326 for i, m := range g.Members {
327 if strings.ToLower(m.Name) == name {
328 return i
329 }
330 }
331 return -1
332}
333
334func (g *Group) member(name string) int {
335 i := g.find(name)
336 if i < 0 {
337 panic("gnosplit: no member called " + strings.TrimSpace(name))
338 }
339 return i
340}
341
342// members reads a comma-separated list of names, "" for everyone.
343func (g *Group) members(list string) []int {
344 names := splitList(list)
345 if len(names) == 0 {
346 all := make([]int, len(g.Members))
347 for i := range all {
348 all[i] = i
349 }
350 return all
351 }
352 var out []int
353 seen := make([]bool, len(g.Members))
354 for _, n := range names {
355 i := g.member(n)
356 if seen[i] {
357 panic("gnosplit: " + g.Members[i].Name + " is listed twice")
358 }
359 seen[i] = true
360 out = append(out, i)
361 }
362 return out
363}
364
365// pairs reads "Name:value, Name:value" with parse checking each value.
366func (g *Group) pairs(list string, parse func(string) (int64, bool), hint string) ([]int, []int64) {
367 var who []int
368 var vals []int64
369 seen := make([]bool, len(g.Members))
370 for _, p := range splitList(list) {
371 name, val, ok := strings.Cut(p, ":")
372 if !ok {
373 panic("gnosplit: write each one as Name:value, like Alice:2")
374 }
375 i := g.member(name)
376 v, ok := parse(strings.TrimSpace(val))
377 if !ok {
378 panic("gnosplit: " + hint)
379 }
380 if seen[i] {
381 panic("gnosplit: " + g.Members[i].Name + " is listed twice")
382 }
383 seen[i] = true
384 who, vals = append(who, i), append(vals, v)
385 }
386 if len(who) == 0 {
387 panic("gnosplit: name at least one member")
388 }
389 return who, vals
390}
391
392// editor is the index of the member whose wallet is a, or -1.
393func (g *Group) editor(a address) int {
394 for i, m := range g.Members {
395 if m.Addr == a {
396 return i
397 }
398 }
399 return -1
400}
401
402// editable is the one write check: the group exists, is still kept here, and
403// the immediate caller is one of its members with a wallet.
404func editable(cur realm, group string) (*Group, int) {
405 g := mustGroup(group)
406 if g.Moved {
407 panic("gnosplit: this group moved to " + successor)
408 }
409 by := g.editor(cur.Previous().Address())
410 if by < 0 {
411 panic("gnosplit: only a member with a linked wallet can change this group")
412 }
413 return g, by
414}
415
416func getGroup(group string) *Group {
417 id, err := strconv.Atoi(strings.TrimSpace(group))
418 if err != nil {
419 return nil
420 }
421 v := groups.Get(key(id))
422 if v == nil {
423 return nil
424 }
425 return v.(*Group)
426}
427
428func mustGroup(group string) *Group {
429 g := getGroup(group)
430 if g == nil {
431 panic("gnosplit: no group " + group)
432 }
433 return g
434}
435
436func mustAmount(s string) (int64, string) {
437 total, note, ok := ledger.ParseAmount(s)
438 if !ok {
439 panic("gnosplit: an amount is like 42.50, or 50 USD x0.92 for a foreign one")
440 }
441 return total, note
442}
443
444func parseAddr(s string) address {
445 s = strings.TrimSpace(s)
446 if s == "" {
447 return ""
448 }
449 a := address(s)
450 if !a.IsValid() {
451 panic("gnosplit: not an address: " + s)
452 }
453 return a
454}
455
456// splitList cuts a comma-separated list, dropping blanks.
457func splitList(s string) []string {
458 var out []string
459 for _, p := range strings.Split(s, ",") {
460 if p = strings.TrimSpace(p); p != "" {
461 out = append(out, p)
462 }
463 }
464 return out
465}
466
467// validText is 1 to max bytes with no control character.
468func validText(s string, max int) bool {
469 if len(s) == 0 || len(s) > max {
470 return false
471 }
472 for i := 0; i < len(s); i++ {
473 if s[i] < 0x20 || s[i] == 0x7f {
474 return false
475 }
476 }
477 return true
478}
479
480// validName also refuses the separators the forms use.
481func validName(s string) bool {
482 return validText(s, maxName) && !strings.ContainsAny(s, ",:;=")
483}
484
485// key pads an ID so the trees keep numeric order.
486func key(id int) string {
487 s := strconv.Itoa(id)
488 return strings.Repeat("0", 10-len(s)) + s
489}
490
491func itoa(n int) string { return strconv.Itoa(n) }