341 lines
10 KiB
Go
341 lines
10 KiB
Go
package ledger
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/tech/sendico/ledger/storage"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
pmodel "github.com/tech/sendico/pkg/model"
|
|
"github.com/tech/sendico/pkg/model/account_role"
|
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
|
)
|
|
|
|
type accountStoreStub struct {
|
|
createErr error
|
|
createErrSettlement error
|
|
created []*pmodel.LedgerAccount
|
|
existing *pmodel.LedgerAccount
|
|
existingErr error
|
|
existingByRole map[account_role.AccountRole]*pmodel.LedgerAccount
|
|
defaultSettlement *pmodel.LedgerAccount
|
|
defaultErr error
|
|
createErrs []error
|
|
}
|
|
|
|
func (s *accountStoreStub) Create(_ context.Context, account *pmodel.LedgerAccount) error {
|
|
if account.Role == account_role.AccountRoleSettlement {
|
|
if s.createErrSettlement != nil {
|
|
return s.createErrSettlement
|
|
}
|
|
} else {
|
|
if len(s.createErrs) > 0 {
|
|
err := s.createErrs[0]
|
|
s.createErrs = s.createErrs[1:]
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if s.createErr != nil {
|
|
return s.createErr
|
|
}
|
|
}
|
|
|
|
if account.GetID() == nil || account.GetID().IsZero() {
|
|
account.SetID(bson.NewObjectID())
|
|
}
|
|
|
|
account.CreatedAt = account.CreatedAt.UTC()
|
|
account.UpdatedAt = account.UpdatedAt.UTC()
|
|
|
|
s.created = append(s.created, account)
|
|
return nil
|
|
}
|
|
|
|
func (s *accountStoreStub) GetByAccountCode(_ context.Context, _ bson.ObjectID, _ string, _ string) (*pmodel.LedgerAccount, error) {
|
|
if s.existingErr != nil {
|
|
return nil, s.existingErr
|
|
}
|
|
return s.existing, nil
|
|
}
|
|
|
|
func (s *accountStoreStub) Get(context.Context, bson.ObjectID) (*pmodel.LedgerAccount, error) {
|
|
return nil, storage.ErrAccountNotFound
|
|
}
|
|
|
|
func (s *accountStoreStub) GetByRole(_ context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*pmodel.LedgerAccount, error) {
|
|
if s.existingByRole != nil {
|
|
if acc, ok := s.existingByRole[role]; ok {
|
|
return acc, nil
|
|
}
|
|
}
|
|
for _, acc := range s.created {
|
|
if *acc.OrganizationRef == orgRef && acc.Currency == currency && acc.Role == role {
|
|
return acc, nil
|
|
}
|
|
}
|
|
return nil, storage.ErrAccountNotFound
|
|
}
|
|
|
|
func (s *accountStoreStub) GetSystemAccount(context.Context, pmodel.SystemAccountPurpose, string) (*pmodel.LedgerAccount, error) {
|
|
return nil, storage.ErrAccountNotFound
|
|
}
|
|
|
|
func (s *accountStoreStub) GetDefaultSettlement(context.Context, bson.ObjectID, string) (*pmodel.LedgerAccount, error) {
|
|
if s.defaultErr != nil {
|
|
return nil, s.defaultErr
|
|
}
|
|
if s.defaultSettlement != nil {
|
|
return s.defaultSettlement, nil
|
|
}
|
|
return nil, storage.ErrAccountNotFound
|
|
}
|
|
|
|
func (s *accountStoreStub) ListByOrganization(context.Context, bson.ObjectID, *storage.AccountsFilter, int, int) ([]*pmodel.LedgerAccount, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *accountStoreStub) UpdateStatus(context.Context, bson.ObjectID, pmodel.LedgerAccountStatus) error {
|
|
return nil
|
|
}
|
|
|
|
type repositoryStub struct {
|
|
accounts storage.AccountsStore
|
|
}
|
|
|
|
func (r *repositoryStub) Ping(context.Context) error { return nil }
|
|
func (r *repositoryStub) Accounts() storage.AccountsStore { return r.accounts }
|
|
func (r *repositoryStub) JournalEntries() storage.JournalEntriesStore { return nil }
|
|
func (r *repositoryStub) PostingLines() storage.PostingLinesStore { return nil }
|
|
func (r *repositoryStub) Balances() storage.BalancesStore { return nil }
|
|
func (r *repositoryStub) Outbox() storage.OutboxStore { return nil }
|
|
|
|
func TestCreateAccountResponder_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
orgRef := bson.NewObjectID()
|
|
|
|
accountStore := &accountStoreStub{}
|
|
svc := &Service{
|
|
logger: zap.NewNop(),
|
|
storage: &repositoryStub{accounts: accountStore},
|
|
}
|
|
|
|
req := &ledgerv1.CreateAccountRequest{
|
|
OrganizationRef: orgRef.Hex(),
|
|
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
|
Currency: "usd",
|
|
AllowNegative: false,
|
|
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_FEE,
|
|
Metadata: map[string]string{"purpose": "primary"},
|
|
}
|
|
|
|
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.NotNil(t, resp.Account)
|
|
|
|
// accountCode must be: "{accountType}:{CURRENCY}:{_id}"
|
|
require.NotEmpty(t, resp.Account.AccountCode)
|
|
require.NotEmpty(t, resp.Account.LedgerAccountRef)
|
|
|
|
parts := strings.Split(resp.Account.AccountCode, ":")
|
|
require.Len(t, parts, 3)
|
|
require.Equal(t, "asset", parts[0])
|
|
require.Equal(t, "usd", parts[1])
|
|
require.Len(t, parts[2], 24)
|
|
require.Equal(t, resp.Account.LedgerAccountRef, parts[2])
|
|
|
|
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType)
|
|
require.Equal(t, "USD", resp.Account.Currency)
|
|
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_FEE, resp.Account.Role)
|
|
require.Contains(t, resp.Account.Metadata, "purpose")
|
|
|
|
// Typically: settlement + requested account
|
|
require.Len(t, accountStore.created, 2)
|
|
}
|
|
|
|
func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
orgRef := bson.NewObjectID()
|
|
|
|
accountStore := &accountStoreStub{}
|
|
svc := &Service{
|
|
logger: zap.NewNop(),
|
|
storage: &repositoryStub{accounts: accountStore},
|
|
}
|
|
|
|
req := &ledgerv1.CreateAccountRequest{
|
|
OrganizationRef: orgRef.Hex(),
|
|
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY,
|
|
Currency: "usd",
|
|
}
|
|
|
|
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.NotNil(t, resp.Account)
|
|
|
|
// default role
|
|
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role)
|
|
require.Equal(t, "USD", resp.Account.Currency)
|
|
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, resp.Account.AccountType)
|
|
require.Equal(t, defaultLedgerAccountName, resp.Account.GetDescribable().GetName())
|
|
|
|
// Expect: required topology roles + dedicated operating account
|
|
require.Len(t, accountStore.created, 6)
|
|
|
|
var settlement *pmodel.LedgerAccount
|
|
var operating *pmodel.LedgerAccount
|
|
var operatingCount int
|
|
|
|
roles := make(map[account_role.AccountRole]bool)
|
|
for _, acc := range accountStore.created {
|
|
roles[acc.Role] = true
|
|
|
|
if acc.Role == account_role.AccountRoleSettlement {
|
|
settlement = acc
|
|
}
|
|
if acc.Role == account_role.AccountRoleOperating {
|
|
operatingCount++
|
|
operating = acc
|
|
}
|
|
|
|
// General format check for every created account
|
|
require.NotEmpty(t, acc.AccountCode)
|
|
cc := strings.Split(acc.AccountCode, ":")
|
|
require.Len(t, cc, 3)
|
|
require.Equal(t, "usd", cc[1])
|
|
require.Equal(t, acc.GetID().Hex(), cc[2])
|
|
}
|
|
|
|
require.NotNil(t, settlement)
|
|
require.NotNil(t, operating)
|
|
require.Equal(t, 2, operatingCount)
|
|
|
|
for _, role := range RequiredRolesV1 {
|
|
require.True(t, roles[role])
|
|
}
|
|
|
|
// Responder returns the dedicated operating account created for this request.
|
|
require.Equal(t, operating.AccountCode, resp.Account.AccountCode)
|
|
require.Equal(t, operating.GetID().Hex(), resp.Account.LedgerAccountRef)
|
|
|
|
// Settlement expectations: system, asset, no negative
|
|
stParts := strings.Split(settlement.AccountCode, ":")
|
|
require.Len(t, stParts, 3)
|
|
require.Equal(t, "asset", stParts[0])
|
|
require.Equal(t, "usd", stParts[1])
|
|
require.Equal(t, settlement.GetID().Hex(), stParts[2])
|
|
|
|
require.Equal(t, pmodel.LedgerAccountTypeAsset, settlement.AccountType)
|
|
require.Equal(t, "USD", settlement.Currency)
|
|
require.False(t, settlement.AllowNegative)
|
|
require.Equal(t, account_role.AccountRoleSettlement, settlement.Role)
|
|
require.Equal(t, "true", settlement.Metadata["system"])
|
|
}
|
|
|
|
func TestCreateAccountResponder_OperatingPreservesProvidedNameAndType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
orgRef := bson.NewObjectID()
|
|
accountStore := &accountStoreStub{}
|
|
svc := &Service{
|
|
logger: zap.NewNop(),
|
|
storage: &repositoryStub{accounts: accountStore},
|
|
}
|
|
|
|
req := &ledgerv1.CreateAccountRequest{
|
|
OrganizationRef: orgRef.Hex(),
|
|
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE,
|
|
Currency: "usd",
|
|
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
|
|
Describable: &describablev1.Describable{
|
|
Name: "Incoming revenue",
|
|
},
|
|
}
|
|
|
|
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.NotNil(t, resp.Account)
|
|
|
|
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, resp.Account.AccountType)
|
|
require.Equal(t, "Incoming revenue", resp.Account.GetDescribable().GetName())
|
|
|
|
// Topology accounts + dedicated operating account.
|
|
require.Len(t, accountStore.created, 6)
|
|
}
|
|
|
|
func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
orgRef := bson.NewObjectID()
|
|
accountStore := &accountStoreStub{
|
|
// first create attempt returns conflict, second succeeds
|
|
createErrs: []error{merrors.DataConflict("duplicate")},
|
|
}
|
|
|
|
svc := &Service{
|
|
logger: zap.NewNop(),
|
|
storage: &repositoryStub{accounts: accountStore},
|
|
}
|
|
|
|
req := &ledgerv1.CreateAccountRequest{
|
|
OrganizationRef: orgRef.Hex(),
|
|
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
|
Currency: "usd",
|
|
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_FEE,
|
|
}
|
|
|
|
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.NotNil(t, resp.Account)
|
|
|
|
// settlement + fee
|
|
require.Len(t, accountStore.created, 2)
|
|
|
|
var createdFee *pmodel.LedgerAccount
|
|
for _, acc := range accountStore.created {
|
|
if acc.Role == account_role.AccountRoleFee {
|
|
createdFee = acc
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, createdFee)
|
|
|
|
require.Equal(t, createdFee.AccountCode, resp.Account.AccountCode)
|
|
require.Equal(t, createdFee.GetID().Hex(), resp.Account.LedgerAccountRef)
|
|
|
|
parts := strings.Split(resp.Account.AccountCode, ":")
|
|
require.Len(t, parts, 3)
|
|
require.Equal(t, "asset", parts[0])
|
|
require.Equal(t, "usd", parts[1])
|
|
require.Equal(t, resp.Account.LedgerAccountRef, parts[2])
|
|
}
|
|
|
|
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := &Service{
|
|
logger: zap.NewNop(),
|
|
storage: &repositoryStub{accounts: &accountStoreStub{}},
|
|
}
|
|
|
|
// AccountType missing => must fail
|
|
req := &ledgerv1.CreateAccountRequest{
|
|
OrganizationRef: bson.NewObjectID().Hex(),
|
|
Currency: "USD",
|
|
}
|
|
|
|
_, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
|
require.Error(t, err)
|
|
}
|