feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
8 changed files with 136 additions and 35 deletions
Showing only changes of commit 0b52edf84e - Show all commits
+6
View File
@@ -28,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics // PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct { type PaymentMethodSummary struct {
// Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9).
TotalAmount float64 `json:"total_amount"` 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"` TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"` TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"` AverageOrderValue float64 `json:"average_order_value"`
@@ -42,6 +46,8 @@ type PaymentMethodAnalyticsData struct {
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"` PaymentCount int64 `json:"payment_count"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
CountsAsCashIn bool `json:"counts_as_cash_in"`
} }
type SalesAnalyticsRequest struct { type SalesAnalyticsRequest struct {
+2
View File
@@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct {
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"` PaymentCount int64 `json:"payment_count"`
// EnakPoint used, for the EnakPoint method.
PointsUsed int64 `json:"points_used"`
} }
// SalesAnalytics represents sales analytics data // SalesAnalytics represents sales analytics data
+11
View File
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics // PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct { type PaymentMethodSummary struct {
// 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"` 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"` TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"` TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"` AverageOrderValue float64 `json:"average_order_value"`
@@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct {
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"` PaymentCount int64 `json:"payment_count"`
// Share of the money received; 0 for EnakPoint, which is not money in.
Percentage float64 `json:"percentage"` 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 // SalesAnalyticsRequest represents the request for sales analytics
+22 -7
View File
@@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return nil, fmt.Errorf("failed to get payment method analytics: %w", err) 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 totalOrders int64
var totalPayments int64 var totalPayments int64
for _, data := range analyticsData { 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 totalOrders += data.OrderCount
totalPayments += data.PaymentCount totalPayments += data.PaymentCount
} }
// The value of an order includes what EnakPoint paid, so the average does too.
var averageOrderValue float64 var averageOrderValue float64
if totalOrders > 0 { if totalOrders > 0 {
averageOrderValue = totalAmount / float64(totalOrders) averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders)
} }
// Calculate percentages
var resultData []models.PaymentMethodAnalyticsData var resultData []models.PaymentMethodAnalyticsData
for _, data := range analyticsData { for _, data := range analyticsData {
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
var percentage float64 var percentage float64
if totalAmount > 0 { if cashIn && cashAmount > 0 {
percentage = (data.TotalAmount / totalAmount) * 100 percentage = (data.TotalAmount / cashAmount) * 100
} }
resultData = append(resultData, models.PaymentMethodAnalyticsData{ resultData = append(resultData, models.PaymentMethodAnalyticsData{
@@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
OrderCount: data.OrderCount, OrderCount: data.OrderCount,
PaymentCount: data.PaymentCount, PaymentCount: data.PaymentCount,
Percentage: percentage, Percentage: percentage,
PointsUsed: data.PointsUsed,
CountsAsCashIn: cashIn,
}) })
} }
summary := models.PaymentMethodSummary{ summary := models.PaymentMethodSummary{
TotalAmount: totalAmount, TotalAmount: cashAmount,
PointAmount: pointAmount,
PointsUsed: pointsUsed,
TotalWithPoints: cashAmount + pointAmount,
TotalOrders: totalOrders, TotalOrders: totalOrders,
TotalPayments: totalPayments, TotalPayments: totalPayments,
AverageOrderValue: averageOrderValue, AverageOrderValue: averageOrderValue,
+36 -2
View File
@@ -10,6 +10,7 @@ import (
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -24,10 +25,11 @@ type analyticsRepositoryStub struct {
exclusiveSummaryCalls int exclusiveSummaryCalls int
exclusiveSummaryFrom []time.Time exclusiveSummaryFrom []time.Time
exclusiveSummaryTo []time.Time exclusiveSummaryTo []time.Time
paymentMethods []*entities.PaymentMethodAnalytics
} }
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
return nil, nil return s.paymentMethods, nil
} }
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) { 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.DailySummary, 1)
require.Len(t, result.DailyTransactions, 2) 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)
}
@@ -5,6 +5,7 @@ import (
"os" "os"
"sync" "sync"
"testing" "testing"
"time"
"github.com/alicebob/miniredis/v2" "github.com/alicebob/miniredis/v2"
"github.com/google/uuid" "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) 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") 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)
}
}
+10 -9
View File
@@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
pm.type as payment_method_type, pm.type as payment_method_type,
COALESCE(SUM(p.amount), 0) as total_amount, COALESCE(SUM(p.amount), 0) as total_amount,
COUNT(DISTINCT p.order_id) as order_count, 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 payment_methods pm ON p.payment_method_id = pm.id").
Joins("JOIN orders o ON p.order_id = o.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.weight), 0) as weight_sold,
COALESCE(SUM(oi.total_price), 0) as revenue, COALESCE(SUM(oi.total_price), 0) as revenue,
CASE 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 ELSE 0
END as average_price, END as average_price,
COUNT(DISTINCT oi.order_id) as order_count, COUNT(DISTINCT oi.order_id) as order_count,
@@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
FROM product_recipes pr FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.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 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 ELSE 0
END as fifo_hpp_per_unit, END as fifo_hpp_per_unit,
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total, 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) 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 products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.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, 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 p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_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 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 products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.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 c.id) as category_count,
COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_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 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 products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id"). Joins("JOIN categories c ON p.category_id = c.id").
@@ -60,6 +60,8 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
OrderCount: item.OrderCount, OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount, PaymentCount: item.PaymentCount,
Percentage: item.Percentage, Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
}) })
} }
@@ -72,6 +74,9 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
Summary: contract.PaymentMethodSummary{ Summary: contract.PaymentMethodSummary{
TotalAmount: resp.Summary.TotalAmount, TotalAmount: resp.Summary.TotalAmount,
PointAmount: resp.Summary.PointAmount,
PointsUsed: resp.Summary.PointsUsed,
TotalWithPoints: resp.Summary.TotalWithPoints,
TotalOrders: resp.Summary.TotalOrders, TotalOrders: resp.Summary.TotalOrders,
TotalPayments: resp.Summary.TotalPayments, TotalPayments: resp.Summary.TotalPayments,
AverageOrderValue: resp.Summary.AverageOrderValue, AverageOrderValue: resp.Summary.AverageOrderValue,
@@ -617,6 +622,8 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
OrderCount: item.OrderCount, OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount, PaymentCount: item.PaymentCount,
Percentage: item.Percentage, Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
}) })
} }