service backend
This commit is contained in:
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})
|
||||
}
|
||||
Reference in New Issue
Block a user