feat(loyalty): EnakPoint & EnakCoin #32
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user