Files
apskel-pos-backend/internal/processor/point_refund_db_test.go
T
efrilmandClaude Opus 5.5 4d63673a25 feat(loyalty): give new balances their expiry
Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:29:25 +07:00

152 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}