From 0f58a51c6d82278321e02638d9cdad61b70885c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= <95761178+GoncaloRodri@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:58:47 +0100 Subject: [PATCH] =?UTF-8?q?feat(finance):=20org=20Phases=202-5=20=E2=80=94?= =?UTF-8?q?=20events,=20requests,=20ledger,=20analysis,=20report=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Event planning: - Create/edit/delete/submit events per fiscal year - Budget lines (income + expense) with planned amounts - Admin review flow: approve / reject / comment - Post-mortem feedback comments on closed years Phase 3 — Transaction requests: - 5 types: reimbursement, purchase_order, cash_advance, income, budget_transfer - Full status machine with append-only StatusLog audit trail - PO delivery sub-form (actual vendor, amount, invoice note) - Cash advance settlement sub-form (spent + returned) - Ledger view per fiscal year with income/expense summary - Bank CSV import: parse → preview → confirm → ledger entries Phase 4 — Plan vs actual analysis: - Variance table by event (planned vs actual income/expense) - Variance table by team Phase 5 — Year-end report: - Executive summary with net variance - Per-event section: budget lines + team feedback comments - Feedback form available on closed fiscal years Supporting changes: - New store methods: getLedgerEntries, createLedgerEntry, updateLedgerEntry, getAttachments, createAttachment - New template funcs: dateInput, statusColor, varColor - parseEuroAmount + parseBankCSV helpers in handler_org.go Co-authored-by: Gonçalo Rodrigues Co-authored-by: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 44 +- apps/finance/services/api/main/handler_org.go | 1363 +++++++++++++++++ .../finance/services/api/main/handler_test.go | 9 + apps/finance/services/api/main/models_org.go | 170 ++ apps/finance/services/api/main/store_org.go | 61 + .../api/main/templates/org_analysis.html | 123 ++ .../api/main/templates/org_bank_import.html | 89 ++ .../api/main/templates/org_event_detail.html | 332 ++++ .../api/main/templates/org_events.html | 85 + .../api/main/templates/org_ledger.html | 89 ++ .../api/main/templates/org_report.html | 167 ++ .../main/templates/org_request_detail.html | 311 ++++ .../api/main/templates/org_requests.html | 75 + 13 files changed, 2917 insertions(+), 1 deletion(-) create mode 100644 apps/finance/services/api/main/templates/org_analysis.html create mode 100644 apps/finance/services/api/main/templates/org_bank_import.html create mode 100644 apps/finance/services/api/main/templates/org_event_detail.html create mode 100644 apps/finance/services/api/main/templates/org_events.html create mode 100644 apps/finance/services/api/main/templates/org_ledger.html create mode 100644 apps/finance/services/api/main/templates/org_report.html create mode 100644 apps/finance/services/api/main/templates/org_request_detail.html create mode 100644 apps/finance/services/api/main/templates/org_requests.html diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index ac4590e..8d64a9d 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -98,6 +98,35 @@ func parseTmpl(files ...string) *template.Template { "isOver": func(spent, budget int64) bool { return budget > 0 && spent > budget }, + "dateInput": func(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02") + }, + "statusColor": func(s string) string { + switch s { + case "approved", "paid", "delivered", "settled", "received", "reconciled", "done": + return "rgba(74,222,128,0.12); color:var(--green)" + case "submitted", "under_review", "review", "ordered", "disbursed", "pending_payment": + return "rgba(99,179,237,0.12); color:#63b3ed" + case "rejected", "cancelled", "disputed": + return "rgba(239,68,68,0.12); color:var(--red)" + case "info_requested", "settlement_due", "partial_settlement": + return "rgba(251,191,36,0.1); color:#fbbf24" + default: + return "var(--bg3); color:var(--text3)" + } + }, + "varColor": func(planned, actual int64) string { + if planned == 0 { + return "var(--text2)" + } + if actual > planned { + return "var(--red)" + } + return "var(--green)" + }, "jsonVals": func(m map[string]int64) template.JS { var vals []string for _, v := range m { @@ -135,7 +164,15 @@ var ( 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") + orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html") + orgEventsTmpl = parseTmpl("templates/base.html", "templates/org_events.html") + orgEventDetailTmpl = parseTmpl("templates/base.html", "templates/org_event_detail.html") + orgRequestsTmpl = parseTmpl("templates/base.html", "templates/org_requests.html") + orgRequestDetailTmpl = parseTmpl("templates/base.html", "templates/org_request_detail.html") + orgLedgerTmpl = parseTmpl("templates/base.html", "templates/org_ledger.html") + orgBankImportTmpl = parseTmpl("templates/base.html", "templates/org_bank_import.html") + orgAnalysisTmpl = parseTmpl("templates/base.html", "templates/org_analysis.html") + orgReportTmpl = parseTmpl("templates/base.html", "templates/org_report.html") ) type authInfo struct { @@ -244,6 +281,11 @@ type storeIface interface { 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 + getLedgerEntries(ctx context.Context, orgID, fiscalYearID string, extra bson.M) ([]OrgLedgerEntry, error) + createLedgerEntry(ctx context.Context, e *OrgLedgerEntry) error + updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error + getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error) + createAttachment(ctx context.Context, a *OrgAttachment) error } type Handler struct { diff --git a/apps/finance/services/api/main/handler_org.go b/apps/finance/services/api/main/handler_org.go index 09d34f8..3a29ab3 100644 --- a/apps/finance/services/api/main/handler_org.go +++ b/apps/finance/services/api/main/handler_org.go @@ -2,8 +2,10 @@ package main import ( "crypto/rand" + "encoding/csv" "encoding/hex" "fmt" + "io" "log/slog" "net/http" "regexp" @@ -528,6 +530,1225 @@ func (h *Handler) OrgFiscalYearActivate(w http.ResponseWriter, r *http.Request) })(w, r) } +// ── Events ──────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgEventList(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil { + http.Error(w, "fiscal year not found", http.StatusNotFound) + return + } + events, _ := h.store.getEvents(ctx, org.ID, yearID) + teams, _ := h.store.getTeams(ctx, org.ID) + + teamMap := make(map[string]OrgTeam, len(teams)) + for _, t := range teams { + teamMap[t.ID] = t + } + + summaries := make([]OrgEventSummary, 0, len(events)) + for _, ev := range events { + lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID) + var inc, exp int64 + for _, l := range lines { + if l.Type == BudgetIncome { + inc += l.PlannedCents + } else { + exp += l.PlannedCents + } + } + evTeams := make([]OrgTeam, 0) + for _, tid := range ev.TeamIDs { + if t, ok := teamMap[tid]; ok { + evTeams = append(evTeams, t) + } + } + summaries = append(summaries, OrgEventSummary{ + Event: ev, + TotalIncome: inc, + TotalExpense: exp, + Teams: evTeams, + }) + } + + render(w, orgEventsTmpl, &OrgEventsData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Events", + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: *year, + Events: summaries, + Teams: teams, + }) + })(w, r) +} + +func (h *Handler) OrgEventNew(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil { + http.Error(w, "fiscal year not found", http.StatusNotFound) + return + } + teams, _ := h.store.getTeams(ctx, org.ID) + + if r.Method == http.MethodGet { + render(w, orgEventDetailTmpl, &OrgEventDetailData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: "New Event", + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: *year, + Teams: teams, + }) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + startStr := r.FormValue("date_start") + endStr := r.FormValue("date_end") + start, errS := time.Parse("2006-01-02", startStr) + end, errE := time.Parse("2006-01-02", endStr) + if errS != nil || errE != nil || end.Before(start) { + http.Error(w, "invalid dates", http.StatusBadRequest) + return + } + teamIDs := r.Form["team_ids"] + + ev := &OrgEvent{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + FiscalYearID: yearID, + TeamIDs: teamIDs, + Name: name, + Description: r.FormValue("description"), + Goals: r.FormValue("goals"), + DateStart: start, + DateEnd: end, + Status: EventDraft, + CreatedBy: r.Header.Get("X-Auth-User-Id"), + CreatedAt: time.Now(), + } + if err := h.store.createEvent(ctx, ev); err != nil { + slog.Error("create event", "err", err) + http.Error(w, "could not create event", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+ev.ID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgEventDetail(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil { + http.Error(w, "fiscal year not found", http.StatusNotFound) + return + } + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + lines, _ := h.store.getBudgetLines(ctx, eventID, org.ID) + comments, _ := h.store.getEventComments(ctx, eventID, org.ID) + teams, _ := h.store.getTeams(ctx, org.ID) + + teamMap := make(map[string]OrgTeam, len(teams)) + for _, t := range teams { + teamMap[t.ID] = t + } + evTeams := make([]OrgTeam, 0) + for _, tid := range ev.TeamIDs { + if t, ok := teamMap[tid]; ok { + evTeams = append(evTeams, t) + } + } + + var inc, exp int64 + for _, l := range lines { + if l.Type == BudgetIncome { + inc += l.PlannedCents + } else { + exp += l.PlannedCents + } + } + + render(w, orgEventDetailTmpl, &OrgEventDetailData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: ev.Name, + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: *year, + Event: *ev, + BudgetLines: lines, + Comments: comments, + Teams: teams, + EventTeams: evTeams, + TotalIncome: inc, + TotalExpense: exp, + }) + })(w, r) +} + +func (h *Handler) OrgEventEdit(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status != EventDraft && ev.Status != EventReview { + http.Error(w, "cannot edit event in current status", http.StatusConflict) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + startStr := r.FormValue("date_start") + endStr := r.FormValue("date_end") + start, errS := time.Parse("2006-01-02", startStr) + end, errE := time.Parse("2006-01-02", endStr) + if errS != nil || errE != nil || end.Before(start) { + http.Error(w, "invalid dates", http.StatusBadRequest) + return + } + update := bson.M{ + "name": name, + "description": r.FormValue("description"), + "goals": r.FormValue("goals"), + "date_start": start, + "date_end": end, + "team_ids": r.Form["team_ids"], + } + if err := h.store.updateEvent(ctx, eventID, org.ID, update); err != nil { + slog.Error("update event", "err", err) + http.Error(w, "could not update event", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgEventDelete(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status != EventDraft { + http.Error(w, "only draft events can be deleted", http.StatusConflict) + return + } + _ = h.store.deleteEvent(ctx, eventID, org.ID) + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events", http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgEventSubmit(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status != EventDraft { + http.Error(w, "only draft events can be submitted", http.StatusConflict) + return + } + if err := h.store.updateEvent(ctx, eventID, org.ID, bson.M{"status": EventReview}); err != nil { + slog.Error("submit event", "err", err) + http.Error(w, "could not submit event", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgEventReview(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + action := r.FormValue("action") // "approve", "reject", "comment" + body := strings.TrimSpace(r.FormValue("comment")) + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status != EventReview { + http.Error(w, "event is not under review", http.StatusConflict) + return + } + + if body != "" { + c := &EventComment{ + ID: bson.NewObjectID().Hex(), + EventID: eventID, + OrgID: org.ID, + UserID: me.ID, + UserEmail: me.Email, + Kind: CommentReview, + Body: body, + CreatedAt: time.Now(), + } + _ = h.store.createEventComment(ctx, c) + } + + var newStatus EventStatus + switch action { + case "approve": + newStatus = EventApproved + case "reject": + newStatus = EventRejected + default: + // comment only — stay in review + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + return + } + if err := h.store.updateEvent(ctx, eventID, org.ID, bson.M{"status": newStatus}); err != nil { + slog.Error("review event", "err", err) + http.Error(w, "could not update event status", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +// OrgEventFeedback adds a post-mortem feedback comment (kind=feedback) after year closes. +func (h *Handler) OrgEventFeedback(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil || year.Status != FiscalYearClosed { + http.Error(w, "feedback only allowed on closed fiscal years", http.StatusConflict) + return + } + body := strings.TrimSpace(r.FormValue("comment")) + if body == "" { + http.Error(w, "comment required", http.StatusBadRequest) + return + } + c := &EventComment{ + ID: bson.NewObjectID().Hex(), + EventID: eventID, + OrgID: org.ID, + UserID: me.ID, + UserEmail: me.Email, + Kind: CommentFeedback, + Body: body, + CreatedAt: time.Now(), + } + _ = h.store.createEventComment(ctx, c) + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +// ── Budget lines ────────────────────────────────────────────────────────────── + +func (h *Handler) OrgBudgetLineCreate(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status == EventApproved { + http.Error(w, "cannot modify approved event budget", http.StatusConflict) + return + } + + amtStr := r.FormValue("amount") + amtFloat, err := parseEuroAmount(amtStr) + if err != nil { + http.Error(w, "invalid amount", http.StatusBadRequest) + return + } + lineType := BudgetLineType(r.FormValue("type")) + if lineType != BudgetIncome { + lineType = BudgetExpense + } + category := strings.TrimSpace(r.FormValue("category")) + if category == "" { + category = "General" + } + + line := &BudgetLine{ + ID: bson.NewObjectID().Hex(), + EventID: eventID, + OrgID: org.ID, + Category: category, + Type: lineType, + PlannedCents: amtFloat, + Description: strings.TrimSpace(r.FormValue("description")), + CreatedAt: time.Now(), + } + if err := h.store.createBudgetLine(ctx, line); err != nil { + slog.Error("create budget line", "err", err) + http.Error(w, "could not create budget line", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgBudgetLineDelete(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + eventID := r.PathValue("event_id") + lineID := r.PathValue("line_id") + + ev, err := h.store.getEvent(ctx, eventID, org.ID) + if err != nil { + http.Error(w, "event not found", http.StatusNotFound) + return + } + if ev.Status == EventApproved { + http.Error(w, "cannot modify approved event budget", http.StatusConflict) + return + } + _ = h.store.deleteBudgetLine(ctx, lineID, org.ID) + http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther) + })(w, r) +} + +// ── Transaction Requests ────────────────────────────────────────────────────── + +func (h *Handler) OrgRequestList(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + statusFilter := r.URL.Query().Get("status") + + filter := bson.M{"org_id": org.ID} + if statusFilter != "" { + filter["status_log"] = bson.M{"$elemMatch": bson.M{"status": statusFilter}} + } + // Guest team members only see their team's requests + if len(me.TeamIDs) > 0 { + teams, _ := h.store.getTeams(ctx, org.ID) + guestOnly := true + for _, t := range teams { + for _, tid := range me.TeamIDs { + if t.ID == tid && t.Type == TeamTypeInternal { + guestOnly = false + } + } + } + if guestOnly { + filter["team_id"] = bson.M{"$in": me.TeamIDs} + } + } + + requests, _ := h.store.getTxRequests(ctx, org.ID, filter) + events, _ := h.store.getEvents(ctx, org.ID, "") + teams, _ := h.store.getTeams(ctx, org.ID) + + render(w, orgRequestsTmpl, &OrgRequestsData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Requests", + Route: "orgs", + Org: *org, + MyRole: me.Role, + Requests: requests, + Events: events, + Teams: teams, + StatusFilter: statusFilter, + }) + })(w, r) +} + +func (h *Handler) OrgRequestNew(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + + // only in active fiscal year + activeYear, err := h.store.getActiveFiscalYear(ctx, org.ID) + if err != nil || activeYear == nil { + http.Error(w, "no active fiscal year — requests can only be submitted during an active year", http.StatusConflict) + return + } + + events, _ := h.store.getEvents(ctx, org.ID, activeYear.ID) + teams, _ := h.store.getTeams(ctx, org.ID) + + if r.Method == http.MethodGet { + render(w, orgRequestDetailTmpl, &OrgRequestDetailData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: "New Request", + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: activeYear, + // Use Request.ID="" to signal "new form" in template + }) + _ = events + _ = teams + return + } + + txType := TxRequestType(r.FormValue("type")) + switch txType { + case TxReimbursement, TxPurchaseOrder, TxCashAdvance, TxIncome, TxBudgetTransfer: + default: + http.Error(w, "invalid request type", http.StatusBadRequest) + return + } + + amtCents, err := parseEuroAmount(r.FormValue("amount")) + if err != nil { + http.Error(w, "invalid amount", http.StatusBadRequest) + return + } + + req := &TxRequest{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + FiscalYearID: activeYear.ID, + EventID: r.FormValue("event_id"), + BudgetLineID: r.FormValue("budget_line_id"), + TeamID: r.FormValue("team_id"), + SubmittedBy: me.ID, + SubmitterEmail: me.Email, + Type: txType, + Description: strings.TrimSpace(r.FormValue("description")), + AmountCents: amtCents, + Vendor: strings.TrimSpace(r.FormValue("vendor")), + PayerName: strings.TrimSpace(r.FormValue("payer_name")), + PaymentMethod: r.FormValue("payment_method"), + AttachmentIDs: []string{}, + StatusLog: []StatusLogEntry{{ + Status: TxDraft, + ChangedBy: me.ID, + ChangedAt: time.Now(), + }}, + CreatedAt: time.Now(), + } + if dueDateStr := r.FormValue("due_date"); dueDateStr != "" { + if d, err := time.Parse("2006-01-02", dueDateStr); err == nil { + req.DueDate = d + } + } + if txType == TxBudgetTransfer { + req.FromBudgetLineID = r.FormValue("from_budget_line_id") + req.ToBudgetLineID = r.FormValue("to_budget_line_id") + } + + if err := h.store.createTxRequest(ctx, req); err != nil { + slog.Error("create tx request", "err", err) + http.Error(w, "could not create request", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+req.ID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgRequestDetail(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + reqID := r.PathValue("req_id") + + req, err := h.store.getTxRequest(ctx, reqID, org.ID) + if err != nil { + http.Error(w, "request not found", http.StatusNotFound) + return + } + + var ev *OrgEvent + var bl *BudgetLine + var team *OrgTeam + var fy *FiscalYear + + if req.EventID != "" { + ev, _ = h.store.getEvent(ctx, req.EventID, org.ID) + } + if req.FiscalYearID != "" { + fy, _ = h.store.getFiscalYear(ctx, req.FiscalYearID, org.ID) + } + if req.BudgetLineID != "" { + lines, _ := h.store.getBudgetLines(ctx, req.EventID, org.ID) + for i := range lines { + if lines[i].ID == req.BudgetLineID { + bl = &lines[i] + break + } + } + } + if req.TeamID != "" { + t, _ := h.store.getTeam(ctx, req.TeamID, org.ID) + team = t + } + attachments, _ := h.store.getAttachments(ctx, reqID, org.ID) + + render(w, orgRequestDetailTmpl, &OrgRequestDetailData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: string(req.Type) + " Request", + Route: "orgs", + Org: *org, + MyRole: me.Role, + Request: *req, + Event: ev, + BudgetLine: bl, + Team: team, + FiscalYear: fy, + Attachments: attachments, + }) + })(w, r) +} + +func (h *Handler) OrgRequestAction(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + reqID := r.PathValue("req_id") + action := r.FormValue("action") + comment := strings.TrimSpace(r.FormValue("comment")) + + req, err := h.store.getTxRequest(ctx, reqID, org.ID) + if err != nil { + http.Error(w, "request not found", http.StatusNotFound) + return + } + current := req.CurrentStatus() + + canManage := me.Role == OrgRoleAdmin || me.Role == OrgRoleFinance + + var newStatus TxRequestStatus + switch action { + case "submit": + if current != TxDraft { + http.Error(w, "only draft requests can be submitted", http.StatusConflict) + return + } + newStatus = TxSubmitted + case "request_info": + if !canManage { + http.Error(w, "insufficient permissions", http.StatusForbidden) + return + } + if comment == "" { + http.Error(w, "comment required when requesting information", http.StatusBadRequest) + return + } + newStatus = TxInfoRequested + case "review": + if !canManage { + http.Error(w, "insufficient permissions", http.StatusForbidden) + return + } + newStatus = TxUnderReview + case "approve": + if !canManage { + http.Error(w, "insufficient permissions", http.StatusForbidden) + return + } + newStatus = TxApproved + case "reject": + if !canManage { + http.Error(w, "insufficient permissions", http.StatusForbidden) + return + } + newStatus = TxRejected + case "cancel": + if current != TxDraft && current != TxSubmitted && current != TxInfoRequested { + http.Error(w, "cannot cancel in current status", http.StatusConflict) + return + } + newStatus = TxCancelled + case "mark_paid": + if !canManage || req.Type != TxReimbursement { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxPaid + case "mark_ordered": + if !canManage || req.Type != TxPurchaseOrder { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxOrdered + case "mark_delivered": + if req.Type != TxPurchaseOrder { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxDelivered + case "dispute": + if !canManage || req.Type != TxPurchaseOrder { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxDisputed + case "disburse": + if !canManage || req.Type != TxCashAdvance { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxDisbursed + case "settlement_due": + if !canManage || req.Type != TxCashAdvance { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxSettlementDue + case "mark_pending_payment": + if !canManage || req.Type != TxIncome { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxPendingPayment + case "mark_received": + if !canManage || req.Type != TxIncome { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxReceived + case "reconcile": + if !canManage { + http.Error(w, "insufficient permissions", http.StatusForbidden) + return + } + newStatus = TxReconciled + case "done": + if !canManage || req.Type != TxBudgetTransfer { + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + newStatus = TxDone + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + + entry := StatusLogEntry{ + Status: newStatus, + ChangedBy: me.ID, + ChangedAt: time.Now(), + Comment: comment, + } + if err := h.store.appendStatusLog(ctx, reqID, org.ID, entry); err != nil { + slog.Error("append status log", "err", err) + http.Error(w, "could not update request status", http.StatusInternalServerError) + return + } + + // when approved, create a ledger entry + if newStatus == TxApproved { + ledger := &OrgLedgerEntry{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + FiscalYearID: req.FiscalYearID, + EventID: req.EventID, + BudgetLineID: req.BudgetLineID, + TeamID: req.TeamID, + RequestID: req.ID, + AmountCents: req.AmountCents, + Description: req.Description, + Date: time.Now(), + CreatedAt: time.Now(), + } + if err := h.store.createLedgerEntry(ctx, ledger); err != nil { + slog.Error("create ledger entry", "err", err) + } + } + + http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgRequestDelivery(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + reqID := r.PathValue("req_id") + + req, err := h.store.getTxRequest(ctx, reqID, org.ID) + if err != nil || req.Type != TxPurchaseOrder { + http.Error(w, "purchase order not found", http.StatusNotFound) + return + } + if req.CurrentStatus() != TxOrdered { + http.Error(w, "delivery can only be recorded on ordered requests", http.StatusConflict) + return + } + amtCents, err := parseEuroAmount(r.FormValue("actual_amount")) + if err != nil { + http.Error(w, "invalid amount", http.StatusBadRequest) + return + } + delivery := &PODelivery{ + ActualAmountCents: amtCents, + ActualVendor: strings.TrimSpace(r.FormValue("actual_vendor")), + DeliveredAt: time.Now(), + StoreChanged: r.FormValue("store_changed") == "true", + ChangeNote: strings.TrimSpace(r.FormValue("change_note")), + } + update := bson.M{"delivery": delivery} + if err := h.store.updateTxRequest(ctx, reqID, org.ID, update); err != nil { + slog.Error("record delivery", "err", err) + http.Error(w, "could not record delivery", http.StatusInternalServerError) + return + } + entry := StatusLogEntry{Status: TxDelivered, ChangedBy: me.ID, ChangedAt: time.Now()} + _ = h.store.appendStatusLog(ctx, reqID, org.ID, entry) + http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther) + })(w, r) +} + +func (h *Handler) OrgRequestSettle(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + reqID := r.PathValue("req_id") + + req, err := h.store.getTxRequest(ctx, reqID, org.ID) + if err != nil || req.Type != TxCashAdvance { + http.Error(w, "cash advance not found", http.StatusNotFound) + return + } + cs := req.CurrentStatus() + if cs != TxDisbursed && cs != TxSettlementDue && cs != TxPartialSettlement { + http.Error(w, "cannot settle in current status", http.StatusConflict) + return + } + spentCents, err := parseEuroAmount(r.FormValue("amount_spent")) + if err != nil { + http.Error(w, "invalid spent amount", http.StatusBadRequest) + return + } + returnedCents, err := parseEuroAmount(r.FormValue("amount_returned")) + if err != nil { + returnedCents = 0 + } + settlement := &CashSettlement{ + AmountSpentCents: spentCents, + AmountReturnedCents: returnedCents, + SettledAt: time.Now(), + } + update := bson.M{"settlement": settlement} + if err := h.store.updateTxRequest(ctx, reqID, org.ID, update); err != nil { + slog.Error("record settlement", "err", err) + http.Error(w, "could not record settlement", http.StatusInternalServerError) + return + } + + newStatus := TxSettled + if returnedCents < (req.AmountCents - spentCents) { + newStatus = TxPartialSettlement + } + entry := StatusLogEntry{Status: newStatus, ChangedBy: me.ID, ChangedAt: time.Now()} + _ = h.store.appendStatusLog(ctx, reqID, org.ID, entry) + http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther) + })(w, r) +} + +// ── Ledger ──────────────────────────────────────────────────────────────────── + +func (h *Handler) OrgLedger(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.URL.Query().Get("year_id") + + years, _ := h.store.getFiscalYears(ctx, org.ID) + var activeYear *FiscalYear + for i := range years { + if years[i].Status == FiscalYearActive { + activeYear = &years[i] + } + } + if yearID == "" && activeYear != nil { + yearID = activeYear.ID + } + + var fy *FiscalYear + for i := range years { + if years[i].ID == yearID { + fy = &years[i] + break + } + } + + entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{}) + events, _ := h.store.getEvents(ctx, org.ID, yearID) + teams, _ := h.store.getTeams(ctx, org.ID) + + evMap := make(map[string]OrgEvent, len(events)) + for _, e := range events { + evMap[e.ID] = e + } + teamMap := make(map[string]OrgTeam, len(teams)) + for _, t := range teams { + teamMap[t.ID] = t + } + + var inc, exp int64 + for _, e := range entries { + if e.AmountCents >= 0 { + inc += e.AmountCents + } else { + exp += -e.AmountCents + } + } + + render(w, orgLedgerTmpl, &OrgLedgerData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Ledger", + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: fy, + FiscalYears: years, + Entries: entries, + Events: evMap, + Teams: teamMap, + TotalIncome: inc, + TotalExpense: exp, + }) + })(w, r) +} + +func (h *Handler) OrgBankImport(w http.ResponseWriter, r *http.Request) { + h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + + activeYear, _ := h.store.getActiveFiscalYear(ctx, org.ID) + + if r.Method == http.MethodGet { + render(w, orgBankImportTmpl, &OrgBankImportData{ + UserID: r.Header.Get("X-Auth-User-Id"), + Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Bank Import", + Route: "orgs", + Org: *org, + MyRole: me.Role, + FiscalYear: activeYear, + }) + return + } + + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "could not parse form", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("csv") + if err != nil { + http.Error(w, "csv file required", http.StatusBadRequest) + return + } + defer file.Close() + + rows, err := parseBankCSV(file) + if err != nil { + render(w, orgBankImportTmpl, &OrgBankImportData{ + UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Bank Import", Route: "orgs", + Org: *org, MyRole: me.Role, FiscalYear: activeYear, + Error: "could not parse CSV: " + err.Error(), + }) + return + } + + if r.FormValue("confirm") != "1" { + // preview mode + render(w, orgBankImportTmpl, &OrgBankImportData{ + UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Bank Import", Route: "orgs", + Org: *org, MyRole: me.Role, FiscalYear: activeYear, + Rows: rows, + }) + return + } + + // import confirmed + fy := activeYear + if fy == nil { + http.Error(w, "no active fiscal year", http.StatusConflict) + return + } + imported := 0 + for _, row := range rows { + d, err := time.Parse("2006-01-02", row.Date) + if err != nil { + d = time.Now() + } + entry := &OrgLedgerEntry{ + ID: bson.NewObjectID().Hex(), + OrgID: org.ID, + FiscalYearID: fy.ID, + AmountCents: row.AmountCents, + Description: row.Description, + BankRef: row.Reference, + Date: d, + CreatedAt: time.Now(), + } + if err := h.store.createLedgerEntry(ctx, entry); err == nil { + imported++ + } + } + render(w, orgBankImportTmpl, &OrgBankImportData{ + UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Bank Import", Route: "orgs", + Org: *org, MyRole: me.Role, FiscalYear: fy, + Imported: imported, + }) + })(w, r) +} + +// ── Plan vs actual analysis ─────────────────────────────────────────────────── + +func (h *Handler) OrgAnalysis(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil { + http.Error(w, "fiscal year not found", http.StatusNotFound) + return + } + years, _ := h.store.getFiscalYears(ctx, org.ID) + events, _ := h.store.getEvents(ctx, org.ID, yearID) + teams, _ := h.store.getTeams(ctx, org.ID) + entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{}) + + // build actual maps + actualByEvent := make(map[string]int64) + actualByTeam := make(map[string]int64) + for _, e := range entries { + actualByEvent[e.EventID] += e.AmountCents + actualByTeam[e.TeamID] += e.AmountCents + } + + teamMap := make(map[string]OrgTeam, len(teams)) + for _, t := range teams { + teamMap[t.ID] = t + } + + var totalPI, totalAI, totalPE, totalAE int64 + + eventRows := make([]AnalysisEventRow, 0, len(events)) + for _, ev := range events { + lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID) + var pi, pe int64 + for _, l := range lines { + if l.Type == BudgetIncome { + pi += l.PlannedCents + } else { + pe += l.PlannedCents + } + } + actual := actualByEvent[ev.ID] + var ai, ae int64 + if actual >= 0 { + ai = actual + } else { + ae = -actual + } + totalPI += pi + totalPE += pe + totalAI += ai + totalAE += ae + eventRows = append(eventRows, AnalysisEventRow{ + Event: ev, PlannedIncome: pi, ActualIncome: ai, + PlannedExpense: pe, ActualExpense: ae, + }) + } + + // team rows — aggregate from events by team membership + teamActual := make(map[string]struct{ pi, pe, ai, ae int64 }) + for _, ev := range events { + lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID) + var pi, pe int64 + for _, l := range lines { + if l.Type == BudgetIncome { + pi += l.PlannedCents + } else { + pe += l.PlannedCents + } + } + actual := actualByTeam[ev.ID] + var ai, ae int64 + if actual >= 0 { + ai = actual + } else { + ae = -actual + } + for _, tid := range ev.TeamIDs { + a := teamActual[tid] + a.pi += pi / int64(max(len(ev.TeamIDs), 1)) + a.pe += pe / int64(max(len(ev.TeamIDs), 1)) + a.ai += ai / int64(max(len(ev.TeamIDs), 1)) + a.ae += ae / int64(max(len(ev.TeamIDs), 1)) + teamActual[tid] = a + } + } + teamRows := make([]AnalysisTeamRow, 0, len(teams)) + for _, t := range teams { + a := teamActual[t.ID] + teamRows = append(teamRows, AnalysisTeamRow{ + Team: t, PlannedIncome: a.pi, ActualIncome: a.ai, + PlannedExpense: a.pe, ActualExpense: a.ae, + }) + } + + render(w, orgAnalysisTmpl, &OrgAnalysisData{ + UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — Analysis", Route: "orgs", + Org: *org, MyRole: me.Role, FiscalYear: *year, FiscalYears: years, + EventRows: eventRows, TeamRows: teamRows, + TotalPlannedIncome: totalPI, TotalActualIncome: totalAI, + TotalPlannedExpense: totalPE, TotalActualExpense: totalAE, + }) + })(w, r) +} + +// ── Year-end report ─────────────────────────────────────────────────────────── + +func (h *Handler) OrgReport(w http.ResponseWriter, r *http.Request) { + h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { + ctx := r.Context() + yearID := r.PathValue("year_id") + + year, err := h.store.getFiscalYear(ctx, yearID, org.ID) + if err != nil { + http.Error(w, "fiscal year not found", http.StatusNotFound) + return + } + years, _ := h.store.getFiscalYears(ctx, org.ID) + events, _ := h.store.getEvents(ctx, org.ID, yearID) + teams, _ := h.store.getTeams(ctx, org.ID) + entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{}) + + teamMap := make(map[string]OrgTeam, len(teams)) + for _, t := range teams { + teamMap[t.ID] = t + } + actualByEvent := make(map[string]int64) + for _, e := range entries { + actualByEvent[e.EventID] += e.AmountCents + } + + var totalPI, totalAI, totalPE, totalAE int64 + + eventReports := make([]EventReport, 0, len(events)) + for _, ev := range events { + lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID) + comments, _ := h.store.getEventComments(ctx, ev.ID, org.ID) + + var pi, pe int64 + for _, l := range lines { + if l.Type == BudgetIncome { + pi += l.PlannedCents + } else { + pe += l.PlannedCents + } + } + actual := actualByEvent[ev.ID] + var ai, ae int64 + if actual >= 0 { + ai = actual + } else { + ae = -actual + } + totalPI += pi + totalPE += pe + totalAI += ai + totalAE += ae + + feedbackComments := make([]EventComment, 0) + for _, c := range comments { + if c.Kind == CommentFeedback { + feedbackComments = append(feedbackComments, c) + } + } + evTeams := make([]OrgTeam, 0) + for _, tid := range ev.TeamIDs { + if t, ok := teamMap[tid]; ok { + evTeams = append(evTeams, t) + } + } + eventReports = append(eventReports, EventReport{ + Event: ev, BudgetLines: lines, Comments: feedbackComments, + PlannedIncome: pi, ActualIncome: ai, + PlannedExpense: pe, ActualExpense: ae, + Teams: evTeams, + }) + } + + render(w, orgReportTmpl, &OrgReportData{ + UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"), + Title: org.Name + " — " + year.Label + " Report", Route: "orgs", + Org: *org, MyRole: me.Role, FiscalYear: *year, FiscalYears: years, + EventReports: eventReports, + TotalPlannedIncome: totalPI, TotalActualIncome: totalAI, + TotalPlannedExpense: totalPE, TotalActualExpense: totalAE, + }) + })(w, r) +} + +// ── Fiscal year close ───────────────────────────────────────────────────────── + +func (h *Handler) OrgFiscalYearClose(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") + if err := h.store.updateFiscalYearStatus(ctx, yearID, org.ID, FiscalYearClosed, bson.M{ + "closed_at": time.Now(), + }); err != nil { + slog.Error("close fiscal year", "err", err) + http.Error(w, "could not close 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) { @@ -559,6 +1780,38 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) { // Fiscal years mux.HandleFunc("POST /orgs/{slug}/years", h.OrgFiscalYearCreate) mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/activate", h.OrgFiscalYearActivate) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/close", h.OrgFiscalYearClose) + + // Events (literal "new" before {event_id} wildcard) + mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events", h.OrgEventList) + mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events/new", h.OrgEventNew) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/new", h.OrgEventNew) + mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events/{event_id}", h.OrgEventDetail) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/edit", h.OrgEventEdit) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/delete", h.OrgEventDelete) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/submit", h.OrgEventSubmit) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/review", h.OrgEventReview) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/feedback", h.OrgEventFeedback) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget", h.OrgBudgetLineCreate) + mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget/{line_id}/delete", h.OrgBudgetLineDelete) + + // Analysis & report + mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/analysis", h.OrgAnalysis) + mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/report", h.OrgReport) + + // Transaction requests (literal "new" before {req_id} wildcard) + mux.HandleFunc("GET /orgs/{slug}/requests", h.OrgRequestList) + mux.HandleFunc("GET /orgs/{slug}/requests/new", h.OrgRequestNew) + mux.HandleFunc("POST /orgs/{slug}/requests/new", h.OrgRequestNew) + mux.HandleFunc("GET /orgs/{slug}/requests/{req_id}", h.OrgRequestDetail) + mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/action", h.OrgRequestAction) + mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/delivery", h.OrgRequestDelivery) + mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/settle", h.OrgRequestSettle) + + // Ledger (literal "import" before potential wildcards) + mux.HandleFunc("GET /orgs/{slug}/ledger", h.OrgLedger) + mux.HandleFunc("GET /orgs/{slug}/ledger/import", h.OrgBankImport) + mux.HandleFunc("POST /orgs/{slug}/ledger/import", h.OrgBankImport) } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -568,3 +1821,113 @@ func randomHex(n int) string { _, _ = rand.Read(b) return hex.EncodeToString(b) } + +// parseEuroAmount converts a user-entered decimal string (e.g. "12.50") to cents. +func parseEuroAmount(s string) (int64, error) { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, ",", ".") + var euros, cents int64 + parts := strings.SplitN(s, ".", 2) + var err error + if euros, err = parseInt64(parts[0]); err != nil { + return 0, fmt.Errorf("invalid amount") + } + if len(parts) == 2 { + centStr := parts[1] + if len(centStr) == 1 { + centStr += "0" + } else if len(centStr) > 2 { + centStr = centStr[:2] + } + if cents, err = parseInt64(centStr); err != nil { + return 0, fmt.Errorf("invalid amount") + } + } + return euros*100 + cents, nil +} + +func parseInt64(s string) (int64, error) { + if s == "" { + return 0, nil + } + var n int64 + neg := false + if s[0] == '-' { + neg = true + s = s[1:] + } + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("not a number") + } + n = n*10 + int64(c-'0') + } + if neg { + n = -n + } + return n, nil +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func csvReader(r io.Reader) ([][]string, error) { + cr := csv.NewReader(r) + cr.TrimLeadingSpace = true + return cr.ReadAll() +} + +// parseBankCSV reads a bank statement CSV. It expects at minimum columns: +// date, description, amount (and optionally reference). +// Negative amounts are expenses, positive are income. +func parseBankCSV(r io.Reader) ([]BankImportRow, error) { + records, err := csvReader(r) + if err != nil { + return nil, err + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV is empty") + } + header := records[0] + idx := func(name string) int { + for i, h := range header { + if strings.EqualFold(strings.TrimSpace(h), name) { + return i + } + } + return -1 + } + dateIdx := idx("date") + descIdx := idx("description") + amtIdx := idx("amount") + refIdx := idx("reference") + if dateIdx < 0 || descIdx < 0 || amtIdx < 0 { + return nil, fmt.Errorf("CSV must have columns: date, description, amount") + } + + rows := make([]BankImportRow, 0, len(records)-1) + for _, rec := range records[1:] { + if len(rec) <= amtIdx { + continue + } + amt, err := parseEuroAmount(rec[amtIdx]) + if err != nil { + continue + } + ref := "" + if refIdx >= 0 && refIdx < len(rec) { + ref = strings.TrimSpace(rec[refIdx]) + } + rows = append(rows, BankImportRow{ + Date: strings.TrimSpace(rec[dateIdx]), + Description: strings.TrimSpace(rec[descIdx]), + AmountCents: amt, + Reference: ref, + }) + } + return rows, nil +} diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index 5046c21..ecb3b8e 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -217,6 +217,15 @@ func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, er 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 } +func (m *mockStore) getLedgerEntries(_ context.Context, _, _ string, _ bson.M) ([]OrgLedgerEntry, error) { + return nil, nil +} +func (m *mockStore) createLedgerEntry(_ context.Context, _ *OrgLedgerEntry) error { return nil } +func (m *mockStore) updateLedgerEntry(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachment, error) { + return nil, nil +} +func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil } // ── helpers ─────────────────────────────────────────────────────────────────── diff --git a/apps/finance/services/api/main/models_org.go b/apps/finance/services/api/main/models_org.go index cf66e02..337bbd2 100644 --- a/apps/finance/services/api/main/models_org.go +++ b/apps/finance/services/api/main/models_org.go @@ -354,3 +354,173 @@ type OrgInviteData struct { Error string Link string // generated invite link shown after creation } + +type OrgEventsData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear FiscalYear + Events []OrgEventSummary + Teams []OrgTeam +} + +type OrgEventSummary struct { + Event OrgEvent + TotalIncome int64 + TotalExpense int64 + Teams []OrgTeam +} + +type OrgEventDetailData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear FiscalYear + Event OrgEvent + BudgetLines []BudgetLine + Comments []EventComment + Teams []OrgTeam + EventTeams []OrgTeam + TotalIncome int64 + TotalExpense int64 + Error string +} + +// ── Phase 3 page data ──────────────────────────────────────────────────────── + +type OrgRequestsData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + Requests []TxRequest + Events []OrgEvent + Teams []OrgTeam + StatusFilter string +} + +type OrgRequestDetailData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + Request TxRequest + Event *OrgEvent + BudgetLine *BudgetLine + Team *OrgTeam + FiscalYear *FiscalYear + Attachments []OrgAttachment + Error string +} + +type OrgLedgerData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear *FiscalYear + FiscalYears []FiscalYear + Entries []OrgLedgerEntry + Events map[string]OrgEvent + Teams map[string]OrgTeam + TotalIncome int64 + TotalExpense int64 +} + +type OrgBankImportData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear *FiscalYear + Rows []BankImportRow + Error string + Imported int +} + +type BankImportRow struct { + Date string + Description string + AmountCents int64 + Reference string + Matched bool + MatchedID string +} + +// ── Phase 4 page data ──────────────────────────────────────────────────────── + +type OrgAnalysisData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear FiscalYear + FiscalYears []FiscalYear + EventRows []AnalysisEventRow + TeamRows []AnalysisTeamRow + TotalPlannedIncome int64 + TotalActualIncome int64 + TotalPlannedExpense int64 + TotalActualExpense int64 +} + +type AnalysisEventRow struct { + Event OrgEvent + PlannedIncome int64 + ActualIncome int64 + PlannedExpense int64 + ActualExpense int64 +} + +type AnalysisTeamRow struct { + Team OrgTeam + PlannedIncome int64 + ActualIncome int64 + PlannedExpense int64 + ActualExpense int64 +} + +// ── Phase 5 page data ──────────────────────────────────────────────────────── + +type OrgReportData struct { + UserID string + Email string + Title string + Route string + Org Org + MyRole OrgRole + FiscalYear FiscalYear + FiscalYears []FiscalYear + EventReports []EventReport + TotalPlannedIncome int64 + TotalActualIncome int64 + TotalPlannedExpense int64 + TotalActualExpense int64 +} + +type EventReport struct { + Event OrgEvent + BudgetLines []BudgetLine + Comments []EventComment // kind=feedback only + PlannedIncome int64 + ActualIncome int64 + PlannedExpense int64 + ActualExpense int64 + Teams []OrgTeam +} diff --git a/apps/finance/services/api/main/store_org.go b/apps/finance/services/api/main/store_org.go index 207a035..51a8ed0 100644 --- a/apps/finance/services/api/main/store_org.go +++ b/apps/finance/services/api/main/store_org.go @@ -527,3 +527,64 @@ func (s *Store) updateTxRequest(ctx context.Context, reqID, orgID string, update ) return err } + +// ── Ledger ──────────────────────────────────────────────────────────────────── + +func (s *Store) getLedgerEntries(ctx context.Context, orgID, fiscalYearID string, extra bson.M) ([]OrgLedgerEntry, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getLedgerEntries") + defer span.End() + + filter := bson.M{"org_id": orgID} + if fiscalYearID != "" { + filter["fiscal_year_id"] = fiscalYearID + } + for k, v := range extra { + filter[k] = v + } + cur, err := s.orgLedger().Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "date", Value: -1}})) + if err != nil { + return nil, err + } + var entries []OrgLedgerEntry + if err := cur.All(ctx, &entries); err != nil { + return nil, err + } + return entries, nil +} + +func (s *Store) createLedgerEntry(ctx context.Context, e *OrgLedgerEntry) error { + ctx, span := mongo.StartSpan(ctx, "Store.createLedgerEntry") + defer span.End() + _, err := s.orgLedger().InsertOne(ctx, e) + return err +} + +func (s *Store) updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateLedgerEntry") + defer span.End() + _, err := s.orgLedger().UpdateOne(ctx, bson.M{"_id": id, "org_id": orgID}, bson.M{"$set": update}) + return err +} + +// ── Attachments ─────────────────────────────────────────────────────────────── + +func (s *Store) getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getAttachments") + defer span.End() + cur, err := s.orgAttachments().Find(ctx, bson.M{"request_id": requestID, "org_id": orgID}) + if err != nil { + return nil, err + } + var attachments []OrgAttachment + if err := cur.All(ctx, &attachments); err != nil { + return nil, err + } + return attachments, nil +} + +func (s *Store) createAttachment(ctx context.Context, a *OrgAttachment) error { + ctx, span := mongo.StartSpan(ctx, "Store.createAttachment") + defer span.End() + _, err := s.orgAttachments().InsertOne(ctx, a) + return err +} diff --git a/apps/finance/services/api/main/templates/org_analysis.html b/apps/finance/services/api/main/templates/org_analysis.html new file mode 100644 index 0000000..d4cacdd --- /dev/null +++ b/apps/finance/services/api/main/templates/org_analysis.html @@ -0,0 +1,123 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + {{$d.FiscalYear.Label}} / Analysis +
+

Plan vs Actual

+
+
+ Year-end report +
+
+ + +
+ {{range $d.FiscalYears}} + + {{.Label}} + + {{end}} +
+ + +
+
+

Planned income

+
{{cents $d.TotalPlannedIncome}}
+
+
+

Actual income

+
{{cents $d.TotalActualIncome}}
+
+ {{if $d.TotalPlannedIncome}}{{cents (sub $d.TotalActualIncome $d.TotalPlannedIncome)}} vs plan{{end}} +
+
+
+

Planned expense

+
{{cents $d.TotalPlannedExpense}}
+
+
+

Actual expense

+
{{cents $d.TotalActualExpense}}
+
+ {{if $d.TotalPlannedExpense}}{{cents (sub $d.TotalActualExpense $d.TotalPlannedExpense)}} vs plan{{end}} +
+
+
+ + +

By event

+{{if $d.EventRows}} +
+ + + + + + + + + + + + + {{range $d.EventRows}} + {{$netPlan := sub .PlannedIncome .PlannedExpense}} + {{$netActual := sub .ActualIncome .ActualExpense}} + + + + + + + + + {{end}} + +
EventPlanned incomeActual incomePlanned expenseActual expenseVariance
+ {{.Event.Name}} + {{cents .PlannedIncome}}{{cents .ActualIncome}}{{cents .PlannedExpense}}{{cents .ActualExpense}} + {{cents (sub $netActual $netPlan)}} +
+
+{{else}} +

No events in this fiscal year.

+{{end}} + + +

By team

+{{if $d.TeamRows}} +
+ + + + + + + + + + + + {{range $d.TeamRows}} + + + + + + + + {{end}} + +
TeamPlanned incomeActual incomePlanned expenseActual expense
{{.Team.Name}}{{cents .PlannedIncome}}{{cents .ActualIncome}}{{cents .PlannedExpense}}{{cents .ActualExpense}}
+
+{{else}} +

No teams configured.

+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_bank_import.html b/apps/finance/services/api/main/templates/org_bank_import.html new file mode 100644 index 0000000..942f17d --- /dev/null +++ b/apps/finance/services/api/main/templates/org_bank_import.html @@ -0,0 +1,89 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + Ledger / Import +
+

Bank CSV Import

+
+
+ +{{if $d.Imported}} +
+
+

Import complete

+

{{$d.Imported}} entries imported into the ledger.

+ View ledger +
+{{else if $d.Rows}} + +
+

Preview — {{len $d.Rows}} rows

+

Review the parsed rows below, then confirm import.

+
+ + + + + + + + + + + {{range $d.Rows}} + + + + + + + {{end}} + +
DateDescriptionAmountReference
{{.Date}}{{.Description}}{{cents .AmountCents}}{{if .Reference}}{{.Reference}}{{else}}—{{end}}
+
+
+
+ + + Start over +
+{{else}} + +{{if not $d.FiscalYear}} +
+

⚠ No active fiscal year. Activate a fiscal year before importing bank transactions.

+
+{{end}} + +
+

Upload bank statement

+

+ CSV must have columns: date, description, amount. + Optionally: reference. Amounts use decimal notation (e.g. -50.00). +

+ {{if $d.Error}} +
{{$d.Error}}
+ {{end}} +
+
+ + +
+ +
+
+ +
+

Expected CSV format

+
date,description,amount,reference
+2025-01-15,Supermarket purchase,-45.20,REF001
+2025-01-16,Client payment received,1200.00,REF002
+2025-01-17,Office supplies,-89.99,REF003
+
+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_event_detail.html b/apps/finance/services/api/main/templates/org_event_detail.html new file mode 100644 index 0000000..a1de7bd --- /dev/null +++ b/apps/finance/services/api/main/templates/org_event_detail.html @@ -0,0 +1,332 @@ +{{define "content"}} +{{$d := .}} + +{{$isNew := eq $d.Event.ID ""}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + Events / + {{if $isNew}}New event{{else}}{{$d.Event.Name}}{{end}} +
+

{{if $isNew}}New event{{else}}{{$d.Event.Name}}{{end}}

+
+ {{if not $isNew}} +
+ {{if eq (print $d.Event.Status) "approved"}} + ✓ Approved + {{else if eq (print $d.Event.Status) "review"}} + Under review + {{else if eq (print $d.Event.Status) "rejected"}} + Rejected + {{else}} + Draft + {{end}} + + {{if eq (print $d.Event.Status) "draft"}} + +
+ +
+
+ +
+ {{else if eq (print $d.Event.Status) "review"}} + + {{end}} + + {{if and (or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")) (eq (print $d.Event.Status) "review")}} + + {{end}} +
+ {{end}} +
+ +{{if $isNew}} + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ {{if $d.Teams}} +
+ +
+ {{range $d.Teams}} + + {{end}} +
+
+ {{end}} +
+ + Cancel +
+
+
+ +{{else}} + +
+
+

Planned income

+
{{cents $d.TotalIncome}}
+
+
+

Planned expense

+
{{cents $d.TotalExpense}}
+
+
+

Net

+
{{cents (sub $d.TotalIncome $d.TotalExpense)}}
+
+
+ +
+
+ +
+

Details

+
+
+
PERIOD
+
{{dateShort $d.Event.DateStart}} — {{dateShort $d.Event.DateEnd}}
+
+
+
TEAMS
+
{{if $d.EventTeams}}{{range $d.EventTeams}}{{.Name}}{{end}}{{else}}—{{end}}
+
+
+ {{if $d.Event.Description}} +
+
DESCRIPTION
+

{{$d.Event.Description}}

+
+ {{end}} + {{if $d.Event.Goals}} +
+
GOALS
+

{{$d.Event.Goals}}

+
+ {{end}} +
+ + +
+
+

Budget lines

+ {{if ne (print $d.Event.Status) "approved"}} + + {{end}} +
+ {{if $d.BudgetLines}} + + + + + + + + {{if ne (print $d.Event.Status) "approved"}}{{end}} + + + + {{range $d.BudgetLines}} + + + + + + {{if ne (print $d.Event.Status) "approved"}} + + {{end}} + + {{end}} + +
CategoryDescriptionTypeAmount
{{.Category}}{{.Description}} + {{if eq (print .Type) "income"}} + income + {{else}} + expense + {{end}} + {{cents .PlannedCents}} +
+ +
+
+ {{else}} +

No budget lines yet. Add income and expense lines to plan this event's finances.

+ {{end}} +
+ + +
+

Comments

+ {{if $d.Comments}} +
+ {{range $d.Comments}} +
+
+ {{.UserEmail}} + {{dateShort .CreatedAt}} · {{.Kind}} +
+

{{.Body}}

+
+ {{end}} +
+ {{end}} + + {{if and (eq (print $d.FiscalYear.Status) "closed") (ne (print $d.MyRole) "viewer")}} +
+
+ + +
+ +
+ {{end}} +
+
+ + +
+
+

Quick actions

+ +
+
+
+ + + + + + + + +{{if and (or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")) (eq (print $d.Event.Status) "review")}} + +{{end}} + +{{end}}{{end}} diff --git a/apps/finance/services/api/main/templates/org_events.html b/apps/finance/services/api/main/templates/org_events.html new file mode 100644 index 0000000..258a839 --- /dev/null +++ b/apps/finance/services/api/main/templates/org_events.html @@ -0,0 +1,85 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + {{$d.FiscalYear.Label}} — Events +
+

Events

+
+
+ Analysis + {{if ne (print $d.FiscalYear.Status) "closed"}} + + New event + {{end}} +
+
+ + +
+ Fiscal year: + {{$d.FiscalYear.Label}} + + {{$d.FiscalYear.Status}} + + {{dateShort $d.FiscalYear.StartDate}} — {{dateShort $d.FiscalYear.EndDate}} +
+ +{{if $d.Events}} +
+ + + + + + + + + + + + + + {{range $d.Events}} + + + + + + + + + + {{end}} + +
EventTeamsDatesPlanned incomePlanned expenseStatus
{{.Event.Name}} + {{if .Teams}}{{range .Teams}}{{.Name}}{{end}}{{else}}—{{end}} + {{dateShort .Event.DateStart}} — {{dateShort .Event.DateEnd}}{{cents .TotalIncome}}{{cents .TotalExpense}} + {{if eq (print .Event.Status) "approved"}} + approved + {{else if eq (print .Event.Status) "review"}} + review + {{else if eq (print .Event.Status) "rejected"}} + rejected + {{else}} + draft + {{end}} + + View +
+
+{{else}} +
+
🗓️
+

No events yet

+

Plan events with budgets for teams to execute this fiscal year.

+ Create first event +
+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_ledger.html b/apps/finance/services/api/main/templates/org_ledger.html new file mode 100644 index 0000000..637b691 --- /dev/null +++ b/apps/finance/services/api/main/templates/org_ledger.html @@ -0,0 +1,89 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / Ledger +
+

Ledger

+
+
+ Import bank CSV +
+
+ + +
+ {{range $d.FiscalYears}} + + {{.Label}} + + {{end}} +
+ + +
+
+

Income

+
{{cents $d.TotalIncome}}
+
+
+

Expenses

+
{{cents $d.TotalExpense}}
+
+
+

Net

+
{{cents (sub $d.TotalIncome $d.TotalExpense)}}
+
+
+ +{{if $d.Entries}} +
+ + + + + + + + + + + + + + {{range $d.Entries}} + + + + + + + + + + {{end}} + +
DateDescriptionEventTeamAmountBank refReconciled
{{dateShort .Date}}{{.Description}} + {{if .EventID}}{{with index $d.Events .EventID}}{{.Name}}{{end}}{{else}}—{{end}} + + {{if .TeamID}}{{with index $d.Teams .TeamID}}{{.Name}}{{end}}{{else}}—{{end}} + + {{cents .AmountCents}} + {{if .BankRef}}{{.BankRef}}{{else}}—{{end}} + {{if .Reconciled}}{{else}}{{end}} +
+
+{{else}} +
+
📒
+

No ledger entries

+

Entries are created automatically when requests are approved, or via bank CSV import.

+ Import bank CSV +
+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_report.html b/apps/finance/services/api/main/templates/org_report.html new file mode 100644 index 0000000..180f784 --- /dev/null +++ b/apps/finance/services/api/main/templates/org_report.html @@ -0,0 +1,167 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + {{$d.FiscalYear.Label}} / Report +
+

{{$d.FiscalYear.Label}} Year-End Report

+
+
+ Analysis +
+
+ + +
+ {{range $d.FiscalYears}} + + {{.Label}} + + {{end}} +
+ + +
+

Executive Summary

+
+
+
PLANNED INCOME
+
{{cents $d.TotalPlannedIncome}}
+
+
+
ACTUAL INCOME
+
{{cents $d.TotalActualIncome}}
+
+
+
PLANNED EXPENSE
+
{{cents $d.TotalPlannedExpense}}
+
+
+
ACTUAL EXPENSE
+
{{cents $d.TotalActualExpense}}
+
+
+
+
+ Planned net: + {{cents (sub $d.TotalPlannedIncome $d.TotalPlannedExpense)}} +
+
+ Actual net: + + {{cents (sub $d.TotalActualIncome $d.TotalActualExpense)}} + +
+
+ Events: + {{len $d.EventReports}} +
+
+ Period: + {{dateShort $d.FiscalYear.StartDate}} — {{dateShort $d.FiscalYear.EndDate}} +
+
+
+ + +{{range $d.EventReports}} +{{$ev := .Event}} +
+
+
+

{{$ev.Name}}

+
{{dateShort $ev.DateStart}} — {{dateShort $ev.DateEnd}}
+ {{if .Teams}} +
+ {{range .Teams}}{{.Name}}{{end}} +
+ {{end}} +
+
+
+
PLAN INC
+
{{cents .PlannedIncome}}
+
+
+
ACT INC
+
{{cents .ActualIncome}}
+
+
+
PLAN EXP
+
{{cents .PlannedExpense}}
+
+
+
ACT EXP
+
{{cents .ActualExpense}}
+
+
+
+ + {{if $ev.Description}} +

{{$ev.Description}}

+ {{end}} + + {{if .BudgetLines}} +
+ BUDGET LINES ({{len .BudgetLines}}) + + + + + + + + + + + {{range .BudgetLines}} + + + + + + + {{end}} + +
CategoryDescriptionTypePlanned
{{.Category}}{{.Description}}{{.Type}}{{cents .PlannedCents}}
+
+ {{end}} + + {{if .Comments}} +
+
TEAM FEEDBACK
+
+ {{range .Comments}} +
+
+ {{.UserEmail}} + {{dateShort .CreatedAt}} +
+

{{.Body}}

+
+ {{end}} +
+
+ {{else if eq (print $d.FiscalYear.Status) "closed"}} +

No team feedback submitted for this event.

+ {{end}} +
+{{else}} +
+
📊
+

No events in this fiscal year

+

Events and their outcomes will appear here once created.

+
+{{end}} + +{{if eq (print $d.FiscalYear.Status) "active"}} +
+

⚠ This fiscal year is still active. The report will be final once the year is closed. Team feedback can be added after closing.

+
+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_request_detail.html b/apps/finance/services/api/main/templates/org_request_detail.html new file mode 100644 index 0000000..e982374 --- /dev/null +++ b/apps/finance/services/api/main/templates/org_request_detail.html @@ -0,0 +1,311 @@ +{{define "content"}} +{{$d := .}} +{{$req := $d.Request}} +{{$isNew := eq $req.ID ""}} +{{$status := $req.CurrentStatus}} +{{$isManager := or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")}} +{{$isOwner := eq $req.SubmittedBy $d.UserID}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / + Requests / + {{if $isNew}}New{{else}}{{$req.Type}}{{end}} +
+

{{if $isNew}}New Request{{else}}{{$req.Type}} Request{{end}}

+
+ {{if not $isNew}} + {{$status}} + {{end}} +
+ +{{if $isNew}} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+
+
+ + Cancel +
+
+
+ + + +{{else}} + +
+
+ +
+

Details

+
+
+
AMOUNT
+
{{cents $req.AmountCents}}
+
+
+
SUBMITTED BY
+
{{$req.SubmitterEmail}}
+
+ {{if $req.Vendor}} +
+
VENDOR
+
{{$req.Vendor}}
+
+ {{end}} + {{if $req.PaymentMethod}} +
+
PAYMENT METHOD
+
{{$req.PaymentMethod}}
+
+ {{end}} + {{if $d.Event}} +
+
EVENT
+ +
+ {{end}} + {{if $d.Team}} +
+
TEAM
+
{{$d.Team.Name}}
+
+ {{end}} +
+
DESCRIPTION
+
{{$req.Description}}
+
+
+
+ + + {{if and (eq (print $req.Type) "purchase_order") (eq (print $status) "ordered")}} +
+

Record Delivery

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+ +
+
+ {{end}} + + {{if $req.Delivery}} +
+

Delivery record

+
+
Actual amount: {{cents $req.Delivery.ActualAmountCents}}
+
Vendor: {{$req.Delivery.ActualVendor}}
+
Delivered: {{dateShort $req.Delivery.DeliveredAt}}
+ {{if $req.Delivery.StoreChanged}}
⚠ Store changed — {{$req.Delivery.ChangeNote}}
{{end}} +
+
+ {{end}} + + + {{if and (eq (print $req.Type) "cash_advance") (or (eq (print $status) "disbursed") (eq (print $status) "settlement_due") (eq (print $status) "partial_settlement"))}} +
+

Submit Settlement

+
+
+
+ + +
+
+ + +
+
+ +
+
+ {{end}} + + {{if $req.Settlement}} +
+

Settlement record

+
+
Spent: {{cents $req.Settlement.AmountSpentCents}}
+
Returned: {{cents $req.Settlement.AmountReturnedCents}}
+
Settled at: {{dateShort $req.Settlement.SettledAt}}
+
+
+ {{end}} + + +
+

Attachments

+ {{if $d.Attachments}} +
+ {{range $d.Attachments}} +
+ {{.Filename}} + {{.MimeType}} +
+ {{end}} +
+ {{else}} +

No attachments yet.

+ {{end}} +
+ + +
+

Status history

+
+ {{range $req.StatusLog}} +
+
+
+
+ {{.Status}} + {{dateShort .ChangedAt}} · {{.ChangedBy}} +
+ {{if .Comment}}

{{.Comment}}

{{end}} +
+
+ {{end}} +
+
+
+ + +
+
+

Actions

+
+
+ + +
+ + {{if eq (print $status) "draft"}} + {{if $isOwner}}{{end}} + + {{end}} + {{if eq (print $status) "submitted"}} + {{if $isManager}}{{end}} + + {{end}} + {{if eq (print $status) "info_requested"}} + {{if $isOwner}}{{end}} + {{end}} + {{if eq (print $status) "under_review"}} + {{if $isManager}} + + + + {{end}} + {{end}} + {{if eq (print $status) "approved"}} + {{if $isManager}} + {{if eq (print $req.Type) "reimbursement"}}{{end}} + {{if eq (print $req.Type) "purchase_order"}}{{end}} + {{if eq (print $req.Type) "cash_advance"}}{{end}} + {{if eq (print $req.Type) "income"}}{{end}} + {{if eq (print $req.Type) "budget_transfer"}}{{end}} + {{end}} + {{end}} + {{if eq (print $status) "disbursed"}} + {{if $isManager}}{{end}} + {{end}} + {{if eq (print $status) "pending_payment"}} + {{if $isManager}}{{end}} + {{end}} + {{if or (eq (print $status) "paid") (eq (print $status) "delivered") (eq (print $status) "received") (eq (print $status) "settled")}} + {{if $isManager}}{{end}} + {{end}} +
+
+
+
+{{end}} +{{end}} diff --git a/apps/finance/services/api/main/templates/org_requests.html b/apps/finance/services/api/main/templates/org_requests.html new file mode 100644 index 0000000..f09aca9 --- /dev/null +++ b/apps/finance/services/api/main/templates/org_requests.html @@ -0,0 +1,75 @@ +{{define "content"}} +{{$d := .}} + +
+
+
+ Orgs / + {{$d.Org.Name}} / Requests +
+

Transaction Requests

+
+
+ Ledger + + New request +
+
+ + +{{$cur := $d.StatusFilter}} +
+ All + draft + submitted + under_review + info_requested + approved + rejected + paid + ordered + settled +
+ +{{if $d.Requests}} +
+ + + + + + + + + + + + + {{range $d.Requests}} + {{$status := .CurrentStatus}} + + + + + + + + + {{end}} + +
TypeDescriptionByAmountStatus
+ {{.Type}} + {{.Description}}{{.SubmitterEmail}}{{cents .AmountCents}} + {{$status}} + + View +
+
+{{else}} +
+
📋
+

No requests{{if $d.StatusFilter}} with status "{{$d.StatusFilter}}"{{end}}

+

Submit transaction requests to track expenses, income, and transfers.

+ New request +
+{{end}} +{{end}}