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>
168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
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)
|
||
}
|