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
+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
}