feat(loyalty): EnakPoint & EnakCoin #32
@@ -399,6 +399,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetOrderPaidHook(earningProcessor)
|
||||
orderProcessor.SetOrderRefundedHook(earningProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ReversalOutcome is what a reversal took back, and what it could not.
|
||||
type ReversalOutcome struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
PointShortfall int64
|
||||
CoinShortfall int64
|
||||
}
|
||||
|
||||
// OnOrderRefunded is called after an order was voided or (partly) refunded and that
|
||||
// has committed. It never fails the caller: a refund is never blocked by the loyalty
|
||||
// balance (Q3), so errors are logged.
|
||||
func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := p.ReverseForOrder(ctx, orderID); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReverseForOrder takes back what an order earned, as far as it has been voided or
|
||||
// refunded (docs/prd-point-coin.md F10):
|
||||
//
|
||||
// - void: everything the order earned;
|
||||
// - refund: floor(earned × refunded / basis), never more than was earned, with the
|
||||
// refunded amount being the order's cumulative refund.
|
||||
//
|
||||
// Only the part not asked back yet is taken, so calling it again, or after each of
|
||||
// several partial refunds, never takes more than the order earned. It draws from the
|
||||
// lots the EARN created first, then from the others in K9 order, and takes what the
|
||||
// balance has when it is short, recording the rest as shortfall (Q3).
|
||||
func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) {
|
||||
order, err := p.orders.GetOrderForEarning(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
earns, err := p.orders.ListEarnTransactions(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := &ReversalOutcome{}
|
||||
if len(earns) == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, earn := range earns {
|
||||
target := earningReversalTarget(order, earn)
|
||||
requested, err := p.orders.ReversalRequested(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amount := target - requested
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reason := "REFUND"
|
||||
if order.IsVoid {
|
||||
reason = "VOID"
|
||||
}
|
||||
earnID := earn.ID
|
||||
res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: earn.CustomerID,
|
||||
Currency: earn.Currency,
|
||||
Type: constants.WalletTxTypeEarnReversal,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
ReversesTransactionID: &earnID,
|
||||
OutletID: earn.OutletID,
|
||||
Description: earningReversalDescription(order),
|
||||
Metadata: entities.Metadata{
|
||||
"reason": reason,
|
||||
"refund_amount": order.RefundAmount,
|
||||
"target": target,
|
||||
},
|
||||
// The target only grows with each refund, so each refund gets its own
|
||||
// key while a retry of the same one replays.
|
||||
IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target),
|
||||
},
|
||||
PreferredLotIDs: lots,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reversing %s: %w", earn.Currency, err)
|
||||
}
|
||||
|
||||
var taken int64
|
||||
if res.Transaction != nil {
|
||||
taken = -res.Transaction.Amount
|
||||
} else {
|
||||
// Nothing to take: the ledger has no row to carry the shortfall.
|
||||
logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{
|
||||
"order_id": order.ID.String(), "customer_id": earn.CustomerID.String(),
|
||||
"currency": earn.Currency, "shortfall": res.Shortfall,
|
||||
}, nil)
|
||||
}
|
||||
switch earn.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
outcome.Points += taken
|
||||
outcome.PointShortfall += res.Shortfall
|
||||
case constants.WalletCurrencyCoin:
|
||||
outcome.Coins += taken
|
||||
outcome.CoinShortfall += res.Shortfall
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// earningReversalTarget is how much of an EARN row should have been taken back in
|
||||
// total, given the order's void flag and cumulative refund. It works in cents like
|
||||
// CalculateEarning, with the basis frozen on the EARN row.
|
||||
func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 {
|
||||
earned := earn.Amount
|
||||
if order.IsVoid {
|
||||
return earned
|
||||
}
|
||||
refundCents := toCents(order.RefundAmount)
|
||||
if refundCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
basis, _ := earn.Metadata["basis"].(float64)
|
||||
basisCents := toCents(basis)
|
||||
// A refund can include tax, which the basis does not, so it can reach past it.
|
||||
if basisCents <= 0 || refundCents >= basisCents {
|
||||
return earned
|
||||
}
|
||||
return earned * refundCents / basisCents
|
||||
}
|
||||
|
||||
func earningReversalDescription(order *repository.EarningOrder) string {
|
||||
verb := "Refund"
|
||||
if order.IsVoid {
|
||||
verb = "Batal"
|
||||
}
|
||||
description := verb + " #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningReversal_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, outlet := uuid.New(), uuid.New(), uuid.New()
|
||||
voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{voider, refunder, spender, loyal}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
for _, c := range customers {
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, c, org)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
s, err := settings.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled, s.Coin.Enabled = true, true
|
||||
_, err = settings.UpdateOutlet(ctx, org, outlet, user, *s)
|
||||
require.NoError(t, err)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)
|
||||
|
||||
// Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin.
|
||||
paidOrder := func(customer uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`,
|
||||
id, org, outlet, user, customer, "ORD-"+id.String()[:8])
|
||||
outcome, err := earning.EarnForOrder(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(875), outcome.Points)
|
||||
return id
|
||||
}
|
||||
balance := func(customer uuid.UUID) (points, coins int64) {
|
||||
t.Helper()
|
||||
var b struct{ PointBalance, CoinBalance int64 }
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b.PointBalance, b.CoinBalance
|
||||
}
|
||||
reversed := func(order uuid.UUID, currency string) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error)
|
||||
return total
|
||||
}
|
||||
|
||||
// Void: everything comes back.
|
||||
voided := paidOrder(voider)
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided)
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
assert.Equal(t, int64(3), reversed(voided, "COIN"))
|
||||
p, c := balance(voider)
|
||||
assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c})
|
||||
|
||||
var rev struct {
|
||||
ReversesTransactionID string
|
||||
Description string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error)
|
||||
var earnID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error)
|
||||
assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses")
|
||||
assert.Contains(t, rev.Description, "Batal #ORD-")
|
||||
|
||||
// Repeating it takes nothing more.
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
|
||||
// Partial refunds are proportional and add up to at most what was earned.
|
||||
refunded := paidOrder(refunder)
|
||||
for _, step := range []struct {
|
||||
refundAmount float64
|
||||
wantTotal int64
|
||||
}{
|
||||
{20000, 200}, // floor(875 × 20000 / 87500)
|
||||
{20000, 200}, // same refund again: nothing more
|
||||
{50000, 500}, // cumulative refund grew
|
||||
{96250, 875}, // the whole total, tax included, cannot exceed the earning
|
||||
{96250, 875},
|
||||
} {
|
||||
exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded)
|
||||
outcome, err := earning.ReverseForOrder(ctx, refunded)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.PointShortfall)
|
||||
assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount)
|
||||
}
|
||||
assert.Equal(t, int64(3), reversed(refunded, "COIN"))
|
||||
|
||||
// Balance already spent: what is left is taken, the rest recorded as shortfall,
|
||||
// and the refund is not blocked.
|
||||
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,
|
||||
Description: "Bayar"}})
|
||||
})
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
|
||||
outcome, err := earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(75), outcome.Points)
|
||||
assert.Equal(t, int64(800), outcome.PointShortfall)
|
||||
p, _ = balance(spender)
|
||||
assert.Equal(t, int64(0), p, "never negative")
|
||||
var metadata string
|
||||
require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error)
|
||||
assert.Contains(t, metadata, `"shortfall": 800`)
|
||||
assert.Contains(t, metadata, `"requested_amount": 875`)
|
||||
// Asked back in full already, shortfall included: a repeat takes nothing.
|
||||
outcome, err = earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.Points)
|
||||
|
||||
// The EARN's own lots are drawn first, even when an older lot would come first.
|
||||
older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
})
|
||||
loyalOrder := paidOrder(loyal)
|
||||
exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder)
|
||||
_, err = earning.ReverseForOrder(ctx, loyalOrder)
|
||||
require.NoError(t, err)
|
||||
var fromOlder int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a
|
||||
JOIN wallet_transactions t ON t.id = a.transaction_id
|
||||
WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error)
|
||||
assert.Zero(t, fromOlder, "the older lot is untouched")
|
||||
assert.Equal(t, int64(200), reversed(loyalOrder, "POINT"))
|
||||
|
||||
// An order that never earned has nothing to reverse.
|
||||
outcome, err = earning.ReverseForOrder(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound)
|
||||
assert.Nil(t, outcome)
|
||||
|
||||
// Every reversal kept balances, ledger and lots in step (§7.5).
|
||||
found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
for _, c := range customers {
|
||||
assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// committed runs fn in a transaction that commits before it returns, for engine calls
|
||||
// made directly from a test.
|
||||
func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T {
|
||||
t.Helper()
|
||||
var out T
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
var err error
|
||||
out, err = fn(ctx)
|
||||
return err
|
||||
}))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestEarningReversalTarget(t *testing.T) {
|
||||
earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}}
|
||||
target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) }
|
||||
|
||||
assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything")
|
||||
assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)")
|
||||
assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500}))
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned")
|
||||
|
||||
noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}}
|
||||
assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all")
|
||||
}
|
||||
@@ -179,3 +179,68 @@ func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
||||
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error {
|
||||
r.order.Status, r.order.IsVoid = status, true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error {
|
||||
r.order.RefundAmount = o.RefundAmount
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error {
|
||||
r.order.IsRefund = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error {
|
||||
for _, p := range r.created {
|
||||
if p.ID == id {
|
||||
p.RefundAmount = amount
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type orderRefundedHookFake struct{ calls []uuid.UUID }
|
||||
|
||||
func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
|
||||
h.calls = append(h.calls, orderID)
|
||||
}
|
||||
|
||||
func TestOrderRefundedHook(t *testing.T) {
|
||||
t.Run("void", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
})
|
||||
|
||||
t.Run("refund order", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
amount := 25000.0
|
||||
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
})
|
||||
|
||||
t.Run("refund payment", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
payments := p.paymentRepo.(*hookPaymentRepo)
|
||||
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
||||
payments.created = append(payments.created, payment)
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
|
||||
// A refund that fails does not run the hook.
|
||||
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
|
||||
assert.Len(t, hook.calls, 1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ type OrderProcessorImpl struct {
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
orderPaidHook OrderPaidHook
|
||||
orderRefundedHook OrderRefundedHook
|
||||
}
|
||||
|
||||
// OrderPaidHook is told when an order has just become fully paid and the payment has
|
||||
@@ -123,6 +124,27 @@ func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) {
|
||||
p.orderPaidHook = hook
|
||||
}
|
||||
|
||||
// OrderRefundedHook is told when an order has been voided or (partly) refunded and
|
||||
// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10).
|
||||
type OrderRefundedHook interface {
|
||||
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
|
||||
}
|
||||
|
||||
// SetOrderRefundedHook sets what runs when an order is voided or refunded.
|
||||
func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) {
|
||||
p.orderRefundedHook = hook
|
||||
}
|
||||
|
||||
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
|
||||
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
||||
// never block or fail the void or refund.
|
||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
if p.orderRefundedHook == nil {
|
||||
return
|
||||
}
|
||||
p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -717,6 +739,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
|
||||
}
|
||||
|
||||
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
|
||||
// back; this keeps a void correct if that rule changes. The order repository writes
|
||||
// outside the caller's transaction, so the void is already committed here.
|
||||
p.onOrderRefunded(ctx, req.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -812,6 +838,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
}
|
||||
}
|
||||
|
||||
p.onOrderRefunded(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -869,7 +896,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
|
||||
return fmt.Errorf("refund amount cannot exceed payment amount")
|
||||
}
|
||||
|
||||
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
|
||||
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
p.onOrderRefunded(ctx, payment.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
|
||||
|
||||
@@ -28,6 +28,8 @@ type EarningOrder struct {
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
// Cumulative rupiah refunded so far.
|
||||
RefundAmount float64
|
||||
// Nil when the order has no customer, or the customer row is gone.
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
@@ -52,6 +54,11 @@ type EarningRepository interface {
|
||||
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
|
||||
// ListEarnTransactions returns the EARN rows written for an order.
|
||||
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
|
||||
// ReversalRequested is how much has been asked back from an EARN row so far: what
|
||||
// was taken plus any shortfall.
|
||||
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
|
||||
// LotIDsCreatedBy returns the lots a credit created.
|
||||
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
type earningRepository struct {
|
||||
@@ -74,6 +81,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
DiscountAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
RefundAmount float64
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
@@ -81,7 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
|
||||
COALESCE(o.is_void, false) AS is_void,
|
||||
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
@@ -102,6 +110,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
DiscountAmount: row.DiscountAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
RefundAmount: row.RefundAmount,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
@@ -191,3 +200,35 @@ func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uu
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0)
|
||||
FROM wallet_transactions
|
||||
WHERE reverses_transaction_id = ? AND type = ?`,
|
||||
earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum earning reversals: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_lots").
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("expires_at NULLS LAST, created_at, id").
|
||||
Pluck("id::text", &ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list lots of a credit: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user