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>
152 lines
6.3 KiB
Go
152 lines
6.3 KiB
Go
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)
|
||
}
|