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,221 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||
// database being unreachable right after a payment.
|
||||
type failingSettings struct {
|
||||
real outletSettingsReader
|
||||
mu sync.Mutex
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
f.mu.Lock()
|
||||
fail := f.fail
|
||||
f.mu.Unlock()
|
||||
if fail {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
return f.real.Outlet(ctx, outletID)
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user := uuid.New(), uuid.New()
|
||||
earningOutlet, quietOutlet := uuid.New(), uuid.New()
|
||||
regular, inactive := uuid.New(), uuid.New()
|
||||
var walkIn uuid.UUID
|
||||
var customers []uuid.UUID
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'earning test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES
|
||||
(?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org)
|
||||
// Creating the organization created its walk-in customer (trigger_create_default_customer).
|
||||
var walkInID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error)
|
||||
walkIn = uuid.MustParse(walkInID)
|
||||
customers = []uuid.UUID{regular, walkIn, inactive}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet)
|
||||
require.NoError(t, err)
|
||||
outletSettings.Point.Enabled = true
|
||||
outletSettings.Coin.Enabled = true
|
||||
_, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings := &failingSettings{real: settingsProcessor}
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm)
|
||||
|
||||
orderNo := 0
|
||||
newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID {
|
||||
t.Helper()
|
||||
orderNo++
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`,
|
||||
id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid)
|
||||
return id
|
||||
}
|
||||
earnRows := func(orderID uuid.UUID) map[string]int64 {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error)
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Currency] += r.Amount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin.
|
||||
paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000)
|
||||
earning.OnOrderPaid(ctx, paid)
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
var row struct {
|
||||
Description string
|
||||
Metadata string
|
||||
OutletID string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions
|
||||
WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error)
|
||||
assert.Contains(t, row.Description, "di Kemang")
|
||||
assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row")
|
||||
assert.Contains(t, row.Metadata, `"basis": 87500`)
|
||||
assert.Equal(t, earningOutlet.String(), row.OutletID)
|
||||
|
||||
// Called again, and five times at once, it is still one earning.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }()
|
||||
}
|
||||
wg.Wait()
|
||||
outcome, err := earning.EarnForOrder(ctx, paid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning")
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
// A self-order goes through the same payment path and the same rules.
|
||||
selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0)
|
||||
earning.OnOrderPaid(ctx, selfOrder)
|
||||
assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder))
|
||||
|
||||
// A split bill earns once, on the payment that settles it: while partial, nothing.
|
||||
split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Empty(t, earnRows(split))
|
||||
exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split))
|
||||
|
||||
// Orders that must not earn.
|
||||
for name, c := range map[string]struct {
|
||||
id uuid.UUID
|
||||
skip string
|
||||
}{
|
||||
"walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer},
|
||||
"inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer},
|
||||
"no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer},
|
||||
"void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid},
|
||||
"unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid},
|
||||
"outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn},
|
||||
} {
|
||||
outcome, err := earning.EarnForOrder(ctx, c.id)
|
||||
require.NoError(t, err, name)
|
||||
assert.Equal(t, c.skip, outcome.Skipped, name)
|
||||
assert.Empty(t, earnRows(c.id), name)
|
||||
}
|
||||
|
||||
// An earning that fails does not surface to the payment, and the job picks it up.
|
||||
settings.mu.Lock()
|
||||
settings.fail = true
|
||||
settings.mu.Unlock()
|
||||
missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0)
|
||||
assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) })
|
||||
assert.Empty(t, earnRows(missed))
|
||||
|
||||
settings.mu.Lock()
|
||||
settings.fail = false
|
||||
settings.mu.Unlock()
|
||||
since := time.Now().Add(-time.Hour)
|
||||
checked, earned, err := earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, earned, 1)
|
||||
assert.GreaterOrEqual(t, checked, earned)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// The job only looks at orders that could earn and have not.
|
||||
candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000)
|
||||
require.NoError(t, err)
|
||||
var ours []uuid.UUID
|
||||
for _, c := range candidates {
|
||||
var n int64
|
||||
db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n)
|
||||
if n > 0 {
|
||||
ours = append(ours, c.ID)
|
||||
}
|
||||
}
|
||||
assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates")
|
||||
|
||||
// A second run finds nothing more to do for these orders.
|
||||
_, _, err = earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
balance := struct{ PointBalance, CoinBalance int64 }{}
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
||||
}
|
||||
Reference in New Issue
Block a user