diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 1d2045f..3b48206 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -28,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { + // Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9). TotalAmount float64 `json:"total_amount"` + PointAmount float64 `json:"point_amount"` + PointsUsed int64 `json:"points_used"` + TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -42,6 +46,8 @@ type PaymentMethodAnalyticsData struct { OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` Percentage float64 `json:"percentage"` + PointsUsed int64 `json:"points_used"` + CountsAsCashIn bool `json:"counts_as_cash_in"` } type SalesAnalyticsRequest struct { diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 87b9644..8446e86 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` + // EnakPoint used, for the EnakPoint method. + PointsUsed int64 `json:"points_used"` } // SalesAnalytics represents sales analytics data @@ -173,15 +175,15 @@ type BudgetCutOffWeek struct { // DashboardOverview represents dashboard overview data type DashboardOverview struct { - TotalSales float64 `json:"total_sales"` - TotalOrders int64 `json:"total_orders"` - AverageOrderValue float64 `json:"average_order_value"` - TotalCustomers int64 `json:"total_customers"` - VoidedOrders int64 `json:"voided_orders"` - RefundedOrders int64 `json:"refunded_orders"` - TotalItemSold int64 `json:"total_item_sold"` - TotalLowStock int64 `json:"total_low_stock"` - TotalProductActive int64 `json:"total_product_active"` + TotalSales float64 `json:"total_sales"` + TotalOrders int64 `json:"total_orders"` + AverageOrderValue float64 `json:"average_order_value"` + TotalCustomers int64 `json:"total_customers"` + VoidedOrders int64 `json:"voided_orders"` + RefundedOrders int64 `json:"refunded_orders"` + TotalItemSold int64 `json:"total_item_sold"` + TotalLowStock int64 `json:"total_low_stock"` + TotalProductActive int64 `json:"total_product_active"` } type ProfitLossAnalytics struct { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 109a65b..141dac2 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - TotalAmount float64 `json:"total_amount"` + // Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9, + // K7) and is reported apart; its accounting treatment waits on note N2. + TotalAmount float64 `json:"total_amount"` + // Rupiah paid with EnakPoint, and how many EnakPoint that was. + PointAmount float64 `json:"point_amount"` + PointsUsed int64 `json:"points_used"` + // TotalAmount plus PointAmount: the value of the orders paid. + TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - Percentage float64 `json:"percentage"` + // Share of the money received; 0 for EnakPoint, which is not money in. + Percentage float64 `json:"percentage"` + PointsUsed int64 `json:"points_used"` + // False for EnakPoint. + CountsAsCashIn bool `json:"counts_as_cash_in"` } // SalesAnalyticsRequest represents the request for sales analytics @@ -464,12 +475,12 @@ type ProfitLossAnalyticsResponse struct { } type ProfitLossPurchasing struct { - TodayTotal float64 `json:"today_total"` - MtdTotal float64 `json:"mtd_total"` - TodayRawMaterial float64 `json:"today_raw_material"` - MtdRawMaterial float64 `json:"mtd_raw_material"` - TodayExpense float64 `json:"today_expense"` - MtdExpense float64 `json:"mtd_expense"` + TodayTotal float64 `json:"today_total"` + MtdTotal float64 `json:"mtd_total"` + TodayRawMaterial float64 `json:"today_raw_material"` + MtdRawMaterial float64 `json:"mtd_raw_material"` + TodayExpense float64 `json:"today_expense"` + MtdExpense float64 `json:"mtd_expense"` Items []ProfitLossPurchasingItem `json:"items"` } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 1297e90..9764551 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, return nil, fmt.Errorf("failed to get payment method analytics: %w", err) } - var totalAmount float64 + // EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own + // method but left out of the money received and of the shares. How it is booked + // waits on note N2. + var cashAmount, pointAmount float64 + var pointsUsed int64 var totalOrders int64 var totalPayments int64 for _, data := range analyticsData { - totalAmount += data.TotalAmount + if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) { + pointAmount += data.TotalAmount + pointsUsed += data.PointsUsed + } else { + cashAmount += data.TotalAmount + } totalOrders += data.OrderCount totalPayments += data.PaymentCount } + // The value of an order includes what EnakPoint paid, so the average does too. var averageOrderValue float64 if totalOrders > 0 { - averageOrderValue = totalAmount / float64(totalOrders) + averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders) } - // Calculate percentages var resultData []models.PaymentMethodAnalyticsData for _, data := range analyticsData { + cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint) var percentage float64 - if totalAmount > 0 { - percentage = (data.TotalAmount / totalAmount) * 100 + if cashIn && cashAmount > 0 { + percentage = (data.TotalAmount / cashAmount) * 100 } resultData = append(resultData, models.PaymentMethodAnalyticsData{ @@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, OrderCount: data.OrderCount, PaymentCount: data.PaymentCount, Percentage: percentage, + PointsUsed: data.PointsUsed, + CountsAsCashIn: cashIn, }) } summary := models.PaymentMethodSummary{ - TotalAmount: totalAmount, + TotalAmount: cashAmount, + PointAmount: pointAmount, + PointsUsed: pointsUsed, + TotalWithPoints: cashAmount + pointAmount, TotalOrders: totalOrders, TotalPayments: totalPayments, AverageOrderValue: averageOrderValue, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 7ae2d0e..33f9f79 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -10,6 +10,7 @@ import ( "apskel-pos-be/internal/models" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,10 +25,11 @@ type analyticsRepositoryStub struct { exclusiveSummaryCalls int exclusiveSummaryFrom []time.Time exclusiveSummaryTo []time.Time + paymentMethods []*entities.PaymentMethodAnalytics } -func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { - return nil, nil +func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { + return s.paymentMethods, nil } func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { @@ -570,3 +572,35 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t require.Len(t, result.DailySummary, 1) require.Len(t, result.DailyTransactions, 2) } + +// EnakPoint is listed as its own method but is not money in (F9, K7). +func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) { + repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{ + {PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2}, + {PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1}, + {PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000}, + }} + p := NewAnalyticsProcessorImpl(repo, nil) + + got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{ + OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(), + }) + require.NoError(t, err) + assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out") + assert.Equal(t, 30000.0, got.Summary.PointAmount) + assert.Equal(t, int64(30000), got.Summary.PointsUsed) + assert.Equal(t, 120000.0, got.Summary.TotalWithPoints) + assert.Equal(t, int64(4), got.Summary.TotalOrders) + assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid") + + byType := map[string]models.PaymentMethodAnalyticsData{} + for _, d := range got.Data { + byType[d.PaymentMethodType] = d + } + assert.True(t, byType["cash"].CountsAsCashIn) + assert.False(t, byType["point"].CountsAsCashIn) + assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received") + assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01) + assert.Zero(t, byType["point"].Percentage) + assert.Equal(t, int64(30000), byType["point"].PointsUsed) +} diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go index 7f72d3a..3ab8c2f 100644 --- a/internal/processor/point_payment_db_test.go +++ b/internal/processor/point_payment_db_test.go @@ -5,6 +5,7 @@ import ( "os" "sync" "testing" + "time" "github.com/alicebob/miniredis/v2" "github.com/google/uuid" @@ -374,3 +375,27 @@ func TestPointPayment_InApp(t *testing.T) { require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) assert.Nil(t, createdBy, "no cashier took an in-app payment") } + +// The payment method report counts only money actually received as money in; the +// EnakPoint part is listed apart (F9). +func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) { + e := newPointPaymentEnv(t) + customer := e.customerWith(100000) + order := e.order(customer, 87500) + _, err := e.payPoints(order, 20000, e.code(customer)) + require.NoError(t, err) + _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) + require.NoError(t, err) + + report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(), + &models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)}) + require.NoError(t, err) + assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total") + assert.Equal(t, 20000.0, report.Summary.PointAmount) + assert.Equal(t, int64(20000), report.Summary.PointsUsed) + assert.Equal(t, 87500.0, report.Summary.TotalWithPoints) + require.Len(t, report.Data, 2) + for _, d := range report.Data { + assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName) + } +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 1696973..a4fddcb 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, pm.type as payment_method_type, COALESCE(SUM(p.amount), 0) as total_amount, COUNT(DISTINCT p.order_id) as order_count, - COUNT(p.id) as payment_count + COUNT(p.id) as payment_count, + COALESCE(SUM(p.points_used), 0) as points_used `). Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id"). Joins("JOIN orders o ON p.order_id = o.id"). @@ -478,7 +479,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ COALESCE(SUM(oi.weight), 0) as weight_sold, COALESCE(SUM(oi.total_price), 0) as revenue, CASE - WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `) + WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`) ELSE 0 END as average_price, COUNT(DISTINCT oi.order_id) as order_count, @@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id WHERE pr.product_id = p.id - ), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total, + ), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total, CASE - WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `) + WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`) ELSE 0 END as fifo_hpp_per_unit, COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit, - COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total + COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -551,9 +552,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). @@ -604,9 +605,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COUNT(DISTINCT c.id) as category_count, COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT oi.order_id) as order_count, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, - COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp `). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 8590c18..a6267cc 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -60,6 +60,8 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, + PointsUsed: item.PointsUsed, + CountsAsCashIn: item.CountsAsCashIn, }) } @@ -72,6 +74,9 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe GroupBy: resp.GroupBy, Summary: contract.PaymentMethodSummary{ TotalAmount: resp.Summary.TotalAmount, + PointAmount: resp.Summary.PointAmount, + PointsUsed: resp.Summary.PointsUsed, + TotalWithPoints: resp.Summary.TotalWithPoints, TotalOrders: resp.Summary.TotalOrders, TotalPayments: resp.Summary.TotalPayments, AverageOrderValue: resp.Summary.AverageOrderValue, @@ -617,6 +622,8 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, + PointsUsed: item.PointsUsed, + CountsAsCashIn: item.CountsAsCashIn, }) }