Files
apskel-pos-backend/internal/processor/point_payment_refund.go
T
2026-09-30 15:31:44 +07:00

158 lines
4.9 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/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)
}