From 6ed848a00135fb57a48359691aa793566a0f815b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sun, 14 Jun 2026 12:43:48 +0100 Subject: [PATCH] =?UTF-8?q?feat(finance):=20org=20management=20scaffold=20?= =?UTF-8?q?=E2=80=94=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds multi-tenant organisation support inside the existing finance namespace. Users can create organisations, invite others via a copy-paste token link, and manage teams/members with RBAC (admin, finance, member, viewer). Fiscal year lifecycle is gated: activation requires all planned events to be approved first. All org data lives in `org_`-prefixed MongoDB collections. New files: - models_org.go — domain types (Org, OrgTeam, OrgMember, OrgInvite, FiscalYear, OrgEvent, BudgetLine, EventComment, TxRequest with full StatusLog audit trail, etc.) - store_org.go — MongoDB store methods for all org collections - handler_org.go — HTTP handlers + RegisterOrgRoutes(); join invite route lives at /join/{token} to avoid ServeMux conflict with /orgs/{slug}/... wildcard routes - templates/org_*.html — list, create, home, teams, members, invite, join Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 52 ++ apps/finance/services/api/main/handler_org.go | 570 ++++++++++++++++++ .../finance/services/api/main/handler_test.go | 48 ++ apps/finance/services/api/main/models_org.go | 356 +++++++++++ apps/finance/services/api/main/store_org.go | 529 ++++++++++++++++ .../services/api/main/templates/base.html | 2 + .../api/main/templates/org_create.html | 43 ++ .../services/api/main/templates/org_home.html | 140 +++++ .../api/main/templates/org_invite.html | 90 +++ .../services/api/main/templates/org_join.html | 33 + .../services/api/main/templates/org_list.html | 30 + .../api/main/templates/org_members.html | 99 +++ .../api/main/templates/org_teams.html | 90 +++ 13 files changed, 2082 insertions(+) create mode 100644 apps/finance/services/api/main/handler_org.go create mode 100644 apps/finance/services/api/main/models_org.go create mode 100644 apps/finance/services/api/main/store_org.go create mode 100644 apps/finance/services/api/main/templates/org_create.html create mode 100644 apps/finance/services/api/main/templates/org_home.html create mode 100644 apps/finance/services/api/main/templates/org_invite.html create mode 100644 apps/finance/services/api/main/templates/org_join.html create mode 100644 apps/finance/services/api/main/templates/org_list.html create mode 100644 apps/finance/services/api/main/templates/org_members.html create mode 100644 apps/finance/services/api/main/templates/org_teams.html diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index a8c4fb1..ac4590e 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -127,6 +127,15 @@ var ( autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html") peopleTmpl = parseTmpl("templates/base.html", "templates/people.html") settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html") + + // Org + orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html") + orgCreateTmpl = parseTmpl("templates/base.html", "templates/org_create.html") + orgHomeTmpl = parseTmpl("templates/base.html", "templates/org_home.html") + orgTeamsTmpl = parseTmpl("templates/base.html", "templates/org_teams.html") + orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html") + orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html") + orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html") ) type authInfo struct { @@ -194,6 +203,47 @@ type storeIface interface { getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error) createImportSchedule(ctx context.Context, sched *ImportSchedule) error deleteImportSchedule(ctx context.Context, id, userID string) error + + // Org + getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error) + getOrg(ctx context.Context, orgID string) (*Org, error) + getOrgBySlug(ctx context.Context, slug string) (*Org, error) + createOrg(ctx context.Context, o *Org) error + slugExists(ctx context.Context, slug string) (bool, error) + getTeams(ctx context.Context, orgID string) ([]OrgTeam, error) + getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error) + createTeam(ctx context.Context, t *OrgTeam) error + deleteTeam(ctx context.Context, teamID, orgID string) error + getMembers(ctx context.Context, orgID string) ([]OrgMember, error) + getMember(ctx context.Context, orgID, userID string) (*OrgMember, error) + createMember(ctx context.Context, m *OrgMember) error + updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error + removeMember(ctx context.Context, memberID, orgID string) error + getInvites(ctx context.Context, orgID string) ([]OrgInvite, error) + getInviteByToken(ctx context.Context, token string) (*OrgInvite, error) + createInvite(ctx context.Context, inv *OrgInvite) error + consumeInvite(ctx context.Context, inviteID string) error + revokeInvite(ctx context.Context, inviteID, orgID string) error + getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error) + getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error) + getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error) + createFiscalYear(ctx context.Context, y *FiscalYear) error + updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error + getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error) + getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error) + createEvent(ctx context.Context, e *OrgEvent) error + updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error + deleteEvent(ctx context.Context, eventID, orgID string) error + getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error) + createBudgetLine(ctx context.Context, l *BudgetLine) error + deleteBudgetLine(ctx context.Context, lineID, orgID string) error + getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error) + createEventComment(ctx context.Context, c *EventComment) error + getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error) + getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error) + createTxRequest(ctx context.Context, r *TxRequest) error + appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error + updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error } type Handler struct { @@ -2488,6 +2538,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /tax", h.Tax) mux.HandleFunc("GET /tax/export.csv", h.TaxExport) mux.HandleFunc("GET /auto-import", h.AutoImport) + + h.RegisterOrgRoutes(mux) } func sortStrings(s []string) { diff --git a/apps/finance/services/api/main/handler_org.go b/apps/finance/services/api/main/handler_org.go new file mode 100644 index 0000000..09d34f8 --- /dev/null +++ b/apps/finance/services/api/main/handler_org.go @@ -0,0 +1,570 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// slugRe validates org slugs: lowercase letters, digits, hyphens. +var slugRe = regexp.MustCompile(`^[a-z0-9-]{2,40}$`) + +// orgTmpl and friends are registered in handler.go parseTmpl block (below). + +// ── Middleware helpers ──────────────────────────────────────────────────────── + +// orgMW loads the org and the caller's membership. Injects them via context values +// wrapped in the request, or returns 403/404. next receives the same request. +// +// It sets two request-scoped values accessible via orgFromCtx / memberFromCtx: +// these are passed as function arguments here for simplicity (no context key needed). +func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc { + return h.authMW(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + slug := r.PathValue("slug") + + org, err := h.store.getOrgBySlug(ctx, slug) + if err != nil { + http.Error(w, "organisation not found", http.StatusNotFound) + return + } + me, err := h.store.getMember(ctx, org.ID, a.UserID) + if err != nil { + http.Error(w, "you are not a member of this organisation", http.StatusForbidden) + return + } + next(w, r, org, me) + }) +} + +func (h *Handler) requireOrgRole(roles ...OrgRole) func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc { + return func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc { + return h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + for _, role := range roles { + if me.Role == role { + next(w, r, org, me) + return + } + } + http.Error(w, "insufficient permissions", http.StatusForbidden) + }) + } +} + +// canManageOrg is true for admin and finance. +func canManageOrg(role OrgRole) bool { + return role == OrgRoleAdmin || role == OrgRoleFinance +} + +// ── Org list & creation ─────────────────────────────────────────────────────── + +func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + + orgs, err := h.store.getOrgsForUser(ctx, a.UserID) + if err != nil { + slog.Error("get orgs for user", "err", err) + orgs = nil + } + + render(w, orgListTmpl, &OrgListData{ + UserID: a.UserID, + Email: a.Email, + Title: "Organisations", + Route: "orgs", + Orgs: orgs, + }) +} + +func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + + if r.Method == http.MethodGet { + render(w, orgCreateTmpl, map[string]any{ + "Title": "New Organisation", + "Route": "orgs", + "UserID": a.UserID, + "Email": a.Email, + }) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + slug := strings.TrimSpace(strings.ToLower(r.FormValue("slug"))) + + errMsg := "" + switch { + case name == "": + errMsg = "Name is required." + case !slugRe.MatchString(slug): + errMsg = "Slug must be 2–40 lowercase letters, digits or hyphens." + } + + if errMsg == "" { + exists, _ := h.store.slugExists(ctx, slug) + if exists { + errMsg = "That slug is already taken — choose another." + } + } + + if errMsg != "" { + render(w, orgCreateTmpl, map[string]any{ + "Title": "New Organisation", + "Route": "orgs", + "UserID": a.UserID, + "Email": a.Email, + "Error": errMsg, + "Name": name, + "Slug": slug, + }) + return + } + + org := &Org{ + ID: bson.NewObjectID().Hex(), + Name: name, + Slug: slug, + OwnerUserID: a.UserID, + CreatedAt: time.Now(), + } + if err := h.store.createOrg(ctx, org); err != nil { + slog.Error("create org", "err", err) + http.Error(w, "could not create organisation", http.StatusInternalServerError) + return + } + + // creator becomes admin + member := &OrgMember{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + UserID: a.UserID, + Email: a.Email, + Role: OrgRoleAdmin, + CreatedAt: time.Now(), + } + if err := h.store.createMember(ctx, member); err != nil { + slog.Error("create founding member", "err", err) + } + + http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) +} + +// ── Org home ────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgHome(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + + years, _ := h.store.getFiscalYears(ctx, org.ID) + teams, _ := h.store.getTeams(ctx, org.ID) + members, _ := h.store.getMembers(ctx, org.ID) + + var active *FiscalYear + for i := range years { + if years[i].Status == FiscalYearActive { + active = &years[i] + break + } + } + + render(w, orgHomeTmpl, &OrgHomeData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name, + Route: "orgs", + Org: *org, + MyRole: me.Role, + MyTeamIDs: me.TeamIDs, + FiscalYears: years, + ActiveYear: active, + Teams: teams, + Members: members, + }) + })(w, r) +} + +// ── Teams ───────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgTeams(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + teams, _ := h.store.getTeams(ctx, org.ID) + members, _ := h.store.getMembers(ctx, org.ID) + + render(w, orgTeamsTmpl, &OrgTeamsData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Teams", + Route: "orgs", + Org: *org, + MyRole: me.Role, + Teams: teams, + Members: members, + }) + })(w, r) +} + +func (h *Handler) OrgTeamCreate(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + name := strings.TrimSpace(r.FormValue("name")) + teamType := TeamType(r.FormValue("type")) + if teamType != TeamTypeGuest { + teamType = TeamTypeInternal + } + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + team := &OrgTeam{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + Name: name, + Type: teamType, + CreatedAt: time.Now(), + } + if err := h.store.createTeam(ctx, team); err != nil { + slog.Error("create team", "err", err) + http.Error(w, "could not create team", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgTeamDelete(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + teamID := r.PathValue("team_id") + if err := h.store.deleteTeam(r.Context(), teamID, org.ID); err != nil { + slog.Error("delete team", "err", err) + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther) + })(w, r) +} + +// ── Members ─────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgMembers(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + members, _ := h.store.getMembers(ctx, org.ID) + teams, _ := h.store.getTeams(ctx, org.ID) + invites, _ := h.store.getInvites(ctx, org.ID) + + render(w, orgMembersTmpl, &OrgMembersData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Members", + Route: "orgs", + Org: *org, + MyRole: me.Role, + Members: members, + Teams: teams, + Invites: invites, + }) + })(w, r) +} + +func (h *Handler) OrgMemberRoleUpdate(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + memberID := r.PathValue("member_id") + role := OrgRole(r.FormValue("role")) + switch role { + case OrgRoleAdmin, OrgRoleFinance, OrgRoleMember, OrgRoleViewer: + default: + http.Error(w, "invalid role", http.StatusBadRequest) + return + } + if err := h.store.updateMemberRole(ctx, memberID, org.ID, role); err != nil { + slog.Error("update member role", "err", err) + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgMemberRemove(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + memberID := r.PathValue("member_id") + // prevent removing yourself + a := getAuth(r) + if me.UserID == a.UserID && memberID == me.ID { + http.Error(w, "cannot remove yourself", http.StatusBadRequest) + return + } + if err := h.store.removeMember(r.Context(), memberID, org.ID); err != nil { + slog.Error("remove member", "err", err) + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther) + })(w, r) +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgInviteNew(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + teams, _ := h.store.getTeams(ctx, org.ID) + + if r.Method == http.MethodGet { + render(w, orgInviteTmpl, &OrgInviteData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: "Invite to " + org.Name, + Route: "orgs", + Org: *org, + MyRole: me.Role, + Teams: teams, + }) + return + } + + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + role := OrgRole(r.FormValue("role")) + teamIDs := r.Form["team_ids"] + + errMsg := "" + switch { + case email == "": + errMsg = "Email is required." + case role != OrgRoleAdmin && role != OrgRoleFinance && role != OrgRoleMember && role != OrgRoleViewer: + errMsg = "Invalid role." + } + + if errMsg != "" { + render(w, orgInviteTmpl, &OrgInviteData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: "Invite to " + org.Name, + Route: "orgs", + Org: *org, + MyRole: me.Role, + Teams: teams, + Error: errMsg, + }) + return + } + + token := randomHex(32) + inv := &OrgInvite{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + Email: email, + Role: role, + TeamIDs: teamIDs, + Token: token, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + CreatedAt: time.Now(), + } + if err := h.store.createInvite(ctx, inv); err != nil { + slog.Error("create invite", "err", err) + http.Error(w, "could not create invite", http.StatusInternalServerError) + return + } + + // Show the link — email delivery is Phase 5 + scheme := "https" + if r.TLS == nil { + scheme = "http" + } + link := fmt.Sprintf("%s://%s/join/%s", scheme, r.Host, token) + + render(w, orgInviteTmpl, &OrgInviteData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: "Invite to " + org.Name, + Route: "orgs", + Org: *org, + MyRole: me.Role, + Teams: teams, + Link: link, + }) + })(w, r) +} + +func (h *Handler) OrgInviteRevoke(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + inviteID := r.PathValue("invite_id") + if err := h.store.revokeInvite(r.Context(), inviteID, org.ID); err != nil { + slog.Error("revoke invite", "err", err) + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther) + })(w, r) +} + +// OrgJoin handles the invite link: GET shows a confirmation page, POST accepts. +func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + token := r.PathValue("token") + + inv, err := h.store.getInviteByToken(ctx, token) + if err != nil { + http.Error(w, "invite not found or expired", http.StatusNotFound) + return + } + org, err := h.store.getOrg(ctx, inv.OrgID) + if err != nil { + http.Error(w, "organisation not found", http.StatusNotFound) + return + } + + if r.Method == http.MethodGet { + render(w, orgJoinTmpl, map[string]any{ + "Title": "Join " + org.Name, + "Route": "orgs", + "UserID": a.UserID, + "Email": a.Email, + "Org": org, + "Invite": inv, + }) + return + } + + // check already a member + if _, err := h.store.getMember(ctx, org.ID, a.UserID); err == nil { + // already in — just consume the invite and redirect + _ = h.store.consumeInvite(ctx, inv.ID) + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) + return + } + + member := &OrgMember{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + UserID: a.UserID, + Email: a.Email, + Role: inv.Role, + TeamIDs: inv.TeamIDs, + CreatedAt: time.Now(), + } + if err := h.store.createMember(ctx, member); err != nil { + slog.Error("create member from invite", "err", err) + http.Error(w, "could not join organisation", http.StatusInternalServerError) + return + } + if err := h.store.consumeInvite(ctx, inv.ID); err != nil { + slog.Error("consume invite", "err", err) + } + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) +} + +// ── Fiscal Years ────────────────────────────────────────────────────────────── + +func (h *Handler) OrgFiscalYearCreate(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + label := strings.TrimSpace(r.FormValue("label")) + startStr := r.FormValue("start_date") + endStr := r.FormValue("end_date") + + start, errS := time.Parse("2006-01-02", startStr) + end, errE := time.Parse("2006-01-02", endStr) + if label == "" || errS != nil || errE != nil || !end.After(start) { + http.Error(w, "invalid fiscal year data", http.StatusBadRequest) + return + } + + y := &FiscalYear{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + Label: label, + Status: FiscalYearDraft, + StartDate: start, + EndDate: end, + CreatedAt: time.Now(), + } + if err := h.store.createFiscalYear(ctx, y); err != nil { + slog.Error("create fiscal year", "err", err) + http.Error(w, "could not create fiscal year", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgFiscalYearActivate(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + + // verify no other year is already active + if active, _ := h.store.getActiveFiscalYear(ctx, org.ID); active != nil { + http.Error(w, "another fiscal year is already active — close it first", http.StatusConflict) + return + } + + // verify all events in this year are approved + events, err := h.store.getEvents(ctx, org.ID, yearID) + if err != nil { + http.Error(w, "could not load events", http.StatusInternalServerError) + return + } + for _, e := range events { + if e.Status != EventApproved { + http.Error(w, fmt.Sprintf("event %q is not yet approved", e.Name), http.StatusConflict) + return + } + } + + if err := h.store.updateFiscalYearStatus(ctx, yearID, org.ID, FiscalYearActive, bson.M{ + "started_at": time.Now(), + }); err != nil { + slog.Error("activate fiscal year", "err", err) + http.Error(w, "could not activate fiscal year", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) + })(w, r) +} + +// ── Route registration ──────────────────────────────────────────────────────── + +func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) { + // Exact/literal patterns first (must precede wildcard {slug} routes) + mux.HandleFunc("GET /orgs", h.authMW(h.OrgList)) + mux.HandleFunc("GET /orgs/new", h.authMW(h.OrgCreate)) + mux.HandleFunc("POST /orgs/new", h.authMW(h.OrgCreate)) + mux.HandleFunc("GET /join/{token}", h.authMW(h.OrgJoin)) + mux.HandleFunc("POST /join/{token}", h.authMW(h.OrgJoin)) + + // {slug} wildcard routes + mux.HandleFunc("GET /orgs/{slug}", h.OrgHome) + + // Teams + mux.HandleFunc("GET /orgs/{slug}/teams", h.OrgTeams) + mux.HandleFunc("POST /orgs/{slug}/teams", h.OrgTeamCreate) + mux.HandleFunc("POST /orgs/{slug}/teams/{team_id}/delete", h.OrgTeamDelete) + + // Members + mux.HandleFunc("GET /orgs/{slug}/members", h.OrgMembers) + mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/role", h.OrgMemberRoleUpdate) + mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/remove", h.OrgMemberRemove) + + // Invites + mux.HandleFunc("GET /orgs/{slug}/invite", h.OrgInviteNew) + mux.HandleFunc("POST /orgs/{slug}/invite", h.OrgInviteNew) + mux.HandleFunc("POST /orgs/{slug}/invites/{invite_id}/revoke", h.OrgInviteRevoke) + + // Fiscal years + mux.HandleFunc("POST /orgs/{slug}/years", h.OrgFiscalYearCreate) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/activate", h.OrgFiscalYearActivate) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index 608a0cd..5046c21 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -170,6 +170,54 @@ func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSch func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil } func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil } +// ── Org stubs (not exercised in unit tests) ─────────────────────────────────── + +func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) { + return nil, nil +} +func (m *mockStore) getOrg(_ context.Context, _ string) (*Org, error) { return nil, nil } +func (m *mockStore) getOrgBySlug(_ context.Context, _ string) (*Org, error) { return nil, nil } +func (m *mockStore) createOrg(_ context.Context, _ *Org) error { return nil } +func (m *mockStore) slugExists(_ context.Context, _ string) (bool, error) { return false, nil } +func (m *mockStore) getTeams(_ context.Context, _ string) ([]OrgTeam, error) { return nil, nil } +func (m *mockStore) getTeam(_ context.Context, _, _ string) (*OrgTeam, error) { return nil, nil } +func (m *mockStore) createTeam(_ context.Context, _ *OrgTeam) error { return nil } +func (m *mockStore) deleteTeam(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getMembers(_ context.Context, _ string) ([]OrgMember, error) { return nil, nil } +func (m *mockStore) getMember(_ context.Context, _, _ string) (*OrgMember, error) { + return nil, nil +} +func (m *mockStore) createMember(_ context.Context, _ *OrgMember) error { return nil } +func (m *mockStore) updateMemberRole(_ context.Context, _, _ string, _ OrgRole) error { return nil } +func (m *mockStore) removeMember(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getInvites(_ context.Context, _ string) ([]OrgInvite, error) { return nil, nil } +func (m *mockStore) getInviteByToken(_ context.Context, _ string) (*OrgInvite, error) { return nil, nil } +func (m *mockStore) createInvite(_ context.Context, _ *OrgInvite) error { return nil } +func (m *mockStore) consumeInvite(_ context.Context, _ string) error { return nil } +func (m *mockStore) revokeInvite(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { return nil, nil } +func (m *mockStore) getFiscalYear(_ context.Context, _, _ string) (*FiscalYear, error) { return nil, nil } +func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { return nil, nil } +func (m *mockStore) createFiscalYear(_ context.Context, _ *FiscalYear) error { return nil } +func (m *mockStore) updateFiscalYearStatus(_ context.Context, _, _ string, _ FiscalYearStatus, _ bson.M) error { + return nil +} +func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { return nil, nil } +func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error) { return nil, nil } +func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil } +func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getBudgetLines(_ context.Context, _, _ string) ([]BudgetLine, error) { return nil, nil } +func (m *mockStore) createBudgetLine(_ context.Context, _ *BudgetLine) error { return nil } +func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getEventComments(_ context.Context, _, _ string) ([]EventComment, error) { return nil, nil } +func (m *mockStore) createEventComment(_ context.Context, _ *EventComment) error { return nil } +func (m *mockStore) getTxRequests(_ context.Context, _ string, _ bson.M) ([]TxRequest, error) { return nil, nil } +func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, error) { return nil, nil } +func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil } +func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil } +func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil } + // ── helpers ─────────────────────────────────────────────────────────────────── func newHandler(store *mockStore) *Handler { diff --git a/apps/finance/services/api/main/models_org.go b/apps/finance/services/api/main/models_org.go new file mode 100644 index 0000000..cf66e02 --- /dev/null +++ b/apps/finance/services/api/main/models_org.go @@ -0,0 +1,356 @@ +package main + +import "time" + +// ── Org RBAC ───────────────────────────────────────────────────────────────── + +type OrgRole string + +const ( + OrgRoleAdmin OrgRole = "admin" + OrgRoleFinance OrgRole = "finance" + OrgRoleMember OrgRole = "member" + OrgRoleViewer OrgRole = "viewer" +) + +type TeamType string + +const ( + TeamTypeInternal TeamType = "internal" + TeamTypeGuest TeamType = "guest" +) + +// ── Core entities ───────────────────────────────────────────────────────────── + +type Org struct { + ID string `bson:"_id" json:"id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + OwnerUserID string `bson:"owner_user_id" json:"owner_user_id"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type OrgTeam struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Type TeamType `bson:"type" json:"type"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// OrgMember links a user to an org with a role and optional team subset. +// All financial approvals go through org-level finance/admin regardless of team. +// Guest team members are scoped to their own team's data only (visibility scope, +// not approval scope). +type OrgMember struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + UserID string `bson:"user_id" json:"user_id"` + Email string `bson:"email" json:"email"` + Role OrgRole `bson:"role" json:"role"` + TeamIDs []string `bson:"team_ids" json:"team_ids"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// OrgInvite is a pending invitation. Token is a random hex string. +// Email delivery is a TODO (Phase 5) — for now the link is displayed to the inviter. +type OrgInvite struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + Role OrgRole `bson:"role" json:"role"` + TeamIDs []string `bson:"team_ids" json:"team_ids"` + Token string `bson:"token" json:"token"` + ExpiresAt time.Time `bson:"expires_at" json:"expires_at"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UsedAt time.Time `bson:"used_at,omitempty" json:"used_at,omitempty"` +} + +// ── Fiscal year ─────────────────────────────────────────────────────────────── + +type FiscalYearStatus string + +const ( + FiscalYearDraft FiscalYearStatus = "draft" + FiscalYearActive FiscalYearStatus = "active" + FiscalYearClosed FiscalYearStatus = "closed" +) + +type FiscalYear struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + Label string `bson:"label" json:"label"` // e.g. "2025" + Status FiscalYearStatus `bson:"status" json:"status"` + StartDate time.Time `bson:"start_date" json:"start_date"` + EndDate time.Time `bson:"end_date" json:"end_date"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` + ClosedAt time.Time `bson:"closed_at,omitempty" json:"closed_at,omitempty"` +} + +// ── Planning: Events & Budget ───────────────────────────────────────────────── + +type EventStatus string + +const ( + EventDraft EventStatus = "draft" + EventReview EventStatus = "review" + EventApproved EventStatus = "approved" + EventRejected EventStatus = "rejected" +) + +type OrgEvent struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"` + TeamIDs []string `bson:"team_ids" json:"team_ids"` + Name string `bson:"name" json:"name"` + Description string `bson:"description" json:"description"` + Goals string `bson:"goals" json:"goals"` + DateStart time.Time `bson:"date_start" json:"date_start"` + DateEnd time.Time `bson:"date_end" json:"date_end"` + Status EventStatus `bson:"status" json:"status"` + CreatedBy string `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type BudgetLineType string + +const ( + BudgetIncome BudgetLineType = "income" + BudgetExpense BudgetLineType = "expense" +) + +type BudgetLine struct { + ID string `bson:"_id" json:"id"` + EventID string `bson:"event_id" json:"event_id"` + OrgID string `bson:"org_id" json:"org_id"` + Category string `bson:"category" json:"category"` + Type BudgetLineType `bson:"type" json:"type"` + PlannedCents int64 `bson:"planned_cents" json:"planned_cents"` + Description string `bson:"description" json:"description"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// EventComment is used for two purposes distinguished by Kind: +// - "review" — admin → team during planning (request changes / feedback) +// - "feedback" — team post-mortem after year closes (included in year-end report) +type EventCommentKind string + +const ( + CommentReview EventCommentKind = "review" + CommentFeedback EventCommentKind = "feedback" +) + +type EventComment struct { + ID string `bson:"_id" json:"id"` + EventID string `bson:"event_id" json:"event_id"` + OrgID string `bson:"org_id" json:"org_id"` + UserID string `bson:"user_id" json:"user_id"` + UserEmail string `bson:"user_email" json:"user_email"` + Kind EventCommentKind `bson:"kind" json:"kind"` + Body string `bson:"body" json:"body"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// ── Execution: Transaction Requests ────────────────────────────────────────── + +type TxRequestType string + +const ( + TxReimbursement TxRequestType = "reimbursement" + TxPurchaseOrder TxRequestType = "purchase_order" + TxCashAdvance TxRequestType = "cash_advance" + TxIncome TxRequestType = "income" + TxBudgetTransfer TxRequestType = "budget_transfer" +) + +type TxRequestStatus string + +const ( + TxDraft TxRequestStatus = "draft" + TxSubmitted TxRequestStatus = "submitted" + TxInfoRequested TxRequestStatus = "info_requested" + TxUnderReview TxRequestStatus = "under_review" + TxApproved TxRequestStatus = "approved" + TxRejected TxRequestStatus = "rejected" + TxCancelled TxRequestStatus = "cancelled" + // Reimbursement + TxPaid TxRequestStatus = "paid" + // Purchase Order + TxOrdered TxRequestStatus = "ordered" + TxDelivered TxRequestStatus = "delivered" + TxDisputed TxRequestStatus = "disputed" + // Cash Advance + TxDisbursed TxRequestStatus = "disbursed" + TxSettlementDue TxRequestStatus = "settlement_due" + TxSettled TxRequestStatus = "settled" + TxPartialSettlement TxRequestStatus = "partial_settlement" + // Income + TxPendingPayment TxRequestStatus = "pending_payment" + TxReceived TxRequestStatus = "received" + // Shared terminal + TxReconciled TxRequestStatus = "reconciled" + // Budget transfer terminal + TxDone TxRequestStatus = "done" +) + +// StatusLogEntry is appended on every status change. Never mutated. +// When status = info_requested, Comment is required. +type StatusLogEntry struct { + Status TxRequestStatus `bson:"status" json:"status"` + ChangedBy string `bson:"changed_by" json:"changed_by"` + ChangedAt time.Time `bson:"changed_at" json:"changed_at"` + Comment string `bson:"comment,omitempty" json:"comment,omitempty"` +} + +// PODelivery is filled in by the requester when a Purchase Order arrives. +type PODelivery struct { + ActualAmountCents int64 `bson:"actual_amount_cents" json:"actual_amount_cents"` + ActualVendor string `bson:"actual_vendor" json:"actual_vendor"` + DeliveredAt time.Time `bson:"delivered_at" json:"delivered_at"` + InvoiceAttachmentID []string `bson:"invoice_attachment_ids" json:"invoice_attachment_ids"` + StoreChanged bool `bson:"store_changed" json:"store_changed"` + ChangeNote string `bson:"change_note,omitempty" json:"change_note,omitempty"` +} + +// CashSettlement is filled in by the requester when settling a Cash Advance. +type CashSettlement struct { + AmountSpentCents int64 `bson:"amount_spent_cents" json:"amount_spent_cents"` + AmountReturnedCents int64 `bson:"amount_returned_cents" json:"amount_returned_cents"` + ReceiptAttachmentIDs []string `bson:"receipt_attachment_ids" json:"receipt_attachment_ids"` + SettledAt time.Time `bson:"settled_at" json:"settled_at"` +} + +type TxRequest struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"` + EventID string `bson:"event_id" json:"event_id"` + BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"` + TeamID string `bson:"team_id" json:"team_id"` + SubmittedBy string `bson:"submitted_by" json:"submitted_by"` + SubmitterEmail string `bson:"submitter_email" json:"submitter_email"` + Type TxRequestType `bson:"type" json:"type"` + Description string `bson:"description" json:"description"` + AmountCents int64 `bson:"amount_cents" json:"amount_cents"` + Vendor string `bson:"vendor,omitempty" json:"vendor,omitempty"` + PayerName string `bson:"payer_name,omitempty" json:"payer_name,omitempty"` + DueDate time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"` + PaymentMethod string `bson:"payment_method,omitempty" json:"payment_method,omitempty"` + // PO-specific + Delivery *PODelivery `bson:"delivery,omitempty" json:"delivery,omitempty"` + // Cash advance specific + Settlement *CashSettlement `bson:"settlement,omitempty" json:"settlement,omitempty"` + // Budget transfer specific + FromBudgetLineID string `bson:"from_budget_line_id,omitempty" json:"from_budget_line_id,omitempty"` + ToBudgetLineID string `bson:"to_budget_line_id,omitempty" json:"to_budget_line_id,omitempty"` + + AttachmentIDs []string `bson:"attachment_ids" json:"attachment_ids"` + StatusLog []StatusLogEntry `bson:"status_log" json:"status_log"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// CurrentStatus returns the latest status from the log. +func (r *TxRequest) CurrentStatus() TxRequestStatus { + if len(r.StatusLog) == 0 { + return TxDraft + } + return r.StatusLog[len(r.StatusLog)-1].Status +} + +// OrgAttachment stores file metadata; the file lives on disk. +type OrgAttachment struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + RequestID string `bson:"request_id" json:"request_id"` + UploadedBy string `bson:"uploaded_by" json:"uploaded_by"` + UploadedAt time.Time `bson:"uploaded_at" json:"uploaded_at"` + Filename string `bson:"filename" json:"filename"` + MimeType string `bson:"mime_type" json:"mime_type"` + SizeBytes int64 `bson:"size_bytes" json:"size_bytes"` + // Path on disk: /data/org-files/{org_id}/{request_id}/{id} + StoragePath string `bson:"storage_path" json:"storage_path"` +} + +// OrgLedgerEntry is created from an approved TxRequest. +// bank_ref is set when the entry is matched to a bank CSV row (reconciliation). +type OrgLedgerEntry struct { + ID string `bson:"_id" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` + FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"` + EventID string `bson:"event_id" json:"event_id"` + BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"` + TeamID string `bson:"team_id" json:"team_id"` + RequestID string `bson:"request_id,omitempty" json:"request_id,omitempty"` + AmountCents int64 `bson:"amount_cents" json:"amount_cents"` + Description string `bson:"description" json:"description"` + Date time.Time `bson:"date" json:"date"` + BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"` + Reconciled bool `bson:"reconciled" json:"reconciled"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// ── Page data structs ───────────────────────────────────────────────────────── + +type OrgListData struct { + UserID string + Email string + Title string + Route string + Orgs []OrgWithRole +} + +type OrgWithRole struct { + Org Org + Role OrgRole +} + +type OrgHomeData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + MyTeamIDs []string + FiscalYears []FiscalYear + ActiveYear *FiscalYear + Teams []OrgTeam + Members []OrgMember +} + +type OrgTeamsData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + Teams []OrgTeam + Members []OrgMember // for showing team membership counts +} + +type OrgMembersData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + Members []OrgMember + Teams []OrgTeam + Invites []OrgInvite +} + +type OrgInviteData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + Teams []OrgTeam + Error string + Link string // generated invite link shown after creation +} diff --git a/apps/finance/services/api/main/store_org.go b/apps/finance/services/api/main/store_org.go new file mode 100644 index 0000000..207a035 --- /dev/null +++ b/apps/finance/services/api/main/store_org.go @@ -0,0 +1,529 @@ +package main + +import ( + "context" + "fmt" + "time" + + "homelab/pkg/mongo" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + mgmongo "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ── Collection helpers ──────────────────────────────────────────────────────── + +func (s *Store) orgs() *mgmongo.Collection { + return s.db.Collection("org_organizations") +} + +func (s *Store) orgTeams() *mgmongo.Collection { + return s.db.Collection("org_teams") +} + +func (s *Store) orgMembers() *mgmongo.Collection { + return s.db.Collection("org_members") +} + +func (s *Store) orgInvites() *mgmongo.Collection { + return s.db.Collection("org_invites") +} + +func (s *Store) fiscalYears() *mgmongo.Collection { + return s.db.Collection("org_fiscal_years") +} + +func (s *Store) orgEvents() *mgmongo.Collection { + return s.db.Collection("org_events") +} + +func (s *Store) budgetLines() *mgmongo.Collection { + return s.db.Collection("org_budget_lines") +} + +func (s *Store) eventComments() *mgmongo.Collection { + return s.db.Collection("org_event_comments") +} + +func (s *Store) txRequests() *mgmongo.Collection { + return s.db.Collection("org_tx_requests") +} + +func (s *Store) orgLedger() *mgmongo.Collection { + return s.db.Collection("org_ledger") +} + +func (s *Store) orgAttachments() *mgmongo.Collection { + return s.db.Collection("org_attachments") +} + +// ── Organizations ───────────────────────────────────────────────────────────── + +func (s *Store) getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getOrgsForUser") + defer span.End() + + cur, err := s.orgMembers().Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, fmt.Errorf("find memberships: %w", err) + } + defer cur.Close(ctx) + + var members []OrgMember + if err := cur.All(ctx, &members); err != nil { + return nil, fmt.Errorf("decode memberships: %w", err) + } + + if len(members) == 0 { + return nil, nil + } + + orgIDs := make([]string, len(members)) + roleByOrg := make(map[string]OrgRole, len(members)) + for i, m := range members { + orgIDs[i] = m.OrgID + roleByOrg[m.OrgID] = m.Role + } + + cur2, err := s.orgs().Find(ctx, bson.M{"_id": bson.M{"$in": orgIDs}}) + if err != nil { + return nil, fmt.Errorf("find orgs: %w", err) + } + defer cur2.Close(ctx) + + var orgs []Org + if err := cur2.All(ctx, &orgs); err != nil { + return nil, fmt.Errorf("decode orgs: %w", err) + } + + result := make([]OrgWithRole, len(orgs)) + for i, o := range orgs { + result[i] = OrgWithRole{Org: o, Role: roleByOrg[o.ID]} + } + return result, nil +} + +func (s *Store) getOrg(ctx context.Context, orgID string) (*Org, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getOrg") + defer span.End() + var o Org + if err := s.orgs().FindOne(ctx, bson.M{"_id": orgID}).Decode(&o); err != nil { + return nil, fmt.Errorf("find org: %w", err) + } + return &o, nil +} + +func (s *Store) getOrgBySlug(ctx context.Context, slug string) (*Org, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getOrgBySlug") + defer span.End() + var o Org + if err := s.orgs().FindOne(ctx, bson.M{"slug": slug}).Decode(&o); err != nil { + return nil, fmt.Errorf("find org by slug: %w", err) + } + return &o, nil +} + +func (s *Store) createOrg(ctx context.Context, o *Org) error { + ctx, span := mongo.StartSpan(ctx, "Store.createOrg") + defer span.End() + _, err := s.orgs().InsertOne(ctx, o) + return err +} + +func (s *Store) slugExists(ctx context.Context, slug string) (bool, error) { + ctx, span := mongo.StartSpan(ctx, "Store.slugExists") + defer span.End() + n, err := s.orgs().CountDocuments(ctx, bson.M{"slug": slug}) + return n > 0, err +} + +// ── Teams ───────────────────────────────────────────────────────────────────── + +func (s *Store) getTeams(ctx context.Context, orgID string) ([]OrgTeam, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTeams") + defer span.End() + opts := options.Find().SetSort(bson.M{"name": 1}) + cur, err := s.orgTeams().Find(ctx, bson.M{"org_id": orgID}, opts) + if err != nil { + return nil, fmt.Errorf("find teams: %w", err) + } + defer cur.Close(ctx) + var teams []OrgTeam + if err := cur.All(ctx, &teams); err != nil { + return nil, fmt.Errorf("decode teams: %w", err) + } + return teams, nil +} + +func (s *Store) getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTeam") + defer span.End() + var t OrgTeam + if err := s.orgTeams().FindOne(ctx, bson.M{"_id": teamID, "org_id": orgID}).Decode(&t); err != nil { + return nil, fmt.Errorf("find team: %w", err) + } + return &t, nil +} + +func (s *Store) createTeam(ctx context.Context, t *OrgTeam) error { + ctx, span := mongo.StartSpan(ctx, "Store.createTeam") + defer span.End() + _, err := s.orgTeams().InsertOne(ctx, t) + return err +} + +func (s *Store) deleteTeam(ctx context.Context, teamID, orgID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.deleteTeam") + defer span.End() + _, err := s.orgTeams().DeleteOne(ctx, bson.M{"_id": teamID, "org_id": orgID}) + return err +} + +// ── Members ─────────────────────────────────────────────────────────────────── + +func (s *Store) getMembers(ctx context.Context, orgID string) ([]OrgMember, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getMembers") + defer span.End() + opts := options.Find().SetSort(bson.M{"email": 1}) + cur, err := s.orgMembers().Find(ctx, bson.M{"org_id": orgID}, opts) + if err != nil { + return nil, fmt.Errorf("find members: %w", err) + } + defer cur.Close(ctx) + var members []OrgMember + if err := cur.All(ctx, &members); err != nil { + return nil, fmt.Errorf("decode members: %w", err) + } + return members, nil +} + +func (s *Store) getMember(ctx context.Context, orgID, userID string) (*OrgMember, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getMember") + defer span.End() + var m OrgMember + if err := s.orgMembers().FindOne(ctx, bson.M{"org_id": orgID, "user_id": userID}).Decode(&m); err != nil { + return nil, fmt.Errorf("find member: %w", err) + } + return &m, nil +} + +func (s *Store) createMember(ctx context.Context, m *OrgMember) error { + ctx, span := mongo.StartSpan(ctx, "Store.createMember") + defer span.End() + _, err := s.orgMembers().InsertOne(ctx, m) + return err +} + +func (s *Store) updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateMemberRole") + defer span.End() + _, err := s.orgMembers().UpdateOne(ctx, + bson.M{"_id": memberID, "org_id": orgID}, + bson.M{"$set": bson.M{"role": role}}, + ) + return err +} + +func (s *Store) removeMember(ctx context.Context, memberID, orgID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.removeMember") + defer span.End() + _, err := s.orgMembers().DeleteOne(ctx, bson.M{"_id": memberID, "org_id": orgID}) + return err +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (s *Store) getInvites(ctx context.Context, orgID string) ([]OrgInvite, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getInvites") + defer span.End() + // only pending (not yet used, not expired) + cur, err := s.orgInvites().Find(ctx, bson.M{ + "org_id": orgID, + "expires_at": bson.M{"$gt": time.Now()}, + "used_at": bson.M{"$exists": false}, + }) + if err != nil { + return nil, fmt.Errorf("find invites: %w", err) + } + defer cur.Close(ctx) + var invites []OrgInvite + if err := cur.All(ctx, &invites); err != nil { + return nil, fmt.Errorf("decode invites: %w", err) + } + return invites, nil +} + +func (s *Store) getInviteByToken(ctx context.Context, token string) (*OrgInvite, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getInviteByToken") + defer span.End() + var inv OrgInvite + err := s.orgInvites().FindOne(ctx, bson.M{ + "token": token, + "expires_at": bson.M{"$gt": time.Now()}, + "used_at": bson.M{"$exists": false}, + }).Decode(&inv) + if err != nil { + return nil, fmt.Errorf("find invite: %w", err) + } + return &inv, nil +} + +func (s *Store) createInvite(ctx context.Context, inv *OrgInvite) error { + ctx, span := mongo.StartSpan(ctx, "Store.createInvite") + defer span.End() + _, err := s.orgInvites().InsertOne(ctx, inv) + return err +} + +func (s *Store) consumeInvite(ctx context.Context, inviteID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.consumeInvite") + defer span.End() + _, err := s.orgInvites().UpdateOne(ctx, + bson.M{"_id": inviteID}, + bson.M{"$set": bson.M{"used_at": time.Now()}}, + ) + return err +} + +func (s *Store) revokeInvite(ctx context.Context, inviteID, orgID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.revokeInvite") + defer span.End() + // set expiry to past + _, err := s.orgInvites().UpdateOne(ctx, + bson.M{"_id": inviteID, "org_id": orgID}, + bson.M{"$set": bson.M{"expires_at": time.Now().Add(-time.Second)}}, + ) + return err +} + +// ── Fiscal Years ────────────────────────────────────────────────────────────── + +func (s *Store) getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYears") + defer span.End() + opts := options.Find().SetSort(bson.M{"start_date": -1}) + cur, err := s.fiscalYears().Find(ctx, bson.M{"org_id": orgID}, opts) + if err != nil { + return nil, fmt.Errorf("find fiscal years: %w", err) + } + defer cur.Close(ctx) + var years []FiscalYear + if err := cur.All(ctx, &years); err != nil { + return nil, fmt.Errorf("decode fiscal years: %w", err) + } + return years, nil +} + +func (s *Store) getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYear") + defer span.End() + var y FiscalYear + if err := s.fiscalYears().FindOne(ctx, bson.M{"_id": yearID, "org_id": orgID}).Decode(&y); err != nil { + return nil, fmt.Errorf("find fiscal year: %w", err) + } + return &y, nil +} + +func (s *Store) getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getActiveFiscalYear") + defer span.End() + var y FiscalYear + err := s.fiscalYears().FindOne(ctx, bson.M{"org_id": orgID, "status": FiscalYearActive}).Decode(&y) + if err != nil { + return nil, err + } + return &y, nil +} + +func (s *Store) createFiscalYear(ctx context.Context, y *FiscalYear) error { + ctx, span := mongo.StartSpan(ctx, "Store.createFiscalYear") + defer span.End() + _, err := s.fiscalYears().InsertOne(ctx, y) + return err +} + +func (s *Store) updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateFiscalYearStatus") + defer span.End() + set := bson.M{"status": status} + for k, v := range extraSet { + set[k] = v + } + _, err := s.fiscalYears().UpdateOne(ctx, + bson.M{"_id": yearID, "org_id": orgID}, + bson.M{"$set": set}, + ) + return err +} + +// ── Events ──────────────────────────────────────────────────────────────────── + +func (s *Store) getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getEvents") + defer span.End() + q := bson.M{"org_id": orgID} + if fiscalYearID != "" { + q["fiscal_year_id"] = fiscalYearID + } + opts := options.Find().SetSort(bson.M{"date_start": 1}) + cur, err := s.orgEvents().Find(ctx, q, opts) + if err != nil { + return nil, fmt.Errorf("find events: %w", err) + } + defer cur.Close(ctx) + var events []OrgEvent + if err := cur.All(ctx, &events); err != nil { + return nil, fmt.Errorf("decode events: %w", err) + } + return events, nil +} + +func (s *Store) getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getEvent") + defer span.End() + var e OrgEvent + if err := s.orgEvents().FindOne(ctx, bson.M{"_id": eventID, "org_id": orgID}).Decode(&e); err != nil { + return nil, fmt.Errorf("find event: %w", err) + } + return &e, nil +} + +func (s *Store) createEvent(ctx context.Context, e *OrgEvent) error { + ctx, span := mongo.StartSpan(ctx, "Store.createEvent") + defer span.End() + _, err := s.orgEvents().InsertOne(ctx, e) + return err +} + +func (s *Store) updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateEvent") + defer span.End() + _, err := s.orgEvents().UpdateOne(ctx, + bson.M{"_id": eventID, "org_id": orgID}, + bson.M{"$set": update}, + ) + return err +} + +func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent") + defer span.End() + _, err := s.orgEvents().DeleteOne(ctx, bson.M{"_id": eventID, "org_id": orgID}) + return err +} + +// ── Budget Lines ────────────────────────────────────────────────────────────── + +func (s *Store) getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getBudgetLines") + defer span.End() + cur, err := s.budgetLines().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID}) + if err != nil { + return nil, fmt.Errorf("find budget lines: %w", err) + } + defer cur.Close(ctx) + var lines []BudgetLine + if err := cur.All(ctx, &lines); err != nil { + return nil, fmt.Errorf("decode budget lines: %w", err) + } + return lines, nil +} + +func (s *Store) createBudgetLine(ctx context.Context, l *BudgetLine) error { + ctx, span := mongo.StartSpan(ctx, "Store.createBudgetLine") + defer span.End() + _, err := s.budgetLines().InsertOne(ctx, l) + return err +} + +func (s *Store) deleteBudgetLine(ctx context.Context, lineID, orgID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.deleteBudgetLine") + defer span.End() + _, err := s.budgetLines().DeleteOne(ctx, bson.M{"_id": lineID, "org_id": orgID}) + return err +} + +// ── Event Comments ──────────────────────────────────────────────────────────── + +func (s *Store) getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getEventComments") + defer span.End() + opts := options.Find().SetSort(bson.M{"created_at": 1}) + cur, err := s.eventComments().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID}, opts) + if err != nil { + return nil, fmt.Errorf("find event comments: %w", err) + } + defer cur.Close(ctx) + var comments []EventComment + if err := cur.All(ctx, &comments); err != nil { + return nil, fmt.Errorf("decode event comments: %w", err) + } + return comments, nil +} + +func (s *Store) createEventComment(ctx context.Context, c *EventComment) error { + ctx, span := mongo.StartSpan(ctx, "Store.createEventComment") + defer span.End() + _, err := s.eventComments().InsertOne(ctx, c) + return err +} + +// ── Transaction Requests ────────────────────────────────────────────────────── + +func (s *Store) getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTxRequests") + defer span.End() + q := bson.M{"org_id": orgID} + for k, v := range filter { + q[k] = v + } + opts := options.Find().SetSort(bson.M{"created_at": -1}) + cur, err := s.txRequests().Find(ctx, q, opts) + if err != nil { + return nil, fmt.Errorf("find tx requests: %w", err) + } + defer cur.Close(ctx) + var reqs []TxRequest + if err := cur.All(ctx, &reqs); err != nil { + return nil, fmt.Errorf("decode tx requests: %w", err) + } + return reqs, nil +} + +func (s *Store) getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTxRequest") + defer span.End() + var r TxRequest + if err := s.txRequests().FindOne(ctx, bson.M{"_id": reqID, "org_id": orgID}).Decode(&r); err != nil { + return nil, fmt.Errorf("find tx request: %w", err) + } + return &r, nil +} + +func (s *Store) createTxRequest(ctx context.Context, r *TxRequest) error { + ctx, span := mongo.StartSpan(ctx, "Store.createTxRequest") + defer span.End() + _, err := s.txRequests().InsertOne(ctx, r) + return err +} + +func (s *Store) appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error { + ctx, span := mongo.StartSpan(ctx, "Store.appendStatusLog") + defer span.End() + _, err := s.txRequests().UpdateOne(ctx, + bson.M{"_id": reqID, "org_id": orgID}, + bson.M{"$push": bson.M{"status_log": entry}}, + ) + return err +} + +func (s *Store) updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateTxRequest") + defer span.End() + _, err := s.txRequests().UpdateOne(ctx, + bson.M{"_id": reqID, "org_id": orgID}, + bson.M{"$set": update}, + ) + return err +} diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index 5f5e56a..5931399 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -504,6 +504,7 @@ People + Organisations {{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}