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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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