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