From d7138b8f87239b0741a3091e3136626ee0b28aef Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 11:55:30 +0700 Subject: [PATCH] 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 --- internal/handler/order_handler.go | 5 + internal/processor/order_processor.go | 52 +++++- internal/processor/point_payment_refund.go | 157 ++++++++++++++++++ internal/processor/point_refund_db_test.go | 151 +++++++++++++++++ internal/repository/earning_repository.go | 8 +- .../repository/point_payment_repository.go | 143 ++++++++++++++++ 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 internal/processor/point_payment_refund.go create mode 100644 internal/processor/point_refund_db_test.go diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index bfcab38..df26367 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -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 } diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 7d1ff50..bea796c 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -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 +} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go new file mode 100644 index 0000000..e869795 --- /dev/null +++ b/internal/processor/point_payment_refund.go @@ -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) +} diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go new file mode 100644 index 0000000..f6a5956 --- /dev/null +++ b/internal/processor/point_refund_db_test.go @@ -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) +} diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index 7b31af3..81d3711 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -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 diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go index eb6cbfa..43508bf 100644 --- a/internal/repository/point_payment_repository.go +++ b/internal/repository/point_payment_repository.go @@ -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 +}