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