feat(loyalty): take back earning when an order is voided or refunded

Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204).

VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded
hook, called once their writes have committed and, like onOrderPaid,
detached from the request so it can never block or fail the void or
refund. For RefundPayment that is after its transaction.

EarningProcessor.ReverseForOrder computes how much of each EARN row should
have come back in total: everything for a void, otherwise
floor(earned × refunded / basis) with the order's cumulative refund and
the basis frozen on the EARN row, never more than was earned (a refund
including tax can pass the basis). It takes only what has not been asked
back yet, what was taken plus any shortfall, so repeats and successive
partial refunds never add up to more than the earning. It writes an
EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots
the EARN created first, and records the shortfall when the balance was
already spent.

When the balance is empty there is no ledger row to carry the shortfall;
that case is logged. VoidOrder still refuses fully paid orders, so a void
has nothing to take back today; the hook keeps it correct if that changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 10:45:33 +07:00
co-authored by Claude Opus 5.5
parent 78c0c11774
commit eb5b63677f
7 changed files with 538 additions and 2 deletions
+42 -1
View File
@@ -28,6 +28,8 @@ type EarningOrder struct {
DiscountAmount float64
PaymentStatus string
IsVoid bool
// Cumulative rupiah refunded so far.
RefundAmount float64
// Nil when the order has no customer, or the customer row is gone.
CustomerIsDefault *bool
CustomerIsActive *bool
@@ -52,6 +54,11 @@ type EarningRepository interface {
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
// ListEarnTransactions returns the EARN rows written for an order.
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
// ReversalRequested is how much has been asked back from an EARN row so far: what
// was taken plus any shortfall.
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
// LotIDsCreatedBy returns the lots a credit created.
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
}
type earningRepository struct {
@@ -74,6 +81,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
DiscountAmount float64
PaymentStatus string
IsVoid bool
RefundAmount float64
CustomerIsDefault *bool
CustomerIsActive *bool
}
@@ -81,7 +89,7 @@ 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.is_void, false) AS is_void, COALESCE(o.refund_amount, 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
@@ -102,6 +110,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
DiscountAmount: row.DiscountAmount,
PaymentStatus: row.PaymentStatus,
IsVoid: row.IsVoid,
RefundAmount: row.RefundAmount,
CustomerIsDefault: row.CustomerIsDefault,
CustomerIsActive: row.CustomerIsActive,
}
@@ -191,3 +200,35 @@ func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uu
}
return rows, nil
}
func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) {
var total int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0)
FROM wallet_transactions
WHERE reverses_transaction_id = ? AND type = ?`,
earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum earning reversals: %w", err)
}
return total, nil
}
func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("wallet_lots").
Where("source_transaction_id = ?", transactionID).
Order("expires_at NULLS LAST, created_at, id").
Pluck("id::text", &ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list lots of a credit: %w", err)
}
out := make([]uuid.UUID, 0, len(ids))
for _, s := range ids {
if id, err := uuid.Parse(s); err == nil {
out = append(out, id)
}
}
return out, nil
}