Every lot now gets its expiry when it is created (docs/prd-point-coin.md F12, PC-502), where it used to never expire until note N4 was settled: - EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's settings for that currency, from the moment received. - EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint received now expire (F4). - PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at least seven days from the refund (N4, decided). A lot that never expired stays so. - TRANSFER_IN: unchanged, exactly the sender's expiry. Turning expiry on for a currency for the first time dates every lot of the organization that still holds something and has no expiry, MIGRATION lots included, in the same transaction as the setting: a full period from now when ROLLING, the second fixed date on or after today when FIXED_DATE, so no customer loses a balance soon after the rule is announced (N4, decided). Turning it off leaves dated lots as they are. PUT /marketing/loyalty-settings reports these as expiry_activations (currency, lots, amount, expires_at); a dry run counts them without dating anything. The earning processor now also reads the organization settings, and the wallet admin processor takes the settings reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)
|
|
}
|