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
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEarningBackfillInterval = 30 * time.Minute
|
||||
// How far back to look for paid orders that never earned.
|
||||
earningBackfillWindow = 72 * time.Hour
|
||||
// Orders looked at per run at most, so one run cannot run away.
|
||||
earningBackfillMaxOrders = 5000
|
||||
)
|
||||
|
||||
type missingEarner interface {
|
||||
EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error)
|
||||
}
|
||||
|
||||
// EarningBackfillJob is the safety net behind earning at payment time
|
||||
// (docs/prd-point-coin.md F3, PC-203). Every run it earns for orders paid in the last
|
||||
// few days that should have earned and did not, for example because the database was
|
||||
// briefly unreachable right after the payment committed.
|
||||
type EarningBackfillJob struct {
|
||||
earner missingEarner
|
||||
now func() time.Time
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewEarningBackfillJob(earner missingEarner) *EarningBackfillJob {
|
||||
return &EarningBackfillJob{earner: earner, now: time.Now, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (j *EarningBackfillJob) Start(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = defaultEarningBackfillInterval
|
||||
}
|
||||
go func() {
|
||||
j.RunOnce(context.Background())
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
j.RunOnce(context.Background())
|
||||
case <-j.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.NonContext.Infof("Earning backfill job started (interval: %s)", interval)
|
||||
}
|
||||
|
||||
func (j *EarningBackfillJob) Stop() {
|
||||
j.stopOnce.Do(func() { close(j.stopCh) })
|
||||
}
|
||||
|
||||
// RunOnce earns for every missed order in the window and reports how many it fixed.
|
||||
// It is quiet when nothing was missed.
|
||||
func (j *EarningBackfillJob) RunOnce(ctx context.Context) int {
|
||||
checked, earned, err := j.earner.EarnMissing(ctx, j.now().Add(-earningBackfillWindow), earningBackfillMaxOrders)
|
||||
if err != nil {
|
||||
logger.NonContext.Error("Earning backfill failed to run", err)
|
||||
}
|
||||
if earned > 0 {
|
||||
logger.NonContext.WarnWithFields("Earning backfill credited orders that had missed their earning",
|
||||
map[string]interface{}{"checked": checked, "earned": earned}, nil)
|
||||
}
|
||||
return earned
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
)
|
||||
|
||||
type missingEarnerFake struct {
|
||||
since time.Time
|
||||
max int
|
||||
earned int
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *missingEarnerFake) EarnMissing(_ context.Context, since time.Time, maxOrders int) (int, int, error) {
|
||||
f.calls++
|
||||
f.since, f.max = since, maxOrders
|
||||
return f.earned * 2, f.earned, f.err
|
||||
}
|
||||
|
||||
func TestEarningBackfillJob(t *testing.T) {
|
||||
logger.Setup("fatal", "json")
|
||||
now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
|
||||
earner := &missingEarnerFake{earned: 3}
|
||||
job := NewEarningBackfillJob(earner)
|
||||
job.now = func() time.Time { return now }
|
||||
|
||||
assert.Equal(t, 3, job.RunOnce(context.Background()))
|
||||
assert.Equal(t, now.Add(-72*time.Hour), earner.since, "looks back three days")
|
||||
assert.Equal(t, earningBackfillMaxOrders, earner.max)
|
||||
|
||||
// A failing run is logged, not fatal.
|
||||
earner.earned, earner.err = 0, errors.New("db down")
|
||||
assert.Equal(t, 0, job.RunOnce(context.Background()))
|
||||
|
||||
job.Start(time.Hour)
|
||||
job.Stop()
|
||||
job.Stop()
|
||||
}
|
||||
Reference in New Issue
Block a user