feat(loyalty): refund EnakPoint payments as EnakPoint only
Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13, PC-307). After a void or refund, onOrderRefunded now returns EnakPoint before taking earning back. For each EnakPoint payment of the order it returns everything on a void, and floor(refunded rupiah / the frozen point_value) when the payment itself was refunded, so a later change of the point value does not change how many come back and a remainder below one EnakPoint is lost. It never returns more than the payment used, and only what has not come back yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they reverse, and the EnakPoint go back into lots with the expiry of the lots they were taken from, longest-lasting first (the 7-day extension waits on note N4). RefundOrder, which hands money back in cash or another method, is now limited to what was paid with other methods; the EnakPoint part has to be refunded through its own payment. That answers 400. Fixes earning reversal from PC-204: a refund of the EnakPoint part raised orders.refund_amount and so took earning back, although that part never earned. It is now left out of the refund the reversal uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
43eac0ced4
commit
d7138b8f87
@@ -205,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
|
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
|
||||||
|
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
|
||||||
|
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
|
||||||
|
return
|
||||||
|
}
|
||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,10 +212,16 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
|
|||||||
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
||||||
// never block or fail the void or refund.
|
// never block or fail the void or refund.
|
||||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||||
if p.loyalty == nil {
|
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
|
||||||
return
|
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
|
||||||
|
if p.pointPayments != nil {
|
||||||
|
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
|
||||||
|
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.loyalty != nil {
|
||||||
|
p.loyalty.OnOrderRefunded(ctx, orderID)
|
||||||
}
|
}
|
||||||
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
|
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
|
||||||
@@ -860,6 +866,18 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
|||||||
reason = *req.Reason
|
reason = *req.Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An order refund is handed back in cash or another method, so it cannot cover what
|
||||||
|
// was paid with EnakPoint (K7). Checked before anything is written.
|
||||||
|
if p.pointPayments != nil {
|
||||||
|
planned, err := p.plannedOrderRefund(ctx, id, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process refund based on request type
|
// Process refund based on request type
|
||||||
if req.RefundAmount != nil {
|
if req.RefundAmount != nil {
|
||||||
// Full or partial refund by amount
|
// Full or partial refund by amount
|
||||||
@@ -1833,3 +1851,31 @@ func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID,
|
|||||||
}
|
}
|
||||||
return mappers.PaymentEntityToResponse(payment), nil
|
return mappers.PaymentEntityToResponse(payment), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
|
||||||
|
// way it will be, without writing anything.
|
||||||
|
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
|
||||||
|
if req.RefundAmount != nil {
|
||||||
|
return *req.RefundAmount, nil
|
||||||
|
}
|
||||||
|
var total float64
|
||||||
|
for _, itemRefund := range req.OrderItems {
|
||||||
|
if itemRefund.RefundAmount != nil {
|
||||||
|
total += *itemRefund.RefundAmount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("order item not found: %w", err)
|
||||||
|
}
|
||||||
|
if orderItem.OrderID != orderID {
|
||||||
|
return 0, fmt.Errorf("order item does not belong to this order")
|
||||||
|
}
|
||||||
|
quantity := itemRefund.RefundQuantity
|
||||||
|
if quantity == 0 {
|
||||||
|
quantity = orderItem.Quantity
|
||||||
|
}
|
||||||
|
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the
|
||||||
|
// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7):
|
||||||
|
//
|
||||||
|
// - void: every EnakPoint used on the order;
|
||||||
|
// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value),
|
||||||
|
// so a later change of the point value does not change how many come back, and a
|
||||||
|
// rupiah remainder below one EnakPoint is lost (Q13).
|
||||||
|
//
|
||||||
|
// Never more than the payment used, and only what has not come back yet, so it can be
|
||||||
|
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
|
||||||
|
// they were taken from (the 7-day extension waits on note N4). It returns how many came
|
||||||
|
// back in total.
|
||||||
|
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
||||||
|
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
payments, err := p.repo.ListPointPayments(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var returned int64
|
||||||
|
for _, payment := range payments {
|
||||||
|
if payment.LedgerID == nil || payment.CustomerID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := pointRefundTarget(order.IsVoid, payment)
|
||||||
|
if target == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
|
if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var already int64
|
||||||
|
for _, amount := range refunded {
|
||||||
|
already += amount
|
||||||
|
}
|
||||||
|
toReturn := target - already
|
||||||
|
if toReturn <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the lots the payment took from, each up to what it gave.
|
||||||
|
var lots []WalletLotInput
|
||||||
|
left := toReturn
|
||||||
|
for _, a := range allocations {
|
||||||
|
if left == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
room := a.Amount - refunded[a.LotID]
|
||||||
|
if room <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
take := min(room, left)
|
||||||
|
left -= take
|
||||||
|
lotID := a.LotID
|
||||||
|
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||||
|
}
|
||||||
|
toReturn -= left
|
||||||
|
|
||||||
|
ledgerID := *payment.LedgerID
|
||||||
|
if _, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||||
|
WalletEntry: WalletEntry{
|
||||||
|
CustomerID: *payment.CustomerID,
|
||||||
|
Currency: constants.WalletCurrencyPoint,
|
||||||
|
Type: constants.WalletTxTypePaymentRefund,
|
||||||
|
Amount: toReturn,
|
||||||
|
ReferenceType: constants.WalletRefTypePayment,
|
||||||
|
ReferenceID: payment.PaymentID,
|
||||||
|
ReversesTransactionID: &ledgerID,
|
||||||
|
OutletID: payment.OutletID,
|
||||||
|
Description: pointRefundDescription(order),
|
||||||
|
Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid},
|
||||||
|
IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target),
|
||||||
|
},
|
||||||
|
Lots: lots,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n = toReturn
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err)
|
||||||
|
}
|
||||||
|
returned += n
|
||||||
|
}
|
||||||
|
return returned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash
|
||||||
|
// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded
|
||||||
|
// through its own payment, and comes back as EnakPoint.
|
||||||
|
func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error {
|
||||||
|
refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if paidWithPoints == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if toCents(amount) > toCents(refundable) {
|
||||||
|
return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint",
|
||||||
|
ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 {
|
||||||
|
if orderVoid {
|
||||||
|
return payment.PointsUsed
|
||||||
|
}
|
||||||
|
if payment.Status != string(entities.PaymentTransactionStatusRefunded) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
valueCents := toCents(payment.PointValue)
|
||||||
|
if valueCents <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointRefundDescription(order *repository.PointPaymentOrder) string {
|
||||||
|
description := "Pengembalian #" + order.OrderNumber
|
||||||
|
if order.OutletName != "" {
|
||||||
|
description += " di " + order.OutletName
|
||||||
|
}
|
||||||
|
return truncateRunes(description, walletDescriptionLimit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
|
||||||
|
e.t.Helper()
|
||||||
|
var n int64
|
||||||
|
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
|
||||||
|
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
|
||||||
|
JOIN payments p ON p.id = t.reference_id
|
||||||
|
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
|
||||||
|
e.t.Helper()
|
||||||
|
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||||
|
require.NoError(e.t, err)
|
||||||
|
for _, d := range found {
|
||||||
|
for _, c := range customers {
|
||||||
|
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
|
||||||
|
e := newPointPaymentEnv(t)
|
||||||
|
customer := e.customerWith(0)
|
||||||
|
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
|
||||||
|
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||||
|
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
|
||||||
|
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||||
|
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
|
||||||
|
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Part of the order paid with EnakPoint, then the order is voided.
|
||||||
|
order := e.order(customer, 50000)
|
||||||
|
_, err := e.payPoints(order, 30000, e.code(customer))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(10000), e.balance(customer))
|
||||||
|
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
|
||||||
|
|
||||||
|
assert.Equal(t, int64(30000), e.returned(order))
|
||||||
|
assert.Equal(t, int64(40000), e.balance(customer))
|
||||||
|
var expiry time.Time
|
||||||
|
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
||||||
|
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
|
||||||
|
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
|
||||||
|
|
||||||
|
// Calling it again returns nothing more.
|
||||||
|
e.orders.onOrderRefunded(e.ctx, order)
|
||||||
|
assert.Equal(t, int64(30000), e.returned(order))
|
||||||
|
e.assertReconciled(customer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPointRefund_PartialRefundFloors(t *testing.T) {
|
||||||
|
e := newPointPaymentEnv(t)
|
||||||
|
customer := e.customerWith(100000)
|
||||||
|
order := e.order(customer, 50000)
|
||||||
|
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
|
||||||
|
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
|
||||||
|
assert.Equal(t, int64(12345), e.returned(order))
|
||||||
|
assert.Equal(t, int64(50000+12345), e.balance(customer))
|
||||||
|
e.assertReconciled(customer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
||||||
|
e := newPointPaymentEnv(t)
|
||||||
|
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||||
|
setValue := func(v int64) {
|
||||||
|
s, err := settings.Organization(context.Background(), e.org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s.PointValue = v
|
||||||
|
_, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
setValue(100)
|
||||||
|
|
||||||
|
customer := e.customerWith(1000)
|
||||||
|
order := e.order(customer, 50000)
|
||||||
|
payment, err := e.payPoints(order, 500, e.code(customer))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
|
||||||
|
|
||||||
|
// The value changes before the refund; the customer still gets back what they used.
|
||||||
|
setValue(250)
|
||||||
|
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
|
||||||
|
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
|
||||||
|
assert.Equal(t, int64(1000), e.balance(customer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
|
||||||
|
e := newPointPaymentEnv(t)
|
||||||
|
customer := e.customerWith(100000)
|
||||||
|
order := e.order(customer, 50000)
|
||||||
|
_, err := e.payPoints(order, 20000, e.code(customer))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
amount := 40000.0
|
||||||
|
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
|
||||||
|
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
|
||||||
|
var refunded float64
|
||||||
|
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
|
||||||
|
assert.Zero(t, refunded, "nothing was written")
|
||||||
|
|
||||||
|
amount = 30000
|
||||||
|
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
|
||||||
|
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Giving back the EnakPoint part does not take earning back: that part never earned.
|
||||||
|
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
|
||||||
|
e := newPointPaymentEnv(t)
|
||||||
|
customer := e.customerWith(100000)
|
||||||
|
order := e.order(customer, 87500)
|
||||||
|
pointPayment, err := e.payPoints(order, 20000, e.code(customer))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||||
|
require.NoError(t, err)
|
||||||
|
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
|
||||||
|
require.Equal(t, int64(675), earned)
|
||||||
|
|
||||||
|
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
|
||||||
|
assert.Equal(t, int64(20000), e.returned(order))
|
||||||
|
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
|
||||||
|
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
|
||||||
|
e.assertReconciled(customer)
|
||||||
|
}
|
||||||
@@ -92,7 +92,13 @@ 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.refund_amount, 0) AS refund_amount,
|
COALESCE(o.is_void, false) AS is_void,
|
||||||
|
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
|
||||||
|
-- so giving it back must not take earning back.
|
||||||
|
COALESCE(o.refund_amount, 0) - COALESCE((
|
||||||
|
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
|
||||||
|
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||||
|
WHERE p.order_id = o.id AND pm.type = 'point'), 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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -50,6 +51,17 @@ type PointPaymentRepository interface {
|
|||||||
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
||||||
// nothing is.
|
// nothing is.
|
||||||
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
||||||
|
|
||||||
|
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
|
||||||
|
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
|
||||||
|
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
|
||||||
|
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
|
||||||
|
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
|
||||||
|
// returned for a PAYMENT row.
|
||||||
|
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
|
||||||
|
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
|
||||||
|
// back: paid minus already refunded.
|
||||||
|
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type pointPaymentRepository struct {
|
type pointPaymentRepository struct {
|
||||||
@@ -194,3 +206,134 @@ func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organization
|
|||||||
}
|
}
|
||||||
return uuid.Parse(ids[0])
|
return uuid.Parse(ids[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
|
||||||
|
type PointPaymentRow struct {
|
||||||
|
PaymentID uuid.UUID
|
||||||
|
Status string
|
||||||
|
PointsUsed int64
|
||||||
|
PointValue float64
|
||||||
|
RefundAmount float64
|
||||||
|
// The PAYMENT ledger row that took the balance; nil if none was written.
|
||||||
|
LedgerID *uuid.UUID
|
||||||
|
CustomerID *uuid.UUID
|
||||||
|
OutletID *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
|
||||||
|
// expiry, so a refund can return it to the same expiry.
|
||||||
|
type PointPaymentAllocation struct {
|
||||||
|
LotID uuid.UUID
|
||||||
|
Amount int64
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
|
||||||
|
var rows []struct {
|
||||||
|
PaymentID string
|
||||||
|
Status string
|
||||||
|
PointsUsed int64
|
||||||
|
PointValue float64
|
||||||
|
RefundAmount float64
|
||||||
|
LedgerID *string
|
||||||
|
CustomerID *string
|
||||||
|
OutletID *string
|
||||||
|
}
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
|
||||||
|
COALESCE(p.refund_amount, 0) AS refund_amount,
|
||||||
|
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
|
||||||
|
FROM payments p
|
||||||
|
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||||
|
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
|
||||||
|
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
|
||||||
|
ORDER BY p.created_at, p.id`,
|
||||||
|
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]PointPaymentRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
|
||||||
|
p.PaymentID, _ = uuid.Parse(row.PaymentID)
|
||||||
|
p.LedgerID = parseOptionalUUID(row.LedgerID)
|
||||||
|
p.CustomerID = parseOptionalUUID(row.CustomerID)
|
||||||
|
p.OutletID = parseOptionalUUID(row.OutletID)
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
|
||||||
|
var rows []struct {
|
||||||
|
LotID string
|
||||||
|
Amount int64
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
}
|
||||||
|
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
|
||||||
|
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
|
||||||
|
WHERE a.transaction_id = ?
|
||||||
|
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]PointPaymentAllocation, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
id, err := uuid.Parse(row.LotID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
|
||||||
|
var rows []struct {
|
||||||
|
OriginLotID string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
|
||||||
|
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
|
||||||
|
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
|
||||||
|
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[uuid.UUID]int64, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if id, err := uuid.Parse(row.OriginLotID); err == nil {
|
||||||
|
out[id] = row.Amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||||
|
var total float64
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
|
||||||
|
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||||
|
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
|
||||||
|
orderID, constants.PaymentMethodTypePoint,
|
||||||
|
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalUUID(s *string) *uuid.UUID {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(*s)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user