@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Account struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
UserID string `db:"user_id" json:"user_id"`
|
||||
Mode string `db:"mode" json:"mode"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Currency string `db:"currency" json:"currency"`
|
||||
LedgerStatus string `db:"ledger_status" json:"ledger_status"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Account) TableName() string { return "veyra_account" }
|
||||
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// CashEntry amounts are integer CNY cents; no floating-point accounting.
|
||||
type CashEntry struct {
|
||||
ID string `db:"id"`
|
||||
AccountID string `db:"account_id"`
|
||||
EntryType string `db:"entry_type"`
|
||||
AmountMinor int64 `db:"amount_minor"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (CashEntry) TableName() string { return "veyra_cash_entry" }
|
||||
|
||||
type AccountSummary struct {
|
||||
Account
|
||||
CashBalance *string `json:"cash_balance"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"veyra/services/api/internal/platform/apperror"
|
||||
)
|
||||
|
||||
var moneyPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$`)
|
||||
|
||||
// ParseMoney converts decimal text into exact cents, rejecting rounding and exponents.
|
||||
func ParseMoney(value string) (int64, error) {
|
||||
if !moneyPattern.MatchString(value) {
|
||||
return 0, apperror.ErrInvalidInput
|
||||
}
|
||||
whole, fraction, _ := strings.Cut(value, ".")
|
||||
units, err := strconv.ParseInt(whole, 10, 64)
|
||||
if err != nil {
|
||||
return 0, apperror.ErrInvalidInput
|
||||
}
|
||||
fraction += strings.Repeat("0", 2-len(fraction))
|
||||
cents, err := strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil {
|
||||
return 0, apperror.ErrInvalidInput
|
||||
}
|
||||
amount := units*100 + cents
|
||||
if amount > 100000000000 {
|
||||
return 0, apperror.ErrInvalidInput
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func FormatMoney(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) }
|
||||
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExactMoney(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
minor int64
|
||||
formatted string
|
||||
}{{"0", 0, "0.00"}, {"0.01", 1, "0.01"}, {"100000.10", 10000010, "100000.10"}, {"3.1", 310, "3.10"}, {"1000000000.00", 100000000000, "1000000000.00"}} {
|
||||
amount, err := ParseMoney(tc.input)
|
||||
if err != nil || amount != tc.minor || FormatMoney(amount) != tc.formatted {
|
||||
t.Fatalf("%s: amount=%d err=%v", tc.input, amount, err)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"-1", "1.001", "1e3", "NaN", " 1", "01", "1.", "1000000000.01", "999999999999999999999", ""} {
|
||||
if _, err := ParseMoney(input); err == nil {
|
||||
t.Fatalf("accepted invalid amount %q", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"veyra/services/api/internal/platform/apperror"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"veyra/services/api/internal/portfolio/domain"
|
||||
)
|
||||
|
||||
type Postgres struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} }
|
||||
|
||||
func (r *Postgres) Create(ctx context.Context, a domain.Account, entry *domain.CashEntry) (domain.AccountSummary, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
// Lock the owner so account creation cannot race with disabling the user.
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM veyra_user WHERE id=$1 AND role='USER' FOR UPDATE`, a.UserID).Scan(&status); err != nil {
|
||||
return domain.AccountSummary{}, mapError(err)
|
||||
}
|
||||
if status != "ACTIVE" {
|
||||
return domain.AccountSummary{}, apperror.ErrForbidden
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO veyra_account(id,user_id,mode,name,currency,ledger_status) VALUES($1,$2,$3,$4,$5,$6) RETURNING created_at`, a.ID, a.UserID, a.Mode, a.Name, a.Currency, a.LedgerStatus).Scan(&a.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.AccountSummary{}, mapError(err)
|
||||
}
|
||||
var balance *string
|
||||
if entry != nil {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO veyra_cash_entry(id,account_id,entry_type,amount_minor) VALUES($1,$2,$3,$4)`, entry.ID, entry.AccountID, entry.EntryType, entry.AmountMinor); err != nil {
|
||||
return domain.AccountSummary{}, mapError(err)
|
||||
}
|
||||
value := domain.FormatMoney(entry.AmountMinor)
|
||||
balance = &value
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
return domain.AccountSummary{Account: a, CashBalance: balance}, nil
|
||||
}
|
||||
|
||||
const summarySelect = `SELECT a.id,a.user_id,a.mode,a.name,a.currency,a.ledger_status,a.created_at,CASE WHEN a.ledger_status='COMPLETE' THEN (SELECT COALESCE(sum(e.amount_minor),0)::bigint FROM veyra_cash_entry e WHERE e.account_id=a.id) ELSE NULL END FROM veyra_account a`
|
||||
|
||||
func scanSummary(row pgx.Row) (domain.AccountSummary, error) {
|
||||
var s domain.AccountSummary
|
||||
var minor *int64
|
||||
err := row.Scan(&s.ID, &s.UserID, &s.Mode, &s.Name, &s.Currency, &s.LedgerStatus, &s.CreatedAt, &minor)
|
||||
if err != nil {
|
||||
return s, mapError(err)
|
||||
}
|
||||
if minor != nil {
|
||||
value := domain.FormatMoney(*minor)
|
||||
s.CashBalance = &value
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *Postgres) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) {
|
||||
return scanSummary(r.pool.QueryRow(ctx, summarySelect+` WHERE a.user_id=$1 AND a.id=$2`, userID, accountID))
|
||||
}
|
||||
|
||||
func (r *Postgres) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) {
|
||||
rows, err := r.pool.Query(ctx, summarySelect+` WHERE a.user_id=$1 ORDER BY a.created_at,a.id`, userID)
|
||||
if err != nil {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
defer rows.Close()
|
||||
list := make([]domain.AccountSummary, 0)
|
||||
for rows.Next() {
|
||||
s, err := scanSummary(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
return list, mapError(rows.Err())
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return apperror.ErrNotFound
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return apperror.ErrConflict
|
||||
}
|
||||
return apperror.ErrUnavailable
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"veyra/services/api/internal/platform/apperror"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"veyra/services/api/internal/portfolio/domain"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(context.Context, domain.Account, *domain.CashEntry) (domain.AccountSummary, error)
|
||||
List(context.Context, string) ([]domain.AccountSummary, error)
|
||||
Get(context.Context, string, string) (domain.AccountSummary, error)
|
||||
}
|
||||
|
||||
type AccountService struct{ repo Repository }
|
||||
|
||||
func NewAccountService(repo Repository) *AccountService { return &AccountService{repo: repo} }
|
||||
|
||||
func (s *AccountService) Create(ctx context.Context, userID, mode, name string, initialCash *string) (domain.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return domain.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 80 {
|
||||
return domain.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
account := domain.Account{ID: uuid.NewString(), UserID: userID, Mode: mode, Name: name, Currency: "CNY", LedgerStatus: "INCOMPLETE"}
|
||||
var entry *domain.CashEntry
|
||||
switch mode {
|
||||
case "simulation":
|
||||
if initialCash == nil {
|
||||
return domain.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
amount, err := domain.ParseMoney(*initialCash)
|
||||
if err != nil {
|
||||
return domain.AccountSummary{}, err
|
||||
}
|
||||
account.LedgerStatus = "COMPLETE"
|
||||
entry = &domain.CashEntry{ID: uuid.NewString(), AccountID: account.ID, EntryType: "INITIAL", AmountMinor: amount}
|
||||
case "manual_real":
|
||||
if initialCash != nil {
|
||||
return domain.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
default:
|
||||
return domain.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
return s.repo.Create(ctx, account, entry)
|
||||
}
|
||||
|
||||
func (s *AccountService) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
return s.repo.List(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *AccountService) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return domain.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(accountID); err != nil {
|
||||
return domain.AccountSummary{}, apperror.ErrNotFound
|
||||
}
|
||||
return s.repo.Get(ctx, userID, accountID)
|
||||
}
|
||||
Reference in New Issue
Block a user