feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
7 changed files with 538 additions and 2 deletions
Showing only changes of commit eb5b63677f - Show all commits
+1
View File
@@ -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),
+167
View File
@@ -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)
})
}
+32 -1
View File
@@ -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) {
+42 -1
View File
@@ -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
}