feat(loyalty): report EnakPoint apart from money received
Keeps EnakPoint out of the money in on the payment method analytics (docs/prd-point-coin.md F9, K7, PC-308), the one report that sums payments; the daily transaction and profit-loss PDFs do not break payments down by method. summary.total_amount is now only money actually received. EnakPoint stays listed as its own method, with points_used, and the summary adds point_amount, points_used and total_with_points. Each method row says whether it counts_as_cash_in, and the shares are of the money received, 0 for EnakPoint. The average order value still includes what EnakPoint paid, since that is part of what the orders were worth. How EnakPoint is booked waits on note N2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d7138b8f87
commit
0b52edf84e
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user