feat(loyalty): remove paying with EnakPoint
EnakPoint can only be redeemed for vouchers now: it can no longer pay for orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001, EG-002). No order was ever paid with EnakPoint, so there is no data to move. Removed: - POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points and GET /orders/:id/point-payment/preview, with their processors, repositories, services, handlers and tests. - The point payment method type: paying, splitting and refunding with it, the outlet filter on the method list, and the system-method guard. - points and payment_code on CreatePayment; points_used and point_value on payments; accepts_point_payment on the customer outlets. - The outlet point_payment settings. A PUT that still sends them is rejected as an unknown field. - The EnakPoint split in the payment method analytics. - PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used them as a generic EnakPoint debit use REWARD_REDEEM. - The EnakPoint-paid part from the earning basis, which is subtotal − discount again. Migration 000102 drops the trigger, the point methods and their index, the payments columns, and the outlet settings, and restores the method type CHECK without point. payments.payment_method_id is ON DELETE RESTRICT, so it fails rather than lose a payment made with EnakPoint. The integration docs list the removed endpoints and fields, and the EnakPoint & EnakCoin PRD and tasks note what is superseded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3ebc09f818
commit
2c9753fae7
@@ -63,37 +63,27 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
|
||||
}
|
||||
|
||||
// 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 totalAmount float64
|
||||
var totalOrders int64
|
||||
var totalPayments int64
|
||||
|
||||
for _, data := range analyticsData {
|
||||
if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) {
|
||||
pointAmount += data.TotalAmount
|
||||
pointsUsed += data.PointsUsed
|
||||
} else {
|
||||
cashAmount += data.TotalAmount
|
||||
}
|
||||
totalAmount += 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 = (cashAmount + pointAmount) / float64(totalOrders)
|
||||
averageOrderValue = totalAmount / float64(totalOrders)
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
var resultData []models.PaymentMethodAnalyticsData
|
||||
for _, data := range analyticsData {
|
||||
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
|
||||
var percentage float64
|
||||
if cashIn && cashAmount > 0 {
|
||||
percentage = (data.TotalAmount / cashAmount) * 100
|
||||
if totalAmount > 0 {
|
||||
percentage = (data.TotalAmount / totalAmount) * 100
|
||||
}
|
||||
|
||||
resultData = append(resultData, models.PaymentMethodAnalyticsData{
|
||||
@@ -104,16 +94,11 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
OrderCount: data.OrderCount,
|
||||
PaymentCount: data.PaymentCount,
|
||||
Percentage: percentage,
|
||||
PointsUsed: data.PointsUsed,
|
||||
CountsAsCashIn: cashIn,
|
||||
})
|
||||
}
|
||||
|
||||
summary := models.PaymentMethodSummary{
|
||||
TotalAmount: cashAmount,
|
||||
PointAmount: pointAmount,
|
||||
PointsUsed: pointsUsed,
|
||||
TotalWithPoints: cashAmount + pointAmount,
|
||||
TotalAmount: totalAmount,
|
||||
TotalOrders: totalOrders,
|
||||
TotalPayments: totalPayments,
|
||||
AverageOrderValue: averageOrderValue,
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -574,38 +573,6 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
|
||||
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)
|
||||
}
|
||||
|
||||
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||
// default share, and the team limit shrinks by the same amount.
|
||||
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||
|
||||
@@ -129,8 +129,6 @@ func (p *CustomerOrderProcessor) Detail(ctx context.Context, customerID, orderID
|
||||
Amount: pay.Amount,
|
||||
Status: pay.Status,
|
||||
RefundAmount: pay.RefundAmount,
|
||||
PointsUsed: pay.PointsUsed,
|
||||
PointValue: pay.PointValue,
|
||||
CreatedAt: pay.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
|
||||
repo, customer, mine, _ := newCustomerOrderTest()
|
||||
variant, unit := "Large", "ons"
|
||||
weight := 4.2
|
||||
points, value := int64(12500), 1.0
|
||||
repo.items = map[uuid.UUID][]repository.CustomerOrderItemRow{
|
||||
mine: {
|
||||
{ProductName: "Kopi Susu", VariantName: &variant, Quantity: 2, UnitPrice: 25000, TotalPrice: 50000, Status: "completed"},
|
||||
@@ -119,7 +118,7 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
|
||||
}
|
||||
repo.payments = map[uuid.UUID][]repository.CustomerOrderPaymentRow{
|
||||
mine: {
|
||||
{MethodName: "EnakPoint", MethodType: "point", Amount: 12500, Status: "completed", PointsUsed: &points, PointValue: &value},
|
||||
{MethodName: "Card", MethodType: "card", Amount: 12500, Status: "completed"},
|
||||
{MethodName: "Cash", MethodType: "cash", Amount: 86500, Status: "completed"},
|
||||
},
|
||||
}
|
||||
@@ -135,7 +134,8 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
|
||||
assert.Equal(t, []map[string]interface{}{}, got.Items[0].Modifiers, "no modifiers is an empty list, not null")
|
||||
assert.Equal(t, 4.2, *got.Items[1].Weight)
|
||||
require.Len(t, got.Payments, 2)
|
||||
assert.Equal(t, int64(12500), *got.Payments[0].PointsUsed)
|
||||
assert.Equal(t, "Card", got.Payments[0].MethodName)
|
||||
assert.Equal(t, float64(12500), got.Payments[0].Amount)
|
||||
}
|
||||
|
||||
func TestCustomerOrders_AnotherCustomersOrderIsNotFound(t *testing.T) {
|
||||
|
||||
@@ -37,12 +37,11 @@ func (p *CustomerOutletProcessor) List(ctx context.Context, customerID uuid.UUID
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, models.CustomerOutlet{
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Address: o.Address,
|
||||
AcceptsPointPayment: settings.PointPayment.AcceptPayment,
|
||||
EarnsPoints: settings.Point.Enabled,
|
||||
EarnsCoins: settings.Coin.Enabled,
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Address: o.Address,
|
||||
EarnsPoints: settings.Point.Enabled,
|
||||
EarnsCoins: settings.Coin.Enabled,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
|
||||
@@ -48,17 +48,14 @@ func TestCustomerOutlets_ListsTheCustomersOrganizationWithLoyaltyFlags(t *testin
|
||||
},
|
||||
}
|
||||
settings := outletSettingsFake{
|
||||
kemang: {
|
||||
Point: models.LoyaltyEarnSettings{Enabled: true},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: true},
|
||||
},
|
||||
kemang: {Point: models.LoyaltyEarnSettings{Enabled: true}},
|
||||
}
|
||||
|
||||
got, err := NewCustomerOutletProcessor(repo, settings).List(context.Background(), customer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []models.CustomerOutlet{
|
||||
{ID: blokm, Name: "Blok M"},
|
||||
{ID: kemang, Name: "Kemang", Address: &addr, AcceptsPointPayment: true, EarnsPoints: true},
|
||||
{ID: kemang, Name: "Kemang", Address: &addr, EarnsPoints: true},
|
||||
}, got)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ const (
|
||||
PinActionTransfer PinAction = "TRANSFER"
|
||||
)
|
||||
|
||||
type pinVerifier interface {
|
||||
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
|
||||
}
|
||||
|
||||
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
|
||||
const (
|
||||
PinErrNotSet = "PIN_NOT_SET"
|
||||
|
||||
@@ -21,7 +21,7 @@ type EarningLine struct {
|
||||
|
||||
// EarningResult is what an order earns.
|
||||
type EarningResult struct {
|
||||
// subtotal − discount − the part paid with EnakPoint, in rupiah, never negative.
|
||||
// subtotal − discount, in rupiah, never negative.
|
||||
// Tax and anything else added on top of the subtotal are not part of it (Q1).
|
||||
Basis float64
|
||||
Point EarningLine
|
||||
@@ -49,17 +49,16 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
|
||||
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
|
||||
//
|
||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||
// basis = subtotal − discount_amount
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
|
||||
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||
// floor never lands one short on a value like 87500.00 that float64 cannot hold
|
||||
// exactly. It has no side effects.
|
||||
func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult {
|
||||
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount)
|
||||
// Money is handled in whole cents so floor never lands one short on a value like
|
||||
// 87500.00 that float64 cannot hold exactly. It has no side effects.
|
||||
func CalculateEarning(order *entities.Order, settings models.OutletLoyaltySettings) EarningResult {
|
||||
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount)
|
||||
if basisCents < 0 {
|
||||
basisCents = 0
|
||||
}
|
||||
|
||||
@@ -22,35 +22,29 @@ func TestCalculateEarning_PRDExample(t *testing.T) {
|
||||
// Subtotal after discount Rp 87.500, paid in full in cash.
|
||||
order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125}
|
||||
|
||||
got := CalculateEarning(order, 0, prdEarningSettings())
|
||||
got := CalculateEarning(order, prdEarningSettings())
|
||||
assert.Equal(t, 87500.0, got.Basis)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
|
||||
// Rp 20.000 of it paid with EnakPoint earns nothing.
|
||||
got = CalculateEarning(order, 20000, prdEarningSettings())
|
||||
assert.Equal(t, 67500.0, got.Basis)
|
||||
assert.Equal(t, int64(675), got.Point.Amount)
|
||||
assert.Equal(t, int64(2), got.Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) {
|
||||
withoutTax := &entities.Order{Subtotal: 50000}
|
||||
withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500}
|
||||
assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings()))
|
||||
assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount)
|
||||
assert.Equal(t, CalculateEarning(withoutTax, prdEarningSettings()), CalculateEarning(withTax, prdEarningSettings()))
|
||||
assert.Equal(t, int64(500), CalculateEarning(withTax, prdEarningSettings()).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_BelowMinimum(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.MinOrderAmount = 50000
|
||||
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns")
|
||||
// The minimum applies to the basis, after discount and EnakPoint.
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount)
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount)
|
||||
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount, "the minimum itself earns")
|
||||
// The minimum applies to the basis, after discount.
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000, DiscountAmount: 15000}, s).Point.Amount)
|
||||
// Coin has its own minimum.
|
||||
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount)
|
||||
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
|
||||
@@ -58,36 +52,36 @@ func TestCalculateEarning_MaxPerOrder(t *testing.T) {
|
||||
max := int64(300)
|
||||
s.Point.MaxPerOrder = &max
|
||||
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, s)
|
||||
assert.Equal(t, int64(300), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"])
|
||||
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 20000}, s)
|
||||
assert.Equal(t, int64(200), got.Point.Amount)
|
||||
assert.False(t, got.Point.Capped)
|
||||
|
||||
zero := int64(0)
|
||||
s.Point.MaxPerOrder = &zero
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.Enabled = false
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, s)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own")
|
||||
|
||||
s.Coin.Enabled = false
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, s)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
assert.Equal(t, 87500.0, got.Basis, "the basis is still reported")
|
||||
|
||||
// The defaults of an outlet that never set anything earn nothing.
|
||||
var defaults models.OutletLoyaltySettings
|
||||
loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test")
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, defaults)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
}
|
||||
@@ -96,22 +90,22 @@ func TestCalculateEarning_EdgeCases(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
|
||||
// floor, not round.
|
||||
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount)
|
||||
// Values float64 cannot hold exactly do not lose a point: computed in float64 this
|
||||
// basis divides to 4956.999…, which a naive floor turns into 4956.
|
||||
per250 := prdEarningSettings()
|
||||
per250.Point.EarnPerAmount = 250
|
||||
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount)
|
||||
// Paying more with EnakPoint than the basis leaves nothing, never a negative amount.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s)
|
||||
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 905.48}, per250).Point.Amount)
|
||||
// A discount larger than the subtotal leaves nothing, never a negative amount.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 10000, DiscountAmount: 15000}, s)
|
||||
assert.Equal(t, 0.0, got.Basis)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
// earn_value multiplies.
|
||||
s.Point.EarnValue = 5
|
||||
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
// A zero earn_value earns nothing even when enabled.
|
||||
s.Point.EarnValue = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
@@ -120,26 +114,24 @@ func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
s.Point.EarnPercent = 1
|
||||
|
||||
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s)
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, s)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
// The part paid with EnakPoint earns nothing in this mode either.
|
||||
assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount)
|
||||
|
||||
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
|
||||
s.Point.EarnPercent = 2.5
|
||||
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
s.Point.EarnPercent = 0.01
|
||||
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount)
|
||||
|
||||
// The minimum and the cap apply as in the other mode.
|
||||
s.Point.EarnPercent = 10
|
||||
s.Point.MinOrderAmount = 50000
|
||||
max := int64(6000)
|
||||
s.Point.MaxPerOrder = &max
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount)
|
||||
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, s)
|
||||
assert.Equal(t, int64(6000), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
|
||||
@@ -149,11 +141,11 @@ func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
|
||||
// A zero percent earns nothing even when enabled.
|
||||
s.Point.EarnPercent = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, prdEarningSettings())
|
||||
assert.Equal(t, entities.Metadata{
|
||||
"basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false,
|
||||
}, got.Metadata(got.Point))
|
||||
|
||||
@@ -86,11 +86,7 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings)
|
||||
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, *settings)
|
||||
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
|
||||
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@ func TestEarningReversal_AgainstPostgres(t *testing.T) {
|
||||
spent := paidOrder(spender)
|
||||
committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem,
|
||||
Amount: 800, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Bayar"}})
|
||||
})
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
|
||||
|
||||
@@ -257,10 +257,6 @@ func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
|
||||
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
s, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
}, *s)
|
||||
}
|
||||
|
||||
@@ -130,11 +129,10 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.
|
||||
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "true",
|
||||
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "500",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "50",
|
||||
"loyalty.unknown": "ignored",
|
||||
constants.LoyaltyPointEnabledKey: "true",
|
||||
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "500",
|
||||
"loyalty.unknown": "ignored",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "100",
|
||||
@@ -151,7 +149,6 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
assert.True(t, outlet.Point.Enabled)
|
||||
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
org, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
@@ -168,11 +165,10 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "yes please",
|
||||
constants.LoyaltyPointEarnPerAmountKey: "0",
|
||||
constants.LoyaltyCoinEarnValueKey: "-1",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "abc",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "150",
|
||||
constants.LoyaltyPointEnabledKey: "yes please",
|
||||
constants.LoyaltyPointEarnPerAmountKey: "0",
|
||||
constants.LoyaltyCoinEarnValueKey: "-1",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "abc",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "0",
|
||||
@@ -188,7 +184,6 @@ func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
|
||||
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
|
||||
assert.Nil(t, outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
|
||||
repo.orgValues = map[string]string{}
|
||||
@@ -288,17 +283,14 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
|
||||
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
|
||||
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
|
||||
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
|
||||
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
|
||||
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
|
||||
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
|
||||
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
} {
|
||||
s, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
@@ -21,9 +20,6 @@ type OrderProcessor interface {
|
||||
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
|
||||
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
|
||||
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
|
||||
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
|
||||
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
|
||||
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
|
||||
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
|
||||
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
|
||||
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
|
||||
@@ -115,9 +111,6 @@ type OrderProcessorImpl struct {
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
loyalty OrderLoyalty
|
||||
pointPayments *PointPaymentProcessor
|
||||
paymentCodes paymentCodeRedeemer
|
||||
pins pinVerifier
|
||||
}
|
||||
|
||||
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
||||
@@ -144,58 +137,6 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
||||
p.loyalty = loyalty
|
||||
}
|
||||
|
||||
type paymentCodeRedeemer interface {
|
||||
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
|
||||
// payments to pointPayments approved by the code the customer shows at the cashier,
|
||||
// and PayWithPointsInApp approved by the customer's PIN (F9).
|
||||
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
|
||||
p.pointPayments = pointPayments
|
||||
p.paymentCodes = codes
|
||||
p.pins = pins
|
||||
}
|
||||
|
||||
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
|
||||
// generic payment path, which would record the payment without taking any balance.
|
||||
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
|
||||
if p.pointPayments == nil || p.paymentCodes == nil {
|
||||
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
||||
}
|
||||
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
|
||||
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
|
||||
}
|
||||
var cashier *uuid.UUID
|
||||
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
|
||||
cashier = &id
|
||||
}
|
||||
code := *req.PaymentCode
|
||||
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
||||
OrderID: req.OrderID,
|
||||
PaymentMethodID: req.PaymentMethodID,
|
||||
Points: *req.Points,
|
||||
CashierID: cashier,
|
||||
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
||||
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Completed {
|
||||
p.onOrderPaid(ctx, req.OrderID)
|
||||
}
|
||||
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
||||
}
|
||||
return mappers.PaymentEntityToResponse(payment), nil
|
||||
}
|
||||
|
||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
||||
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
||||
// after the payment has committed. The hook runs detached from the caller's
|
||||
@@ -213,12 +154,6 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||
// never block or fail the void or refund.
|
||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
|
||||
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
|
||||
if p.pointPayments != nil {
|
||||
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
|
||||
}
|
||||
}
|
||||
if p.loyalty != nil {
|
||||
p.loyalty.OnOrderRefunded(ctx, orderID)
|
||||
}
|
||||
@@ -896,18 +831,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
reason = *req.Reason
|
||||
}
|
||||
|
||||
// An order refund is handed back in cash or another method, so it cannot cover what
|
||||
// was paid with EnakPoint (K7). Checked before anything is written.
|
||||
if p.pointPayments != nil {
|
||||
planned, err := p.plannedOrderRefund(ctx, id, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Process refund based on request type
|
||||
if req.RefundAmount != nil {
|
||||
// Full or partial refund by amount
|
||||
@@ -999,13 +922,10 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
|
||||
return nil, fmt.Errorf("order is already fully paid")
|
||||
}
|
||||
|
||||
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
if method.Type == entities.PaymentMethodTypePoint {
|
||||
return p.createPointPayment(ctx, req)
|
||||
}
|
||||
|
||||
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
@@ -1390,11 +1310,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
// Splitting with EnakPoint would record a payment without taking any balance; an
|
||||
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
|
||||
if payment.Type == entities.PaymentMethodTypePoint {
|
||||
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
|
||||
}
|
||||
|
||||
customer := &entities.Customer{}
|
||||
if req.CustomerID != uuid.Nil {
|
||||
@@ -1841,71 +1756,3 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
|
||||
// app or a self-order. The session alone is not enough: the customer's PIN approves
|
||||
// it (K8). An order that is not the customer's own is reported as not found, so the
|
||||
// endpoint does not reveal other customers' orders.
|
||||
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
|
||||
if p.pointPayments == nil || p.pins == nil {
|
||||
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
||||
}
|
||||
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner == nil || *owner != customerID {
|
||||
return nil, repository.ErrPointPaymentOrderNotFound
|
||||
}
|
||||
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
||||
OrderID: orderID,
|
||||
PaymentMethodID: methodID,
|
||||
Points: points,
|
||||
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
||||
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Completed {
|
||||
p.onOrderPaid(ctx, orderID)
|
||||
}
|
||||
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
||||
}
|
||||
return mappers.PaymentEntityToResponse(payment), nil
|
||||
}
|
||||
|
||||
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
|
||||
// way it will be, without writing anything.
|
||||
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
|
||||
if req.RefundAmount != nil {
|
||||
return *req.RefundAmount, nil
|
||||
}
|
||||
var total float64
|
||||
for _, itemRefund := range req.OrderItems {
|
||||
if itemRefund.RefundAmount != nil {
|
||||
total += *itemRefund.RefundAmount
|
||||
continue
|
||||
}
|
||||
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("order item not found: %w", err)
|
||||
}
|
||||
if orderItem.OrderID != orderID {
|
||||
return 0, fmt.Errorf("order item does not belong to this order")
|
||||
}
|
||||
quantity := itemRefund.RefundQuantity
|
||||
if quantity == 0 {
|
||||
quantity = orderItem.Quantity
|
||||
}
|
||||
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
paymentCodeDigits = 6
|
||||
paymentCodeTTL = 2 * time.Minute
|
||||
paymentCodeAttempts = 5
|
||||
// PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code.
|
||||
PaymentCodeQRPrefix = "enakpoint:"
|
||||
)
|
||||
|
||||
// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used,
|
||||
// or belongs to another customer.
|
||||
var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired")
|
||||
|
||||
type pinVerifier interface {
|
||||
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
|
||||
}
|
||||
|
||||
// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a
|
||||
// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with
|
||||
// their PIN on their own phone and shows the code; the PIN is never typed on the
|
||||
// cashier's device.
|
||||
type PaymentCodeProcessor struct {
|
||||
codes repository.PaymentCodeRepository
|
||||
pins pinVerifier
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor {
|
||||
return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now}
|
||||
}
|
||||
|
||||
// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two
|
||||
// minutes and bound to the customer. A new code retires the previous one.
|
||||
func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) {
|
||||
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for attempt := 0; attempt < paymentCodeAttempts; attempt++ {
|
||||
code, err := randomDigits(paymentCodeDigits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = p.codes.Save(ctx, code, customerID, paymentCodeTTL)
|
||||
if errors.Is(err, repository.ErrPaymentCodeTaken) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PaymentCode{
|
||||
Code: code,
|
||||
QRPayload: PaymentCodeQRPrefix + code,
|
||||
ExpiresAt: p.now().Add(paymentCodeTTL),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts)
|
||||
}
|
||||
|
||||
// Redeem uses a code up for a payment by the given customer. It accepts the code as
|
||||
// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid.
|
||||
func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||
code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix)
|
||||
if len(code) != paymentCodeDigits {
|
||||
return ErrPaymentCodeInvalid
|
||||
}
|
||||
err := p.codes.Consume(ctx, code, customerID)
|
||||
if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) {
|
||||
return ErrPaymentCodeInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be
|
||||
// predicted.
|
||||
func randomDigits(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
d, err := rand.Int(rand.Reader, big.NewInt(10))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to draw a payment code: %w", err)
|
||||
}
|
||||
b.WriteByte(byte('0' + d.Int64()))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type pinVerifierFake struct{ good string }
|
||||
|
||||
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
|
||||
if action != PinActionPay {
|
||||
return &PinError{Code: "UNEXPECTED_ACTION"}
|
||||
}
|
||||
if pin != f.good {
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
|
||||
}
|
||||
|
||||
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
|
||||
var pe *PinError
|
||||
require.ErrorAs(t, err, &pe)
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
|
||||
}
|
||||
|
||||
func TestPaymentCode_Lifecycle(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer, other := uuid.New(), uuid.New()
|
||||
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code.Code, 6)
|
||||
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
|
||||
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
|
||||
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
|
||||
|
||||
// A code of another customer is refused, and stays usable by its owner.
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
|
||||
// Scanned from the QR it works; used once, it is gone.
|
||||
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// An expired code is refused.
|
||||
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
mr.FastForward(2*time.Minute + time.Second)
|
||||
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// A new code retires the previous one.
|
||||
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
if first.Code != second.Code {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
|
||||
}
|
||||
require.NoError(t, p.Redeem(ctx, second.Code, customer))
|
||||
|
||||
// Garbage is refused without touching Redis.
|
||||
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// Two cashiers scanning the same code at once: exactly one gets it.
|
||||
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
|
||||
p, _ := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wins int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if p.Redeem(ctx, code.Code, customer) == nil {
|
||||
atomic.AddInt32(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, int32(1), wins)
|
||||
}
|
||||
|
||||
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
defer client.Close()
|
||||
repo := repository.NewPaymentCodeRepository(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
|
||||
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
|
||||
"a live code is never handed to a second customer")
|
||||
}
|
||||
|
||||
func TestRandomDigits(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 200; i++ {
|
||||
d, err := randomDigits(6)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 6)
|
||||
for _, r := range d {
|
||||
require.True(t, r >= '0' && r <= '9')
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
assert.Greater(t, len(seen), 190, "codes do not repeat")
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/mappers"
|
||||
@@ -23,26 +20,17 @@ type PaymentMethodProcessor interface {
|
||||
GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error)
|
||||
}
|
||||
|
||||
// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint
|
||||
// method, which the system owns (docs/prd-point-coin.md F9).
|
||||
var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type")
|
||||
|
||||
type PaymentMethodProcessorImpl struct {
|
||||
paymentMethodRepo repository.PaymentMethodRepository
|
||||
outletSettings outletSettingsReader
|
||||
}
|
||||
|
||||
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl {
|
||||
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl {
|
||||
return &PaymentMethodProcessorImpl{
|
||||
paymentMethodRepo: paymentMethodRepo,
|
||||
outletSettings: outletSettings,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) {
|
||||
if req.Type == constants.PaymentMethodTypePoint {
|
||||
return nil, ErrSystemPaymentMethod
|
||||
}
|
||||
exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err)
|
||||
@@ -89,17 +77,6 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req
|
||||
if req.Search != "" {
|
||||
filters["search"] = req.Search
|
||||
}
|
||||
// At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in
|
||||
// the query so paging stays right.
|
||||
if req.OutletID != nil && p.outletSettings != nil {
|
||||
settings, err := p.outletSettings.Outlet(ctx, *req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err)
|
||||
}
|
||||
if !settings.PointPayment.AcceptPayment {
|
||||
filters["exclude_type"] = string(constants.PaymentMethodTypePoint)
|
||||
}
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
|
||||
@@ -139,15 +116,6 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
|
||||
// The EnakPoint method keeps its type, and no other method can become one.
|
||||
if req.Type != nil {
|
||||
wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint
|
||||
isPoint := *req.Type == constants.PaymentMethodTypePoint
|
||||
if wasPoint != isPoint {
|
||||
return nil, ErrSystemPaymentMethod
|
||||
}
|
||||
}
|
||||
|
||||
// Check name uniqueness if name is being updated
|
||||
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
|
||||
exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id)
|
||||
@@ -179,13 +147,10 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
|
||||
|
||||
func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error {
|
||||
// Check if payment method exists
|
||||
existing, err := p.paymentMethodRepo.GetByID(ctx, id)
|
||||
_, err := p.paymentMethodRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
if existing.Type == entities.PaymentMethodTypePoint {
|
||||
return ErrSystemPaymentMethod
|
||||
}
|
||||
|
||||
// TODO: Check if payment method is being used in any payments
|
||||
// For now, allow deletion
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// pointPaymentEnv is an order flow wired as in the app, against Postgres and a
|
||||
// miniredis for payment codes.
|
||||
type pointPaymentEnv struct {
|
||||
t *testing.T
|
||||
db *gorm.DB
|
||||
orders *OrderProcessorImpl
|
||||
payments *PointPaymentProcessor
|
||||
codes *PaymentCodeProcessor
|
||||
org uuid.UUID
|
||||
cashier uuid.UUID
|
||||
outlet uuid.UUID
|
||||
point uuid.UUID
|
||||
cash uuid.UUID
|
||||
walkIn uuid.UUID
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()}
|
||||
e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org)
|
||||
e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t")
|
||||
e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org)
|
||||
e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org)
|
||||
var ids []string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error)
|
||||
require.Len(t, ids, 1)
|
||||
e.point = uuid.MustParse(ids[0])
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error)
|
||||
e.walkIn = uuid.MustParse(ids[0])
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm)
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"})
|
||||
|
||||
e.orders = &OrderProcessorImpl{
|
||||
orderRepo: repository.NewOrderRepositoryImpl(db),
|
||||
orderItemRepo: repository.NewOrderItemRepositoryImpl(db),
|
||||
paymentRepo: repository.NewPaymentRepositoryImpl(db),
|
||||
paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db),
|
||||
splitBillProcessor: nil,
|
||||
txManager: txm,
|
||||
}
|
||||
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
|
||||
e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"})
|
||||
|
||||
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled = true
|
||||
s.PointPayment.AcceptPayment = true
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String())
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) exec(q string, args ...any) {
|
||||
e.t.Helper()
|
||||
require.NoError(e.t, e.db.Exec(q, args...).Error)
|
||||
}
|
||||
|
||||
// customerWith creates a customer holding the given EnakPoint.
|
||||
func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org)
|
||||
if points > 0 {
|
||||
require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points,
|
||||
ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, tax_amount, total_amount, remaining_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`,
|
||||
id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal)
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) code(customer uuid.UUID) string {
|
||||
e.t.Helper()
|
||||
c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(e.t, err)
|
||||
return c.Code
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) {
|
||||
return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code})
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 {
|
||||
e.t.Helper()
|
||||
var b int64
|
||||
require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) {
|
||||
e.t.Helper()
|
||||
var row struct {
|
||||
PaymentStatus string
|
||||
RemainingAmount float64
|
||||
}
|
||||
require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error)
|
||||
return row.PaymentStatus, row.RemainingAmount
|
||||
}
|
||||
|
||||
func TestPointPayment_FullPayment(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
|
||||
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50000.0, payment.Amount)
|
||||
require.NotNil(t, payment.PointsUsed)
|
||||
assert.Equal(t, int64(50000), *payment.PointsUsed)
|
||||
assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment")
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Zero(t, remaining)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
|
||||
var ledger struct {
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID string
|
||||
OutletID string
|
||||
CreatedByUser string
|
||||
}
|
||||
require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id,
|
||||
created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error)
|
||||
assert.Equal(t, int64(-50000), ledger.Amount)
|
||||
assert.Equal(t, "PAYMENT", ledger.ReferenceType)
|
||||
assert.Equal(t, payment.ID.String(), ledger.ReferenceID)
|
||||
assert.Equal(t, e.outlet.String(), ledger.OutletID)
|
||||
assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it")
|
||||
|
||||
// Paid entirely with EnakPoint, so nothing earns (Q10).
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Zero(t, earned)
|
||||
}
|
||||
|
||||
func TestPointPayment_PartialThenCash(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)
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "partial", status)
|
||||
assert.Equal(t, 67500.0, remaining)
|
||||
|
||||
// The rest in cash settles it; earning counts only the part not paid with
|
||||
// EnakPoint: floor(67.500 / 100) = 675.
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||
require.NoError(t, err)
|
||||
status, _ = e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Equal(t, int64(675), earned)
|
||||
assert.Equal(t, int64(100000-20000+675), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_PercentCap(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.PointPayment.MaxPaymentPercent = 50
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 100000)
|
||||
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, order)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, preview.Eligible)
|
||||
assert.Equal(t, int64(50000), preview.MaxPoints)
|
||||
assert.Equal(t, int64(100000), preview.PointBalance)
|
||||
|
||||
_, err = e.payPoints(order, 50001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
_, err = e.payPoints(order, 30000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 20001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap")
|
||||
_, err = e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_Refusals(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
other := e.customerWith(100000)
|
||||
|
||||
// A walk-in order cannot be paid with EnakPoint.
|
||||
walkInOrder := e.order(e.walkIn, 10000)
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, preview.Eligible)
|
||||
assert.Contains(t, preview.Reason, "walk-in")
|
||||
_, err = e.payPoints(walkInOrder, 1000, "123456")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
order := e.order(customer, 10000)
|
||||
_, err = e.payPoints(order, 1000, "000000")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code")
|
||||
_, err = e.payPoints(order, 1000, e.code(other))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code")
|
||||
missing := int64(1000)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all")
|
||||
|
||||
code := e.code(customer)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once")
|
||||
_, err = e.payPoints(order, 9001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left")
|
||||
|
||||
// Splitting with the EnakPoint method would skip the balance, so it is refused.
|
||||
e.orders.splitBillProcessor = splitFake{}
|
||||
_, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything")
|
||||
assert.Equal(t, int64(100000), e.balance(other))
|
||||
}
|
||||
|
||||
// Two payments for the same customer at once, on two orders: the balance is taken
|
||||
// once, never twice. Authorization is taken as given so only the balance decides.
|
||||
func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(30000)
|
||||
orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, len(orders))
|
||||
for i, order := range orders {
|
||||
wg.Add(1)
|
||||
go func(i int, order uuid.UUID) {
|
||||
defer wg.Done()
|
||||
_, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{
|
||||
OrderID: order, PaymentMethodID: e.point, Points: 20000,
|
||||
Authorize: func(context.Context, uuid.UUID) error { return nil },
|
||||
})
|
||||
}(i, order)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
succeeded := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two")
|
||||
assert.Equal(t, int64(10000), e.balance(customer))
|
||||
|
||||
// And the wallet still reconciles.
|
||||
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
assert.NotEqual(t, customer, d.CustomerID, d.Check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointPayment_InApp(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
owner := e.customerWith(100000)
|
||||
stranger := e.customerWith(100000)
|
||||
order := e.order(owner, 60000)
|
||||
info := models.CustomerPinRequestInfo{}
|
||||
|
||||
// Another customer cannot pay it, and is not told it exists.
|
||||
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
|
||||
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
|
||||
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
|
||||
|
||||
// The session alone is not enough: a wrong PIN takes nothing.
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
|
||||
var pe *PinError
|
||||
require.ErrorAs(t, err, &pe)
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, int64(100000), e.balance(owner))
|
||||
|
||||
// The owner pays part, then the rest, with the same rules as at the cashier.
|
||||
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10000), *payment.PointsUsed)
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "partial", status)
|
||||
assert.Equal(t, 50000.0, remaining)
|
||||
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
|
||||
require.NoError(t, err)
|
||||
status, _ = e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Equal(t, int64(40000), e.balance(owner))
|
||||
assert.Equal(t, int64(100000), e.balance(stranger))
|
||||
|
||||
var createdBy *string
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestPointPaymentMethod_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) error { return db.Exec(q, args...).Error }
|
||||
require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org))
|
||||
require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org))
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing})
|
||||
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing})
|
||||
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// A new organization gets exactly one EnakPoint method from the trigger.
|
||||
var methods []struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error)
|
||||
require.Len(t, methods, 1)
|
||||
assert.Equal(t, "EnakPoint", methods[0].Name)
|
||||
assert.Equal(t, "point", methods[0].Type)
|
||||
pointID := uuid.MustParse(methods[0].ID)
|
||||
|
||||
// The database refuses a second one.
|
||||
assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org))
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
s, err := settings.Outlet(ctx, accepting)
|
||||
require.NoError(t, err)
|
||||
s.PointPayment.AcceptPayment = true
|
||||
_, err = settings.UpdateOutlet(ctx, org, accepting, user, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings)
|
||||
cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The API cannot make, retype or delete an EnakPoint method.
|
||||
_, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
toCash := constants.PaymentMethodTypeCash
|
||||
_, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
toPoint := constants.PaymentMethodTypePoint
|
||||
_, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint})
|
||||
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
|
||||
assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod)
|
||||
|
||||
// Renaming it is fine; its type stays.
|
||||
name := "Bayar pakai EnakPoint"
|
||||
renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, name, renamed.Name)
|
||||
|
||||
// At the cashier it shows only where the outlet accepts EnakPoint.
|
||||
types := func(outlet *uuid.UUID) []constants.PaymentMethodType {
|
||||
t.Helper()
|
||||
list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50})
|
||||
require.NoError(t, err)
|
||||
var out []constants.PaymentMethodType
|
||||
for _, m := range list.PaymentMethods {
|
||||
out = append(out, m.Type)
|
||||
}
|
||||
assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed")
|
||||
return out
|
||||
}
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting))
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing))
|
||||
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it")
|
||||
|
||||
// A payment either records both points_used and point_value, or neither.
|
||||
var orderID uuid.UUID
|
||||
require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t"))
|
||||
orderID = uuid.New()
|
||||
require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount)
|
||||
VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8]))
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID)
|
||||
db.Exec(`DELETE FROM orders WHERE id = ?`, orderID)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
})
|
||||
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID))
|
||||
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID))
|
||||
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID))
|
||||
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID))
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the
|
||||
// order, the customer, the outlet or the amount. The message says which.
|
||||
var ErrPointPaymentRejected = errors.New("EnakPoint payment refused")
|
||||
|
||||
type pointPaymentSettings interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
type spendableReader interface {
|
||||
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
||||
}
|
||||
|
||||
// PointPaymentInput is one payment of an order with EnakPoint.
|
||||
type PointPaymentInput struct {
|
||||
OrderID uuid.UUID
|
||||
PaymentMethodID uuid.UUID
|
||||
Points int64
|
||||
// The cashier taking the payment at the POS; nil when the customer pays in the app.
|
||||
CashierID *uuid.UUID
|
||||
// Authorize proves the customer agreed, before anything is taken: at the POS it
|
||||
// redeems the payment code, in the app it checks the PIN (K8).
|
||||
Authorize func(ctx context.Context, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
// PointPaymentResult is the payment made and where it left the order.
|
||||
type PointPaymentResult struct {
|
||||
Payment *entities.Payment
|
||||
// True when this payment settled the order.
|
||||
Completed bool
|
||||
// Rupiah still to pay with another method.
|
||||
Remaining float64
|
||||
}
|
||||
|
||||
// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents:
|
||||
//
|
||||
// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint)
|
||||
// max_points = min(balance, floor(cap / point_value))
|
||||
type pointPaymentLimits struct {
|
||||
RemainingCents int64
|
||||
CapCents int64
|
||||
MaxPoints int64
|
||||
}
|
||||
|
||||
func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits {
|
||||
remaining := toCents(total) - toCents(totalPaid)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints)
|
||||
capCents := min(remaining, byPercent)
|
||||
if capCents < 0 {
|
||||
capCents = 0
|
||||
}
|
||||
maxPoints := int64(0)
|
||||
if pointValue > 0 {
|
||||
maxPoints = min(balance, capCents/(pointValue*100))
|
||||
}
|
||||
if maxPoints < 0 {
|
||||
maxPoints = 0
|
||||
}
|
||||
return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints}
|
||||
}
|
||||
|
||||
// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9).
|
||||
type PointPaymentProcessor struct {
|
||||
repo repository.PointPaymentRepository
|
||||
settings pointPaymentSettings
|
||||
spendable spendableReader
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor {
|
||||
return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with
|
||||
// EnakPoint, and at most how much.
|
||||
func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPointPaymentOrderNotFound
|
||||
}
|
||||
preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointValue = value
|
||||
preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints
|
||||
preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent
|
||||
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
preview.Reason = reason
|
||||
return preview, nil
|
||||
}
|
||||
limits, balance, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointBalance = balance
|
||||
preview.RemainingAmount = float64(limits.RemainingCents) / 100
|
||||
preview.MaxPoints = limits.MaxPoints
|
||||
preview.MaxAmount = limits.MaxPoints * value
|
||||
if limits.MaxPoints < settings.PointPayment.MinPaymentPoints {
|
||||
preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order"
|
||||
return preview, nil
|
||||
}
|
||||
preview.Eligible = true
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// Pay takes EnakPoint from the order's customer and records the payment. The payment
|
||||
// row, the ledger PAYMENT row, the balance and the order change in one transaction
|
||||
// with the order row and the wallet locked (F9 steps 1–6).
|
||||
func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) {
|
||||
reject := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
if in.Points <= 0 {
|
||||
return nil, reject("the number of EnakPoint must be positive")
|
||||
}
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID {
|
||||
return nil, reject("the payment method is not this organization's EnakPoint method")
|
||||
}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return nil, reject("%s", reason)
|
||||
}
|
||||
if in.Points < settings.PointPayment.MinPaymentPoints {
|
||||
return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints)
|
||||
}
|
||||
customerID := *order.CustomerID
|
||||
|
||||
// The customer agrees before anything is taken. A code is used up here even if the
|
||||
// payment then fails, and the customer shows a new one.
|
||||
if in.Authorize == nil {
|
||||
return nil, reject("the customer has not approved the payment")
|
||||
}
|
||||
if err := in.Authorize(ctx, customerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &PointPaymentResult{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
// Lock the order, then the wallet, and read everything again: another payment
|
||||
// may have landed since the checks above.
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return reject("%s", reason)
|
||||
}
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, _, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Points > limits.MaxPoints {
|
||||
return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints)
|
||||
}
|
||||
amountCents := in.Points * value * 100
|
||||
// No change is ever given for EnakPoint (K7); the limits already keep it
|
||||
// within what is left, this only guards that.
|
||||
if amountCents > limits.RemainingCents {
|
||||
return reject("EnakPoint cannot pay more than what is left on the order")
|
||||
}
|
||||
|
||||
pointsUsed := in.Points
|
||||
frozenValue := float64(value)
|
||||
payment := &entities.Payment{
|
||||
ID: uuid.New(),
|
||||
OrderID: order.ID,
|
||||
PaymentMethodID: in.PaymentMethodID,
|
||||
Amount: float64(amountCents) / 100,
|
||||
Status: entities.PaymentTransactionStatusCompleted,
|
||||
PointsUsed: &pointsUsed,
|
||||
PointValue: &frozenValue,
|
||||
Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value},
|
||||
}
|
||||
if err := p.repo.InsertPayment(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outletID := order.OutletID
|
||||
if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePayment,
|
||||
Amount: in.Points,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: payment.ID,
|
||||
OutletID: &outletID,
|
||||
CreatedByUser: in.CashierID,
|
||||
Description: pointPaymentDescription(order, amountCents),
|
||||
Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount},
|
||||
IdempotencyKey: "payment:" + payment.ID.String(),
|
||||
}}); err != nil {
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return reject("the customer does not have enough EnakPoint")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
remainingCents := limits.RemainingCents - amountCents
|
||||
completed := remainingCents == 0
|
||||
if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Payment = payment
|
||||
result.Completed = completed
|
||||
result.Remaining = float64(remainingCents) / 100
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) {
|
||||
totalPaid, err := p.repo.TotalPaid(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now())
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balance := balances[constants.WalletCurrencyPoint]
|
||||
return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil
|
||||
}
|
||||
|
||||
// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or
|
||||
// "" when it can.
|
||||
func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string {
|
||||
switch {
|
||||
case order.IsVoid:
|
||||
return "the order is void"
|
||||
case order.PaymentStatus == string(entities.PaymentStatusCompleted):
|
||||
return "the order is already paid"
|
||||
case !settings.PointPayment.AcceptPayment:
|
||||
return "this outlet does not accept EnakPoint"
|
||||
case order.CustomerID == nil || order.CustomerIsDefault == nil:
|
||||
return "the order has no customer"
|
||||
case *order.CustomerIsDefault:
|
||||
return "a walk-in order cannot be paid with EnakPoint"
|
||||
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
|
||||
return "the customer is not active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string {
|
||||
description := "Bayar #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
description += " (Rp " + formatRupiah(amountCents/100) + ")"
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// formatRupiah writes 50000 as 50.000.
|
||||
func formatRupiah(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, '.')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// PointMethodID returns the organization's EnakPoint payment method.
|
||||
func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
|
||||
return p.repo.PointMethodID(ctx, organizationID)
|
||||
}
|
||||
|
||||
// OrderOwner returns the organization and customer of an order, for checking that a
|
||||
// customer pays only their own order.
|
||||
func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
return order.OrganizationID, order.CustomerID, nil
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputePointPaymentLimits(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
total, paid, paidWithPoints float64
|
||||
percent, value, balance int64
|
||||
wantRemaining, wantMax int64
|
||||
}{
|
||||
// The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point.
|
||||
"PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000},
|
||||
"balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550},
|
||||
"part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000},
|
||||
"capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000},
|
||||
"percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000},
|
||||
"percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0},
|
||||
"point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875},
|
||||
"nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0},
|
||||
"overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0},
|
||||
"no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0},
|
||||
"cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000},
|
||||
} {
|
||||
got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance)
|
||||
assert.Equal(t, c.wantRemaining, got.RemainingCents, name)
|
||||
assert.Equal(t, c.wantMax, got.MaxPoints, name)
|
||||
assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRupiah(t *testing.T) {
|
||||
for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} {
|
||||
assert.Equal(t, want, formatRupiah(n))
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the
|
||||
// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7):
|
||||
//
|
||||
// - void: every EnakPoint used on the order;
|
||||
// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value),
|
||||
// so a later change of the point value does not change how many come back, and a
|
||||
// rupiah remainder below one EnakPoint is lost (Q13).
|
||||
//
|
||||
// Never more than the payment used, and only what has not come back yet, so it can be
|
||||
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
|
||||
// they were taken from, but at least seven days from the refund (note N4, decided). It
|
||||
// returns how many came back in total.
|
||||
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payments, err := p.repo.ListPointPayments(ctx, orderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var returned int64
|
||||
for _, payment := range payments {
|
||||
if payment.LedgerID == nil || payment.CustomerID == nil {
|
||||
continue
|
||||
}
|
||||
target := pointRefundTarget(order.IsVoid, payment)
|
||||
if target == 0 {
|
||||
continue
|
||||
}
|
||||
var n int64
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var already int64
|
||||
for _, amount := range refunded {
|
||||
already += amount
|
||||
}
|
||||
toReturn := target - already
|
||||
if toReturn <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fill the lots the payment took from, each up to what it gave.
|
||||
var lots []WalletLotInput
|
||||
left := toReturn
|
||||
for _, a := range allocations {
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
room := a.Amount - refunded[a.LotID]
|
||||
if room <= 0 {
|
||||
continue
|
||||
}
|
||||
take := min(room, left)
|
||||
left -= take
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
|
||||
}
|
||||
toReturn -= left
|
||||
|
||||
ledgerID := *payment.LedgerID
|
||||
if _, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: *payment.CustomerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePaymentRefund,
|
||||
Amount: toReturn,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: payment.PaymentID,
|
||||
ReversesTransactionID: &ledgerID,
|
||||
OutletID: payment.OutletID,
|
||||
Description: pointRefundDescription(order),
|
||||
Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid},
|
||||
IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target),
|
||||
},
|
||||
Lots: lots,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
n = toReturn
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err)
|
||||
}
|
||||
returned += n
|
||||
}
|
||||
return returned, nil
|
||||
}
|
||||
|
||||
// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash
|
||||
// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded
|
||||
// through its own payment, and comes back as EnakPoint.
|
||||
func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error {
|
||||
refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if paidWithPoints == 0 {
|
||||
return nil
|
||||
}
|
||||
if toCents(amount) > toCents(refundable) {
|
||||
return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint",
|
||||
ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 {
|
||||
if orderVoid {
|
||||
return payment.PointsUsed
|
||||
}
|
||||
if payment.Status != string(entities.PaymentTransactionStatusRefunded) {
|
||||
return 0
|
||||
}
|
||||
valueCents := toCents(payment.PointValue)
|
||||
if valueCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents)
|
||||
}
|
||||
|
||||
func pointRefundDescription(order *repository.PointPaymentOrder) string {
|
||||
description := "Pengembalian #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
|
||||
e.t.Helper()
|
||||
var n int64
|
||||
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
|
||||
return n
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
|
||||
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
|
||||
JOIN payments p ON p.id = t.reference_id
|
||||
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
|
||||
e.t.Helper()
|
||||
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||
require.NoError(e.t, err)
|
||||
for _, d := range found {
|
||||
for _, c := range customers {
|
||||
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(0)
|
||||
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
|
||||
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
|
||||
})
|
||||
return err
|
||||
}))
|
||||
|
||||
// Part of the order paid with EnakPoint, then the order is voided.
|
||||
order := e.order(customer, 50000)
|
||||
_, err := e.payPoints(order, 30000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10000), e.balance(customer))
|
||||
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
|
||||
|
||||
assert.Equal(t, int64(30000), e.returned(order))
|
||||
assert.Equal(t, int64(40000), e.balance(customer))
|
||||
var expiry time.Time
|
||||
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
||||
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
|
||||
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
|
||||
|
||||
// Calling it again returns nothing more.
|
||||
e.orders.onOrderRefunded(e.ctx, order)
|
||||
assert.Equal(t, int64(30000), e.returned(order))
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
|
||||
func TestPointRefund_PartialRefundFloors(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
|
||||
assert.Equal(t, int64(12345), e.returned(order))
|
||||
assert.Equal(t, int64(50000+12345), e.balance(customer))
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
|
||||
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||
setValue := func(v int64) {
|
||||
s, err := settings.Organization(context.Background(), e.org)
|
||||
require.NoError(t, err)
|
||||
s.PointValue = v
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
setValue(100)
|
||||
|
||||
customer := e.customerWith(1000)
|
||||
order := e.order(customer, 50000)
|
||||
payment, err := e.payPoints(order, 500, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
|
||||
|
||||
// The value changes before the refund; the customer still gets back what they used.
|
||||
setValue(250)
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
|
||||
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
|
||||
assert.Equal(t, int64(1000), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
_, 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: 30000})
|
||||
require.NoError(t, err)
|
||||
|
||||
amount := 40000.0
|
||||
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
|
||||
var refunded float64
|
||||
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
|
||||
assert.Zero(t, refunded, "nothing was written")
|
||||
|
||||
amount = 30000
|
||||
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
|
||||
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
|
||||
}
|
||||
|
||||
// Giving back the EnakPoint part does not take earning back: that part never earned.
|
||||
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 87500)
|
||||
pointPayment, 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)
|
||||
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
|
||||
require.Equal(t, int64(675), earned)
|
||||
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
|
||||
assert.Equal(t, int64(20000), e.returned(order))
|
||||
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
|
||||
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
@@ -28,6 +28,10 @@ type organizationSettingsReader interface {
|
||||
Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error)
|
||||
}
|
||||
|
||||
type spendableReader interface {
|
||||
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
||||
}
|
||||
|
||||
// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md
|
||||
// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin.
|
||||
type WalletExchangeProcessor struct {
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
e.credit(t, earn(a, 30, nil)) // never
|
||||
e.earnCoins(t, a, 4, e.at(-time.Minute))
|
||||
// A payment already used part of the first lot; only the rest expires.
|
||||
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
|
||||
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: redeem(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
|
||||
require.NoError(t, err)
|
||||
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
var payment *WalletResult
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
payment, err = wallet.Debit(ctx, pay(b, 30))
|
||||
payment, err = wallet.Debit(ctx, redeem(b, 30))
|
||||
return err
|
||||
}))
|
||||
|
||||
|
||||
@@ -483,19 +483,17 @@ type walletTypeRule struct {
|
||||
}
|
||||
|
||||
var walletTypeRules = map[string]walletTypeRule{
|
||||
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
|
||||
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
|
||||
constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true},
|
||||
constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true},
|
||||
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
|
||||
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
|
||||
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
|
||||
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
|
||||
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
|
||||
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
|
||||
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
|
||||
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
|
||||
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
|
||||
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
|
||||
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
|
||||
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
|
||||
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
|
||||
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
|
||||
}
|
||||
|
||||
func validateWalletEntry(in *WalletEntry, credit bool) error {
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestWalletProcessor_AgainstPostgres(t *testing.T) {
|
||||
|
||||
// Overdraw fails and rolls back cleanly.
|
||||
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := p.Debit(ctx, pay(b, 121))
|
||||
_, err := p.Debit(ctx, redeem(b, 121))
|
||||
return err
|
||||
})
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
|
||||
@@ -300,16 +300,16 @@ func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCredit
|
||||
}
|
||||
}
|
||||
|
||||
func pay(customerID uuid.UUID, amount int64) WalletDebitInput {
|
||||
func redeem(customerID uuid.UUID, amount int64) WalletDebitInput {
|
||||
return WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePayment,
|
||||
Type: constants.WalletTxTypeRewardRedeem,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceType: constants.WalletRefTypeRewardRedemption,
|
||||
ReferenceID: uuid.New(),
|
||||
OutletID: ptr(uuid.New()),
|
||||
Description: "Bayar",
|
||||
Description: "Tukar voucher",
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) {
|
||||
second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0]
|
||||
third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0]
|
||||
|
||||
res, err := e.p.Debit(e.ctx, pay(c, 70))
|
||||
res, err := e.p.Debit(e.ctx, redeem(c, 70))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(-70), res.Transaction.Amount)
|
||||
@@ -415,7 +415,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
|
||||
|
||||
var order []uuid.UUID
|
||||
for i := 0; i < 4; i++ {
|
||||
res, err := e.p.Debit(e.ctx, pay(c, 10))
|
||||
res, err := e.p.Debit(e.ctx, redeem(c, 10))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Allocations, 1)
|
||||
order = append(order, res.Allocations[0].LotID)
|
||||
@@ -425,7 +425,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
|
||||
// The expired lot still counts in the balance until the expiry job removes it,
|
||||
// but it cannot be spent (§7.3).
|
||||
assert.Equal(t, int64(10), e.balance(t, c))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 10))
|
||||
_, err := e.p.Debit(e.ctx, redeem(c, 10))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
}
|
||||
|
||||
@@ -434,14 +434,14 @@ func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) {
|
||||
c := e.customer()
|
||||
e.credit(t, earn(c, 50, nil))
|
||||
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 51))
|
||||
_, err := e.p.Debit(e.ctx, redeem(c, 51))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
assert.Equal(t, int64(50), e.balance(t, c))
|
||||
assert.Len(t, e.repo.transactions, 1)
|
||||
assert.Empty(t, e.repo.allocations)
|
||||
|
||||
// A customer who never had a wallet has nothing to spend.
|
||||
_, err = e.p.Debit(e.ctx, pay(e.customer(), 1))
|
||||
_, err = e.p.Debit(e.ctx, redeem(e.customer(), 1))
|
||||
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
earned := e.credit(t, earn(c, 100, nil))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 70))
|
||||
_, err := e.p.Debit(e.ctx, redeem(c, 70))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned))
|
||||
@@ -510,7 +510,7 @@ func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) {
|
||||
e.credit(t, earn(a, 10, nil))
|
||||
other := e.credit(t, earn(b, 10, nil))
|
||||
|
||||
in := pay(a, 5)
|
||||
in := redeem(a, 5)
|
||||
in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID}
|
||||
_, err := e.p.Debit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
@@ -567,7 +567,7 @@ func TestWalletProcessor_IdempotentDebit(t *testing.T) {
|
||||
c := e.customer()
|
||||
e.credit(t, earn(c, 30, e.at(time.Hour)))
|
||||
e.credit(t, earn(c, 30, nil))
|
||||
in := pay(c, 40)
|
||||
in := redeem(c, 40)
|
||||
in.IdempotencyKey = "pay:1"
|
||||
|
||||
first, err := e.p.Debit(e.ctx, in)
|
||||
@@ -585,7 +585,7 @@ func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
c := e.customer()
|
||||
earned := e.credit(t, earn(c, 100, nil))
|
||||
_, err := e.p.Debit(e.ctx, pay(c, 60))
|
||||
_, err := e.p.Debit(e.ctx, redeem(c, 60))
|
||||
require.NoError(t, err)
|
||||
in := reversal(c, 100, earned)
|
||||
in.IdempotencyKey = "reverse:order-1"
|
||||
@@ -614,7 +614,7 @@ func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) {
|
||||
_, err := e.p.Credit(e.ctx, other)
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
|
||||
debit := pay(c, 100)
|
||||
debit := redeem(c, 100)
|
||||
debit.IdempotencyKey = "k"
|
||||
_, err = e.p.Debit(e.ctx, debit)
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
@@ -673,8 +673,8 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
||||
credits := map[string]func(*WalletCreditInput){
|
||||
"unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" },
|
||||
"debit-only type as credit": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypePayment
|
||||
in.ReferenceType = constants.WalletRefTypePayment
|
||||
in.Type = constants.WalletTxTypeRewardRedeem
|
||||
in.ReferenceType = constants.WalletRefTypeRewardRedemption
|
||||
},
|
||||
"unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" },
|
||||
"zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil },
|
||||
@@ -701,10 +701,6 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
||||
in.ReferenceType = constants.WalletRefTypeWalletTx
|
||||
in.CounterpartyCustomerID = ptr(uuid.New())
|
||||
},
|
||||
"PAYMENT_REFUND without source": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypePaymentRefund
|
||||
in.ReferenceType = constants.WalletRefTypePayment
|
||||
},
|
||||
"ADJUSTMENT without reason": func(in *WalletCreditInput) {
|
||||
in.Type = constants.WalletTxTypeAdjustment
|
||||
in.ReferenceType = constants.WalletRefTypeUser
|
||||
@@ -744,7 +740,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
||||
in.Type = constants.WalletTxTypeMigration
|
||||
in.ReferenceType = constants.WalletRefTypeLegacyPoints
|
||||
},
|
||||
"PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
|
||||
"REWARD_REDEEM in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
|
||||
"GAME_SPEND in POINT": func(in *WalletDebitInput) {
|
||||
in.Type = constants.WalletTxTypeGameSpend
|
||||
in.ReferenceType = constants.WalletRefTypeGamePlay
|
||||
@@ -772,7 +768,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
|
||||
e := newWalletTestEnv(t)
|
||||
e.repo.customers[c] = e.org
|
||||
e.credit(t, earn(c, 100, nil))
|
||||
in := pay(c, 10)
|
||||
in := redeem(c, 10)
|
||||
mutate(&in)
|
||||
_, err := e.p.Debit(e.ctx, in)
|
||||
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
|
||||
|
||||
@@ -87,8 +87,8 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
|
||||
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
|
||||
},
|
||||
transactions: []entities.WalletTransaction{
|
||||
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
|
||||
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
|
||||
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, Amount: -50, BalanceAfter: 250,
|
||||
ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, Description: "Tukar voucher", CreatedAt: created.Add(time.Hour),
|
||||
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
|
||||
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
|
||||
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
|
||||
@@ -112,7 +112,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
|
||||
|
||||
require.Len(t, data.RecentTransactions, 2)
|
||||
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
|
||||
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
|
||||
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeRewardRedemption, ID: payment}, pay.Destination)
|
||||
assert.Nil(t, pay.Source)
|
||||
assert.Empty(t, pay.Lots)
|
||||
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
|
||||
@@ -124,7 +124,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
|
||||
assert.Equal(t, int64(250), data.TotalPoints)
|
||||
require.Len(t, data.PointsHistory, 2)
|
||||
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
|
||||
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
|
||||
assert.Equal(t, constants.WalletTxTypeRewardRedeem, data.PointsHistory[0].Type)
|
||||
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
|
||||
|
||||
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
|
||||
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
|
||||
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
|
||||
Page: 3, Limit: 10, Currency: "point", Type: "earn, REWARD_REDEEM", From: "2026-05-01", To: "2026-05-31",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
|
||||
@@ -192,7 +192,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
|
||||
assert.Equal(t, 20, f.Offset)
|
||||
assert.Equal(t, 10, f.Limit)
|
||||
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
|
||||
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
|
||||
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypeRewardRedeem}, f.Types)
|
||||
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
|
||||
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string
|
||||
}
|
||||
|
||||
// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B
|
||||
// pays 30. Tracing B's payment leads to A's order #ORD-1.
|
||||
func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
// redeems 30. Tracing B's redemption leads to A's order #ORD-1.
|
||||
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
@@ -95,14 +95,14 @@ func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
e.credit(t, ord2)
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
payment, err := e.p.Debit(e.ctx, pay(b, 30))
|
||||
payment, err := e.p.Debit(e.ctx, redeem(b, 30))
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
|
||||
trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type)
|
||||
assert.Equal(t, constants.WalletTxTypeRewardRedeem, trace.Transaction.Type)
|
||||
assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name)
|
||||
require.Len(t, trace.Lots, 1)
|
||||
assert.Equal(t, int64(30), trace.Lots[0].Amount)
|
||||
|
||||
Reference in New Issue
Block a user