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" ) // 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 model.LedgerAccountStatus `bson:"status" json:"status"` Role model.AccountRole `bson:"role,omitempty" json:"role,omitempty"` // Legal ownership history Ownerships []Ownership `bson:"ownerships,omitempty" json:"ownerships,omitempty"` CurrentOwners []Ownership `bson:"currentOwners,omitempty" json:"currentOwners,omitempty"` // denormalized cache } 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 model.LedgerAccountStatusActive, model.LedgerAccountStatusFrozen, model.LedgerAccountStatusClosed: default: veAdd(&verr, "status", "invalid", "expected active|frozen|closed") } if role := strings.TrimSpace(string(a.Role)); role != "" { switch a.Role { case model.AccountRoleOperating, model.AccountRoleHold, model.AccountRoleTransit, model.AccountRoleSettlement, model.AccountRoleClearing, model.AccountRolePending, model.AccountRoleReserve, model.AccountRoleLiquidity, model.AccountRoleFee, model.AccountRoleChargeback, model.AccountRoleAdjustment: default: veAdd(&verr, "role", "invalid", "unknown account role") } } // 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 }