2026-09-30 11:55:30 +07:00
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
2026-09-30 13:29:25 +07:00
// they were taken from, but at least seven days from the refund (note N4, decided). It
// returns how many came back in total.
2026-09-30 11:55:30 +07:00
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
2026-09-30 13:29:25 +07:00
lots = append ( lots , WalletLotInput { Amount : take , ExpiresAt : RefundExpiry ( a . ExpiresAt , p . now ()), OriginLotID : & lotID })
2026-09-30 11:55:30 +07:00
}
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 )
}