Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:11 +07:00
parent 5e28b05770
commit c6062c4bb9
195 changed files with 20058 additions and 2177 deletions
+167
View File
@@ -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)
}