service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "MeetX Connectica Ledger Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,133 @@
package ledger
import (
"regexp"
"strconv"
"strings"
"time"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// AccountType controls normal balance side.
type AccountType string
const (
AccountTypeAsset AccountType = "asset"
AccountTypeLiability AccountType = "liability"
AccountTypeRevenue AccountType = "revenue"
AccountTypeExpense AccountType = "expense"
)
type AccountStatus string
const (
AccountStatusActive AccountStatus = "active"
AccountStatusFrozen AccountStatus = "frozen"
)
// lowercase a-z0-9 segments separated by ':'
var accountKeyRe = regexp.MustCompile(`^[a-z0-9]+(?:[:][a-z0-9]+)*$`)
type Account struct {
model.PermissionBound `bson:",inline" json:",inline"`
// Immutable identifier used by postings, balances, etc.
AccountKey string `bson:"accountKey" json:"accountKey"` // e.g., "asset:cash:operating"
PathParts []string `bson:"pathParts,omitempty" json:"pathParts,omitempty"` // optional: ["asset","cash","operating"]
// Classification
AccountType AccountType `bson:"accountType" json:"accountType"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
// Managing entity in your platform (not legal owner).
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
// Posting policy & lifecycle
AllowNegative bool `bson:"allowNegative" json:"allowNegative"`
Status AccountStatus `bson:"status" json:"status"`
// Legal ownership history
Ownerships []Ownership `bson:"ownerships,omitempty" json:"ownerships,omitempty"`
CurrentOwners []Ownership `bson:"currentOwners,omitempty" json:"currentOwners,omitempty"` // denormalized cache
// Operational flags
IsSettlement bool `bson:"isSettlement,omitempty" json:"isSettlement,omitempty"`
}
func (a *Account) NormalizeKey() {
a.AccountKey = strings.TrimSpace(strings.ToLower(a.AccountKey))
if len(a.PathParts) == 0 && a.AccountKey != "" {
a.PathParts = strings.Split(a.AccountKey, ":")
}
}
func (a *Account) Validate() error {
var verr *ValidationError
if strings.TrimSpace(a.AccountKey) == "" {
veAdd(&verr, "accountKey", "required", "accountKey is required")
} else if !accountKeyRe.MatchString(a.AccountKey) {
veAdd(&verr, "accountKey", "invalid_format", "use lowercase a-z0-9 segments separated by ':'")
}
switch a.AccountType {
case AccountTypeAsset, AccountTypeLiability, AccountTypeRevenue, AccountTypeExpense:
default:
veAdd(&verr, "accountType", "invalid", "expected asset|liability|revenue|expense")
}
switch a.Status {
case AccountStatusActive, AccountStatusFrozen:
default:
veAdd(&verr, "status", "invalid", "expected active|frozen")
}
// Validate ownership arrays with index context
for i := range a.Ownerships {
if err := a.Ownerships[i].Validate(); err != nil {
veAdd(&verr, "ownerships["+strconv.Itoa(i)+"]", "invalid", err.Error())
}
}
for i := range a.CurrentOwners {
if err := a.CurrentOwners[i].Validate(); err != nil {
veAdd(&verr, "currentOwners["+strconv.Itoa(i)+"]", "invalid", err.Error())
}
}
return verr
}
// ResolveCurrentOwners recomputes CurrentOwners for a given moment.
func (a *Account) ResolveCurrentOwners(asOf time.Time) {
dst := dstSlice(a.CurrentOwners, 0, len(a.Ownerships))
for _, o := range a.Ownerships {
if o.ActiveAt(asOf) {
dst = append(dst, o)
}
}
a.CurrentOwners = dst
}
// BalanceSide returns +1 for debit-normal (asset, expense), -1 for credit-normal (liability, revenue).
func (a *Account) BalanceSide() int {
switch a.AccountType {
case AccountTypeAsset, AccountTypeExpense:
return +1
default:
return -1
}
}
// CloseOwnershipPeriod sets the To date for the first matching active ownership.
func (a *Account) CloseOwnershipPeriod(partyID primitive.ObjectID, role OwnershipRole, to time.Time) bool {
for i := range a.Ownerships {
o := &a.Ownerships[i]
if o.OwnerPartyRef == partyID && o.Role == role && o.ActiveAt(to) {
o.To = &to
return true
}
}
return false
}

View File

@@ -0,0 +1,19 @@
package ledger
import (
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type AccountBalance struct {
model.PermissionBound `bson:",inline" json:",inline"`
LedgerAccountRef primitive.ObjectID `bson:"ledgerAccountRef" json:"ledgerAccountRef"` // unique
Balance decimal.Decimal `bson:"balance" json:"balance"`
Version int64 `bson:"version" json:"version"` // for optimistic locking
}
func (a *AccountBalance) Collection() string {
return mservice.LedgerBalances
}

View File

@@ -0,0 +1,46 @@
// journal_entry.go
package ledger
import (
"time"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// EntryType is a closed set of journal entry kinds.
type EntryType string
const (
EntryCredit EntryType = "credit"
EntryDebit EntryType = "debit"
EntryTransfer EntryType = "transfer"
EntryFX EntryType = "fx"
EntryFee EntryType = "fee"
EntryAdjust EntryType = "adjust"
EntryReverse EntryType = "reverse"
)
type JournalEntry struct {
model.PermissionBound `bson:",inline" json:",inline"`
// Idempotency/de-dup within your chosen scope (e.g., org/request)
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
EventTime time.Time `bson:"eventTime" json:"eventTime"`
EntryType EntryType `bson:"entryType" json:"entryType"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
// Monotonic ordering within your chosen scope (e.g., per org/ledger)
Version int64 `bson:"version" json:"version"`
// Denormalized set of all affected ledger accounts (for entry-level access control & queries)
LedgerAccountRefs []primitive.ObjectID `bson:"ledgerAccountRefs,omitempty" json:"ledgerAccountRefs,omitempty"`
// Optional backlink for reversals
ReversalOf *primitive.ObjectID `bson:"reversalOf,omitempty" json:"reversalOf,omitempty"`
}
func (j *JournalEntry) Collection() string {
return mservice.LedgerEntries
}

View File

@@ -0,0 +1,35 @@
package ledger
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
// Delivery status enum
type OutboxStatus string
const (
OutboxPending OutboxStatus = "pending"
OutboxSent OutboxStatus = "sent"
OutboxFailed OutboxStatus = "failed" // terminal after max retries, or keep pending with NextAttemptAt=nil
)
type OutboxEvent struct {
storable.Base `bson:",inline" json:",inline"`
EventID string `bson:"eventId" json:"eventId"` // deterministic; use as NATS Msg-Id
Subject string `bson:"subject" json:"subject"` // NATS subject / stream routing key
Payload []byte `bson:"payload" json:"payload"` // JSON (or other) payload
Status OutboxStatus `bson:"status" json:"status"` // enum
Attempts int `bson:"attempts" json:"attempts"` // total tries
NextAttemptAt *time.Time `bson:"nextAttemptAt,omitempty" json:"nextAttemptAt,omitempty"` // for backoff scheduler
SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` // brief reason of last failure
CorrelationRef string `bson:"correlationRef,omitempty" json:"correlationRef,omitempty"` // e.g., journalEntryRef or idempotencyKey
}
func (o *OutboxEvent) Collection() string {
return mservice.LedgerOutbox
}

View File

@@ -0,0 +1,57 @@
package ledger
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// OwnershipRole captures legal roles (not permissions).
type OwnershipRole string
const (
RoleLegalOwner OwnershipRole = "legal_owner"
RoleBeneficialOwner OwnershipRole = "beneficial_owner"
RoleCustodian OwnershipRole = "custodian"
RoleSignatory OwnershipRole = "signatory"
)
type Ownership struct {
OwnerPartyRef primitive.ObjectID `bson:"ownerPartyRef" json:"ownerPartyRef"`
Role OwnershipRole `bson:"role" json:"role"`
SharePct *float64 `bson:"sharePct,omitempty" json:"sharePct,omitempty"` // 0..100; nil = unspecified
From time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
To *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` // active if t < To; nil = open
}
func (o *Ownership) Validate() error {
var verr *ValidationError
if o.OwnerPartyRef.IsZero() {
veAdd(&verr, "ownerPartyRef", "required", "owner party reference required")
}
switch o.Role {
case RoleLegalOwner, RoleBeneficialOwner, RoleCustodian, RoleSignatory:
default:
veAdd(&verr, "role", "invalid", "unknown ownership role")
}
if o.SharePct != nil {
if *o.SharePct < 0 || *o.SharePct > 100 {
veAdd(&verr, "sharePct", "out_of_range", "must be between 0 and 100")
}
}
if o.To != nil && o.To.Before(o.From) {
veAdd(&verr, "effectiveTo", "before_from", "must be >= effectiveFrom")
}
return verr
}
func (o *Ownership) ActiveAt(t time.Time) bool {
if t.Before(o.From) {
return false
}
if o.To != nil && !t.Before(*o.To) { // active iff t < To
return false
}
return true
}

View File

@@ -0,0 +1,76 @@
package ledger
import (
"encoding/json"
"strings"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// PartyKind (string-backed enum) — readable in BSON/JSON, safe in Go.
type PartyKind string
const (
PartyKindPerson PartyKind = "person"
PartyKindOrganization PartyKind = "organization"
PartyKindExternal PartyKind = "external" // not mapped to internal user/org
)
func (k *PartyKind) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
switch PartyKind(s) {
case PartyKindPerson, PartyKindOrganization, PartyKindExternal:
*k = PartyKind(s)
return nil
default:
return &ValidationError{Issues: []ValidationIssue{{
Field: "kind", Code: "invalid_kind", Msg: "expected person|organization|external",
}}}
}
}
// Party represents a legal person or organization that can own accounts.
// Composed with your storable.Base and model.PermissionBound.
type Party struct {
model.PermissionBound `bson:",inline" json:",inline"`
Kind PartyKind `bson:"kind" json:"kind"`
Name string `bson:"name" json:"name"`
UserRef *primitive.ObjectID `bson:"userRef,omitempty" json:"userRef,omitempty"` // internal user, if applicable
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // internal org, if applicable
// add your own fields here if needed (KYC flags, etc.)
}
func (p *Party) Collection() string {
return mservice.LedgerParties
}
func (p *Party) Validate() error {
var verr *ValidationError
if strings.TrimSpace(p.Name) == "" {
veAdd(&verr, "name", "required", "party name is required")
}
switch p.Kind {
case PartyKindPerson:
if p.OrganizationRef != nil {
veAdd(&verr, "organizationRef", "must_be_nil", "person party cannot have organizationRef")
}
case PartyKindOrganization:
if p.UserRef != nil {
veAdd(&verr, "userRef", "must_be_nil", "organization party cannot have userRef")
}
case PartyKindExternal:
if p.UserRef != nil || p.OrganizationRef != nil {
veAdd(&verr, "refs", "must_be_nil", "external party cannot reference internal user/org")
}
default:
veAdd(&verr, "kind", "invalid", "unknown party kind")
}
return verr
}

View File

@@ -0,0 +1,37 @@
// posting_line.go
package ledger
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// LineType is a closed set of posting line roles within an entry.
type LineType string
const (
LineMain LineType = "main"
LineFee LineType = "fee"
LineSpread LineType = "spread"
LineReversal LineType = "reversal"
)
type PostingLine struct {
storable.Base `bson:",inline" json:",inline"`
JournalEntryRef primitive.ObjectID `bson:"journalEntryRef" json:"journalEntryRef"`
LedgerAccountRef primitive.ObjectID `bson:"ledgerAccountRef" json:"ledgerAccountRef"`
// Amount sign convention: positive = credit, negative = debit
Amount decimal.Decimal `bson:"amount" json:"amount"`
Currency model.Currency `bson:"currency" json:"currency"`
LineType LineType `bson:"lineType" json:"lineType"`
}
func (p *PostingLine) Collection() string {
return mservice.LedgerPlines
}

View File

@@ -0,0 +1,10 @@
package ledger
// dstSlice returns dst[:n] if capacity is enough, otherwise a new slice with capHint capacity.
// Avoids fmt/errors; tiny helper for in-place reuse when recomputing CurrentOwners.
func dstSlice[T any](dst []T, n, capHint int) []T {
if cap(dst) >= capHint {
return dst[:n]
}
return make([]T, n, capHint)
}

View File

@@ -0,0 +1,31 @@
package ledger
// ValidationIssue describes a single validation problem.
type ValidationIssue struct {
Field string `json:"field"`
Code string `json:"code"`
Msg string `json:"msg"`
}
// ValidationError aggregates issues. Implements error without fmt/errors.
type ValidationError struct {
Issues []ValidationIssue `json:"issues"`
}
func (e *ValidationError) Error() string {
if e == nil || len(e.Issues) == 0 {
return ""
}
if len(e.Issues) == 1 {
return e.Issues[0].Field + ": " + e.Issues[0].Msg
}
return "validation failed"
}
// veAdd appends a new issue into a (possibly nil) *ValidationError.
func veAdd(e **ValidationError, field, code, msg string) {
if *e == nil {
*e = &ValidationError{Issues: make([]ValidationIssue, 0, 4)}
}
(*e).Issues = append((*e).Issues, ValidationIssue{Field: field, Code: code, Msg: msg})
}

View File

@@ -0,0 +1,160 @@
package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/ledger/internal/service/ledger"
"github.com/tech/sendico/ledger/storage"
mongostorage "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
service *ledger.Service
feesConn *grpc.ClientConn
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees FeesClientConfig `yaml:"fees"`
}
type FeesClientConfig struct {
Address string `yaml:"address"`
TimeoutSeconds int `yaml:"timeout_seconds"`
}
const defaultFeesTimeout = 3 * time.Second
func (c FeesClientConfig) timeout() time.Duration {
if c.TimeoutSeconds <= 0 {
return defaultFeesTimeout
}
return time.Duration(c.TimeoutSeconds) * time.Second
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
if i.service != nil {
i.service.Shutdown()
}
if i.feesConn != nil {
_ = i.feesConn.Close()
}
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
if i.feesConn != nil {
_ = i.feesConn.Close()
}
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
var feesClient feesv1.FeeEngineClient
feesTimeout := cfg.Fees.timeout()
if addr := strings.TrimSpace(cfg.Fees.Address); addr != "" {
ctx, cancel := context.WithTimeout(context.Background(), feesTimeout)
defer cancel()
conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
} else {
i.logger.Info("Connected to fees service", zap.String("address", addr))
i.feesConn = conn
feesClient = feesv1.NewFeeEngineClient(conn)
}
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
svc := ledger.NewService(logger, repo, producer, feesClient, feesTimeout)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "ledger", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50052",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/ledger/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,208 @@
package ledger
import (
"context"
"errors"
"strings"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"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,
}
}

