134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
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
|
|
}
|