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 }