View File

@@ -0,0 +1,168 @@
package ledger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
type accountStoreStub struct {
createErr error
created []*model.Account
existing *model.Account
existingErr error
}
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
if s.createErr != nil {
return s.createErr
}
if account.GetID() == nil || account.GetID().IsZero() {
account.SetID(primitive.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, _ primitive.ObjectID, _ string, _ string) (*model.Account, error) {
if s.existingErr != nil {
return nil, s.existingErr
}
return s.existing, nil
}
func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Account, error) {
return nil, storage.ErrAccountNotFound
}
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
return nil, storage.ErrAccountNotFound
}
func (s *accountStoreStub) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
return nil, nil
}
func (s *accountStoreStub) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) 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 := primitive.NewObjectID()
accountStore := &accountStoreStub{}
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: accountStore},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
AllowNegative: false,
IsSettlement: true,
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)
require.Equal(t, "asset:cash:main", resp.Account.AccountCode)
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, resp.Account.AccountType)
require.Equal(t, "USD", resp.Account.Currency)
require.True(t, resp.Account.IsSettlement)
require.Contains(t, resp.Account.Metadata, "purpose")
require.NotEmpty(t, resp.Account.LedgerAccountRef)
require.Len(t, accountStore.created, 1)
}
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
t.Parallel()
orgRef := primitive.NewObjectID()
existing := &model.Account{
AccountCode: "asset:cash:main",
Currency: "USD",
AccountType: model.AccountTypeAsset,
Status: model.AccountStatusActive,
AllowNegative: false,
IsSettlement: true,
Metadata: map[string]string{"purpose": "existing"},
}
existing.OrganizationRef = orgRef
existing.SetID(primitive.NewObjectID())
existing.CreatedAt = time.Now().Add(-time.Hour).UTC()
existing.UpdatedAt = time.Now().UTC()
accountStore := &accountStoreStub{
createErr: merrors.DataConflict("duplicate"),
existing: existing,
existingErr: nil,
}
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: accountStore},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountCode: "asset:cash:main",
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: "usd",
}
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, existing.GetID().Hex(), resp.Account.LedgerAccountRef)
require.Equal(t, existing.Metadata["purpose"], resp.Account.Metadata["purpose"])
}
func TestCreateAccountResponder_InvalidAccountType(t *testing.T) {
t.Parallel()
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: &accountStoreStub{}},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: primitive.NewObjectID().Hex(),
AccountCode: "asset:cash:main",
Currency: "USD",
}
_, err := svc.createAccountResponder(context.Background(), req)(context.Background())
require.Error(t, err)
}

View File

@@ -0,0 +1,166 @@
package ledger
import (
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
// parseObjectID converts a hex string to ObjectID
func parseObjectID(hexID string) (primitive.ObjectID, error) {
if hexID == "" {
return primitive.NilObjectID, merrors.InvalidArgument("empty object ID")
}
oid, err := primitive.ObjectIDFromHex(hexID)
if err != nil {
return primitive.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("invalid object ID: %v", err))
}
return oid, nil
}
// parseDecimal converts a string amount to decimal
func parseDecimal(amount string) (decimal.Decimal, error) {
if amount == "" {
return decimal.Zero, merrors.InvalidArgument("empty amount")
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return decimal.Zero, merrors.InvalidArgument(fmt.Sprintf("invalid decimal amount: %v", err))
}
return dec, nil
}
// validateMoney checks that a Money message is valid
func validateMoney(m *moneyv1.Money, fieldName string) error {
if m == nil {
return merrors.InvalidArgument(fmt.Sprintf("%s: money is required", fieldName))
}
if m.Amount == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: amount is required", fieldName))
}
if m.Currency == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: currency is required", fieldName))
}
// Validate it's a valid decimal
if _, err := parseDecimal(m.Amount); err != nil {
return err
}
return nil
}
// validatePostingLines validates charge lines
func validatePostingLines(lines []*ledgerv1.PostingLine) error {
for i, line := range lines {
if line == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: nil posting line", i))
}
if line.LedgerAccountRef == "" {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: ledger_account_ref is required", i))
}
if line.Money == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: money is required", i))
}
if err := validateMoney(line.Money, fmt.Sprintf("charges[%d].money", i)); err != nil {
return err
}
// Charges should not be MAIN type
if line.LineType == ledgerv1.LineType_LINE_MAIN {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: cannot have LINE_MAIN type", i))
}
}
return nil
}
// getEventTime extracts event time from proto or defaults to now
func getEventTime(ts *timestamppb.Timestamp) time.Time {
if ts != nil && ts.IsValid() {
return ts.AsTime()
}
return time.Now().UTC()
}
// protoLineTypeToModel converts proto LineType to model LineType
func protoLineTypeToModel(lt ledgerv1.LineType) model.LineType {
switch lt {
case ledgerv1.LineType_LINE_MAIN:
return model.LineTypeMain
case ledgerv1.LineType_LINE_FEE:
return model.LineTypeFee
case ledgerv1.LineType_LINE_SPREAD:
return model.LineTypeSpread
case ledgerv1.LineType_LINE_REVERSAL:
return model.LineTypeReversal
default:
return model.LineTypeMain
}
}
// modelLineTypeToProto converts model LineType to proto LineType
func modelLineTypeToProto(lt model.LineType) ledgerv1.LineType {
switch lt {
case model.LineTypeMain:
return ledgerv1.LineType_LINE_MAIN
case model.LineTypeFee:
return ledgerv1.LineType_LINE_FEE
case model.LineTypeSpread:
return ledgerv1.LineType_LINE_SPREAD
case model.LineTypeReversal:
return ledgerv1.LineType_LINE_REVERSAL
default:
return ledgerv1.LineType_LINE_TYPE_UNSPECIFIED
}
}
// modelEntryTypeToProto converts model EntryType to proto EntryType
func modelEntryTypeToProto(et model.EntryType) ledgerv1.EntryType {
switch et {
case model.EntryTypeCredit:
return ledgerv1.EntryType_ENTRY_CREDIT
case model.EntryTypeDebit:
return ledgerv1.EntryType_ENTRY_DEBIT
case model.EntryTypeTransfer:
return ledgerv1.EntryType_ENTRY_TRANSFER
case model.EntryTypeFX:
return ledgerv1.EntryType_ENTRY_FX
case model.EntryTypeFee:
return ledgerv1.EntryType_ENTRY_FEE
case model.EntryTypeAdjust:
return ledgerv1.EntryType_ENTRY_ADJUST
case model.EntryTypeReverse:
return ledgerv1.EntryType_ENTRY_REVERSE
default:
return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
}
}
// calculateBalance computes net balance from a set of posting lines
func calculateBalance(lines []*model.PostingLine) (decimal.Decimal, error) {
balance := decimal.Zero
for _, line := range lines {
amount, err := parseDecimal(line.Amount)
if err != nil {
return decimal.Zero, fmt.Errorf("invalid line amount: %w", err)
}
balance = balance.Add(amount)
}
return balance, nil
}
// validateBalanced ensures posting lines sum to zero (double-entry accounting)
func validateBalanced(lines []*model.PostingLine) error {
balance, err := calculateBalance(lines)
if err != nil {
return err
}
if !balance.IsZero() {
return merrors.InvalidArgument(fmt.Sprintf("journal entry must balance (sum=0), got: %s", balance.String()))
}
return nil
}

View File

