service backend
This commit is contained in:
27
api/ledger/internal/appversion/version.go
Normal file
27
api/ledger/internal/appversion/version.go
Normal 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)
|
||||
}
|
||||
133
api/ledger/internal/model/account.go
Normal file
133
api/ledger/internal/model/account.go
Normal 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
|
||||
}
|
||||
19
api/ledger/internal/model/balance.go
Normal file
19
api/ledger/internal/model/balance.go
Normal 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
|
||||
}
|
||||
46
api/ledger/internal/model/jentry.go
Normal file
46
api/ledger/internal/model/jentry.go
Normal 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
|
||||
}
|
||||
35
api/ledger/internal/model/outbox.go
Normal file
35
api/ledger/internal/model/outbox.go
Normal 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
|
||||
}
|
||||
57
api/ledger/internal/model/ownership.go
Normal file
57
api/ledger/internal/model/ownership.go
Normal 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
|
||||
}
|
||||
76
api/ledger/internal/model/party.go
Normal file
76
api/ledger/internal/model/party.go
Normal 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
|
||||
}
|
||||
37
api/ledger/internal/model/pline.go
Normal file
37
api/ledger/internal/model/pline.go
Normal 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
|
||||
}
|
||||
10
api/ledger/internal/model/util.go
Normal file
10
api/ledger/internal/model/util.go
Normal 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)
|
||||
}
|
||||
31
api/ledger/internal/model/validation.go
Normal file
31
api/ledger/internal/model/validation.go
Normal 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})
|
||||
}
|
||||
160
api/ledger/internal/server/internal/serverimp.go
Normal file
160
api/ledger/internal/server/internal/serverimp.go
Normal 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
|
||||
}
|
||||
11
api/ledger/internal/server/server.go
Normal file
11
api/ledger/internal/server/server.go
Normal 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)
|
||||
}
|
||||
208
api/ledger/internal/service/ledger/accounts.go
Normal file
208
api/ledger/internal/service/ledger/accounts.go
Normal 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,
|
||||
}
|
||||
}
|
||||
168
api/ledger/internal/service/ledger/accounts_test.go
Normal file
168
api/ledger/internal/service/ledger/accounts_test.go
Normal 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)
|
||||
}
|
||||
166
api/ledger/internal/service/ledger/helpers.go
Normal file
166
api/ledger/internal/service/ledger/helpers.go
Normal 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
|
||||
}
|
||||
417
api/ledger/internal/service/ledger/helpers_test.go
Normal file
417
api/ledger/internal/service/ledger/helpers_test.go
Normal 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 := ×tamppb.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)
|
||||
})
|
||||
}
|
||||
144
api/ledger/internal/service/ledger/metrics.go
Normal file
144
api/ledger/internal/service/ledger/metrics.go
Normal 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()
|
||||
}
|
||||
206
api/ledger/internal/service/ledger/outbox_publisher.go
Normal file
206
api/ledger/internal/service/ledger/outbox_publisher.go
Normal 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"`
|
||||
}
|
||||
142
api/ledger/internal/service/ledger/outbox_publisher_test.go
Normal file
142
api/ledger/internal/service/ledger/outbox_publisher_test.go
Normal 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
|
||||
}
|
||||
239
api/ledger/internal/service/ledger/posting.go
Normal file
239
api/ledger/internal/service/ledger/posting.go
Normal 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
|
||||
}
|
||||
}
|
||||
233
api/ledger/internal/service/ledger/posting_debit.go
Normal file
233
api/ledger/internal/service/ledger/posting_debit.go
Normal 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
|
||||
}
|
||||
}
|
||||
254
api/ledger/internal/service/ledger/posting_fx.go
Normal file
254
api/ledger/internal/service/ledger/posting_fx.go
Normal 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
|
||||
}
|
||||
}
|
||||
228
api/ledger/internal/service/ledger/posting_support.go
Normal file
228
api/ledger/internal/service/ledger/posting_support.go
Normal 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
|
||||
}
|
||||
282
api/ledger/internal/service/ledger/posting_support_test.go
Normal file
282
api/ledger/internal/service/ledger/posting_support_test.go
Normal 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)
|
||||
}
|
||||
238
api/ledger/internal/service/ledger/posting_transfer.go
Normal file
238
api/ledger/internal/service/ledger/posting_transfer.go
Normal 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
|
||||
}
|
||||
}
|
||||
269
api/ledger/internal/service/ledger/queries.go
Normal file
269
api/ledger/internal/service/ledger/queries.go
Normal 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))
|
||||
}
|
||||
99
api/ledger/internal/service/ledger/queries_test.go
Normal file
99
api/ledger/internal/service/ledger/queries_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
357
api/ledger/internal/service/ledger/service.go
Normal file
357
api/ledger/internal/service/ledger/service.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user