package ledger import ( "context" "errors" "strings" "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.uber.org/zap" "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 } metadata := req.GetMetadata() if len(metadata) == 0 { metadata = nil } account := &model.Account{ AccountCode: accountCode, Currency: currency, AccountType: modelType, Status: modelStatus, AllowNegative: req.GetAllowNegative(), IsSettlement: req.GetIsSettlement(), Metadata: metadata, } 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, } }