@@ -0,0 +1,417 @@
package ledger
import (
"testing"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestParseObjectID(t *testing.T) {
t.Run("ValidObjectID", func(t *testing.T) {
validID := primitive.NewObjectID()
result, err := parseObjectID(validID.Hex())
require.NoError(t, err)
assert.Equal(t, validID, result)
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseObjectID("")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "empty object ID")
})
t.Run("InvalidHexString", func(t *testing.T) {
result, err := parseObjectID("invalid-hex-string")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
assert.Contains(t, err.Error(), "invalid object ID")
})
t.Run("IncorrectLength", func(t *testing.T) {
result, err := parseObjectID("abc123")
require.Error(t, err)
assert.Equal(t, primitive.NilObjectID, result)
})
}
func TestParseDecimal(t *testing.T) {
t.Run("ValidDecimal", func(t *testing.T) {
result, err := parseDecimal("123.45")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(123.45)))
})
t.Run("EmptyString", func(t *testing.T) {
result, err := parseDecimal("")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "empty amount")
})
t.Run("InvalidDecimal", func(t *testing.T) {
result, err := parseDecimal("not-a-number")
require.Error(t, err)
assert.True(t, result.IsZero())
assert.Contains(t, err.Error(), "invalid decimal amount")
})
t.Run("NegativeDecimal", func(t *testing.T) {
result, err := parseDecimal("-100.50")
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-100.50)))
})
t.Run("ZeroDecimal", func(t *testing.T) {
result, err := parseDecimal("0")
require.NoError(t, err)
assert.True(t, result.IsZero())
})
}
func TestValidateMoney(t *testing.T) {
t.Run("ValidMoney", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "USD",
}
err := validateMoney(money, "test_field")
assert.NoError(t, err)
})
t.Run("NilMoney", func(t *testing.T) {
err := validateMoney(nil, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: money is required")
})
t.Run("EmptyAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: amount is required")
})
t.Run("EmptyCurrency", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "100.50",
Currency: "",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "test_field: currency is required")
})
t.Run("InvalidAmount", func(t *testing.T) {
money := &moneyv1.Money{
Amount: "invalid",
Currency: "USD",
}
err := validateMoney(money, "test_field")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid decimal amount")
})
}
func TestValidatePostingLines(t *testing.T) {
t.Run("ValidPostingLines", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_FEE,
},
}
err := validatePostingLines(lines)
assert.NoError(t, err)
})
t.Run("EmptyLines", func(t *testing.T) {
err := validatePostingLines([]*ledgerv1.PostingLine{})
assert.NoError(t, err)
})
t.Run("NilLine", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{nil}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "nil posting line")
})
t.Run("EmptyAccountRef", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: "",
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "ledger_account_ref is required")
})
t.Run("NilMoney", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: nil,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "money is required")
})
t.Run("MainLineType", func(t *testing.T) {
lines := []*ledgerv1.PostingLine{
{
LedgerAccountRef: primitive.NewObjectID().Hex(),
Money: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
LineType: ledgerv1.LineType_LINE_MAIN,
},
}
err := validatePostingLines(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot have LINE_MAIN type")
})
}
func TestGetEventTime(t *testing.T) {
t.Run("ValidTimestamp", func(t *testing.T) {
now := time.Now()
ts := timestamppb.New(now)
result := getEventTime(ts)
assert.True(t, result.Sub(now) < time.Second)
})
t.Run("NilTimestamp", func(t *testing.T) {
before := time.Now()
result := getEventTime(nil)
after := time.Now()
assert.True(t, result.After(before) || result.Equal(before))
assert.True(t, result.Before(after) || result.Equal(after))
})
t.Run("InvalidTimestamp", func(t *testing.T) {
// Create an invalid timestamp with negative seconds
ts := &timestamppb.Timestamp{Seconds: -1, Nanos: -1}
// Invalid timestamp should return current time
before := time.Now()
result := getEventTime(ts)
after := time.Now()
// Result should be close to now since timestamp is invalid
assert.True(t, result.After(before.Add(-time.Second)) || result.Equal(before))
assert.True(t, result.Before(after.Add(time.Second)) || result.Equal(after))
})
}
func TestProtoLineTypeToModel(t *testing.T) {
tests := []struct {
name string
input ledgerv1.LineType
expected model.LineType
}{
{"Main", ledgerv1.LineType_LINE_MAIN, model.LineTypeMain},
{"Fee", ledgerv1.LineType_LINE_FEE, model.LineTypeFee},
{"Spread", ledgerv1.LineType_LINE_SPREAD, model.LineTypeSpread},
{"Reversal", ledgerv1.LineType_LINE_REVERSAL, model.LineTypeReversal},
{"Unspecified", ledgerv1.LineType_LINE_TYPE_UNSPECIFIED, model.LineTypeMain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := protoLineTypeToModel(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelLineTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.LineType
expected ledgerv1.LineType
}{
{"Main", model.LineTypeMain, ledgerv1.LineType_LINE_MAIN},
{"Fee", model.LineTypeFee, ledgerv1.LineType_LINE_FEE},
{"Spread", model.LineTypeSpread, ledgerv1.LineType_LINE_SPREAD},
{"Reversal", model.LineTypeReversal, ledgerv1.LineType_LINE_REVERSAL},
{"Unknown", model.LineType("unknown"), ledgerv1.LineType_LINE_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelLineTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelEntryTypeToProto(t *testing.T) {
tests := []struct {
name string
input model.EntryType
expected ledgerv1.EntryType
}{
{"Credit", model.EntryTypeCredit, ledgerv1.EntryType_ENTRY_CREDIT},
{"Debit", model.EntryTypeDebit, ledgerv1.EntryType_ENTRY_DEBIT},
{"Transfer", model.EntryTypeTransfer, ledgerv1.EntryType_ENTRY_TRANSFER},
{"FX", model.EntryTypeFX, ledgerv1.EntryType_ENTRY_FX},
{"Fee", model.EntryTypeFee, ledgerv1.EntryType_ENTRY_FEE},
{"Adjust", model.EntryTypeAdjust, ledgerv1.EntryType_ENTRY_ADJUST},
{"Reverse", model.EntryTypeReverse, ledgerv1.EntryType_ENTRY_REVERSE},
{"Unknown", model.EntryType("unknown"), ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := modelEntryTypeToProto(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCalculateBalance(t *testing.T) {
t.Run("PositiveBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(150.00)))
})
t.Run("NegativeBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "-100.00"},
{Amount: "-50.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.Equal(decimal.NewFromFloat(-150.00)))
})
t.Run("ZeroBalance", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-100.00"},
}
result, err := calculateBalance(lines)
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("EmptyLines", func(t *testing.T) {
result, err := calculateBalance([]*model.PostingLine{})
require.NoError(t, err)
assert.True(t, result.IsZero())
})
t.Run("InvalidAmount", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "invalid"},
}
_, err := calculateBalance(lines)
require.Error(t, err)
})
}
func TestValidateBalanced(t *testing.T) {
t.Run("BalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-100.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("BalancedWithMultipleLines", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"}, // credit
{Amount: "-50.00"}, // debit
{Amount: "-50.00"}, // debit
}
err := validateBalanced(lines)
assert.NoError(t, err)
})
t.Run("UnbalancedEntry", func(t *testing.T) {
lines := []*model.PostingLine{
{Amount: "100.00"},
{Amount: "-50.00"},
}
err := validateBalanced(lines)
require.Error(t, err)
assert.Contains(t, err.Error(), "must balance")
})
t.Run("EmptyLines", func(t *testing.T) {
err := validateBalanced([]*model.PostingLine{})
assert.NoError(t, err)
})
}

View File

@@ -0,0 +1,144 @@
package ledger
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
// Journal entry operations
journalEntriesTotal *prometheus.CounterVec
journalEntryLatency *prometheus.HistogramVec
journalEntryErrors *prometheus.CounterVec
// Balance operations
balanceQueriesTotal *prometheus.CounterVec
balanceQueryLatency *prometheus.HistogramVec
// Transaction amounts
transactionAmounts *prometheus.HistogramVec
// Account operations
accountOperationsTotal *prometheus.CounterVec
// Idempotency
duplicateRequestsTotal *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
// Journal entries posted by type
journalEntriesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_journal_entries_total",
Help: "Total number of journal entries posted to the ledger",
},
[]string{"entry_type", "status"}, // entry_type: credit, debit, transfer, fx, fee, adjust, reverse
)
// Journal entry processing latency
journalEntryLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_journal_entry_duration_seconds",
Help: "Duration of journal entry posting operations",
Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"entry_type"},
)
// Journal entry errors by type
journalEntryErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_journal_entry_errors_total",
Help: "Total number of journal entry posting errors",
},
[]string{"entry_type", "error_type"}, // error_type: validation, insufficient_funds, db_error, etc.
)
// Balance queries
balanceQueriesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_balance_queries_total",
Help: "Total number of balance queries",
},
[]string{"status"}, // success, error
)
// Balance query latency
balanceQueryLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_balance_query_duration_seconds",
Help: "Duration of balance query operations",
Buckets: prometheus.DefBuckets,
},
[]string{"status"},
)
// Transaction amounts (in normalized form)
transactionAmounts = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ledger_transaction_amount",
Help: "Distribution of transaction amounts",
Buckets: []float64{1, 10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000},
},
[]string{"currency", "entry_type"},
)
// Account operations
accountOperationsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_account_operations_total",
Help: "Total number of account-level operations",
},
[]string{"operation", "status"}, // operation: create, freeze, unfreeze
)
// Duplicate/idempotent requests
duplicateRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ledger_duplicate_requests_total",
Help: "Total number of duplicate requests detected via idempotency keys",
},
[]string{"entry_type"},
)
})
}
// Metric recording helpers
func recordJournalEntry(entryType, status string, durationSeconds float64) {
initMetrics()
journalEntriesTotal.WithLabelValues(entryType, status).Inc()
journalEntryLatency.WithLabelValues(entryType).Observe(durationSeconds)
}
func recordJournalEntryError(entryType, errorType string) {
initMetrics()
journalEntryErrors.WithLabelValues(entryType, errorType).Inc()
journalEntriesTotal.WithLabelValues(entryType, "error").Inc()
}
func recordBalanceQuery(status string, durationSeconds float64) {
initMetrics()
balanceQueriesTotal.WithLabelValues(status).Inc()
balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds)
}
func recordTransactionAmount(currency, entryType string, amount float64) {
initMetrics()
transactionAmounts.WithLabelValues(currency, entryType).Observe(amount)
}
func recordAccountOperation(operation, status string) {
initMetrics()
accountOperationsTotal.WithLabelValues(operation, status).Inc()
}
func recordDuplicateRequest(entryType string) {
initMetrics()
duplicateRequestsTotal.WithLabelValues(entryType).Inc()
}

View File

