Files
apskel-pos-backend/internal/processor/earning_reversal.go
2026-09-30 15:31:44 +07:00

168 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}