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:
co-authored by
Claude Opus 5.5
parent
78c0c11774
commit
eb5b63677f
@@ -110,6 +110,7 @@ type OrderProcessorImpl struct {
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
orderPaidHook OrderPaidHook
|
||||
orderRefundedHook OrderRefundedHook
|
||||
}
|
||||
|
||||
// OrderPaidHook is told when an order has just become fully paid and the payment has
|
||||
@@ -123,6 +124,27 @@ func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) {
|
||||
p.orderPaidHook = hook
|
||||
}
|
||||
|
||||
// OrderRefundedHook is told when an order has been voided or (partly) refunded and
|
||||
// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10).
|
||||
type OrderRefundedHook interface {
|
||||
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
|
||||
}
|
||||
|
||||
// SetOrderRefundedHook sets what runs when an order is voided or refunded.
|
||||
func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) {
|
||||
p.orderRefundedHook = hook
|
||||
}
|
||||
|
||||
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
|
||||
// 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.orderRefundedHook == nil {
|
||||
return
|
||||
}
|
||||
p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
||||
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
||||
// after the payment has committed. The hook runs detached from the caller's
|
||||
@@ -717,6 +739,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
|
||||
}
|
||||
|
||||
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
|
||||
// back; this keeps a void correct if that rule changes. The order repository writes
|
||||
// outside the caller's transaction, so the void is already committed here.
|
||||
p.onOrderRefunded(ctx, req.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -812,6 +838,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
}
|
||||
}
|
||||
|
||||
p.onOrderRefunded(ctx, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -869,7 +896,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
|
||||
return fmt.Errorf("refund amount cannot exceed payment amount")
|
||||
}
|
||||
|
||||
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
|
||||
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
p.onOrderRefunded(ctx, payment.OrderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
|
||||
|
||||
Reference in New Issue
Block a user