Add monthly revenue trend for analytics when year is specified.

Exposes payments.revenue_monthly with Jan–Dec SUCCESS totals (UTC) per currency for dashboard charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-17 23:32:36 -07:00
parent a1696bf1e0
commit 2883561525
7 changed files with 251 additions and 5 deletions

View File

@ -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
-- =====================

View File

@ -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 JanDec 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": "112",
"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"
},

View File

@ -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 JanDec 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": "112",
"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"
},

View File

@ -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: 112
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 JanDec 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

View File

@ -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,

View File

@ -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"` // 112
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.

View File

@ -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 JanDec 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,
}
}