Skip to content

Commit 9ac4af7

Browse files
xdammanclaude
andcommitted
feat: improve generate pipeline — events covers, members, images, contributors
- Fetch Luma/Google event calendars during events sync, scrape og:image with proper User-Agent, download cover images locally - Generate members.json from cached Stripe/Odoo provider snapshots - Include members sync in sync_all pipeline - Fix latest/generated/images.json to use all messages in latest/ - Write full fetched message batch to latest/ (not just last month slice) - Fix contributor summary: wire up token counting via nostr-metadata address→Discord mapping, add totalImages and totalDiscordMembers - Move events.md and rooms.md to generated/ subfolder for consistency - Add coverImageLocal to LatestEvent for local image proxy serving - Expand og scraper with Meta struct, User-Agent, ExtractMeta, and tests - Website events API prefers local cover image path for proxy/cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a2efa0 commit 9ac4af7

38 files changed

Lines changed: 12315 additions & 626 deletions

cmd/accounting.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
)
6+
7+
// AccountingSettings holds the accounting configuration from settings.json.
8+
// Rules have moved to ~/.chb/rules.json — the Rules field is kept for migration only.
9+
type AccountingSettings struct {
10+
Categories []CategoryDef `json:"categories"`
11+
DefaultCollective string `json:"defaultCollective,omitempty"` // e.g. "commonshub"
12+
Rules []CategoryRule `json:"rules,omitempty"` // DEPRECATED: migrated to rules.json
13+
Odoo *OdooAccountingConfig `json:"odoo,omitempty"`
14+
}
15+
16+
// OdooAccountingConfig holds the Odoo→local category mapping.
17+
type OdooAccountingConfig struct {
18+
// CategoryMapping maps Odoo analytic account ID (as string) to local category slug.
19+
CategoryMapping map[string]string `json:"categoryMapping"`
20+
}
21+
22+
// CategoryDef defines a category with its slug, label, and direction.
23+
type CategoryDef struct {
24+
Slug string `json:"slug"`
25+
Label string `json:"label"`
26+
Direction string `json:"direction"` // "income" or "expense"
27+
}
28+
29+
// CategoryRule maps transactions to categories based on matching criteria.
30+
// Fields are ANDed: all non-empty fields must match.
31+
type CategoryRule struct {
32+
// Match criteria (all non-empty fields must match)
33+
Account string `json:"account,omitempty"` // account slug (e.g. "fridge", "coffee")
34+
Match string `json:"match,omitempty"` // glob pattern on counterparty/description
35+
Provider string `json:"provider,omitempty"` // "stripe", "etherscan", "monerium"
36+
Currency string `json:"currency,omitempty"` // "EUR", "EURe", "CHT"
37+
TxType string `json:"txType,omitempty"` // "CREDIT", "DEBIT"
38+
39+
// Assignment
40+
Category string `json:"category"` // category slug
41+
Collective string `json:"collective,omitempty"` // collective slug
42+
}
43+
44+
// DefaultAccountingSettings returns a sensible default config for a commons/coworking space.
45+
func DefaultAccountingSettings() *AccountingSettings {
46+
return &AccountingSettings{
47+
Categories: []CategoryDef{
48+
// Income
49+
{Slug: "membership", Label: "Membership", Direction: "income"},
50+
{Slug: "donations", Label: "Donations", Direction: "income"},
51+
{Slug: "rentals", Label: "Rentals", Direction: "income"},
52+
{Slug: "fridge", Label: "Fridge", Direction: "income"},
53+
{Slug: "tickets", Label: "Tickets", Direction: "income"},
54+
{Slug: "grants", Label: "Grants", Direction: "income"},
55+
{Slug: "other-income", Label: "Other Income", Direction: "income"},
56+
// Expenses
57+
{Slug: "rent", Label: "Rent", Direction: "expense"},
58+
{Slug: "salaries", Label: "Salaries", Direction: "expense"},
59+
{Slug: "catering", Label: "Catering", Direction: "expense"},
60+
{Slug: "utilities", Label: "Utilities", Direction: "expense"},
61+
{Slug: "insurance", Label: "Insurance", Direction: "expense"},
62+
{Slug: "supplies", Label: "Supplies", Direction: "expense"},
63+
{Slug: "equipment", Label: "Equipment", Direction: "expense"},
64+
{Slug: "services", Label: "Services", Direction: "expense"},
65+
{Slug: "taxes", Label: "Taxes", Direction: "expense"},
66+
{Slug: "events", Label: "Events", Direction: "expense"},
67+
{Slug: "other-expense", Label: "Other Expense", Direction: "expense"},
68+
},
69+
Rules: []CategoryRule{},
70+
}
71+
}
72+
73+
// Categorizer applies rules to classify transactions.
74+
type Categorizer struct {
75+
rules []Rule
76+
categories map[string]CategoryDef
77+
}
78+
79+
// NewCategorizer creates a categorizer from categories.json + rules.json.
80+
func NewCategorizer(settings *Settings) *Categorizer {
81+
c := &Categorizer{
82+
categories: make(map[string]CategoryDef),
83+
}
84+
85+
for _, cat := range LoadCategories() {
86+
c.categories[cat.Slug] = cat
87+
}
88+
89+
c.rules, _ = LoadRules()
90+
91+
return c
92+
}
93+
94+
// Categorize returns the category slug for a transaction, or "" if uncategorized.
95+
func (c *Categorizer) Categorize(tx TransactionEntry) string {
96+
for _, rule := range c.rules {
97+
if rule.MatchesTransaction(tx) {
98+
return rule.Assign.Category
99+
}
100+
}
101+
return ""
102+
}
103+
104+
// CollectiveFor returns the collective slug for a transaction, or "" if none.
105+
func (c *Categorizer) CollectiveFor(tx TransactionEntry) string {
106+
for _, rule := range c.rules {
107+
if rule.MatchesTransaction(tx) && rule.Assign.Collective != "" {
108+
return rule.Assign.Collective
109+
}
110+
}
111+
return ""
112+
}
113+
114+
// CategoryLabel returns the human label for a category slug.
115+
func (c *Categorizer) CategoryLabel(slug string) string {
116+
if cat, ok := c.categories[slug]; ok {
117+
return cat.Label
118+
}
119+
return slug
120+
}
121+
122+
// CategoryDirection returns "income" or "expense" for a category slug.
123+
func (c *Categorizer) CategoryDirection(slug string) string {
124+
if cat, ok := c.categories[slug]; ok {
125+
return cat.Direction
126+
}
127+
return ""
128+
}
129+
130+
// globMatch does simple glob matching with * wildcards.
131+
func globMatch(pattern, s string) bool {
132+
if pattern == "*" {
133+
return true
134+
}
135+
136+
// Handle prefix*, *suffix, *contains*
137+
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
138+
return strings.Contains(s, pattern[1:len(pattern)-1])
139+
}
140+
if strings.HasSuffix(pattern, "*") {
141+
return strings.HasPrefix(s, pattern[:len(pattern)-1])
142+
}
143+
if strings.HasPrefix(pattern, "*") {
144+
return strings.HasSuffix(s, pattern[1:])
145+
}
146+
147+
return s == pattern
148+
}

0 commit comments

Comments
 (0)