@@ -0,0 +1,206 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/tech/sendico/ledger/storage"
ledgerModel "github.com/tech/sendico/ledger/storage/model"
pmessaging "github.com/tech/sendico/pkg/messaging"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
domainmodel "github.com/tech/sendico/pkg/model"
notification "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
const (
defaultOutboxBatchSize = 100
defaultOutboxPollInterval = time.Second
maxOutboxDeliveryAttempts = 5
outboxPublisherSender = "ledger.outbox.publisher"
)
type outboxPublisher struct {
logger mlogger.Logger
store storage.OutboxStore
producer pmessaging.Producer
batchSize int
pollInterval time.Duration
}
func newOutboxPublisher(logger mlogger.Logger, store storage.OutboxStore, producer pmessaging.Producer) *outboxPublisher {
return &outboxPublisher{
logger: logger.Named("outbox.publisher"),
store: store,
producer: producer,
batchSize: defaultOutboxBatchSize,
pollInterval: defaultOutboxPollInterval,
}
}
func (p *outboxPublisher) run(ctx context.Context) {
p.logger.Info("started")
defer p.logger.Info("stopped")
for {
if ctx.Err() != nil {
return
}
processed, err := p.dispatchPending(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to dispatch ledger outbox events", zap.Error(err))
}
if processed > 0 {
p.logger.Debug("dispatched ledger outbox events",
zap.Int("count", processed),
zap.Int("batch_size", p.batchSize))
}
if ctx.Err() != nil {
return
}
if processed == 0 {
select {
case <-ctx.Done():
return
case <-time.After(p.pollInterval):
}
}
}
}
func (p *outboxPublisher) dispatchPending(ctx context.Context) (int, error) {
if p.store == nil || p.producer == nil {
return 0, nil
}
events, err := p.store.ListPending(ctx, p.batchSize)
if err != nil {
return 0, err
}
for _, event := range events {
if ctx.Err() != nil {
return len(events), ctx.Err()
}
if err := p.publishEvent(ctx, event); err != nil {
if errors.Is(err, context.Canceled) {
return len(events), err
}
p.logger.Warn("failed to publish outbox event",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts))
p.handleFailure(ctx, event)
continue
}
if err := p.markSent(ctx, event); err != nil {
if errors.Is(err, context.Canceled) {
return len(events), err
}
p.logger.Warn("failed to mark outbox event as sent",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
} else {
p.logger.Debug("outbox event marked sent",
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
}
}
return len(events), nil
}
func (p *outboxPublisher) publishEvent(_ context.Context, event *ledgerModel.OutboxEvent) error {
docID := event.GetID()
if docID == nil || docID.IsZero() {
return errors.New("outbox event missing identifier")
}
payload, err := p.wrapPayload(event)
if err != nil {
return err
}
env := me.CreateEnvelope(outboxPublisherSender, domainmodel.NewNotification(mservice.LedgerOutbox, notification.NASent))
if _, err = env.Wrap(payload); err != nil {
return err
}
return p.producer.SendMessage(env)
}
func (p *outboxPublisher) wrapPayload(event *ledgerModel.OutboxEvent) ([]byte, error) {
message := ledgerOutboxMessage{
EventID: event.EventID,
Subject: event.Subject,
Payload: json.RawMessage(event.Payload),
Attempts: event.Attempts,
OrganizationRef: event.OrganizationRef.Hex(),
CreatedAt: event.CreatedAt,
}
return json.Marshal(message)
}
func (p *outboxPublisher) markSent(ctx context.Context, event *ledgerModel.OutboxEvent) error {
eventRef := event.GetID()
if eventRef == nil || eventRef.IsZero() {
return errors.New("outbox event missing identifier")
}
return p.store.MarkSent(ctx, *eventRef, time.Now().UTC())
}
func (p *outboxPublisher) handleFailure(ctx context.Context, event *ledgerModel.OutboxEvent) {
eventRef := event.GetID()
if eventRef == nil || eventRef.IsZero() {
p.logger.Warn("cannot record outbox failure: missing identifier", zap.String("eventId", event.EventID))
return
}
if err := p.store.IncrementAttempts(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to increment outbox attempts",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()))
}
if event.Attempts+1 >= maxOutboxDeliveryAttempts {
if err := p.store.MarkFailed(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) {
p.logger.Warn("failed to mark outbox event failed",
zap.Error(err),
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts+1))
} else {
p.logger.Warn("ledger outbox event marked as failed",
zap.String("eventId", event.EventID),
zap.String("subject", event.Subject),
zap.String("organizationRef", event.OrganizationRef.Hex()),
zap.Int("attempts", event.Attempts+1))
}
}
}
type ledgerOutboxMessage struct {
EventID string `json:"eventId"`
Subject string `json:"subject"`
Payload json.RawMessage `json:"payload"`
Attempts int `json:"attempts"`
OrganizationRef string `json:"organizationRef"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,142 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"github.com/tech/sendico/ledger/storage/model"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestOutboxPublisherDispatchSuccess(t *testing.T) {
logger := zap.NewNop()
event := &model.OutboxEvent{
EventID: "entry-1",
Subject: "ledger.entry.posted",
Payload: []byte(`{"journalEntryRef":"abc123"}`),
Attempts: 0,
}
event.SetID(primitive.NewObjectID())
event.OrganizationRef = primitive.NewObjectID()
store := &recordingOutboxStore{
pending: []*model.OutboxEvent{event},
}
producer := &stubProducer{}
publisher := newOutboxPublisher(logger, store, producer)
processed, err := publisher.dispatchPending(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, processed)
require.Len(t, producer.envelopes, 1)
env := producer.envelopes[0]
assert.Equal(t, outboxPublisherSender, env.GetSender())
assert.Equal(t, "ledger_outbox_sent", env.GetSignature().ToString())
var message ledgerOutboxMessage
require.NoError(t, json.Unmarshal(env.GetData(), &message))
assert.Equal(t, event.EventID, message.EventID)
assert.Equal(t, event.Subject, message.Subject)
assert.Equal(t, event.OrganizationRef.Hex(), message.OrganizationRef)
require.Len(t, store.markedSent, 1)
assert.Equal(t, *event.GetID(), store.markedSent[0])
assert.Empty(t, store.markedFailed)
assert.Empty(t, store.incremented)
}
func TestOutboxPublisherDispatchFailureMarksAttempts(t *testing.T) {
logger := zap.NewNop()
event := &model.OutboxEvent{
EventID: "entry-2",
Subject: "ledger.entry.posted",
Payload: []byte(`{"journalEntryRef":"xyz789"}`),
Attempts: maxOutboxDeliveryAttempts - 1,
}
event.SetID(primitive.NewObjectID())
event.OrganizationRef = primitive.NewObjectID()
store := &recordingOutboxStore{
pending: []*model.OutboxEvent{event},
}
producer := &stubProducer{err: errors.New("publish failed")}
publisher := newOutboxPublisher(logger, store, producer)
processed, err := publisher.dispatchPending(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, processed)
require.Len(t, store.incremented, 1)
assert.Equal(t, *event.GetID(), store.incremented[0])
require.Len(t, store.markedFailed, 1)
assert.Equal(t, *event.GetID(), store.markedFailed[0])
assert.Empty(t, store.markedSent)
}
type recordingOutboxStore struct {
mu sync.Mutex
pending []*model.OutboxEvent
markedSent []primitive.ObjectID
markedFailed []primitive.ObjectID
incremented []primitive.ObjectID
}
func (s *recordingOutboxStore) Create(context.Context, *model.OutboxEvent) error {
return nil
}
func (s *recordingOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
events := s.pending
s.pending = nil
return events, nil
}
func (s *recordingOutboxStore) MarkSent(_ context.Context, eventRef primitive.ObjectID, sentAt time.Time) error {
_ = sentAt
s.mu.Lock()
defer s.mu.Unlock()
s.markedSent = append(s.markedSent, eventRef)
return nil
}
func (s *recordingOutboxStore) MarkFailed(_ context.Context, eventRef primitive.ObjectID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.markedFailed = append(s.markedFailed, eventRef)
return nil
}
func (s *recordingOutboxStore) IncrementAttempts(_ context.Context, eventRef primitive.ObjectID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.incremented = append(s.incremented, eventRef)
return nil
}
type stubProducer struct {
mu sync.Mutex
envelopes []me.Envelope
err error
}
func (p *stubProducer) SendMessage(env me.Envelope) error {
p.mu.Lock()
defer p.mu.Unlock()
p.envelopes = append(p.envelopes, env)
return p.err
}

View File

@@ -0,0 +1,239 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
const ledgerOutboxSubject = "ledger.entry.posted"
// postCreditResponder implements credit posting with charges
func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCreditRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("credit")
s.logger.Info("duplicate credit request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
recordJournalEntryError("credit", "idempotency_check_failed")
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
recordJournalEntryError("credit", "account_not_found")
return nil, merrors.NoData("account not found")
}
recordJournalEntryError("credit", "account_lookup_failed")
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError("credit", "account_invalid")
return nil, err
}
accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account}
eventTime := getEventTime(req.EventTime)
creditAmount, _ := parseDecimal(req.Money.Amount)
entryTotal := creditAmount
charges := req.Charges
if len(charges) == 0 {
if computed, err := s.quoteFeesForCredit(ctx, req); err != nil {
s.logger.Warn("failed to quote fees", zap.Error(err))
} else if len(computed) > 0 {
charges = computed
}
}
if err := validatePostingLines(charges); err != nil {
return nil, err
}
postingLines := make([]*model.PostingLine, 0, 2+len(charges))
mainLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: accountRef,
Amount: creditAmount.String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
mainLine.OrganizationRef = orgRef
postingLines = append(postingLines, mainLine)
for i, charge := range charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
if charge.Money.Currency != req.Money.Currency {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
}
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
}
s.logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
return nil, merrors.Internal("failed to get charge account")
}
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
}
chargeAmount, err := parseDecimal(charge.Money.Amount)
if err != nil {
return nil, err
}
entryTotal = entryTotal.Add(chargeAmount)
chargeLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: chargeAccountRef,
Amount: chargeAmount.String(),
Currency: charge.Money.Currency,
LineType: protoLineTypeToModel(charge.LineType),
}
chargeLine.OrganizationRef = orgRef
postingLines = append(postingLines, chargeLine)
}
contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil {
recordJournalEntryError("credit", "contra_resolve_failed")
return nil, err
}
contraAccountID := contraAccount.GetID()
if contraAccountID == nil {
recordJournalEntryError("credit", "contra_missing_id")
return nil, merrors.Internal("contra account missing identifier")
}
contraAmount := entryTotal.Neg()
if !contraAmount.IsZero() || len(postingLines) == 1 {
contraLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: *contraAccountID,
Amount: contraAmount.String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
contraLine.OrganizationRef = orgRef
postingLines = append(postingLines, contraLine)
entryTotal = entryTotal.Add(contraAmount)
}
if !entryTotal.IsZero() {
recordJournalEntryError("credit", "unbalanced_after_contra")
return nil, merrors.Internal("failed to balance journal entry")
}
mongoStore, ok := s.storage.(*storageMongo.Store)
if !ok {
return nil, merrors.Internal("storage does not support transactions")
}
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeCredit,
Description: req.Description,
Metadata: req.Metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.logger.Warn("failed to create journal entry", zap.Error(err))
return nil, merrors.Internal("failed to create journal entry")
}
entryRef := entry.GetID()
if entryRef == nil {
return nil, merrors.Internal("journal entry missing identifier")
}
for _, line := range postingLines {
line.JournalEntryRef = *entryRef
}
if err := validateBalanced(postingLines); err != nil {
return nil, err
}
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
s.logger.Warn("failed to create posting lines", zap.Error(err))
return nil, merrors.Internal("failed to create posting lines")
}
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
return nil, err
}
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
return nil, err
}
return &ledgerv1.PostResponse{
JournalEntryRef: entryRef.Hex(),
Version: entry.Version,
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
}, nil
})
if err != nil {
recordJournalEntryError("credit", "transaction_failed")
return nil, err
}
amountFloat, _ := creditAmount.Float64()
recordTransactionAmount(req.Money.Currency, "credit", amountFloat)
recordJournalEntry("credit", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,233 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// postDebitResponder implements debit posting with charges
func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("debit")
s.logger.Info("duplicate debit request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_DEBIT,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
return nil, err
}
accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account}
eventTime := getEventTime(req.EventTime)
debitAmount, _ := parseDecimal(req.Money.Amount)
entryTotal := debitAmount.Neg()
charges := req.Charges
if len(charges) == 0 {
if computed, err := s.quoteFeesForDebit(ctx, req); err != nil {
s.logger.Warn("failed to quote fees", zap.Error(err))
} else if len(computed) > 0 {
charges = computed
}
}
if err := validatePostingLines(charges); err != nil {
return nil, err
}
postingLines := make([]*model.PostingLine, 0, 2+len(charges))
mainLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: accountRef,
Amount: debitAmount.Neg().String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
mainLine.OrganizationRef = orgRef
postingLines = append(postingLines, mainLine)
for i, charge := range charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
if charge.Money.Currency != req.Money.Currency {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
}
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
}
s.logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
return nil, merrors.Internal("failed to get charge account")
}
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
}
chargeAmount, err := parseDecimal(charge.Money.Amount)
if err != nil {
return nil, err
}
entryTotal = entryTotal.Add(chargeAmount)
chargeLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: chargeAccountRef,
Amount: chargeAmount.String(),
Currency: charge.Money.Currency,
LineType: protoLineTypeToModel(charge.LineType),
}
chargeLine.OrganizationRef = orgRef
postingLines = append(postingLines, chargeLine)
}
contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil {
recordJournalEntryError("debit", "contra_resolve_failed")
return nil, err
}
contraAccountID := contraAccount.GetID()
if contraAccountID == nil {
recordJournalEntryError("debit", "contra_missing_id")
return nil, merrors.Internal("contra account missing identifier")
}
contraAmount := entryTotal.Neg()
if !contraAmount.IsZero() || len(postingLines) == 1 {
contraLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: *contraAccountID,
Amount: contraAmount.String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
contraLine.OrganizationRef = orgRef
postingLines = append(postingLines, contraLine)
entryTotal = entryTotal.Add(contraAmount)
}
if !entryTotal.IsZero() {
recordJournalEntryError("debit", "unbalanced_after_contra")
return nil, merrors.Internal("failed to balance journal entry")
}
mongoStore, ok := s.storage.(*storageMongo.Store)
if !ok {
return nil, merrors.Internal("storage does not support transactions")
}
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeDebit,
Description: req.Description,
Metadata: req.Metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.logger.Warn("failed to create journal entry", zap.Error(err))
return nil, merrors.Internal("failed to create journal entry")
}
entryRef := entry.GetID()
if entryRef == nil {
return nil, merrors.Internal("journal entry missing identifier")
}
for _, line := range postingLines {
line.JournalEntryRef = *entryRef
}
if err := validateBalanced(postingLines); err != nil {
return nil, err
}
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
s.logger.Warn("failed to create posting lines", zap.Error(err))
return nil, merrors.Internal("failed to create posting lines")
}
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
return nil, err
}
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
return nil, err
}
return &ledgerv1.PostResponse{
JournalEntryRef: entryRef.Hex(),
Version: entry.Version,
EntryType: ledgerv1.EntryType_ENTRY_DEBIT,
}, nil
})
if err != nil {
recordJournalEntryError("debit", "transaction_failed")
return nil, err
}
amountFloat, _ := debitAmount.Float64()
recordTransactionAmount(req.Money.Currency, "debit", amountFloat)
recordJournalEntry("debit", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,254 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// fxResponder implements foreign exchange transactions with charges
func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
// Validate request
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.FromLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("from_ledger_account_ref is required")
}
if req.ToLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("to_ledger_account_ref is required")
}
if req.FromLedgerAccountRef == req.ToLedgerAccountRef {
return nil, merrors.InvalidArgument("cannot exchange to same account")
}
if err := validateMoney(req.FromMoney, "from_money"); err != nil {
return nil, err
}
if err := validateMoney(req.ToMoney, "to_money"); err != nil {
return nil, err
}
if req.FromMoney.Currency == req.ToMoney.Currency {
return nil, merrors.InvalidArgument("from_money and to_money must have different currencies")
}
if req.Rate == "" {
return nil, merrors.InvalidArgument("rate is required")
}
if err := validatePostingLines(req.Charges); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
fromAccountRef, err := parseObjectID(req.FromLedgerAccountRef)
if err != nil {
return nil, err
}
toAccountRef, err := parseObjectID(req.ToLedgerAccountRef)
if err != nil {
return nil, err
}
// Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("fx")
s.logger.Info("duplicate FX request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_FX,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
// Verify both accounts exist and are active
fromAccount, err := s.storage.Accounts().Get(ctx, fromAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("from_account not found")
}
s.logger.Warn("failed to get from_account", zap.Error(err))
return nil, merrors.Internal("failed to get from_account")
}
if err := validateAccountForOrg(fromAccount, orgRef, req.FromMoney.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("from_account: %s", err.Error()))
}
toAccount, err := s.storage.Accounts().Get(ctx, toAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("to_account not found")
}
s.logger.Warn("failed to get to_account", zap.Error(err))
return nil, merrors.Internal("failed to get to_account")
}
if err := validateAccountForOrg(toAccount, orgRef, req.ToMoney.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("to_account: %s", err.Error()))
}
accountsByRef := map[primitive.ObjectID]*model.Account{
fromAccountRef: fromAccount,
toAccountRef: toAccount,
}
eventTime := getEventTime(req.EventTime)
fromAmount, _ := parseDecimal(req.FromMoney.Amount)
toAmount, _ := parseDecimal(req.ToMoney.Amount)
// Create posting lines for FX
// Dr From Account in fromCurrency (debit = negative)
// Cr To Account in toCurrency (credit = positive)
postingLines := make([]*model.PostingLine, 0, 2+len(req.Charges))
// Debit from account
fromLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: fromAccountRef,
Amount: fromAmount.Neg().String(), // negative = debit
Currency: req.FromMoney.Currency,
LineType: model.LineTypeMain,
}
fromLine.OrganizationRef = orgRef
postingLines = append(postingLines, fromLine)
// Credit to account
toLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: toAccountRef,
Amount: toAmount.String(), // positive = credit
Currency: req.ToMoney.Currency,
LineType: model.LineTypeMain,
}
toLine.OrganizationRef = orgRef
postingLines = append(postingLines, toLine)
for i, charge := range req.Charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
}
s.logger.Warn("failed to get FX charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
return nil, merrors.Internal("failed to get charge account")
}
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
}
chargeAmount, err := parseDecimal(charge.Money.Amount)
if err != nil {
return nil, err
}
chargeLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: chargeAccountRef,
Amount: chargeAmount.String(),
Currency: charge.Money.Currency,
LineType: protoLineTypeToModel(charge.LineType),
}
chargeLine.OrganizationRef = orgRef
postingLines = append(postingLines, chargeLine)
}
// Execute in transaction
mongoStore, ok := s.storage.(*storageMongo.Store)
if !ok {
return nil, merrors.Internal("storage does not support transactions")
}
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
metadata := make(map[string]string)
if req.Metadata != nil {
for k, v := range req.Metadata {
metadata[k] = v
}
}
metadata["fx_rate"] = req.Rate
metadata["from_currency"] = req.FromMoney.Currency
metadata["to_currency"] = req.ToMoney.Currency
metadata["from_amount"] = req.FromMoney.Amount
metadata["to_amount"] = req.ToMoney.Amount
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeFX,
Description: req.Description,
Metadata: metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.logger.Warn("failed to create journal entry", zap.Error(err))
return nil, merrors.Internal("failed to create journal entry")
}
entryRef := entry.GetID()
if entryRef == nil {
return nil, merrors.Internal("journal entry missing identifier")
}
for _, line := range postingLines {
line.JournalEntryRef = *entryRef
}
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
s.logger.Warn("failed to create posting lines", zap.Error(err))
return nil, merrors.Internal("failed to create posting lines")
}
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
return nil, err
}
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
return nil, err
}
return &ledgerv1.PostResponse{
JournalEntryRef: entryRef.Hex(),
Version: entry.Version,
EntryType: ledgerv1.EntryType_ENTRY_FX,
}, nil
})
if err != nil {
recordJournalEntryError("fx", "transaction_failed")
return nil, err
}
fromAmountFloat, _ := fromAmount.Float64()
toAmountFloat, _ := toAmount.Float64()
recordTransactionAmount(req.FromMoney.Currency, "fx", fromAmountFloat)
recordTransactionAmount(req.ToMoney.Currency, "fx", toAmountFloat)
recordJournalEntry("fx", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,228 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/shopspring/decimal"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type outboxLinePayload struct {
AccountRef string `json:"accountRef"`
Amount string `json:"amount"`
Currency string `json:"currency"`
LineType string `json:"lineType"`
}
type outboxJournalPayload struct {
JournalEntryRef string `json:"journalEntryRef"`
EntryType string `json:"entryType"`
OrganizationRef string `json:"organizationRef"`
Version int64 `json:"version"`
EventTime time.Time `json:"eventTime"`
Lines []outboxLinePayload `json:"lines"`
}
func validateAccountForOrg(account *model.Account, orgRef primitive.ObjectID, currency string) error {
if account == nil {
return merrors.InvalidArgument("account is required")
}
if account.OrganizationRef != orgRef {
return merrors.InvalidArgument("account does not belong to organization")
}
if account.Status != model.AccountStatusActive {
return merrors.InvalidArgument(fmt.Sprintf("account is %s", account.Status))
}
if currency != "" && account.Currency != currency {
return merrors.InvalidArgument(fmt.Sprintf("account currency mismatch: account=%s, expected=%s", account.Currency, currency))
}
return nil
}
func (s *Service) getAccount(ctx context.Context, cache map[primitive.ObjectID]*model.Account, accountRef primitive.ObjectID) (*model.Account, error) {
if accountRef.IsZero() {
return nil, merrors.InvalidArgument("account reference is required")
}
if account, ok := cache[accountRef]; ok {
return account, nil
}
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
return nil, err
}
cache[accountRef] = account
return account, nil
}
func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency, override string, cache map[primitive.ObjectID]*model.Account) (*model.Account, error) {
if override != "" {
overrideRef, err := parseObjectID(override)
if err != nil {
return nil, err
}
account, err := s.getAccount(ctx, cache, overrideRef)
if err != nil {
if errors.Is(err, storage.ErrAccountNotFound) {
return nil, merrors.NoData("contra account not found")
}
s.logger.Warn("failed to load override contra account", zap.Error(err), zap.String("accountRef", overrideRef.Hex()))
return nil, merrors.Internal("failed to load contra account")
}
if err := validateAccountForOrg(account, orgRef, currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("contra account: %s", err.Error()))
}
return account, nil
}
account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, currency)
if err != nil {
if errors.Is(err, storage.ErrAccountNotFound) {
return nil, merrors.InvalidArgument("no default settlement account configured for currency")
}
s.logger.Warn("failed to resolve default settlement account",
zap.Error(err),
zap.String("organizationRef", orgRef.Hex()),
zap.String("currency", currency))
return nil, merrors.Internal("failed to resolve settlement account")
}
accountID := account.GetID()
if accountID == nil {
return nil, merrors.Internal("settlement account missing identifier")
}
cache[*accountID] = account
if err := validateAccountForOrg(account, orgRef, currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("settlement account: %s", err.Error()))
}
return account, nil
}
func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine, accounts map[primitive.ObjectID]*model.Account) error {
if len(lines) == 0 {
return nil
}
balanceDeltas := make(map[primitive.ObjectID]decimal.Decimal, len(lines))
for _, line := range lines {
delta, err := parseDecimal(line.Amount)
if err != nil {
return err
}
if current, ok := balanceDeltas[line.AccountRef]; ok {
balanceDeltas[line.AccountRef] = current.Add(delta)
continue
}
balanceDeltas[line.AccountRef] = delta
}
balancesStore := s.storage.Balances()
now := time.Now().UTC()
for accountRef, delta := range balanceDeltas {
account := accounts[accountRef]
if account == nil {
s.logger.Warn("account cache missing for balance update", zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("account cache missing for balance update")
}
currentBalance, err := balancesStore.Get(ctx, accountRef)
if err != nil && !errors.Is(err, storage.ErrBalanceNotFound) {
s.logger.Warn("failed to fetch account balance",
zap.Error(err),
zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("failed to update balance")
}
newAmount := delta
version := int64(1)
if currentBalance != nil {
existing, err := parseDecimal(currentBalance.Balance)
if err != nil {
return err
}
newAmount = existing.Add(delta)
version = currentBalance.Version + 1
}
if !account.AllowNegative && newAmount.LessThan(decimal.Zero) {
return merrors.InvalidArgument(fmt.Sprintf("account %s does not allow negative balances", accountRef.Hex()))
}
newBalance := &model.AccountBalance{
AccountRef: accountRef,
Balance: newAmount.String(),
Currency: account.Currency,
Version: version,
LastUpdated: now,
}
newBalance.OrganizationRef = account.OrganizationRef
if err := balancesStore.Upsert(ctx, newBalance); err != nil {
s.logger.Warn("failed to upsert account balance", zap.Error(err), zap.String("accountRef", accountRef.Hex()))
return merrors.Internal("failed to update balance")
}
}
return nil
}
func (s *Service) enqueueOutbox(ctx context.Context, entry *model.JournalEntry, lines []*model.PostingLine) error {
if entry == nil {
return merrors.Internal("journal entry is required")
}
entryID := entry.GetID()
if entryID == nil {
return merrors.Internal("journal entry missing identifier")
}
payload := outboxJournalPayload{
JournalEntryRef: entryID.Hex(),
EntryType: string(entry.EntryType),
OrganizationRef: entry.OrganizationRef.Hex(),
Version: entry.Version,
EventTime: entry.EventTime,
Lines: make([]outboxLinePayload, 0, len(lines)),
}
for _, line := range lines {
payload.Lines = append(payload.Lines, outboxLinePayload{
AccountRef: line.AccountRef.Hex(),
Amount: line.Amount,
Currency: line.Currency,
LineType: string(line.LineType),
})
}
body, err := json.Marshal(payload)
if err != nil {
s.logger.Warn("failed to marshal ledger outbox payload", zap.Error(err))
return merrors.Internal("failed to marshal ledger event")
}
event := &model.OutboxEvent{
EventID: entryID.Hex(),
Subject: ledgerOutboxSubject,
Payload: body,
Status: model.OutboxStatusPending,
Attempts: 0,
}
event.OrganizationRef = entry.OrganizationRef
if err := s.storage.Outbox().Create(ctx, event); err != nil {
s.logger.Warn("failed to enqueue ledger outbox event", zap.Error(err))
return merrors.Internal("failed to enqueue ledger event")
}
return nil
}

View File

@@ -0,0 +1,282 @@
package ledger
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type stubRepository struct {
accounts storage.AccountsStore
balances storage.BalancesStore
outbox storage.OutboxStore
}
func (s *stubRepository) Ping(context.Context) error { return nil }
func (s *stubRepository) Accounts() storage.AccountsStore { return s.accounts }
func (s *stubRepository) JournalEntries() storage.JournalEntriesStore { return nil }
func (s *stubRepository) PostingLines() storage.PostingLinesStore { return nil }
func (s *stubRepository) Balances() storage.BalancesStore { return s.balances }
func (s *stubRepository) Outbox() storage.OutboxStore { return s.outbox }
type stubAccountsStore struct {
getByID map[primitive.ObjectID]*model.Account
defaultSettlement *model.Account
getErr error
defaultErr error
}
func (s *stubAccountsStore) Create(context.Context, *model.Account) error {
return merrors.NotImplemented("create")
}
func (s *stubAccountsStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.Account, error) {
if s.getErr != nil {
return nil, s.getErr
}
if acc, ok := s.getByID[accountRef]; ok {
return acc, nil
}
return nil, storage.ErrAccountNotFound
}
func (s *stubAccountsStore) GetByAccountCode(context.Context, primitive.ObjectID, string, string) (*model.Account, error) {
return nil, merrors.NotImplemented("get by code")
}
func (s *stubAccountsStore) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
if s.defaultErr != nil {
return nil, s.defaultErr
}
if s.defaultSettlement == nil {
return nil, storage.ErrAccountNotFound
}
return s.defaultSettlement, nil
}
func (s *stubAccountsStore) ListByOrganization(context.Context, primitive.ObjectID, int, int) ([]*model.Account, error) {
return nil, merrors.NotImplemented("list")
}
func (s *stubAccountsStore) UpdateStatus(context.Context, primitive.ObjectID, model.AccountStatus) error {
return merrors.NotImplemented("update status")
}
type stubBalancesStore struct {
records map[primitive.ObjectID]*model.AccountBalance
upserts []*model.AccountBalance
getErr error
upErr error
}
func (s *stubBalancesStore) Get(ctx context.Context, accountRef primitive.ObjectID) (*model.AccountBalance, error) {
if s.getErr != nil {
return nil, s.getErr
}
if balance, ok := s.records[accountRef]; ok {
return balance, nil
}
return nil, storage.ErrBalanceNotFound
}
func (s *stubBalancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error {
if s.upErr != nil {
return s.upErr
}
copied := *balance
s.upserts = append(s.upserts, &copied)
if s.records == nil {
s.records = make(map[primitive.ObjectID]*model.AccountBalance)
}
s.records[balance.AccountRef] = &copied
return nil
}
func (s *stubBalancesStore) IncrementBalance(context.Context, primitive.ObjectID, string) error {
return merrors.NotImplemented("increment")
}
type stubOutboxStore struct {
created []*model.OutboxEvent
err error
}
func (s *stubOutboxStore) Create(ctx context.Context, event *model.OutboxEvent) error {
if s.err != nil {
return s.err
}
copied := *event
s.created = append(s.created, &copied)
return nil
}
func (s *stubOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) {
return nil, merrors.NotImplemented("list")
}
func (s *stubOutboxStore) MarkSent(context.Context, primitive.ObjectID, time.Time) error {
return merrors.NotImplemented("mark sent")
}
func (s *stubOutboxStore) MarkFailed(context.Context, primitive.ObjectID) error {
return merrors.NotImplemented("mark failed")
}
func (s *stubOutboxStore) IncrementAttempts(context.Context, primitive.ObjectID) error {
return merrors.NotImplemented("increment attempts")
}
func TestResolveSettlementAccount_Default(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
settlementID := primitive.NewObjectID()
settlement := &model.Account{}
settlement.SetID(settlementID)
settlement.OrganizationRef = orgRef
settlement.Currency = "USD"
settlement.Status = model.AccountStatusActive
accounts := &stubAccountsStore{defaultSettlement: settlement}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
cache := make(map[primitive.ObjectID]*model.Account)
result, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", cache)
require.NoError(t, err)
assert.Equal(t, settlement, result)
assert.Equal(t, settlement, cache[settlementID])
}
func TestResolveSettlementAccount_Override(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
overrideID := primitive.NewObjectID()
override := &model.Account{}
override.SetID(overrideID)
override.OrganizationRef = orgRef
override.Currency = "EUR"
override.Status = model.AccountStatusActive
accounts := &stubAccountsStore{getByID: map[primitive.ObjectID]*model.Account{overrideID: override}}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
cache := make(map[primitive.ObjectID]*model.Account)
result, err := service.resolveSettlementAccount(ctx, orgRef, "EUR", overrideID.Hex(), cache)
require.NoError(t, err)
assert.Equal(t, override, result)
assert.Equal(t, override, cache[overrideID])
}
func TestResolveSettlementAccount_NoDefault(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
accounts := &stubAccountsStore{defaultErr: storage.ErrAccountNotFound}
repo := &stubRepository{accounts: accounts}
service := &Service{logger: zap.NewNop(), storage: repo}
_, err := service.resolveSettlementAccount(ctx, orgRef, "USD", "", map[primitive.ObjectID]*model.Account{})
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
}
func TestUpsertBalances_Succeeds(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
accountRef := primitive.NewObjectID()
account := &model.Account{AllowNegative: false, Currency: "USD"}
account.OrganizationRef = orgRef
balanceLines := []*model.PostingLine{
{
AccountRef: accountRef,
Amount: "50",
Currency: "USD",
},
}
balances := &stubBalancesStore{}
repo := &stubRepository{balances: balances}
service := &Service{logger: zap.NewNop(), storage: repo}
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
require.NoError(t, service.upsertBalances(ctx, balanceLines, accountCache))
require.Len(t, balances.upserts, 1)
assert.Equal(t, "50", balances.upserts[0].Balance)
assert.Equal(t, int64(1), balances.upserts[0].Version)
assert.Equal(t, "USD", balances.upserts[0].Currency)
}
func TestUpsertBalances_DisallowNegative(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
accountRef := primitive.NewObjectID()
account := &model.Account{AllowNegative: false, Currency: "USD"}
account.OrganizationRef = orgRef
balanceLines := []*model.PostingLine{
{
AccountRef: accountRef,
Amount: "-10",
Currency: "USD",
},
}
balances := &stubBalancesStore{}
repo := &stubRepository{balances: balances}
service := &Service{logger: zap.NewNop(), storage: repo}
accountCache := map[primitive.ObjectID]*model.Account{accountRef: account}
err := service.upsertBalances(ctx, balanceLines, accountCache)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
}
func TestEnqueueOutbox_CreatesEvent(t *testing.T) {
ctx := context.Background()
orgRef := primitive.NewObjectID()
entryID := primitive.NewObjectID()
entry := &model.JournalEntry{
IdempotencyKey: "idem",
EventTime: time.Now().UTC(),
EntryType: model.EntryTypeCredit,
Version: 42,
}
entry.OrganizationRef = orgRef
entry.SetID(entryID)
lines := []*model.PostingLine{
{
AccountRef: primitive.NewObjectID(),
Amount: "100",
Currency: "USD",
LineType: model.LineTypeMain,
},
}
producer := &stubOutboxStore{}
repo := &stubRepository{outbox: producer}
service := &Service{logger: zap.NewNop(), storage: repo}
require.NoError(t, service.enqueueOutbox(ctx, entry, lines))
require.Len(t, producer.created, 1)
event := producer.created[0]
assert.Equal(t, entryID.Hex(), event.EventID)
assert.Equal(t, ledgerOutboxSubject, event.Subject)
var payload outboxJournalPayload
require.NoError(t, json.Unmarshal(event.Payload, &payload))
assert.Equal(t, entryID.Hex(), payload.JournalEntryRef)
assert.Equal(t, "credit", payload.EntryType)
assert.Len(t, payload.Lines, 1)
assert.Equal(t, "100", payload.Lines[0].Amount)
}

View File

@@ -0,0 +1,238 @@
package ledger
import (
"context"
"fmt"
"time"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// transferResponder implements internal transfer between accounts
func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
// Validate request
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if req.FromLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("from_ledger_account_ref is required")
}
if req.ToLedgerAccountRef == "" {
return nil, merrors.InvalidArgument("to_ledger_account_ref is required")
}
if req.FromLedgerAccountRef == req.ToLedgerAccountRef {
return nil, merrors.InvalidArgument("cannot transfer to same account")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
if err := validatePostingLines(req.Charges); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
fromAccountRef, err := parseObjectID(req.FromLedgerAccountRef)
if err != nil {
return nil, err
}
toAccountRef, err := parseObjectID(req.ToLedgerAccountRef)
if err != nil {
return nil, err
}
// Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest("transfer")
s.logger.Info("duplicate transfer request (idempotency)",
zap.String("idempotencyKey", req.IdempotencyKey),
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_TRANSFER,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
s.logger.Warn("failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
// Verify both accounts exist and are active
fromAccount, err := s.storage.Accounts().Get(ctx, fromAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("from_account not found")
}
s.logger.Warn("failed to get from_account", zap.Error(err))
return nil, merrors.Internal("failed to get from_account")
}
if err := validateAccountForOrg(fromAccount, orgRef, req.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("from_account: %s", err.Error()))
}
toAccount, err := s.storage.Accounts().Get(ctx, toAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("to_account not found")
}
s.logger.Warn("failed to get to_account", zap.Error(err))
return nil, merrors.Internal("failed to get to_account")
}
if err := validateAccountForOrg(toAccount, orgRef, req.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("to_account: %s", err.Error()))
}
accountsByRef := map[primitive.ObjectID]*model.Account{
fromAccountRef: fromAccount,
toAccountRef: toAccount,
}
eventTime := getEventTime(req.EventTime)
transferAmount, _ := parseDecimal(req.Money.Amount)
// Create posting lines for transfer
// Dr From Account (debit = negative)
// Cr To Account (credit = positive)
postingLines := make([]*model.PostingLine, 0, 2+len(req.Charges))
// Debit from account
fromLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: fromAccountRef,
Amount: transferAmount.Neg().String(), // negative = debit
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
fromLine.OrganizationRef = orgRef
postingLines = append(postingLines, fromLine)
// Credit to account
toLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: toAccountRef,
Amount: transferAmount.String(), // positive = credit
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
toLine.OrganizationRef = orgRef
postingLines = append(postingLines, toLine)
// Process charges (fees/spreads)
for i, charge := range req.Charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
if charge.Money.Currency != req.Money.Currency {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
}
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
}
s.logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
return nil, merrors.Internal("failed to get charge account")
}
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
}
chargeAmount, err := parseDecimal(charge.Money.Amount)
if err != nil {
return nil, err
}
chargeLine := &model.PostingLine{
JournalEntryRef: primitive.NilObjectID,
AccountRef: chargeAccountRef,
Amount: chargeAmount.String(),
Currency: charge.Money.Currency,
LineType: protoLineTypeToModel(charge.LineType),
}
chargeLine.OrganizationRef = orgRef
postingLines = append(postingLines, chargeLine)
}
// Execute in transaction
mongoStore, ok := s.storage.(*storageMongo.Store)
if !ok {
return nil, merrors.Internal("storage does not support transactions")
}
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(ctx, func(txCtx context.Context) (any, error) {
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeTransfer,
Description: req.Description,
Metadata: req.Metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
s.logger.Warn("failed to create journal entry", zap.Error(err))
return nil, merrors.Internal("failed to create journal entry")
}
entryRef := entry.GetID()
if entryRef == nil {
return nil, merrors.Internal("journal entry missing identifier")
}
for _, line := range postingLines {
line.JournalEntryRef = *entryRef
}
if err := validateBalanced(postingLines); err != nil {
return nil, err
}
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
s.logger.Warn("failed to create posting lines", zap.Error(err))
return nil, merrors.Internal("failed to create posting lines")
}
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
return nil, err
}
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
return nil, err
}
return &ledgerv1.PostResponse{
JournalEntryRef: entryRef.Hex(),
Version: entry.Version,
EntryType: ledgerv1.EntryType_ENTRY_TRANSFER,
}, nil
})
if err != nil {
recordJournalEntryError("transfer", "failed")
return nil, err
}
amountFloat, _ := transferAmount.Float64()
recordTransactionAmount(req.Money.Currency, "transfer", amountFloat)
recordJournalEntry("transfer", "success", 0)
return result.(*ledgerv1.PostResponse), nil
}
}

View File

@@ -0,0 +1,269 @@
package ledger
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"strings"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
// getBalanceResponder implements balance query logic
func (s *Service) getBalanceResponder(_ context.Context, req *ledgerv1.GetBalanceRequest) gsresponse.Responder[ledgerv1.BalanceResponse] {
return func(ctx context.Context) (*ledgerv1.BalanceResponse, error) {
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
// Get account to verify it exists
account, err := s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
// Get balance
balance, err := s.storage.Balances().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrBalanceNotFound {
// Return zero balance if account exists but has no balance yet
return &ledgerv1.BalanceResponse{
LedgerAccountRef: req.LedgerAccountRef,
Balance: &moneyv1.Money{
Amount: "0",
Currency: account.Currency,
},
Version: 0,
LastUpdated: timestamppb.Now(),
}, nil
}
s.logger.Warn("failed to get balance", zap.Error(err))
return nil, merrors.Internal("failed to get balance")
}
recordBalanceQuery("success", 0)
return &ledgerv1.BalanceResponse{
LedgerAccountRef: req.LedgerAccountRef,
Balance: &moneyv1.Money{
Amount: balance.Balance,
Currency: account.Currency,
},
Version: balance.Version,
LastUpdated: timestamppb.New(balance.UpdatedAt),
}, nil
}
}
// getJournalEntryResponder implements journal entry query logic
func (s *Service) getJournalEntryResponder(_ context.Context, req *ledgerv1.GetEntryRequest) gsresponse.Responder[ledgerv1.JournalEntryResponse] {
return func(ctx context.Context) (*ledgerv1.JournalEntryResponse, error) {
if req.EntryRef == "" {
return nil, merrors.InvalidArgument("entry_ref is required")
}
entryRef, err := parseObjectID(req.EntryRef)
if err != nil {
return nil, err
}
// Get journal entry
entry, err := s.storage.JournalEntries().Get(ctx, entryRef)
if err != nil {
if err == storage.ErrJournalEntryNotFound {
return nil, merrors.NoData("journal entry not found")
}
s.logger.Warn("failed to get journal entry", zap.Error(err))
return nil, merrors.Internal("failed to get journal entry")
}
// Get posting lines for this entry
lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get posting lines", zap.Error(err))
return nil, merrors.Internal("failed to get posting lines")
}
// Convert to proto
protoLines := make([]*ledgerv1.PostingLine, 0, len(lines))
accountRefs := make([]string, 0, len(lines))
for _, line := range lines {
protoLines = append(protoLines, &ledgerv1.PostingLine{
LedgerAccountRef: line.AccountRef.Hex(),
Money: &moneyv1.Money{
Amount: line.Amount,
Currency: line.Currency,
},
LineType: modelLineTypeToProto(line.LineType),
})
accountRefs = append(accountRefs, line.AccountRef.Hex())
}
return &ledgerv1.JournalEntryResponse{
EntryRef: req.EntryRef,
IdempotencyKey: entry.IdempotencyKey,
EntryType: modelEntryTypeToProto(entry.EntryType),
Description: entry.Description,
EventTime: timestamppb.New(entry.EventTime),
Version: entry.Version,
Lines: protoLines,
Metadata: entry.Metadata,
LedgerAccountRefs: accountRefs,
}, nil
}
}
// getStatementResponder implements account statement query logic
func (s *Service) getStatementResponder(_ context.Context, req *ledgerv1.GetStatementRequest) gsresponse.Responder[ledgerv1.StatementResponse] {
return func(ctx context.Context) (*ledgerv1.StatementResponse, error) {
if req.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument("ledger_account_ref is required")
}
accountRef, err := parseObjectID(req.LedgerAccountRef)
if err != nil {
return nil, err
}
// Verify account exists
_, err = s.storage.Accounts().Get(ctx, accountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData("account not found")
}
s.logger.Warn("failed to get account", zap.Error(err))
return nil, merrors.Internal("failed to get account")
}
// Parse pagination
limit := int(req.Limit)
if limit <= 0 {
limit = 50 // default
}
if limit > 100 {
limit = 100 // max
}
offset := 0
if req.Cursor != "" {
offset, err = parseCursor(req.Cursor)
if err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid cursor: %v", err))
}
}
// Get posting lines for account
postingLines, err := s.storage.PostingLines().ListByAccount(ctx, accountRef, limit+1, offset)
if err != nil {
s.logger.Warn("failed to get posting lines", zap.Error(err))
return nil, merrors.Internal("failed to get posting lines")
}
// Check if there are more results
hasMore := len(postingLines) > limit
if hasMore {
postingLines = postingLines[:limit]
}
// Group by journal entry and fetch entry details
entryMap := make(map[string]bool)
for _, line := range postingLines {
entryMap[line.JournalEntryRef.Hex()] = true
}
entries := make([]*ledgerv1.JournalEntryResponse, 0)
for entryRefHex := range entryMap {
entryRef, _ := parseObjectID(entryRefHex)
entry, err := s.storage.JournalEntries().Get(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get journal entry for statement", zap.Error(err), zap.String("entryRef", entryRefHex))
continue
}
// Get all lines for this entry
lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef)
if err != nil {
s.logger.Warn("failed to get posting lines for entry", zap.Error(err), zap.String("entryRef", entryRefHex))
continue
}
// Convert to proto
protoLines := make([]*ledgerv1.PostingLine, 0, len(lines))
accountRefs := make([]string, 0, len(lines))
for _, line := range lines {
protoLines = append(protoLines, &ledgerv1.PostingLine{
LedgerAccountRef: line.AccountRef.Hex(),
Money: &moneyv1.Money{
Amount: line.Amount,
Currency: line.Currency,
},
LineType: modelLineTypeToProto(line.LineType),
})
accountRefs = append(accountRefs, line.AccountRef.Hex())
}
entries = append(entries, &ledgerv1.JournalEntryResponse{
EntryRef: entryRefHex,
IdempotencyKey: entry.IdempotencyKey,
EntryType: modelEntryTypeToProto(entry.EntryType),
Description: entry.Description,
EventTime: timestamppb.New(entry.EventTime),
Version: entry.Version,
Lines: protoLines,
Metadata: entry.Metadata,
LedgerAccountRefs: accountRefs,
})
}
// Generate next cursor
nextCursor := ""
if hasMore {
nextCursor = encodeCursor(offset + limit)
}
return &ledgerv1.StatementResponse{
Entries: entries,
NextCursor: nextCursor,
}, nil
}
}
// parseCursor decodes a pagination cursor
func parseCursor(cursor string) (int, error) {
decoded, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, fmt.Errorf("invalid base64: %w", err)
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 2 || parts[0] != "offset" {
return 0, fmt.Errorf("invalid cursor format")
}
offset, err := strconv.Atoi(parts[1])
if err != nil {
return 0, fmt.Errorf("invalid offset: %w", err)
}
return offset, nil
}
// encodeCursor encodes an offset into a pagination cursor
func encodeCursor(offset int) string {
cursor := fmt.Sprintf("offset:%d", offset)
return base64.StdEncoding.EncodeToString([]byte(cursor))
}

View File

@@ -0,0 +1,99 @@
package ledger
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseCursor(t *testing.T) {
t.Run("ValidCursor", func(t *testing.T) {
cursor := encodeCursor(100)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 100, offset)
})
t.Run("ZeroOffset", func(t *testing.T) {
cursor := encodeCursor(0)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 0, offset)
})
t.Run("InvalidBase64", func(t *testing.T) {
_, err := parseCursor("not-valid-base64!!!")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid base64")
})
t.Run("InvalidFormat", func(t *testing.T) {
// Encode something that's not in the expected format
invalidCursor := "aW52YWxpZC1mb3JtYXQ=" // base64 of "invalid-format"
_, err := parseCursor(invalidCursor)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid cursor format")
})
t.Run("InvalidOffsetValue", func(t *testing.T) {
// Create a cursor with non-numeric offset
invalidCursor := "b2Zmc2V0OmFiYw==" // base64 of "offset:abc"
_, err := parseCursor(invalidCursor)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid offset")
})
t.Run("NegativeOffset", func(t *testing.T) {
cursor := encodeCursor(-10)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, -10, offset)
})
}
func TestEncodeCursor(t *testing.T) {
t.Run("PositiveOffset", func(t *testing.T) {
cursor := encodeCursor(100)
assert.NotEmpty(t, cursor)
// Verify it can be parsed back
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 100, offset)
})
t.Run("ZeroOffset", func(t *testing.T) {
cursor := encodeCursor(0)
assert.NotEmpty(t, cursor)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 0, offset)
})
t.Run("LargeOffset", func(t *testing.T) {
cursor := encodeCursor(999999)
assert.NotEmpty(t, cursor)
offset, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, 999999, offset)
})
t.Run("RoundTrip", func(t *testing.T) {
testOffsets := []int{0, 1, 10, 50, 100, 500, 1000, 10000}
for _, expected := range testOffsets {
cursor := encodeCursor(expected)
actual, err := parseCursor(cursor)
require.NoError(t, err)
assert.Equal(t, expected, actual)
}
})
}

View File

@@ -0,0 +1,357 @@
package ledger
import (
"context"
"fmt"
"strings"
"sync"
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/shopspring/decimal"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errStorageNotInitialized = serviceError("ledger: storage not initialized")
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
fees feesDependency
outbox struct {
once sync.Once
cancel context.CancelFunc
publisher *outboxPublisher
}
ledgerv1.UnimplementedLedgerServiceServer
}
type feesDependency struct {
client feesv1.FeeEngineClient
timeout time.Duration
}
func (f feesDependency) available() bool {
return f.client != nil
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, feesClient feesv1.FeeEngineClient, feesTimeout time.Duration) *Service {
// Initialize Prometheus metrics
initMetrics()
service := &Service{
logger: logger.Named("ledger"),
storage: repo,
producer: prod,
fees: feesDependency{
client: feesClient,
timeout: feesTimeout,
},
}
service.startOutboxPublisher()
return service
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
ledgerv1.RegisterLedgerServiceServer(reg, s)
})
}
// CreateAccount provisions a new ledger account scoped to an organization.
func (s *Service) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
responder := s.createAccountResponder(ctx, req)
return responder(ctx)
}
// PostCreditWithCharges handles credit posting with fees in one atomic journal entry
func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("credit", "attempted", time.Since(start).Seconds())
}()
responder := s.postCreditResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("credit", "not_implemented")
}
return resp, err
}
// PostDebitWithCharges handles debit posting with fees in one atomic journal entry
func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("debit", "attempted", time.Since(start).Seconds())
}()
responder := s.postDebitResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("debit", "failed")
}
return resp, err
}
// TransferInternal handles internal transfer between accounts
func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("transfer", "attempted", time.Since(start).Seconds())
}()
responder := s.transferResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("transfer", "failed")
}
return resp, err
}
// ApplyFXWithCharges handles foreign exchange transaction with charges
func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
start := time.Now()
defer func() {
recordJournalEntry("fx", "attempted", time.Since(start).Seconds())
}()
responder := s.fxResponder(ctx, req)
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("fx", "failed")
}
return resp, err
}
// GetBalance queries current account balance
func (s *Service) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
start := time.Now()
defer func() {
recordBalanceQuery("attempted", time.Since(start).Seconds())
}()
responder := s.getBalanceResponder(ctx, req)
resp, err := responder(ctx)
return resp, err
}
// GetJournalEntry gets journal entry details
func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
responder := s.getJournalEntryResponder(ctx, req)
return responder(ctx)
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.outbox.cancel != nil {
s.outbox.cancel()
}
}
func (s *Service) startOutboxPublisher() {
if s.storage == nil || s.producer == nil {
return
}
s.outbox.once.Do(func() {
outboxStore := s.storage.Outbox()
if outboxStore == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.outbox.cancel = cancel
s.outbox.publisher = newOutboxPublisher(s.logger, outboxStore, s.producer)
go s.outbox.publisher.run(ctx)
})
}
// GetStatement gets account statement with pagination
func (s *Service) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
responder := s.getStatementResponder(ctx, req)
return responder(ctx)
}
func (s *Service) pingStorage(ctx context.Context) error {
if s.storage == nil {
return errStorageNotInitialized
}
return s.storage.Ping(ctx)
}
func (s *Service) quoteFeesForCredit(ctx context.Context, req *ledgerv1.PostCreditRequest) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
attrs := map[string]string{}
if strings.TrimSpace(req.GetDescription()) != "" {
attrs["description"] = req.GetDescription()
}
return s.quoteFees(ctx, feesv1.Trigger_TRIGGER_CAPTURE, req.GetOrganizationRef(), req.GetIdempotencyKey(), req.GetLedgerAccountRef(), "ledger.post_credit", req.GetIdempotencyKey(), req.GetEventTime(), req.Money, attrs)
}
func (s *Service) quoteFeesForDebit(ctx context.Context, req *ledgerv1.PostDebitRequest) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
attrs := map[string]string{}
if strings.TrimSpace(req.GetDescription()) != "" {
attrs["description"] = req.GetDescription()
}
return s.quoteFees(ctx, feesv1.Trigger_TRIGGER_REFUND, req.GetOrganizationRef(), req.GetIdempotencyKey(), req.GetLedgerAccountRef(), "ledger.post_debit", req.GetIdempotencyKey(), req.GetEventTime(), req.Money, attrs)
}
func (s *Service) quoteFees(ctx context.Context, trigger feesv1.Trigger, organizationRef, idempotencyKey, ledgerAccountRef, originType, originRef string, eventTime *timestamppb.Timestamp, baseAmount *moneyv1.Money, attributes map[string]string) ([]*ledgerv1.PostingLine, error) {
if !s.fees.available() {
return nil, nil
}
if strings.TrimSpace(organizationRef) == "" {
return nil, fmt.Errorf("organization reference is required to quote fees")
}
if baseAmount == nil {
return nil, fmt.Errorf("base amount is required to quote fees")
}
amountCopy := &moneyv1.Money{Amount: baseAmount.GetAmount(), Currency: baseAmount.GetCurrency()}
bookedAt := eventTime
if bookedAt == nil {
bookedAt = timestamppb.Now()
}
trace := &tracev1.TraceContext{
RequestRef: idempotencyKey,
IdempotencyKey: idempotencyKey,
}
req := &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: organizationRef,
Trace: trace,
},
Intent: &feesv1.Intent{
Trigger: trigger,
BaseAmount: amountCopy,
BookedAt: bookedAt,
OriginType: originType,
OriginRef: originRef,
Attributes: map[string]string{},
},
}
if ledgerAccountRef != "" {
req.Intent.Attributes["ledger_account_ref"] = ledgerAccountRef
}
for k, v := range attributes {
if strings.TrimSpace(k) == "" {
continue
}
req.Intent.Attributes[k] = v
}
callCtx := ctx
if s.fees.timeout > 0 {
var cancel context.CancelFunc
callCtx, cancel = context.WithTimeout(ctx, s.fees.timeout)
defer cancel()
}
resp, err := s.fees.client.QuoteFees(callCtx, req)
if err != nil {
return nil, err
}
lines, err := convertFeeDerivedLines(resp.GetLines())
if err != nil {
return nil, err
}
return lines, nil
}
func convertFeeDerivedLines(lines []*feesv1.DerivedPostingLine) ([]*ledgerv1.PostingLine, error) {
result := make([]*ledgerv1.PostingLine, 0, len(lines))
for idx, line := range lines {
if line == nil {
continue
}
if line.GetMoney() == nil {
return nil, fmt.Errorf("fee line %d missing money", idx)
}
dec, err := decimal.NewFromString(line.GetMoney().GetAmount())
if err != nil {
return nil, fmt.Errorf("fee line %d invalid amount: %w", idx, err)
}
dec = ensureAmountForSide(dec, line.GetSide())
posting := &ledgerv1.PostingLine{
LedgerAccountRef: line.GetLedgerAccountRef(),
Money: &moneyv1.Money{
Amount: dec.String(),
Currency: line.GetMoney().GetCurrency(),
},
LineType: mapFeeLineType(line.GetLineType()),
}
result = append(result, posting)
}
return result, nil
}
func ensureAmountForSide(amount decimal.Decimal, side accountingv1.EntrySide) decimal.Decimal {
switch side {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
if amount.Sign() > 0 {
return amount.Neg()
}
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
if amount.Sign() < 0 {
return amount.Neg()
}
}
return amount
}
func mapFeeLineType(lineType accountingv1.PostingLineType) ledgerv1.LineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_FEE:
return ledgerv1.LineType_LINE_FEE
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return ledgerv1.LineType_LINE_SPREAD
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return ledgerv1.LineType_LINE_REVERSAL
default:
return ledgerv1.LineType_LINE_FEE
}
}