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:
co-authored by
Claude Opus 5.5
parent
43eac0ced4
commit
d7138b8f87
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user