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:
efrilm
2026-10-07 13:48:29 +07:00
co-authored by Claude Opus 5.5
parent 3ebc09f818
commit 2c9753fae7
87 changed files with 423 additions and 3071 deletions
+7 -22
View File
@@ -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"
+6 -7
View File
@@ -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
}
+28 -36
View File
@@ -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))
+1 -5
View File
@@ -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)
+1 -154
View File
@@ -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")
}
+2 -37
View File
@@ -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
-401
View File
@@ -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))
}
}
-157
View File
@@ -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)
}
-151
View File
@@ -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 := &notifierFake{}
+1 -1
View File
@@ -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
}))
+11 -13
View File
@@ -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)
+18 -22
View File
@@ -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)