Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user