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
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 {
+2
View File
@@ -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
+11
View File
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
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"`
// 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"`
// 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
+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)
}
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,
+36 -2
View File
@@ -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)
}
@@ -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)
}
}
+2 -1
View File
@@ -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").
@@ -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,
})
}