Main #33

Merged
aefril merged 57 commits from main into staging 2026-09-30 10:17:39 +02:00
5 changed files with 637 additions and 0 deletions
Showing only changes of commit 040780cd2d - Show all commits
+14
View File
@@ -31,6 +31,7 @@ type App struct {
router *router.Router router *router.Router
shutdown chan os.Signal shutdown chan os.Signal
omsetScheduler *service.OmsetMilestoneScheduler omsetScheduler *service.OmsetMilestoneScheduler
walletRecon *service.WalletReconciliationJob
} }
func NewApp(db *gorm.DB, redisClient *redis.Client) *App { func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
@@ -53,6 +54,13 @@ func (a *App) Initialize(cfg *config.Config) error {
processors.notificationProcessor, processors.notificationProcessor,
) )
// Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5)
a.walletRecon = service.NewWalletReconciliationJob(
repository.NewWalletReconciliationRepository(a.db),
repos.userRepo,
processors.notificationProcessor,
)
services := a.initServices(processors, repos, cfg) services := a.initServices(processors, repos, cfg)
validators := a.initValidators() validators := a.initValidators()
middleware := a.initMiddleware(services, cfg) middleware := a.initMiddleware(services, cfg)
@@ -155,6 +163,9 @@ func (a *App) Start(port string) error {
if a.omsetScheduler != nil { if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute) a.omsetScheduler.Start(5 * time.Minute)
} }
if a.walletRecon != nil {
a.walletRecon.Start(6 * time.Hour)
}
engine := a.router.Init() engine := a.router.Init()
@@ -194,6 +205,9 @@ func (a *App) Shutdown() {
if a.omsetScheduler != nil { if a.omsetScheduler != nil {
a.omsetScheduler.Stop() a.omsetScheduler.Stop()
} }
if a.walletRecon != nil {
a.walletRecon.Stop()
}
close(a.shutdown) close(a.shutdown)
} }
@@ -0,0 +1,160 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
// The reconciliation checks of docs/prd-point-coin.md §7.5.
const (
// Wallet balance = SUM(amount) of the customer's ledger rows, per currency.
WalletCheckBalanceVsLedger = "BALANCE_VS_LEDGER"
// Wallet balance = SUM(remaining_amount) of the customer's lots, per currency.
WalletCheckBalanceVsLots = "BALANCE_VS_LOTS"
// For every lot: original_amount - SUM(allocations) = remaining_amount.
WalletCheckLotVsAllocations = "LOT_VS_ALLOCATIONS"
// For every deduction: SUM(allocations) = |amount|.
WalletCheckDebitVsAllocations = "DEBIT_VS_ALLOCATIONS"
// For every addition: SUM(original_amount) of the lots it created = amount. Not
// listed in §7.5, but the engine keeps it and the other checks rely on it.
WalletCheckCreditVsLots = "CREDIT_VS_LOTS"
)
// WalletDiscrepancy is one place where the wallet tables disagree with each other.
type WalletDiscrepancy struct {
Check string
OrganizationID uuid.UUID
CustomerID uuid.UUID
Currency string
// The lot or ledger row the check is about. Nil for the per-wallet checks.
ObjectID *uuid.UUID
Expected int64
Actual int64
}
// WalletReconciliationRepository runs the §7.5 checks across every wallet.
type WalletReconciliationRepository interface {
// FindDiscrepancies returns every discrepancy, at most limit per check, so one
// systematic bug cannot produce an unbounded report.
FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error)
}
type walletReconciliationRepository struct {
db *gorm.DB
}
func NewWalletReconciliationRepository(db *gorm.DB) WalletReconciliationRepository {
return &walletReconciliationRepository{db: db}
}
// Each query returns check, organization_id, customer_id, currency, object_id,
// expected and actual. Aggregates are joined rather than correlated, so each check is
// a handful of scans however many customers there are.
var walletReconciliationQueries = []struct {
check string
sql string
}{
{WalletCheckBalanceVsLedger, `
WITH ledger AS (
SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(amount) AS total
FROM wallet_transactions GROUP BY customer_id, currency
), balances AS (
SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets
UNION ALL
SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets
)
SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id,
COALESCE(b.customer_id, l.customer_id)::text AS customer_id,
COALESCE(b.currency, l.currency) AS currency,
NULL AS object_id,
COALESCE(l.total, 0) AS expected,
COALESCE(b.balance, 0) AS actual
FROM balances b
FULL JOIN ledger l ON l.customer_id = b.customer_id AND l.currency = b.currency
WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0)
LIMIT ?`},
{WalletCheckBalanceVsLots, `
WITH lots AS (
SELECT customer_id, currency, MAX(organization_id::text) AS organization_id, SUM(remaining_amount) AS total
FROM wallet_lots GROUP BY customer_id, currency
), balances AS (
SELECT customer_id, organization_id::text AS organization_id, 'POINT' AS currency, point_balance AS balance FROM customer_wallets
UNION ALL
SELECT customer_id, organization_id::text, 'COIN', coin_balance FROM customer_wallets
)
SELECT COALESCE(b.organization_id, l.organization_id) AS organization_id,
COALESCE(b.customer_id, l.customer_id)::text AS customer_id,
COALESCE(b.currency, l.currency) AS currency,
NULL AS object_id,
COALESCE(l.total, 0) AS expected,
COALESCE(b.balance, 0) AS actual
FROM balances b
FULL JOIN lots l ON l.customer_id = b.customer_id AND l.currency = b.currency
WHERE COALESCE(b.balance, 0) <> COALESCE(l.total, 0)
LIMIT ?`},
{WalletCheckLotVsAllocations, `
SELECT l.organization_id::text AS organization_id, l.customer_id::text AS customer_id, l.currency,
l.id::text AS object_id,
l.original_amount - COALESCE(a.total, 0) AS expected,
l.remaining_amount AS actual
FROM wallet_lots l
LEFT JOIN (SELECT lot_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY lot_id) a ON a.lot_id = l.id
WHERE l.original_amount - COALESCE(a.total, 0) <> l.remaining_amount
LIMIT ?`},
{WalletCheckDebitVsAllocations, `
SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency,
t.id::text AS object_id,
-t.amount AS expected,
COALESCE(a.total, 0) AS actual
FROM wallet_transactions t
LEFT JOIN (SELECT transaction_id, SUM(amount) AS total FROM wallet_lot_allocations GROUP BY transaction_id) a ON a.transaction_id = t.id
WHERE t.amount < 0 AND -t.amount <> COALESCE(a.total, 0)
LIMIT ?`},
{WalletCheckCreditVsLots, `
SELECT t.organization_id::text AS organization_id, t.customer_id::text AS customer_id, t.currency,
t.id::text AS object_id,
t.amount AS expected,
COALESCE(l.total, 0) AS actual
FROM wallet_transactions t
LEFT JOIN (SELECT source_transaction_id, SUM(original_amount) AS total FROM wallet_lots GROUP BY source_transaction_id) l ON l.source_transaction_id = t.id
WHERE t.amount > 0 AND t.amount <> COALESCE(l.total, 0)
LIMIT ?`},
}
func (r *walletReconciliationRepository) FindDiscrepancies(ctx context.Context, limit int) ([]WalletDiscrepancy, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var found []WalletDiscrepancy
for _, q := range walletReconciliationQueries {
var rows []struct {
OrganizationID string
CustomerID string
Currency string
ObjectID *string
Expected int64
Actual int64
}
if err := db.Raw(q.sql, limit).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("wallet reconciliation check %s failed: %w", q.check, err)
}
for _, row := range rows {
d := WalletDiscrepancy{
Check: q.check,
Currency: row.Currency,
Expected: row.Expected,
Actual: row.Actual,
}
d.OrganizationID, _ = uuid.Parse(row.OrganizationID)
d.CustomerID, _ = uuid.Parse(row.CustomerID)
if row.ObjectID != nil {
if id, err := uuid.Parse(*row.ObjectID); err == nil {
d.ObjectID = &id
}
}
found = append(found, d)
}
}
return found, nil
}
@@ -0,0 +1,141 @@
package repository_test
import (
"context"
"os"
"sort"
"testing"
"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/constants"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// Builds consistent wallets through the engine, checks the reconciliation is silent,
// then breaks each §7.5 invariant for a different customer and checks each break is
// found by the right checks and nothing else is. Needs TEST_DATABASE_URL pointing at
// a migrated database; see wallet_repository_test.go.
func TestWalletReconciliation_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 := uuid.New()
names := []string{"clean", "balance", "lot", "allocation", "credit"}
customers := map[string]uuid.UUID{}
var ids []uuid.UUID
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'recon test', 'basic')`, org).Error)
for _, n := range names {
customers[n] = uuid.New()
ids = append(ids, customers[n])
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, ?)`, customers[n], org, n).Error)
}
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, ids)
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id IN ?)`, ids)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, ids)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, ids)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, ids)
db.Exec(`DELETE FROM customers WHERE id IN ?`, ids)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// Every customer: +100 in two lots, +30 coins, -70 points across both lots.
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
for _, id := range ids {
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
WalletEntry: processor.WalletEntry{CustomerID: id, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeMigration, Amount: 100, ReferenceType: constants.WalletRefTypeLegacyPoints,
ReferenceID: uuid.New(), Description: "Saldo awal"},
Lots: []processor.WalletLotInput{{Amount: 60}, {Amount: 40}},
}); err != nil {
return err
}
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: id, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
Amount: 30, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: id, Description: "Saldo awal"}}); err != nil {
return err
}
outlet := uuid.New()
_, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Bayar"}})
return err
}))
}
recon := repository.NewWalletReconciliationRepository(db)
// Other packages' tests may share the database, so only these customers count.
checksByCustomer := func() map[string][]string {
t.Helper()
found, err := recon.FindDiscrepancies(ctx, 1000)
require.NoError(t, err)
byName := map[uuid.UUID]string{}
for n, id := range customers {
byName[id] = n
}
out := map[string][]string{}
for _, d := range found {
if n, ok := byName[d.CustomerID]; ok {
assert.Equal(t, org, d.OrganizationID)
out[n] = append(out[n], d.Check)
}
}
for n := range out {
sort.Strings(out[n])
}
return out
}
assert.Empty(t, checksByCustomer(), "consistent data reports nothing")
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
// A balance moved without a ledger row or a lot.
exec(`UPDATE customer_wallets SET point_balance = point_balance + 5 WHERE customer_id = ?`, customers["balance"])
// A lot's remainder changed without an allocation.
exec(`UPDATE wallet_lots SET remaining_amount = remaining_amount - 1
WHERE id = (SELECT id FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 LIMIT 1)`, customers["lot"])
// An allocation lost.
exec(`DELETE FROM wallet_lot_allocations WHERE (transaction_id, lot_id) IN (
SELECT a.transaction_id, a.lot_id FROM wallet_lot_allocations a
JOIN wallet_transactions t ON t.id = a.transaction_id WHERE t.customer_id = ? LIMIT 1)`, customers["allocation"])
// A credit whose lot was never written, with the balance moved to match the ledger.
exec(`INSERT INTO wallet_transactions (organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
VALUES (?, ?, 'COIN', 'MIGRATION', 10, 40, 'LEGACY_TOKENS', ?, 'x')`, org, customers["credit"], customers["credit"])
exec(`UPDATE customer_wallets SET coin_balance = coin_balance + 10 WHERE customer_id = ?`, customers["credit"])
assert.Equal(t, map[string][]string{
"balance": {repository.WalletCheckBalanceVsLedger, repository.WalletCheckBalanceVsLots},
"lot": {repository.WalletCheckBalanceVsLots, repository.WalletCheckLotVsAllocations},
"allocation": {repository.WalletCheckDebitVsAllocations, repository.WalletCheckLotVsAllocations},
"credit": {repository.WalletCheckBalanceVsLots, repository.WalletCheckCreditVsLots},
}, checksByCustomer(), "each break is found by exactly the checks it violates, and the clean customer by none")
// The per-check limit caps the report.
found, err := recon.FindDiscrepancies(ctx, 1)
require.NoError(t, err)
perCheck := map[string]int{}
for _, d := range found {
perCheck[d.Check]++
}
for check, n := range perCheck {
assert.LessOrEqual(t, n, 1, check)
}
}
@@ -0,0 +1,212 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
defaultWalletReconciliationInterval = 6 * time.Hour
// Per check, so one systematic bug cannot flood the log or the notification.
walletReconciliationLimit = 50
)
type walletDiscrepancyFinder interface {
FindDiscrepancies(ctx context.Context, limit int) ([]repository.WalletDiscrepancy, error)
}
type organizationUserLister interface {
GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
}
type notificationSender interface {
Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error)
}
// WalletReconciliationJob periodically runs the §7.5 checks of
// docs/prd-point-coin.md over every wallet (PC-108). It is silent while the data is
// consistent. When it finds a discrepancy it logs each one and notifies the admins,
// owners and managers of the organization concerned.
//
// An organization is notified again only when its set of discrepancies changes, so an
// unfixed problem does not page the same people every run. That memory is in-process:
// a restart notifies once more, and each running instance keeps its own.
type WalletReconciliationJob struct {
finder walletDiscrepancyFinder
users organizationUserLister
notifier notificationSender
mu sync.Mutex
notified map[uuid.UUID]string // organization -> fingerprint last notified
stopCh chan struct{}
stopOnce sync.Once
}
func NewWalletReconciliationJob(finder walletDiscrepancyFinder, users organizationUserLister, notifier notificationSender) *WalletReconciliationJob {
return &WalletReconciliationJob{
finder: finder,
users: users,
notifier: notifier,
notified: make(map[uuid.UUID]string),
stopCh: make(chan struct{}),
}
}
// Start runs the checks once now and then every interval, in the background.
func (j *WalletReconciliationJob) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultWalletReconciliationInterval
}
go func() {
j.runLogged()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.runLogged()
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("Wallet reconciliation job started (interval: %s)", interval)
}
func (j *WalletReconciliationJob) Stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
func (j *WalletReconciliationJob) runLogged() {
if _, err := j.RunOnce(context.Background()); err != nil {
logger.NonContext.Error("Wallet reconciliation failed to run", err)
}
}
// RunOnce runs every check, reports what it finds, and returns it.
func (j *WalletReconciliationJob) RunOnce(ctx context.Context) ([]repository.WalletDiscrepancy, error) {
found, err := j.finder.FindDiscrepancies(ctx, walletReconciliationLimit)
if err != nil {
return nil, err
}
byOrg := make(map[uuid.UUID][]repository.WalletDiscrepancy)
for _, d := range found {
fields := map[string]interface{}{
"check": d.Check,
"organization_id": d.OrganizationID.String(),
"customer_id": d.CustomerID.String(),
"currency": d.Currency,
"expected": d.Expected,
"actual": d.Actual,
}
if d.ObjectID != nil {
fields["object_id"] = d.ObjectID.String()
}
logger.NonContext.WarnWithFields("Wallet reconciliation found a discrepancy", fields, nil)
byOrg[d.OrganizationID] = append(byOrg[d.OrganizationID], d)
}
j.mu.Lock()
defer j.mu.Unlock()
// Organizations that are clean again are forgotten, so a later problem notifies.
for org := range j.notified {
if _, still := byOrg[org]; !still {
delete(j.notified, org)
}
}
for org, discrepancies := range byOrg {
fingerprint := walletDiscrepancyFingerprint(discrepancies)
if j.notified[org] == fingerprint {
continue
}
if err := j.notify(ctx, org, discrepancies); err != nil {
logger.NonContext.Error(fmt.Sprintf("Wallet reconciliation could not notify organization %s", org), err)
continue
}
j.notified[org] = fingerprint
}
return found, nil
}
func (j *WalletReconciliationJob) notify(ctx context.Context, organizationID uuid.UUID, discrepancies []repository.WalletDiscrepancy) error {
if organizationID == uuid.Nil {
return fmt.Errorf("discrepancy without an organization")
}
users, err := j.users.GetByOrganizationID(ctx, organizationID)
if err != nil {
return err
}
var receivers []uuid.UUID
for _, u := range users {
switch u.Role {
case entities.RoleAdmin, entities.RoleOwner, entities.RoleManager:
receivers = append(receivers, u.ID)
}
}
if len(receivers) == 0 {
return nil
}
perCheck := map[string]int{}
customers := map[string]bool{}
for _, d := range discrepancies {
perCheck[d.Check]++
customers[d.CustomerID.String()] = true
}
customerIDs := make([]string, 0, len(customers))
for id := range customers {
customerIDs = append(customerIDs, id)
}
sort.Strings(customerIDs)
_, err = j.notifier.Send(ctx, &models.SendNotificationRequest{
Title: "Selisih saldo EnakPoint/EnakCoin terdeteksi",
Body: fmt.Sprintf("Pemeriksaan rutin menemukan %d selisih pada saldo %d customer. Saldo belum dikoreksi otomatis; tim teknis perlu memeriksanya.",
len(discrepancies), len(customerIDs)),
Type: "system",
Category: "wallet_reconciliation",
Priority: entities.NotificationPriorityHigh,
NotifiableType: "organization",
NotifiableID: &organizationID,
ReceiverIDs: receivers,
Data: map[string]interface{}{
"organization_id": organizationID.String(),
"discrepancies": len(discrepancies),
"per_check": perCheck,
"customer_ids": customerIDs,
},
})
return err
}
// walletDiscrepancyFingerprint identifies a set of discrepancies regardless of order.
func walletDiscrepancyFingerprint(discrepancies []repository.WalletDiscrepancy) string {
keys := make([]string, 0, len(discrepancies))
for _, d := range discrepancies {
object := ""
if d.ObjectID != nil {
object = d.ObjectID.String()
}
keys = append(keys, fmt.Sprintf("%s|%s|%s|%s|%d|%d", d.Check, d.CustomerID, d.Currency, object, d.Expected, d.Actual))
}
sort.Strings(keys)
h := sha256.New()
for _, k := range keys {
h.Write([]byte(k))
h.Write([]byte{'\n'})
}
return hex.EncodeToString(h.Sum(nil))
}
@@ -0,0 +1,110 @@
package service
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type discrepancyFinderFake struct {
found []repository.WalletDiscrepancy
}
func (f *discrepancyFinderFake) FindDiscrepancies(context.Context, int) ([]repository.WalletDiscrepancy, error) {
return f.found, nil
}
type orgUsersFake map[uuid.UUID][]*entities.User
func (f orgUsersFake) GetByOrganizationID(_ context.Context, org uuid.UUID) ([]*entities.User, error) {
return f[org], nil
}
type notifierFake struct {
sent []*models.SendNotificationRequest
}
func (f *notifierFake) Send(_ context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) {
f.sent = append(f.sent, req)
return &models.NotificationResponse{}, nil
}
func TestWalletReconciliationJob(t *testing.T) {
logger.Setup("fatal", "json")
org := uuid.New()
admin, owner, manager, cashier := uuid.New(), uuid.New(), uuid.New(), uuid.New()
users := orgUsersFake{org: {
{ID: admin, Role: entities.RoleAdmin},
{ID: owner, Role: entities.RoleOwner},
{ID: manager, Role: entities.RoleManager},
{ID: cashier, Role: entities.RoleCashier},
}}
finder := &discrepancyFinderFake{}
notifier := &notifierFake{}
job := NewWalletReconciliationJob(finder, users, notifier)
ctx := context.Background()
// Consistent data: nothing reported.
found, err := job.RunOnce(ctx)
require.NoError(t, err)
assert.Empty(t, found)
assert.Empty(t, notifier.sent)
// A discrepancy notifies the organization's admins, owners and managers.
customer := uuid.New()
lot := uuid.New()
finder.found = []repository.WalletDiscrepancy{
{Check: repository.WalletCheckBalanceVsLots, OrganizationID: org, CustomerID: customer, Currency: "POINT", Expected: 100, Actual: 105},
{Check: repository.WalletCheckLotVsAllocations, OrganizationID: org, CustomerID: customer, Currency: "POINT", ObjectID: &lot, Expected: 50, Actual: 55},
}
found, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, found, 2)
require.Len(t, notifier.sent, 1)
sent := notifier.sent[0]
assert.ElementsMatch(t, []uuid.UUID{admin, owner, manager}, sent.ReceiverIDs, "cashiers are not told")
assert.Equal(t, &org, sent.NotifiableID)
assert.Equal(t, 2, sent.Data["discrepancies"])
assert.Equal(t, []string{customer.String()}, sent.Data["customer_ids"])
assert.Equal(t, entities.NotificationPriorityHigh, sent.Priority)
// The same problem, still unfixed and in a different order, does not notify again.
finder.found = []repository.WalletDiscrepancy{finder.found[1], finder.found[0]}
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 1)
// A changed problem does.
finder.found = finder.found[:1]
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 2)
// Once clean the organization is forgotten, so the same problem coming back
// notifies again.
previous := finder.found
finder.found = nil
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 2)
finder.found = previous
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 3)
}
func TestWalletReconciliationJobStartStop(t *testing.T) {
logger.Setup("fatal", "json")
job := NewWalletReconciliationJob(&discrepancyFinderFake{}, orgUsersFake{}, &notifierFake{})
job.Start(0)
job.Stop()
job.Stop() // stopping twice is harmless
}