Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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) }