432 lines
14 KiB
Go
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())
|
|
}
|