feat(wallet): add wallet engine as the only way to change a balance
WalletProcessor writes the balance, the ledger row and the lots or allocations together, which keeps SUM(ledger) = balance = SUM(lot remaining) (docs/prd-point-coin.md §7.5, PC-104). - Credit writes the ledger row and creates lots, each with its own expiry and origin lot. - Debit draws from the preferred lots first (a reversal's own lots, or the lot being expired), then from unexpired lots in K9 order, and returns the allocations with their expiry so CarryOver can give the receiving side of a transfer or exchange the same expiry. - DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3). - An idempotency key returns the first result; reusing it for a different operation is an error. - §8.1 is checked in code from one rule table, ahead of the database constraints, so callers get a readable error. Each method locks the wallet itself, after validating the input and before checking the idempotency key, so correctness does not depend on the caller. Operations on two wallets still call LockWallets first to keep lock order. Unit tests run on an in-memory repository and check the §7.5 invariants after every scenario; one more test runs the engine against Postgres when TEST_DATABASE_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fc5eecb68a
commit
2eb590caab
@@ -52,6 +52,11 @@ type WalletRepository interface {
|
||||
GetTransactionByIdempotencyKey(ctx context.Context, key string) (*entities.WalletTransaction, error)
|
||||
|
||||
CreateLot(ctx context.Context, lot *entities.WalletLot) error
|
||||
// GetLotsByIDs returns the lots with the given ids, expired or not, in no
|
||||
// particular order. Ids that match no lot are left out.
|
||||
GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
|
||||
// ListLotsBySourceTransaction returns the lots a credit created, oldest first.
|
||||
ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
|
||||
// ListActiveLots returns the lots that still have balance and have not expired at
|
||||
// asOf, in the order they are spent (K9): soonest expiry first, lots without an
|
||||
// expiry last, oldest first within the same expiry.
|
||||
@@ -204,6 +209,32 @@ func (r *walletRepository) CreateLot(ctx context.Context, lot *entities.WalletLo
|
||||
return db.Create(lot).Error
|
||||
}
|
||||
|
||||
func (r *walletRepository) GetLotsByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
if len(ids) == 0 {
|
||||
return lots, nil
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("id IN ?", ids).
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) ListLotsBySourceTransaction(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots by source transaction: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletRepository) ListActiveLots(ctx context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
// Filter and order match idx_wallet_lots_consume.
|
||||
|
||||
Reference in New Issue
Block a user