Files
sendico/api/ledger/internal/service/ledger/accounts.go
2026-02-03 00:40:46 +01:00

432 lines
14 KiB
Go

package ledger
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
// createAccountParams holds validated and normalized fields from a CreateAccountRequest.
type createAccountParams struct {
orgRef bson.ObjectID
currency string
modelType pmodel.LedgerAccountType
modelStatus pmodel.LedgerAccountStatus
modelRole account_role.AccountRole
}
// validateCreateAccountInput validates and normalizes all fields from the request.
func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) {
if req == nil {
return createAccountParams{}, merrors.InvalidArgument("request is required")
}
orgRefStr := strings.TrimSpace(req.GetOrganizationRef())
if orgRefStr == "" {
return createAccountParams{}, merrors.InvalidArgument("organization_ref is required")
}
orgRef, err := parseObjectID(orgRefStr)
if err != nil {
return createAccountParams{}, err
}
currency := strings.TrimSpace(req.GetCurrency())
if currency == "" {
return createAccountParams{}, merrors.InvalidArgument("currency is required")
}
modelType, err := protoAccountTypeToModel(req.GetAccountType())
if err != nil {
return createAccountParams{}, err
}
status := req.GetStatus()
if status == ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED {
status = ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
}
modelStatus, err := protoAccountStatusToModel(status)
if err != nil {
return createAccountParams{}, err
}
modelRole, err := protoAccountRoleToModel(req.GetRole())
if err != nil {
return createAccountParams{}, err
}
return createAccountParams{
orgRef: orgRef,
currency: strings.ToUpper(currency),
modelType: modelType,
modelStatus: modelStatus,
modelRole: modelRole,
}, nil
}
func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.CreateAccountRequest) gsresponse.Responder[ledgerv1.CreateAccountResponse] {
return func(ctx context.Context) (*ledgerv1.CreateAccountResponse, error) {
if s.storage == nil {
return nil, errStorageNotInitialized
}
p, err := validateCreateAccountInput(req)
if err != nil {
return nil, err
}
// Topology roles resolve to existing system accounts.
if isRequiredTopologyRole(p.modelRole) {
return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, p.modelRole)
}
// Non-settlement accounts require a settlement account to exist first.
if p.modelRole != account_role.AccountRoleSettlement {
if _, err := s.ensureSettlementAccount(ctx, p.orgRef, p.currency); err != nil {
return nil, err
}
}
return s.persistNewAccount(ctx, p, req)
}
}
// resolveTopologyAccount ensures ledger topology is initialized and returns the system account for the given role.
func (s *Service) resolveTopologyAccount(ctx context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*ledgerv1.CreateAccountResponse, error) {
if err := s.ensureLedgerTopology(ctx, orgRef, currency); err != nil {
recordAccountOperation("create", "error")
return nil, err
}
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, currency, role)
if err != nil {
recordAccountOperation("create", "error")
if errors.Is(err, storage.ErrAccountNotFound) {
s.logger.Warn("System ledger account missing after topology ensure",
mzap.ObjRef("organization_ref", orgRef),
zap.String("currency", currency),
zap.String("role", string(role)))
return nil, merrors.Internal("failed to resolve ledger account after topology ensure")
}
return nil, err
}
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
}
// persistNewAccount builds and persists a new ledger account, retrying on conflict.
func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
ownerRef, err := parseOwnerRef(req.GetOwnerRef())
if err != nil {
return nil, err
}
metadata := req.GetMetadata()
if len(metadata) == 0 {
metadata = nil
}
describable := describableFromProto(req.GetDescribable())
const maxCreateAttempts = 3
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
accountID := bson.NewObjectID()
account := buildNewAccount(p, metadata, describable, ownerRef, req.GetAllowNegative(), accountID)
err := s.storage.Accounts().Create(ctx, account)
if err == nil {
s.logger.Info("Created ledger account",
mzap.ObjRef("organization_ref", p.orgRef),
zap.String("account_code", account.AccountCode),
zap.String("currency", p.currency),
zap.String("role", string(p.modelRole)))
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
}
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, p.orgRef, p.currency, p.modelRole)
if lookupErr == nil && existing != nil {
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(existing)}, nil
}
if attempt < maxCreateAttempts-1 {
continue
}
}
recordAccountOperation("create", "error")
s.logger.Warn("Failed to create account", zap.Error(err),
mzap.ObjRef("organization_ref", p.orgRef),
zap.String("account_code", account.AccountCode),
zap.String("currency", p.currency))
return nil, merrors.Internal("failed to create account")
}
recordAccountOperation("create", "error")
return nil, merrors.Internal("failed to create account after retries")
}
// parseOwnerRef parses an optional owner reference string into an ObjectID pointer.
func parseOwnerRef(ownerRefStr string) (*bson.ObjectID, error) {
if ownerRefStr == "" {
return nil, nil
}
ownerObjID, err := parseObjectID(ownerRefStr)
if err != nil {
return nil, merrors.InvalidArgument(ownerRefStr, "owner_ref")
}
return &ownerObjID, nil
}
// buildNewAccount constructs a LedgerAccount model from validated parameters.
func buildNewAccount(p createAccountParams, metadata map[string]string, describable *pmodel.Describable, ownerRef *bson.ObjectID, allowNegative bool, accountRef bson.ObjectID) *pmodel.LedgerAccount {
account := &pmodel.LedgerAccount{
AccountCode: generateAccountCode(p.modelType, p.currency, accountRef),
Currency: p.currency,
AccountType: p.modelType,
Status: p.modelStatus,
AllowNegative: allowNegative,
Role: p.modelRole,
Metadata: metadata,
OwnerRef: ownerRef,
Scope: pmodel.LedgerAccountScopeOrganization,
}
if describable != nil {
account.Describable = *describable
}
account.OrganizationRef = &p.orgRef
account.SetID(accountRef)
return account
}
func protoAccountTypeToModel(t ledgerv1.AccountType) (pmodel.LedgerAccountType, error) {
switch t {
case ledgerv1.AccountType_ACCOUNT_TYPE_ASSET:
return pmodel.LedgerAccountTypeAsset, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY:
return pmodel.LedgerAccountTypeLiability, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE:
return pmodel.LedgerAccountTypeRevenue, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE:
return pmodel.LedgerAccountTypeExpense, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED:
return "", merrors.InvalidArgument("account_type is required")
default:
return "", merrors.InvalidArgument("invalid account_type")
}
}
func modelAccountTypeToProto(t pmodel.LedgerAccountType) ledgerv1.AccountType {
switch t {
case pmodel.LedgerAccountTypeAsset:
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
case pmodel.LedgerAccountTypeLiability:
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
case pmodel.LedgerAccountTypeRevenue:
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
case pmodel.LedgerAccountTypeExpense:
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
}
}
func protoAccountRoleToModel(r ledgerv1.AccountRole) (account_role.AccountRole, error) {
switch r {
case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED:
return account_role.AccountRoleOperating, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
return account_role.AccountRoleHold, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
return account_role.AccountRoleTransit, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
return account_role.AccountRoleSettlement, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
return account_role.AccountRoleClearing, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
return account_role.AccountRolePending, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
return account_role.AccountRoleReserve, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
return account_role.AccountRoleLiquidity, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
return account_role.AccountRoleFee, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
return account_role.AccountRoleChargeback, nil
case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
return account_role.AccountRoleAdjustment, nil
default:
return "", merrors.InvalidArgument("invalid account role")
}
}
func modelAccountRoleToProto(r account_role.AccountRole) ledgerv1.AccountRole {
switch r {
case account_role.AccountRoleOperating, "":
return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING
case account_role.AccountRoleHold:
return ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD
case account_role.AccountRoleTransit:
return ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT
case account_role.AccountRoleSettlement:
return ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
case account_role.AccountRoleClearing:
return ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING
case account_role.AccountRolePending:
return ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING
case account_role.AccountRoleReserve:
return ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE
case account_role.AccountRoleLiquidity:
return ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY
case account_role.AccountRoleFee:
return ledgerv1.AccountRole_ACCOUNT_ROLE_FEE
case account_role.AccountRoleChargeback:
return ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK
case account_role.AccountRoleAdjustment:
return ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT
default:
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
}
}
func protoAccountStatusToModel(s ledgerv1.AccountStatus) (pmodel.LedgerAccountStatus, error) {
switch s {
case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE:
return pmodel.LedgerAccountStatusActive, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN:
return pmodel.LedgerAccountStatusFrozen, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED:
return "", merrors.InvalidArgument("account status is required")
default:
return "", merrors.InvalidArgument("invalid account status")
}
}
func modelAccountStatusToProto(s pmodel.LedgerAccountStatus) ledgerv1.AccountStatus {
switch s {
case pmodel.LedgerAccountStatusActive:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case pmodel.LedgerAccountStatusFrozen:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
}
func toProtoAccount(account *pmodel.LedgerAccount) *ledgerv1.LedgerAccount {
if account == nil {
return nil
}
metadata := account.Metadata
if len(metadata) == 0 {
metadata = nil
}
return &ledgerv1.LedgerAccount{
LedgerAccountRef: objectIDPtrHex(account.GetID()),
OrganizationRef: objectIDHex(*account.OrganizationRef),
OwnerRef: objectIDPtrHex(account.OwnerRef),
AccountCode: account.AccountCode,
AccountType: modelAccountTypeToProto(account.AccountType),
Currency: account.Currency,
Status: modelAccountStatusToProto(account.Status),
AllowNegative: account.AllowNegative,
Role: modelAccountRoleToProto(account.Role),
Metadata: metadata,
CreatedAt: toTimestamp(account.CreatedAt),
UpdatedAt: toTimestamp(account.UpdatedAt),
Describable: describableToProto(account.Describable),
}
}
func objectIDHex(id bson.ObjectID) string {
if id.IsZero() {
return ""
}
return id.Hex()
}
func objectIDPtrHex(id *bson.ObjectID) string {
if id == nil || id.IsZero() {
return ""
}
return id.Hex()
}
func toTimestamp(t time.Time) *timestamppb.Timestamp {
if t.IsZero() {
return nil
}
return timestamppb.New(t)
}
func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
if desc == nil {
return nil
}
name := strings.TrimSpace(desc.GetName())
var description *string
if desc.Description != nil {
trimmed := strings.TrimSpace(desc.GetDescription())
if trimmed != "" {
description = &trimmed
}
}
if name == "" && description == nil {
return nil
}
return &pmodel.Describable{
Name: name,
Description: description,
}
}
func describableToProto(desc pmodel.Describable) *describablev1.Describable {
name := strings.TrimSpace(desc.Name)
var description *string
if desc.Description != nil {
trimmed := strings.TrimSpace(*desc.Description)
if trimmed != "" {
description = &trimmed
}
}
if name == "" && description == nil {
return nil
}
return &describablev1.Describable{
Name: name,
Description: description,
}
}
func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef bson.ObjectID, currency string) (*pmodel.LedgerAccount, error) {
return s.ensureRoleAccount(ctx, orgRef, currency, account_role.AccountRoleSettlement)
}
func generateAccountCode(accountType pmodel.LedgerAccountType, currency string, id bson.ObjectID) string {
typePart := strings.ToLower(strings.TrimSpace(string(accountType)))
if typePart == "" {
typePart = "account"
}
currencyPart := strings.ToLower(strings.TrimSpace(currency))
if currencyPart == "" {
currencyPart = "na"
}
return fmt.Sprintf("%s:%s:%s", typePart, currencyPart, id.Hex())
}