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