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>
247 lines
8.2 KiB
Go
247 lines
8.2 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// These fakes embed the interface they stand for and implement only what the paths
|
|
// under test call; anything else would panic, which would show a path doing more than
|
|
// expected.
|
|
type hookOrderRepo struct {
|
|
OrderRepository
|
|
order *entities.Order
|
|
statusUpdates int
|
|
hookCallsAtPay int // hook calls seen when the status was written
|
|
hook *orderPaidHookFake
|
|
}
|
|
|
|
func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) {
|
|
o := *r.order
|
|
return &o, nil
|
|
}
|
|
|
|
func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) {
|
|
o := *r.order
|
|
return &o, nil
|
|
}
|
|
|
|
func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error {
|
|
r.statusUpdates++
|
|
r.hookCallsAtPay = len(r.hook.calls)
|
|
r.order.Status, r.order.PaymentStatus = status, payment
|
|
return nil
|
|
}
|
|
|
|
type hookPaymentRepo struct {
|
|
PaymentRepository
|
|
created []*entities.Payment
|
|
}
|
|
|
|
func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error {
|
|
p.ID = uuid.New()
|
|
r.created = append(r.created, p)
|
|
return nil
|
|
}
|
|
|
|
func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) {
|
|
for _, p := range r.created {
|
|
if p.ID == id {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) {
|
|
return r.created, nil
|
|
}
|
|
|
|
type hookPaymentMethodRepo struct{}
|
|
|
|
func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) {
|
|
return &entities.PaymentMethod{ID: id}, nil
|
|
}
|
|
|
|
type hookOrderItemRepo struct{ OrderItemRepository }
|
|
|
|
func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// splitFake settles the order on the payment that covers what is left, as the real
|
|
// split bill processor does.
|
|
type splitFake struct{ settle bool }
|
|
|
|
func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) {
|
|
if f.settle {
|
|
order.PaymentStatus = entities.PaymentStatusCompleted
|
|
} else {
|
|
order.PaymentStatus = entities.PaymentStatusPartial
|
|
}
|
|
return &models.SplitBillResponse{OrderID: order.ID}, nil
|
|
}
|
|
|
|
func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
|
|
return f.split(order)
|
|
}
|
|
|
|
func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
|
|
return f.split(order)
|
|
}
|
|
|
|
type orderPaidHookFake struct {
|
|
calls []uuid.UUID
|
|
ctxs []context.Context
|
|
}
|
|
|
|
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
|
h.calls = append(h.calls, orderID)
|
|
h.ctxs = append(h.ctxs, ctx)
|
|
}
|
|
|
|
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
|
|
hook := &orderPaidHookFake{}
|
|
orders := &hookOrderRepo{
|
|
order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending},
|
|
hook: hook,
|
|
}
|
|
p := &OrderProcessorImpl{
|
|
orderRepo: orders,
|
|
orderItemRepo: hookOrderItemRepo{},
|
|
paymentRepo: &hookPaymentRepo{},
|
|
paymentMethodRepo: hookPaymentMethodRepo{},
|
|
splitBillProcessor: split,
|
|
txManager: repository.NewTxManager(nil),
|
|
}
|
|
p.SetOrderPaidHook(hook)
|
|
return p, orders, hook
|
|
}
|
|
|
|
func TestOrderPaidHook_CreatePayment(t *testing.T) {
|
|
p, orders, hook := newHookedOrderProcessor(nil)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
_, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction")
|
|
|
|
// The hook's context outlives the request.
|
|
cancel()
|
|
assert.NoError(t, hook.ctxs[0].Err())
|
|
}
|
|
|
|
func TestOrderPaidHook_UpdateOrder(t *testing.T) {
|
|
p, orders, hook := newHookedOrderProcessor(nil)
|
|
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
assert.Equal(t, 1, orders.statusUpdates)
|
|
assert.Zero(t, orders.hookCallsAtPay)
|
|
}
|
|
|
|
func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
|
|
for _, splitType := range []string{"AMOUNT", "ITEM"} {
|
|
t.Run(splitType, func(t *testing.T) {
|
|
req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()}
|
|
|
|
p, orders, hook := newHookedOrderProcessor(splitFake{settle: false})
|
|
req.OrderID = orders.order.ID
|
|
_, err := p.SplitBill(context.Background(), req)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, hook.calls, "a partial split payment does not make the order paid")
|
|
|
|
p, orders, hook = newHookedOrderProcessor(splitFake{settle: true})
|
|
req.OrderID = orders.order.ID
|
|
_, err = p.SplitBill(context.Background(), req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
|
p, orders, _ := newHookedOrderProcessor(nil)
|
|
p.SetOrderPaidHook(nil)
|
|
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error {
|
|
r.order.Status, r.order.IsVoid = status, true
|
|
return nil
|
|
}
|
|
|
|
func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error {
|
|
r.order.RefundAmount = o.RefundAmount
|
|
return nil
|
|
}
|
|
|
|
func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error {
|
|
r.order.IsRefund = true
|
|
return nil
|
|
}
|
|
|
|
func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error {
|
|
for _, p := range r.created {
|
|
if p.ID == id {
|
|
p.RefundAmount = amount
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type orderRefundedHookFake struct{ calls []uuid.UUID }
|
|
|
|
func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
|
|
h.calls = append(h.calls, orderID)
|
|
}
|
|
|
|
func TestOrderRefundedHook(t *testing.T) {
|
|
t.Run("void", func(t *testing.T) {
|
|
p, orders, _ := newHookedOrderProcessor(nil)
|
|
hook := &orderRefundedHookFake{}
|
|
p.SetOrderRefundedHook(hook)
|
|
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
})
|
|
|
|
t.Run("refund order", func(t *testing.T) {
|
|
p, orders, _ := newHookedOrderProcessor(nil)
|
|
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
|
hook := &orderRefundedHookFake{}
|
|
p.SetOrderRefundedHook(hook)
|
|
amount := 25000.0
|
|
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
})
|
|
|
|
t.Run("refund payment", func(t *testing.T) {
|
|
p, orders, _ := newHookedOrderProcessor(nil)
|
|
payments := p.paymentRepo.(*hookPaymentRepo)
|
|
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
|
payments.created = append(payments.created, payment)
|
|
hook := &orderRefundedHookFake{}
|
|
p.SetOrderRefundedHook(hook)
|
|
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
|
|
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
|
|
|
// A refund that fails does not run the hook.
|
|
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
|
|
assert.Len(t, hook.calls, 1)
|
|
})
|
|
}
|