Gonçalo Rodrigues 07e3525dae fix(finance): org polish — broken links, upload handler, populated selects (#21)
- org_home.html: fix Requests/Ledger links (were pointing to non-existent
  /years/{id}/requests routes; corrected to /orgs/{slug}/requests and
  /orgs/{slug}/ledger). Add Analysis link. Add Close year button for admins.
- OrgRequestNew: pass events + teams to GET template so dropdowns are
  populated (were silently discarded with _ = events).
- OrgRequestDetailData: add NewEvents/NewTeams fields for new-request form.
- OrgRequestUpload: implement file upload handler — saves to
  /data/org-files/{org_id}/{req_id}/{id} and records metadata in MongoDB.
  Register POST /orgs/{slug}/requests/{req_id}/upload route.
- org_request_detail.html: show upload form in attachments section;
  populate event/team selects on new request form.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:00:23 +01:00

530 lines
18 KiB
Go

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
}
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
// populated on new-request GET
NewEvents []OrgEvent
NewTeams []OrgTeam
}
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
}