335 lines
10 KiB
Go
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)
|
|
}
|