package processor import ( "context" "fmt" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/repository" ) // RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the // order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7): // // - void: every EnakPoint used on the order; // - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value), // so a later change of the point value does not change how many come back, and a // rupiah remainder below one EnakPoint is lost (Q13). // // Never more than the payment used, and only what has not come back yet, so it can be // called again safely. Returned EnakPoint go back into lots with the expiry of the lots // they were taken from, but at least seven days from the refund (note N4, decided). It // returns how many came back in total. func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) { order, err := p.repo.GetOrder(ctx, orderID, false) if err != nil { return 0, err } payments, err := p.repo.ListPointPayments(ctx, orderID) if err != nil { return 0, err } var returned int64 for _, payment := range payments { if payment.LedgerID == nil || payment.CustomerID == nil { continue } target := pointRefundTarget(order.IsVoid, payment) if target == 0 { continue } var n int64 err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil { return err } allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID) if err != nil { return err } refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID) if err != nil { return err } var already int64 for _, amount := range refunded { already += amount } toReturn := target - already if toReturn <= 0 { return nil } // Fill the lots the payment took from, each up to what it gave. var lots []WalletLotInput left := toReturn for _, a := range allocations { if left == 0 { break } room := a.Amount - refunded[a.LotID] if room <= 0 { continue } take := min(room, left) left -= take lotID := a.LotID lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID}) } toReturn -= left ledgerID := *payment.LedgerID if _, err := p.wallet.Credit(ctx, WalletCreditInput{ WalletEntry: WalletEntry{ CustomerID: *payment.CustomerID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePaymentRefund, Amount: toReturn, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment.PaymentID, ReversesTransactionID: &ledgerID, OutletID: payment.OutletID, Description: pointRefundDescription(order), Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid}, IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target), }, Lots: lots, }); err != nil { return err } n = toReturn return nil }) if err != nil { return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err) } returned += n } return returned, nil } // EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash // or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded // through its own payment, and comes back as EnakPoint. func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error { refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID) if err != nil { return err } paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID) if err != nil { return err } if paidWithPoints == 0 { return nil } if toCents(amount) > toCents(refundable) { return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint", ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100)) } return nil } func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 { if orderVoid { return payment.PointsUsed } if payment.Status != string(entities.PaymentTransactionStatusRefunded) { return 0 } valueCents := toCents(payment.PointValue) if valueCents <= 0 { return 0 } return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents) } func pointRefundDescription(order *repository.PointPaymentOrder) string { description := "Pengembalian #" + order.OrderNumber if order.OutletName != "" { description += " di " + order.OutletName } return truncateRunes(description, walletDescriptionLimit) }