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)
|
// 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