Files
sendico/api/ledger/internal/service/ledger/accounts.go
2026-01-06 19:06:15 +01:00

335 lines
10 KiB
Go

package ledger
import (
"context"
"errors"
"fmt"
"strings"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.uber.org/zap"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
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
}
if req == nil {
return nil, merrors.InvalidArgument("request is required")
}
orgRefStr := strings.TrimSpace(req.GetOrganizationRef())
if orgRefStr == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
orgRef, err := parseObjectID(orgRefStr)
if err != nil {
return nil, err
}
accountCode := strings.TrimSpace(req.GetAccountCode())
if accountCode == "" {
return nil, merrors.InvalidArgument("account_code is required")
}
accountCode = strings.ToLower(accountCode)
currency := strings.TrimSpace(req.GetCurrency())
if currency == "" {
return nil, merrors.InvalidArgument("currency is required")
}
currency = strings.ToUpper(currency)
modelType, err := protoAccountTypeToModel(req.GetAccountType())
if err != nil {
return nil, 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 nil, err
}
if !req.GetIsSettlement() {
if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil {
return nil, err
}
}
metadata := req.GetMetadata()
if len(metadata) == 0 {
metadata = nil
}
describable := describableFromProto(req.GetDescribable())
account := &model.Account{
AccountCode: accountCode,
Currency: currency,
AccountType: modelType,
Status: modelStatus,
AllowNegative: req.GetAllowNegative(),
IsSettlement: req.GetIsSettlement(),
Metadata: metadata,
}
if describable != nil {
account.Describable = *describable
}
account.OrganizationRef = orgRef
err = s.storage.Accounts().Create(ctx, account)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetByAccountCode(ctx, orgRef, accountCode, currency)
if lookupErr != nil {
s.logger.Warn("duplicate account create but failed to load existing",
zap.Error(lookupErr),
zap.String("organizationRef", orgRef.Hex()),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to load existing account after conflict")
}
recordAccountOperation("create", "duplicate")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(existing),
}, nil
}
recordAccountOperation("create", "error")
s.logger.Warn("failed to create account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("accountCode", accountCode),
zap.String("currency", currency))
return nil, merrors.Internal("failed to create account")
}
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{
Account: toProtoAccount(account),
}, nil
}
}
func protoAccountTypeToModel(t ledgerv1.AccountType) (model.AccountType, error) {
switch t {
case ledgerv1.AccountType_ACCOUNT_TYPE_ASSET:
return model.AccountTypeAsset, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY:
return model.AccountTypeLiability, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE:
return model.AccountTypeRevenue, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE:
return model.AccountTypeExpense, nil
case ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED:
return "", merrors.InvalidArgument("account_type is required")
default:
return "", merrors.InvalidArgument("invalid account_type")
}
}
func modelAccountTypeToProto(t model.AccountType) ledgerv1.AccountType {
switch t {
case model.AccountTypeAsset:
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
case model.AccountTypeLiability:
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
case model.AccountTypeRevenue:
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
case model.AccountTypeExpense:
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
}
}
func protoAccountStatusToModel(s ledgerv1.AccountStatus) (model.AccountStatus, error) {
switch s {
case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE:
return model.AccountStatusActive, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN:
return model.AccountStatusFrozen, nil
case ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED:
return "", merrors.InvalidArgument("account status is required")
default:
return "", merrors.InvalidArgument("invalid account status")
}
}
func modelAccountStatusToProto(s model.AccountStatus) ledgerv1.AccountStatus {
switch s {
case model.AccountStatusActive:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
case model.AccountStatusFrozen:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
}
}
func toProtoAccount(account *model.Account) *ledgerv1.LedgerAccount {
if account == nil {
return nil
}
var accountRef string
if id := account.GetID(); id != nil && !id.IsZero() {
accountRef = id.Hex()
}
var organizationRef string
if !account.OrganizationRef.IsZero() {
organizationRef = account.OrganizationRef.Hex()
}
var createdAt *timestamppb.Timestamp
if !account.CreatedAt.IsZero() {
createdAt = timestamppb.New(account.CreatedAt)
}
var updatedAt *timestamppb.Timestamp
if !account.UpdatedAt.IsZero() {
updatedAt = timestamppb.New(account.UpdatedAt)
}
metadata := account.Metadata
if len(metadata) == 0 {
metadata = nil
}
return &ledgerv1.LedgerAccount{
LedgerAccountRef: accountRef,
OrganizationRef: organizationRef,
AccountCode: account.AccountCode,
AccountType: modelAccountTypeToProto(account.AccountType),
Currency: account.Currency,
Status: modelAccountStatusToProto(account.Status),
AllowNegative: account.AllowNegative,
IsSettlement: account.IsSettlement,
Metadata: metadata,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Describable: describableToProto(account.Describable),
}
}
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 primitive.ObjectID, currency string) (*model.Account, error) {
if s.storage == nil || s.storage.Accounts() == nil {
return nil, errStorageNotInitialized
}
normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency))
if normalizedCurrency == "" {
return nil, merrors.InvalidArgument("currency is required")
}
account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
if err == nil {
return account, nil
}
if !errors.Is(err, storage.ErrAccountNotFound) {
s.logger.Warn("failed to resolve default settlement account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", normalizedCurrency))
return nil, merrors.Internal("failed to resolve settlement account")
}
accountCode := defaultSettlementAccountCode(normalizedCurrency)
description := "Auto-created default settlement account"
account = &model.Account{
AccountCode: accountCode,
AccountType: model.AccountTypeAsset,
Currency: normalizedCurrency,
Status: model.AccountStatusActive,
AllowNegative: true,
IsSettlement: true,
}
account.OrganizationRef = orgRef
account.Name = fmt.Sprintf("Settlement %s", normalizedCurrency)
account.Description = &description
if err := s.storage.Accounts().Create(ctx, account); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
if lookupErr == nil && existing != nil {
return existing, nil
}
s.logger.Warn("duplicate settlement account create but failed to load existing",
zap.Error(lookupErr),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", normalizedCurrency))
return nil, merrors.Internal("failed to resolve settlement account after conflict")
}
s.logger.Warn("failed to create default settlement account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", normalizedCurrency),
zap.String("accountCode", accountCode))
return nil, merrors.Internal("failed to create settlement account")
}
s.logger.Info("default settlement account created",
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", normalizedCurrency),
zap.String("accountCode", accountCode))
return account, nil
}
func defaultSettlementAccountCode(currency string) string {
cleaned := strings.ToLower(strings.TrimSpace(currency))
if cleaned == "" {
return "asset:settlement"
}
return fmt.Sprintf("asset:settlement:%s", cleaned)
}