feat(wallet): customer wallet and manual adjustments in the dashboard
Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7, PC-107), under /marketing for admins and managers: - GET /marketing/customers/:id/wallet returns the customer, the ledger and spendable balances, every lot that still holds something (flagged when expired), and a page of history. Unlike the customer's own view, each row carries the real names behind it: the transfer counterparty, the admin or cashier, and the outlet, plus the reason and metadata. - POST /marketing/customers/:id/wallet/adjust takes a signed amount and a required reason. It writes an ADJUSTMENT pointing at the admin through the wallet engine, refuses to take more than the customer can spend, and accepts an optional idempotency key so a retried request adjusts once. Reasons describing a cash-out are refused (K7). The customer must belong to the caller's organization; otherwise both endpoints answer 404. Positive adjustments create non-expiring lots until the expiry model is decided (F12, note N4). The mapping from ledger rows to what the apps show is now shared between the customer and dashboard views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e5db0325cc
commit
a6d5a8b056
@@ -0,0 +1,126 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// WalletCustomer is the customer a dashboard wallet screen is about.
|
||||
type WalletCustomer struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Phone *string
|
||||
}
|
||||
|
||||
// WalletAdminRepository serves what the dashboard adds on top of the customer's own
|
||||
// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance,
|
||||
// and the real names behind a ledger row. Nothing here changes a balance.
|
||||
type WalletAdminRepository interface {
|
||||
// GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to
|
||||
// the organization, so one organization can never read another's customers.
|
||||
GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error)
|
||||
// GetWallet returns nil, nil for a customer who has never had a wallet.
|
||||
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
|
||||
// ListLotsWithBalance returns every lot that still holds something, expired or
|
||||
// not, in K9 spending order.
|
||||
ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error)
|
||||
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
}
|
||||
|
||||
type walletAdminRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository {
|
||||
return &walletAdminRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) {
|
||||
var customers []WalletCustomer
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Select("id, name, COALESCE(phone_number, phone) AS phone").
|
||||
Where("id = ? AND organization_id = ?", customerID, organizationID).
|
||||
Limit(1).
|
||||
Scan(&customers).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer: %w", err)
|
||||
}
|
||||
if len(customers) == 0 {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return &customers[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
|
||||
var wallets []entities.CustomerWallet
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Limit(1).
|
||||
Find(&wallets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
if len(wallets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &wallets[0], nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ? AND remaining_amount > 0", customerID).
|
||||
Order("currency, expires_at NULLS LAST, created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "customers", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "users", ids)
|
||||
}
|
||||
|
||||
func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names(ctx, "outlets", ids)
|
||||
}
|
||||
|
||||
// names looks up the name column of a table by id. The table name is never taken
|
||||
// from user input.
|
||||
func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
names := make(map[uuid.UUID]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table(table).
|
||||
Select("id::text AS id, name").
|
||||
Where("id IN ?", ids).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up %s names: %w", table, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.ID); err == nil {
|
||||
names[id] = row.Name
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
Reference in New Issue
Block a user