Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions backend/internal/domain/models/donation.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ const (

// Donation maps to the crowdfunding.donations table.
type Donation struct {
ID string `json:"id"`
UserID string `json:"user_id"`
InitiativeID string `json:"initiative_id"`
OrganizationID string `json:"organization_id,omitempty"`
Category string `json:"category,omitempty"`
CurrentAmountCents int64 `json:"current_amount_in_cents"`
PONumber string `json:"po_number,omitempty"`
PaymentMethod string `json:"payment_method,omitempty"`
Status string `json:"status,omitempty"`
StripePaymentIntentID string `json:"stripe_payment_intent_id,omitempty"`
StripeChargeID string `json:"stripe_charge_id,omitempty"`
ID string `json:"id"`
UserID string `json:"-"`
InitiativeID string `json:"initiative_id"`
OrganizationID string `json:"-"`
Category string `json:"category,omitempty"`
CurrentAmountCents int64 `json:"amount_cents"`
PONumber string `json:"po_number,omitempty"`
PaymentMethod string `json:"payment_method,omitempty"`
Status string `json:"status,omitempty"`
// Stripe IDs are internal operational fields used by the webhook reconciliation
// flow. They are never serialised to API consumers.
StripePaymentIntentID string `json:"-"`
StripeChargeID string `json:"-"`
CreatedOn time.Time `json:"created_on"`
UpdatedOn time.Time `json:"updated_on"`

Expand All @@ -35,7 +37,7 @@ type Donation struct {

// DonationCreateInput is the request body for creating a donation.
type DonationCreateInput struct {
AmountCents int64 `json:"amount_in_cents"`
AmountCents int64 `json:"amount_cents"`
Category string `json:"category,omitempty"`
Comment thread
mlehotskylf marked this conversation as resolved.
PONumber string `json:"po_number,omitempty"`
PaymentMethod string `json:"payment_method,omitempty"`
Expand All @@ -46,3 +48,17 @@ type DonationCreateInput struct {
// It is not decoded from the JSON body (json:"-").
IdempotencyKey string `json:"-"`
}

// DonationSummary is the public-facing projection returned by the initiative
// donation list (GET /v1/initiatives/{id}/donations). It contains only what is
// safe to show on a public page — no user_id, no Stripe IDs, no PII.
type DonationSummary struct {
Comment thread
mlehotskylf marked this conversation as resolved.
ID string `json:"id"`
AmountCents int64 `json:"amount_cents"`
Status string `json:"status,omitempty"`
Category string `json:"category,omitempty"`
DonorName string `json:"donor_name,omitempty"`
DonorType string `json:"donor_type,omitempty"` // "organization" | "individual"
DonorAvatar string `json:"donor_avatar_url,omitempty"`
CreatedOn time.Time `json:"created_on"`
Comment thread
mlehotskylf marked this conversation as resolved.
Outdated
}
6 changes: 3 additions & 3 deletions backend/internal/domain/models/initiative_relations.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package models
// initiative_id, created_on, and updated_on which are assigned by the database.
type GoalInput struct {
Name string `json:"name"`
AmountInCents int64 `json:"amount_in_cents"`
AmountInCents int64 `json:"amount_cents"`
Allocation string `json:"allocation,omitempty"`
RepoLink string `json:"repo_link,omitempty"`
Description string `json:"description,omitempty"`
Expand Down Expand Up @@ -76,7 +76,7 @@ type OSTIFDetailInput struct {
MonetizationStrategy string `json:"monetization_strategy,omitempty"`
CurrentSecurityStrategy string `json:"current_security_strategy,omitempty"`
LicenseType string `json:"license_type,omitempty"`
TotalBudgetInCents int64 `json:"total_budget_in_cents"`
TotalBudgetInCents int64 `json:"total_budget_cents"`
TermsConditions bool `json:"terms_conditions"`
}

Expand Down Expand Up @@ -156,7 +156,7 @@ type OSTIFDetail struct {
MonetizationStrategy string `json:"monetization_strategy,omitempty"`
CurrentSecurityStrategy string `json:"current_security_strategy,omitempty"`
LicenseType string `json:"license_type,omitempty"`
TotalBudgetInCents int64 `json:"total_budget_in_cents"`
TotalBudgetInCents int64 `json:"total_budget_cents"`
TermsConditions bool `json:"terms_conditions"`
}

Expand Down
26 changes: 14 additions & 12 deletions backend/internal/domain/models/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,19 @@ const (

// Subscription maps to the crowdfunding.subscriptions table.
type Subscription struct {
ID string `json:"id"`
UserID string `json:"user_id"`
InitiativeID string `json:"initiative_id"`
OrganizationID string `json:"organization_id,omitempty"`
Category string `json:"category,omitempty"`
CurrentAmountCents int64 `json:"current_amount_in_cents"`
Frequency string `json:"frequency,omitempty"`
Status string `json:"status,omitempty"`
StripeSubscriptionID string `json:"stripe_subscription_id,omitempty"`
StripeSubscriptionItemID string `json:"stripe_subscription_item_id,omitempty"`
StripePriceID string `json:"stripe_price_id,omitempty"`
ID string `json:"id"`
UserID string `json:"-"`
InitiativeID string `json:"initiative_id"`
OrganizationID string `json:"-"`
Category string `json:"category,omitempty"`
CurrentAmountCents int64 `json:"amount_cents"`
Frequency string `json:"frequency,omitempty"`
Status string `json:"status,omitempty"`
// Stripe IDs are internal operational fields used by webhook reconciliation.
// They are never serialised to API consumers.
StripeSubscriptionID string `json:"-"`
StripeSubscriptionItemID string `json:"-"`
StripePriceID string `json:"-"`
CreatedOn time.Time `json:"created_on"`
UpdatedOn time.Time `json:"updated_on"`

Expand All @@ -36,7 +38,7 @@ type Subscription struct {

// SubscriptionCreateInput is the request body for creating a subscription.
type SubscriptionCreateInput struct {
AmountCents int64 `json:"amount_in_cents"`
AmountCents int64 `json:"amount_cents"`
Frequency string `json:"frequency"`
Category string `json:"category,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/domain/models/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ type Transaction struct {
type TransactionList struct {
Data []Transaction `json:"data"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Size int `json:"size"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
17 changes: 10 additions & 7 deletions backend/internal/handler/donation_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"

"github.com/go-chi/chi/v5"
"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain"
Expand All @@ -30,11 +29,13 @@ func NewDonationHandler(svc *service.DonationService) *DonationHandler {
// List handles GET /v1/initiatives/{id}/donations
func (h *DonationHandler) List(w http.ResponseWriter, r *http.Request) {
initiativeID := chi.URLParam(r, "id")
limit, offset, ok := parsePaginationParams(w, r)
if !ok {
return
}
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))

donations, meta, err := h.svc.ListByInitiative(r.Context(), initiativeID, models.DonationFilter{
summaries, meta, err := h.svc.ListByInitiative(r.Context(), initiativeID, models.DonationFilter{
Status: q.Get("status"),
Limit: limit,
Offset: offset,
Expand All @@ -44,7 +45,7 @@ func (h *DonationHandler) List(w http.ResponseWriter, r *http.Request) {
return
}
JSON(w, http.StatusOK, map[string]any{
"data": donations,
"data": summaries,
"meta": meta,
})
}
Expand All @@ -58,9 +59,11 @@ func (h *DonationHandler) ListForUser(w http.ResponseWriter, r *http.Request) {
return
}

limit, offset, ok := parsePaginationParams(w, r)
if !ok {
return
}
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))

donations, meta, err := h.svc.ListByUser(r.Context(), principal.UserID, models.DonationFilter{
Status: q.Get("status"),
Expand Down
30 changes: 16 additions & 14 deletions backend/internal/handler/initiative_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"

"github.com/go-chi/chi/v5"
Expand Down Expand Up @@ -45,9 +44,11 @@ func NewInitiativeHandler(svc *service.InitiativeService, allowedApprovers []str

// List handles GET /v1/initiatives
func (h *InitiativeHandler) List(w http.ResponseWriter, r *http.Request) {
limit, offset, ok := parsePaginationParams(w, r)
if !ok {
return
}
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))

filter := models.InitiativeFilter{
OwnerID: q.Get("owner_id"),
Expand Down Expand Up @@ -159,7 +160,7 @@ func (h *InitiativeHandler) Update(w http.ResponseWriter, r *http.Request) {
}

// GetTransactions handles GET /v1/initiatives/{id}/transactions
// Accepts ?type=donations|expenses&size=N&page=N (1-based page, defaults to 1).
// Accepts ?type=donations|expenses&limit=N&offset=N.
// Resolves the initiative by slug or UUID, verifies it is published, then calls Ledger.
func (h *InitiativeHandler) GetTransactions(w http.ResponseWriter, r *http.Request) {
value := chi.URLParam(r, "id")
Expand All @@ -174,16 +175,17 @@ func (h *InitiativeHandler) GetTransactions(w http.ResponseWriter, r *http.Reque
ledgerTxnType = "reimbursement"
}

size, _ := strconv.Atoi(q.Get("size"))
if size <= 0 {
size = defaultTransactionPageSize
} else if size > maxTransactionPageSize {
size = maxTransactionPageSize
limit, offset, ok := parsePaginationParams(w, r)
if !ok {
return
}

page, _ := strconv.Atoi(q.Get("page"))
if page <= 0 {
page = 1
if limit <= 0 {
limit = defaultTransactionPageSize
} else if limit > maxTransactionPageSize {
limit = maxTransactionPageSize
}
if offset < 0 {
offset = 0
}
Comment thread
mlehotskylf marked this conversation as resolved.

// Resolve identifier to a UUID, verifying the initiative exists and is published.
Expand All @@ -204,7 +206,7 @@ func (h *InitiativeHandler) GetTransactions(w http.ResponseWriter, r *http.Reque
initiativeID = id
}

list, err := h.svc.GetTransactions(r.Context(), initiativeID, ledgerTxnType, size, page)
list, err := h.svc.GetTransactions(r.Context(), initiativeID, ledgerTxnType, limit, offset)
if err != nil {
Error(w, err)
return
Expand Down
25 changes: 25 additions & 0 deletions backend/internal/handler/respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"

"github.com/linuxfoundation/lfx-v2-initiatives-service/internal/domain"
)
Expand All @@ -17,6 +19,29 @@ type errorBody struct {
Error string `json:"error"`
}

// parsePaginationParams parses ?limit= and ?offset= from r. Returns 400 if
// either value is present but not a valid integer.
func parsePaginationParams(w http.ResponseWriter, r *http.Request) (limit, offset int, ok bool) {
Comment thread
mlehotskylf marked this conversation as resolved.
q := r.URL.Query()
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
Error(w, fmt.Errorf("%w: limit must be an integer", domain.ErrInvalidInput))
return 0, 0, false
}
limit = n
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
Error(w, fmt.Errorf("%w: offset must be an integer", domain.ErrInvalidInput))
return 0, 0, false
}
offset = n
}
return limit, offset, true
}

// JSON writes a JSON-encoded body with the given status code.
func JSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
Expand Down
98 changes: 98 additions & 0 deletions backend/internal/handler/respond_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

package handler

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestParsePaginationParams_ValidLimitOffset(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?limit=20&offset=40", nil)
w := httptest.NewRecorder()

limit, offset, ok := parsePaginationParams(w, r)

if !ok {
t.Fatal("expected ok=true, got false")
}
if limit != 20 {
t.Errorf("limit = %d, want 20", limit)
}
if offset != 40 {
t.Errorf("offset = %d, want 40", offset)
}
}

func TestParsePaginationParams_AbsentParams_ZeroDefaults(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()

limit, offset, ok := parsePaginationParams(w, r)

if !ok {
t.Fatal("expected ok=true for absent params")
}
if limit != 0 || offset != 0 {
t.Errorf("expected 0,0 for absent params, got %d,%d", limit, offset)
}
}

func TestParsePaginationParams_InvalidLimit_Returns400(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?limit=abc", nil)
w := httptest.NewRecorder()

_, _, ok := parsePaginationParams(w, r)

if ok {
t.Fatal("expected ok=false for non-integer limit")
}
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}

func TestParsePaginationParams_InvalidOffset_Returns400(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?offset=xyz", nil)
w := httptest.NewRecorder()

_, _, ok := parsePaginationParams(w, r)

if ok {
t.Fatal("expected ok=false for non-integer offset")
}
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}

func TestParsePaginationParams_LimitFloatRejected(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/?limit=1.5", nil)
w := httptest.NewRecorder()

_, _, ok := parsePaginationParams(w, r)

if ok {
t.Fatal("expected ok=false for float limit")
}
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}

func TestParsePaginationParams_NegativeValues_Accepted(t *testing.T) {
// Negative values are syntactically valid integers; clamping is the handler's job.
r := httptest.NewRequest(http.MethodGet, "/?limit=-1&offset=-5", nil)
w := httptest.NewRecorder()

limit, offset, ok := parsePaginationParams(w, r)

if !ok {
t.Fatal("expected ok=true for negative integers")
}
if limit != -1 || offset != -5 {
t.Errorf("expected -1,-5, got %d,%d", limit, offset)
}
}
Loading
Loading