From 2883561525fb24ba1e1f3747fbf013bc2c375af2 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Sun, 17 May 2026 23:32:36 -0700 Subject: [PATCH] Add monthly revenue trend for analytics when year is specified. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes payments.revenue_monthly with Jan–Dec SUCCESS totals (UTC) per currency for dashboard charts. Co-authored-by: Cursor --- db/query/analytics.sql | 29 +++++++++ docs/docs.go | 37 ++++++++++- docs/swagger.json | 37 ++++++++++- docs/swagger.yaml | 32 +++++++++- gen/db/analytics.sql.go | 62 +++++++++++++++++++ internal/domain/analytics.go | 14 +++++ .../web_server/handlers/analytics_handler.go | 45 +++++++++++++- 7 files changed, 251 insertions(+), 5 deletions(-) diff --git a/db/query/analytics.sql b/db/query/analytics.sql index f6c9793..f632f05 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -206,6 +206,35 @@ LEFT JOIN payments p ON COALESCE(p.paid_at, p.created_at)::date = d.date GROUP BY d.date ORDER BY d.date; +-- Monthly successful revenue for a calendar year (UTC buckets). Months with multiple currencies emit one row each; months with no revenue emit one row (currency ETB, revenue 0). +-- name: AnalyticsRevenueMonthlyByYear :many +WITH months AS ( + SELECT bucket + FROM generate_series( + make_timestamptz(sqlc.arg('report_year')::int, 1, 1, 0, 0, 0, 'UTC'), + make_timestamptz(sqlc.arg('report_year')::int, 12, 1, 0, 0, 0, 'UTC'), + INTERVAL '1 month' + ) AS gs(bucket) +), +by_month_currency AS ( + SELECT + date_trunc('month', COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC') AS ym, + p.currency, + SUM(p.amount)::float8 AS total_revenue + FROM payments p + WHERE p.status = 'SUCCESS' + AND EXTRACT(YEAR FROM COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC')::int = sqlc.arg('report_year')::int + GROUP BY 1, 2 +) +SELECT + (EXTRACT(MONTH FROM m.bucket AT TIME ZONE 'UTC'))::int AS month, + date_trunc('month', m.bucket AT TIME ZONE 'UTC')::date AS month_start, + COALESCE(b.currency, 'ETB'::varchar) AS currency, + COALESCE(b.total_revenue, 0)::float8 AS total_revenue +FROM months m +LEFT JOIN by_month_currency b ON b.ym = date_trunc('month', m.bucket AT TIME ZONE 'UTC') +ORDER BY m.bucket, COALESCE(b.currency, ''::varchar); + -- ===================== -- Course Analytics -- ===================== diff --git a/docs/docs.go b/docs/docs.go index 5447df5..17ec959 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -704,7 +704,7 @@ const docTemplate = `{ }, "/api/v1/analytics/dashboard": { "get": { - "description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range.", + "description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns Jan–Dec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.", "produces": [ "application/json" ], @@ -9835,6 +9835,30 @@ const docTemplate = `{ } } }, + "domain.AnalyticsMonthlyRevenuePoint": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "label": { + "description": "Short English month label, e.g. Jan", + "type": "string" + }, + "month": { + "description": "1–12", + "type": "integer" + }, + "month_start": { + "description": "UTC date of month start (for sorting / tooltips)", + "type": "string" + }, + "revenue": { + "description": "SUCCESS payments aggregate for that bucket", + "type": "number" + } + } + }, "domain.AnalyticsNotificationsSection": { "type": "object", "properties": { @@ -9879,12 +9903,23 @@ const docTemplate = `{ "$ref": "#/definitions/domain.AnalyticsLabelAmount" } }, + "monthly_revenue_year": { + "description": "MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets).", + "type": "integer" + }, "revenue_last_30_days": { "type": "array", "items": { "$ref": "#/definitions/domain.AnalyticsRevenueTimePoint" } }, + "revenue_monthly": { + "description": "RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month).", + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsMonthlyRevenuePoint" + } + }, "successful_payments": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index da58ff5..9185f8e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -696,7 +696,7 @@ }, "/api/v1/analytics/dashboard": { "get": { - "description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range.", + "description": "Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns Jan–Dec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.", "produces": [ "application/json" ], @@ -9827,6 +9827,30 @@ } } }, + "domain.AnalyticsMonthlyRevenuePoint": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "label": { + "description": "Short English month label, e.g. Jan", + "type": "string" + }, + "month": { + "description": "1–12", + "type": "integer" + }, + "month_start": { + "description": "UTC date of month start (for sorting / tooltips)", + "type": "string" + }, + "revenue": { + "description": "SUCCESS payments aggregate for that bucket", + "type": "number" + } + } + }, "domain.AnalyticsNotificationsSection": { "type": "object", "properties": { @@ -9871,12 +9895,23 @@ "$ref": "#/definitions/domain.AnalyticsLabelAmount" } }, + "monthly_revenue_year": { + "description": "MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets).", + "type": "integer" + }, "revenue_last_30_days": { "type": "array", "items": { "$ref": "#/definitions/domain.AnalyticsRevenueTimePoint" } }, + "revenue_monthly": { + "description": "RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month).", + "type": "array", + "items": { + "$ref": "#/definitions/domain.AnalyticsMonthlyRevenuePoint" + } + }, "successful_payments": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 111f033..da4d9a6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -164,6 +164,23 @@ definitions: label: type: string type: object + domain.AnalyticsMonthlyRevenuePoint: + properties: + currency: + type: string + label: + description: Short English month label, e.g. Jan + type: string + month: + description: 1–12 + type: integer + month_start: + description: UTC date of month start (for sorting / tooltips) + type: string + revenue: + description: SUCCESS payments aggregate for that bucket + type: number + type: object domain.AnalyticsNotificationsSection: properties: by_channel: @@ -193,10 +210,20 @@ definitions: items: $ref: '#/definitions/domain.AnalyticsLabelAmount' type: array + monthly_revenue_year: + description: MonthlyRevenueYear is set when RevenueMonthly is non-empty (the + calendar year of those buckets). + type: integer revenue_last_30_days: items: $ref: '#/definitions/domain.AnalyticsRevenueTimePoint' type: array + revenue_monthly: + description: RevenueMonthly is populated only when the request includes year=..., + with 12 months (possibly multiple currencies per month). + items: + $ref: '#/definitions/domain.AnalyticsMonthlyRevenuePoint' + type: array successful_payments: type: integer total_payments: @@ -2907,7 +2934,10 @@ paths: /api/v1/analytics/dashboard: get: description: 'Platform analytics with optional date filters: all-time (default), - year, year+month, or custom from/to range.' + year, year+month, or custom from/to range. When year is set, payments.revenue_monthly + returns Jan–Dec SUCCESS revenue totals (UTC) per currency for that calendar + year — use for yearly revenue charts. Daily series remains in revenue_last_30_days + (see date_filter.series_*). Courses section counts LMS + exam_prep inventory.' parameters: - description: Calendar year (e.g. 2025) in: query diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index 75aacae..23c8754 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -728,6 +728,68 @@ func (q *Queries) AnalyticsRevenueLast30Days(ctx context.Context, arg AnalyticsR return items, nil } +const AnalyticsRevenueMonthlyByYear = `-- name: AnalyticsRevenueMonthlyByYear :many +WITH months AS ( + SELECT bucket + FROM generate_series( + make_timestamptz($1::int, 1, 1, 0, 0, 0, 'UTC'), + make_timestamptz($1::int, 12, 1, 0, 0, 0, 'UTC'), + INTERVAL '1 month' + ) AS gs(bucket) +), +by_month_currency AS ( + SELECT + date_trunc('month', COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC') AS ym, + p.currency, + SUM(p.amount)::float8 AS total_revenue + FROM payments p + WHERE p.status = 'SUCCESS' + AND EXTRACT(YEAR FROM COALESCE(p.paid_at, p.created_at) AT TIME ZONE 'UTC')::int = $1::int + GROUP BY 1, 2 +) +SELECT + (EXTRACT(MONTH FROM m.bucket AT TIME ZONE 'UTC'))::int AS month, + date_trunc('month', m.bucket AT TIME ZONE 'UTC')::date AS month_start, + COALESCE(b.currency, 'ETB'::varchar) AS currency, + COALESCE(b.total_revenue, 0)::float8 AS total_revenue +FROM months m +LEFT JOIN by_month_currency b ON b.ym = date_trunc('month', m.bucket AT TIME ZONE 'UTC') +ORDER BY m.bucket, COALESCE(b.currency, ''::varchar) +` + +type AnalyticsRevenueMonthlyByYearRow struct { + Month int32 `json:"month"` + MonthStart pgtype.Date `json:"month_start"` + Currency string `json:"currency"` + TotalRevenue float64 `json:"total_revenue"` +} + +// Monthly successful revenue for a calendar year (UTC buckets). Months with multiple currencies emit one row each; months with no revenue emit one row (currency ETB, revenue 0). +func (q *Queries) AnalyticsRevenueMonthlyByYear(ctx context.Context, reportYear int32) ([]AnalyticsRevenueMonthlyByYearRow, error) { + rows, err := q.db.Query(ctx, AnalyticsRevenueMonthlyByYear, reportYear) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AnalyticsRevenueMonthlyByYearRow + for rows.Next() { + var i AnalyticsRevenueMonthlyByYearRow + if err := rows.Scan( + &i.Month, + &i.MonthStart, + &i.Currency, + &i.TotalRevenue, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const AnalyticsSubscriptionsByStatus = `-- name: AnalyticsSubscriptionsByStatus :many SELECT COALESCE(us.status, 'unknown') AS status, diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index f7d1793..bbf78dd 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -29,6 +29,15 @@ type AnalyticsRevenueTimePoint struct { Revenue float64 `json:"revenue"` } +// AnalyticsMonthlyRevenuePoint is one calendar month bucket (UTC month start) within a dashboard year query. +type AnalyticsMonthlyRevenuePoint struct { + Month int `json:"month"` // 1–12 + MonthStart time.Time `json:"month_start"` // UTC date of month start (for sorting / tooltips) + Label string `json:"label"` // Short English month label, e.g. Jan + Currency string `json:"currency"` + Revenue float64 `json:"revenue"` // SUCCESS payments aggregate for that bucket +} + type AnalyticsUsersSection struct { TotalUsers int64 `json:"total_users"` NewToday int64 `json:"new_today"` @@ -67,6 +76,11 @@ type AnalyticsPaymentsSection struct { ByMethod []AnalyticsLabelAmount `json:"by_method"` RevenueLast30Days []AnalyticsRevenueTimePoint `json:"revenue_last_30_days"` + + // RevenueMonthly is populated only when the request includes year=..., with 12 months (possibly multiple currencies per month). + RevenueMonthly []AnalyticsMonthlyRevenuePoint `json:"revenue_monthly,omitempty"` + // MonthlyRevenueYear is set when RevenueMonthly is non-empty (the calendar year of those buckets). + MonthlyRevenueYear *int `json:"monthly_revenue_year,omitempty"` } // AnalyticsLMSContentCounts reflects the LMS hierarchy (Learn English): programs → courses → modules → lessons. diff --git a/internal/web_server/handlers/analytics_handler.go b/internal/web_server/handlers/analytics_handler.go index 71cc69f..daa873f 100644 --- a/internal/web_server/handlers/analytics_handler.go +++ b/internal/web_server/handlers/analytics_handler.go @@ -8,6 +8,11 @@ import ( "github.com/gofiber/fiber/v2" ) +// Short month labels for analytics monthly charts (aligned with UTC calendar months). +var analyticsShortMonthLabels = []string{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +} + func toTime(v interface{}) time.Time { if t, ok := v.(time.Time); ok { return t @@ -17,7 +22,7 @@ func toTime(v interface{}) time.Time { // GetAnalyticsDashboard godoc // @Summary Analytics dashboard -// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. The courses section includes LMS (programs→courses→modules→lessons, lms_practices) and exam_prep (catalog_courses→units→unit_modules→lessons, lesson_practices) inventory counts. +// @Description Platform analytics with optional date filters: all-time (default), year, year+month, or custom from/to range. When year is set, payments.revenue_monthly returns Jan–Dec SUCCESS revenue totals (UTC) per currency for that calendar year — use for yearly revenue charts. Daily series remains in revenue_last_30_days (see date_filter.series_*). Courses section counts LMS + exam_prep inventory. // @Tags analytics // @Produce json // @Param year query int false "Calendar year (e.g. 2025)" @@ -103,6 +108,17 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch revenue time series") } + var revenueMonthlyRows []dbgen.AnalyticsRevenueMonthlyByYearRow + var monthlyRevenueYear *int + if filter.Year != nil { + rowsMonthly, err := h.analyticsDB.AnalyticsRevenueMonthlyByYear(ctx, int32(*filter.Year)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch monthly revenue series") + } + revenueMonthlyRows = rowsMonthly + monthlyRevenueYear = filter.Year + } + courseCounts, err := h.analyticsDB.AnalyticsCourseCounts(ctx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch course analytics") @@ -165,7 +181,7 @@ func (h *Handler) GetAnalyticsDashboard(c *fiber.Ctx) error { DateFilter: filter, Users: mapUsersSection(usersSummary, usersByRole, usersByStatus, usersByAge, usersByKnowledge, usersByRegion, userRegs), Subscriptions: mapSubscriptionsSection(subsSummary, subsByStatus, revenueByPlan, newSubs30), - Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30), + Payments: mapPaymentsSection(paymentsSummary, paymentsByStatus, paymentsByMethod, revenue30, revenueMonthlyRows, monthlyRevenueYear), Courses: mapCoursesSection(courseCounts), Content: mapContentSection(questionsCounts, questionsByType, questionSetsByType), Notifications: mapNotificationsSection(notifSummary, notifByChannel, notifByType), @@ -286,6 +302,8 @@ func mapPaymentsSection( byStatus []dbgen.AnalyticsPaymentsByStatusRow, byMethod []dbgen.AnalyticsPaymentsByMethodRow, revenue []dbgen.AnalyticsRevenueLast30DaysRow, + revenueMonthly []dbgen.AnalyticsRevenueMonthlyByYearRow, + monthlyYear *int, ) domain.AnalyticsPaymentsSection { statuses := make([]domain.AnalyticsLabelAmount, len(byStatus)) for i, r := range byStatus { @@ -299,6 +317,27 @@ func mapPaymentsSection( for i, r := range revenue { timePoints[i] = domain.AnalyticsRevenueTimePoint{Date: toTime(r.Date), Revenue: r.TotalRevenue} } + monthlyPoints := make([]domain.AnalyticsMonthlyRevenuePoint, 0, len(revenueMonthly)) + for _, r := range revenueMonthly { + m := int(r.Month) + label := "" + if m >= 1 && m <= 12 { + label = analyticsShortMonthLabels[m-1] + } + ms := time.Time{} + if r.MonthStart.Valid { + t := r.MonthStart.Time.UTC() + ms = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + } + monthlyPoints = append(monthlyPoints, domain.AnalyticsMonthlyRevenuePoint{ + Month: m, + MonthStart: ms, + Label: label, + Currency: r.Currency, + Revenue: r.TotalRevenue, + }) + } + return domain.AnalyticsPaymentsSection{ TotalRevenue: summary.TotalRevenue, AvgTransactionValue: summary.AvgValue, @@ -307,6 +346,8 @@ func mapPaymentsSection( ByStatus: statuses, ByMethod: methods, RevenueLast30Days: timePoints, + RevenueMonthly: monthlyPoints, + MonthlyRevenueYear: monthlyYear, } }