Files
apskel-pos-backend/internal/service/wallet_reconciliation_job_test.go
T
efrilmandClaude Opus 5.5 040780cd2d feat(wallet): reconcile balances, ledger and lots on a schedule
Adds the reconciliation of docs/prd-point-coin.md ยง7.5 (PC-108). One
aggregate query per check, across every wallet:

- wallet balance = SUM(ledger), per currency, including customers with
  ledger rows but no wallet row
- wallet balance = SUM(lot remaining)
- lot original - SUM(allocations) = remaining
- SUM(allocations) = |amount| for every deduction
- lots created = amount for every addition, which the engine keeps and the
  other checks rely on

The check on payments.points_used waits for that column (PC-305).

WalletReconciliationJob runs the checks at startup and every six hours,
alongside the omset scheduler. It is silent while the data is consistent.
Each discrepancy is logged with its check, customer, object and the
expected and actual values, and the organization's admins, owners and
managers get a high-priority notification. An organization is notified
again only when its set of discrepancies changes. Nothing is corrected
automatically. At most 50 discrepancies per check are reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:13:46 +07:00

111 lines
3.5 KiB
Go

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
}