feat(loyalty): earn EnakPoint and EnakCoin when an order is paid
Adds earning at payment time (docs/prd-point-coin.md F3, PC-203).
An order becomes fully paid through UpdateOrder, CreatePayment and both
kinds of split bill. All of them now go through one OrderProcessorImpl
hook, onOrderPaid, called after the payment has committed; for
CreatePayment that is after its transaction, not from updateOrderStatus
inside it. The hook runs detached from the caller's transaction and from
the request being cancelled, and it runs synchronously so the order
response can show what was earned.
EarningProcessor.EarnForOrder skips orders that are not paid, are void,
have no customer, or whose customer is the walk-in customer or inactive.
It computes the earning with CalculateEarning, subtracting any part paid
with EnakPoint (none until phase 3), and credits each currency through the
wallet engine as EARN with key earn:{order_id}:{currency} and the settings
snapshot in metadata. A repeat, even concurrent, credits nothing more.
OnOrderPaid never fails the payment: errors and panics are logged.
EarningBackfillJob is the safety net: every 30 minutes it earns for orders
paid in the last three days that have no EARN row. It only looks at
outlets with earning switched on and pages by (updated_at, id), so orders
that correctly earned nothing cannot starve the ones that were missed.
Lots from earning never expire until the expiry model is decided (F12,
note N4). Adds the point payment method type constant, not yet accepted as
a payment method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5baed18b22
commit
78c0c11774
@@ -109,6 +109,30 @@ type OrderProcessorImpl struct {
|
||||
ingredientRepo IngredientRepository
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
orderPaidHook OrderPaidHook
|
||||
}
|
||||
|
||||
// OrderPaidHook is told when an order has just become fully paid and the payment has
|
||||
// committed. EarningProcessor is one (docs/prd-point-coin.md F3).
|
||||
type OrderPaidHook interface {
|
||||
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||
}
|
||||
|
||||
// SetOrderPaidHook sets what runs when an order becomes fully paid.
|
||||
func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) {
|
||||
p.orderPaidHook = hook
|
||||
}
|
||||
|
||||
// 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
|
||||
// transaction and from the request being cancelled, and anything it does cannot fail
|
||||
// the payment.
|
||||
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
if p.orderPaidHook == nil {
|
||||
return
|
||||
}
|
||||
p.orderPaidHook.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
func NewOrderProcessorImpl(
|
||||
@@ -494,6 +518,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
|
||||
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
|
||||
return nil, fmt.Errorf("failed to update order: %w", err)
|
||||
}
|
||||
p.onOrderPaid(ctx, order.ID)
|
||||
|
||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
|
||||
if err != nil {
|
||||
@@ -818,6 +843,8 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Not from updateOrderStatus: that runs inside the payment's transaction.
|
||||
p.onOrderPaid(ctx, req.OrderID)
|
||||
|
||||
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
|
||||
if err != nil {
|
||||
@@ -1207,6 +1234,10 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Both split paths mark the order paid on the payment that settles it.
|
||||
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
||||
p.onOrderPaid(ctx, order.ID)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user