feat(loyalty): refund EnakPoint payments as EnakPoint only

Adds refunds of EnakPoint payments (docs/prd-point-coin.md F9, K7, Q13,
PC-307).

After a void or refund, onOrderRefunded now returns EnakPoint before taking
earning back. For each EnakPoint payment of the order it returns everything
on a void, and floor(refunded rupiah / the frozen point_value) when the
payment itself was refunded, so a later change of the point value does not
change how many come back and a remainder below one EnakPoint is lost. It
never returns more than the payment used, and only what has not come back
yet, so repeating is safe. PAYMENT_REFUND rows point at the PAYMENT they
reverse, and the EnakPoint go back into lots with the expiry of the lots
they were taken from, longest-lasting first (the 7-day extension waits on
note N4).

RefundOrder, which hands money back in cash or another method, is now
limited to what was paid with other methods; the EnakPoint part has to be
refunded through its own payment. That answers 400.

Fixes earning reversal from PC-204: a refund of the EnakPoint part raised
orders.refund_amount and so took earning back, although that part never
earned. It is now left out of the refund the reversal uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 11:55:30 +07:00
co-authored by Claude Opus 5.5
parent 43eac0ced4
commit d7138b8f87
6 changed files with 512 additions and 4 deletions
+5
View File
@@ -205,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
}
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
if errors.Is(err, processor.ErrPointPaymentRejected) {
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
return
}
+49 -3
View File
@@ -212,10 +212,16 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
// after their writes have committed, with the same guarantees as onOrderPaid: it can
// never block or fail the void or refund.
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
if p.loyalty == nil {
return
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
if p.pointPayments != nil {
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
}
}
if p.loyalty != nil {
p.loyalty.OnOrderRefunded(ctx, orderID)
}
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
}
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
@@ -860,6 +866,18 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
reason = *req.Reason
}
// An order refund is handed back in cash or another method, so it cannot cover what
// was paid with EnakPoint (K7). Checked before anything is written.
if p.pointPayments != nil {
planned, err := p.plannedOrderRefund(ctx, id, req)
if err != nil {
return err
}
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
return err
}
}
// Process refund based on request type
if req.RefundAmount != nil {
// Full or partial refund by amount
@@ -1833,3 +1851,31 @@ func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID,
}
return mappers.PaymentEntityToResponse(payment), nil
}
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
// way it will be, without writing anything.
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
if req.RefundAmount != nil {
return *req.RefundAmount, nil
}
var total float64
for _, itemRefund := range req.OrderItems {
if itemRefund.RefundAmount != nil {
total += *itemRefund.RefundAmount
continue
}
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
if err != nil {
return 0, fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != orderID {
return 0, fmt.Errorf("order item does not belong to this order")
}
quantity := itemRefund.RefundQuantity
if quantity == 0 {
quantity = orderItem.Quantity
}
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
}
return total, nil
}
+157
View File
@@ -0,0 +1,157 @@
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 (the 7-day extension waits on note N4). 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: a.ExpiresAt, 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)
}
+151
View File
@@ -0,0 +1,151 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
e.t.Helper()
var n int64
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
return n
}
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
JOIN payments p ON p.id = t.reference_id
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
}
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
e.t.Helper()
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
require.NoError(e.t, err)
for _, d := range found {
for _, c := range customers {
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
}
}
}
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(0)
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
})
return err
}))
// Part of the order paid with EnakPoint, then the order is voided.
order := e.order(customer, 50000)
_, err := e.payPoints(order, 30000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, int64(10000), e.balance(customer))
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
assert.Equal(t, int64(30000), e.returned(order))
assert.Equal(t, int64(40000), e.balance(customer))
var expiry time.Time
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
// Calling it again returns nothing more.
e.orders.onOrderRefunded(e.ctx, order)
assert.Equal(t, int64(30000), e.returned(order))
e.assertReconciled(customer)
}
func TestPointRefund_PartialRefundFloors(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 50000, e.code(customer))
require.NoError(t, err)
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
assert.Equal(t, int64(12345), e.returned(order))
assert.Equal(t, int64(50000+12345), e.balance(customer))
e.assertReconciled(customer)
}
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
e := newPointPaymentEnv(t)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
setValue := func(v int64) {
s, err := settings.Organization(context.Background(), e.org)
require.NoError(t, err)
s.PointValue = v
_, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
require.NoError(t, err)
}
setValue(100)
customer := e.customerWith(1000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 500, e.code(customer))
require.NoError(t, err)
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
// The value changes before the refund; the customer still gets back what they used.
setValue(250)
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
assert.Equal(t, int64(1000), e.balance(customer))
}
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000})
require.NoError(t, err)
amount := 40000.0
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
var refunded float64
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
assert.Zero(t, refunded, "nothing was written")
amount = 30000
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
}
// Giving back the EnakPoint part does not take earning back: that part never earned.
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
pointPayment, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
require.Equal(t, int64(675), earned)
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
assert.Equal(t, int64(20000), e.returned(order))
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
e.assertReconciled(customer)
}
+7 -1
View File
@@ -92,7 +92,13 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
COALESCE(o.is_void, false) AS is_void,
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
-- so giving it back must not take earning back.
COALESCE(o.refund_amount, 0) - COALESCE((
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount,
c.is_default AS customer_is_default, c.is_active AS customer_is_active
FROM orders o
LEFT JOIN outlets ou ON ou.id = o.outlet_id
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -50,6 +51,17 @@ type PointPaymentRepository interface {
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
// nothing is.
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
// returned for a PAYMENT row.
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
// back: paid minus already refunded.
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error)
}
type pointPaymentRepository struct {
@@ -194,3 +206,134 @@ func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organization
}
return uuid.Parse(ids[0])
}
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
type PointPaymentRow struct {
PaymentID uuid.UUID
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
// The PAYMENT ledger row that took the balance; nil if none was written.
LedgerID *uuid.UUID
CustomerID *uuid.UUID
OutletID *uuid.UUID
}
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
// expiry, so a refund can return it to the same expiry.
type PointPaymentAllocation struct {
LotID uuid.UUID
Amount int64
ExpiresAt *time.Time
}
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
var rows []struct {
PaymentID string
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
LedgerID *string
CustomerID *string
OutletID *string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
COALESCE(p.refund_amount, 0) AS refund_amount,
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
ORDER BY p.created_at, p.id`,
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
}
out := make([]PointPaymentRow, 0, len(rows))
for _, row := range rows {
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
p.PaymentID, _ = uuid.Parse(row.PaymentID)
p.LedgerID = parseOptionalUUID(row.LedgerID)
p.CustomerID = parseOptionalUUID(row.CustomerID)
p.OutletID = parseOptionalUUID(row.OutletID)
out = append(out, p)
}
return out, nil
}
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
var rows []struct {
LotID string
Amount int64
ExpiresAt *time.Time
}
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = ?
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
}
out := make([]PointPaymentAllocation, 0, len(rows))
for _, row := range rows {
id, err := uuid.Parse(row.LotID)
if err != nil {
continue
}
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
}
return out, nil
}
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
var rows []struct {
OriginLotID string
Amount int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
}
out := make(map[uuid.UUID]int64, len(rows))
for _, row := range rows {
if id, err := uuid.Parse(row.OriginLotID); err == nil {
out[id] = row.Amount
}
}
return out, nil
}
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
orderID, constants.PaymentMethodTypePoint,
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
}
return total, nil
}
func parseOptionalUUID(s *string) *uuid.UUID {
if s == nil {
return nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil
}
return &id
}