Files
apskel-pos-backend/internal/repository/wallet_reconciliation_repository.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

161 lines
6.2 KiB
Go

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
}