feat(loyalty): take back earning when an order is voided or refunded
Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204). VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded hook, called once their writes have committed and, like onOrderPaid, detached from the request so it can never block or fail the void or refund. For RefundPayment that is after its transaction. EarningProcessor.ReverseForOrder computes how much of each EARN row should have come back in total: everything for a void, otherwise floor(earned × refunded / basis) with the order's cumulative refund and the basis frozen on the EARN row, never more than was earned (a refund including tax can pass the basis). It takes only what has not been asked back yet, what was taken plus any shortfall, so repeats and successive partial refunds never add up to more than the earning. It writes an EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots the EARN created first, and records the shortfall when the balance was already spent. When the balance is empty there is no ledger row to carry the shortfall; that case is logged. VoidOrder still refuses fully paid orders, so a void has nothing to take back today; the hook keeps it correct if that changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
78c0c11774
commit
eb5b63677f
@@ -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)
|
// 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)
|
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||||
orderProcessor.SetOrderPaidHook(earningProcessor)
|
orderProcessor.SetOrderPaidHook(earningProcessor)
|
||||||
|
orderProcessor.SetOrderRefundedHook(earningProcessor)
|
||||||
|
|
||||||
return &processors{
|
return &processors{
|
||||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
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{})
|
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||||
assert.NoError(t, err)
|
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
|
inventoryMovementService InventoryMovementService
|
||||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||||
orderPaidHook OrderPaidHook
|
orderPaidHook OrderPaidHook
|
||||||
|
orderRefundedHook OrderRefundedHook
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderPaidHook is told when an order has just become fully paid and the payment has
|
// 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
|
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
|
// 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
|
// 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
|
// 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'")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +838,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.onOrderRefunded(ctx, id)
|
||||||
return nil
|
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 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) {
|
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
|
DiscountAmount float64
|
||||||
PaymentStatus string
|
PaymentStatus string
|
||||||
IsVoid bool
|
IsVoid bool
|
||||||
|
// Cumulative rupiah refunded so far.
|
||||||
|
RefundAmount float64
|
||||||
// Nil when the order has no customer, or the customer row is gone.
|
// Nil when the order has no customer, or the customer row is gone.
|
||||||
CustomerIsDefault *bool
|
CustomerIsDefault *bool
|
||||||
CustomerIsActive *bool
|
CustomerIsActive *bool
|
||||||
@@ -52,6 +54,11 @@ type EarningRepository interface {
|
|||||||
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
|
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
|
||||||
// ListEarnTransactions returns the EARN rows written for an order.
|
// ListEarnTransactions returns the EARN rows written for an order.
|
||||||
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
|
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 {
|
type earningRepository struct {
|
||||||
@@ -74,6 +81,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
|||||||
DiscountAmount float64
|
DiscountAmount float64
|
||||||
PaymentStatus string
|
PaymentStatus string
|
||||||
IsVoid bool
|
IsVoid bool
|
||||||
|
RefundAmount float64
|
||||||
CustomerIsDefault *bool
|
CustomerIsDefault *bool
|
||||||
CustomerIsActive *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,
|
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.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,
|
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
|
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
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,
|
DiscountAmount: row.DiscountAmount,
|
||||||
PaymentStatus: row.PaymentStatus,
|
PaymentStatus: row.PaymentStatus,
|
||||||
IsVoid: row.IsVoid,
|
IsVoid: row.IsVoid,
|
||||||
|
RefundAmount: row.RefundAmount,
|
||||||
CustomerIsDefault: row.CustomerIsDefault,
|
CustomerIsDefault: row.CustomerIsDefault,
|
||||||
CustomerIsActive: row.CustomerIsActive,
|
CustomerIsActive: row.CustomerIsActive,
|
||||||
}
|
}
|
||||||
@@ -191,3 +200,35 @@ func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uu
|
|||||||
}
|
}
|
||||||
return rows, nil
|
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