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