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)
|
||
}
|