Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions internal/chart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package ledger

import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
)

type AccountRules struct {
AllowedSources map[string]interface{} `json:"allowedSources"`
AllowedDestinations map[string]interface{} `json:"allowedDestinations"`
}

type AccountSchema struct {
Metadata map[string]string
Rules AccountRules
}

type SegmentSchema struct {
VariableSegment *VariableSegment
FixedSegments map[string]SegmentSchema
Account *AccountSchema
}

type VariableSegment struct {
SegmentSchema

Pattern string
Label string
}

type ChartOfAccounts map[string]SegmentSchema

const SegmentRegex = "^\\$?[a-zA-Z0-9_-]+$"

var Regexp = regexp.MustCompile(SegmentRegex)

func ValidateSegment(addr string) bool {
return Regexp.Match([]byte(addr))
}

func (s *ChartOfAccounts) UnmarshalJSON(data []byte) error {
var rootSegment SegmentSchema
err := rootSegment.UnmarshalJSON(data)
if err != nil {
return err
}
*s = rootSegment.FixedSegments
if rootSegment.VariableSegment != nil {
return errors.New("variable segments are not allowed at the root")
}
if rootSegment.Account != nil {
return errors.New("the chart root is not a valid account")
}
return nil
}
func (s *SegmentSchema) UnmarshalJSON(data []byte) error {
var segment map[string]json.RawMessage
if err := json.Unmarshal(data, &segment); err != nil {
return err
}
isLeaf := true
var isAccount bool
var account AccountSchema
var fixedSegments map[string]SegmentSchema
var variableSegment *VariableSegment
keys := []string{}
for key := range segment {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
value := segment[key]
isSubsegment := key[0] != '_'

if isSubsegment {
if !ValidateSegment(key) {
return fmt.Errorf("invalid address segment: %v", key)
}
var pattern *string
{
var segment map[string]any
err := json.Unmarshal(value, &segment)
if err != nil {
return fmt.Errorf("invalid subsegment: %v", err)
}
if pat, ok := segment["_pattern"]; ok {
if pat, ok := pat.(string); ok {
pattern = &pat
}
}
}
segment := SegmentSchema{}
err := segment.UnmarshalJSON(value)
if err != nil {
return fmt.Errorf("invalid subsegment: %v", err)
}
if pattern != nil {
if key[0] != '$' {
return fmt.Errorf("cannot have a pattern on a fixed segment") // TODO: Should this actually be an error?
}
if variableSegment != nil {
return fmt.Errorf("invalid subsegments: cannot have two variable segments with the same prefix")
}
variableSegment = &VariableSegment{
SegmentSchema: segment,
Pattern: *pattern,
Label: key[1:],
}
} else {
if key[0] == '$' {
return fmt.Errorf("cannot have a variable segment without a pattern") // TODO: Should this actually be an error?
}
if fixedSegments == nil {
fixedSegments = map[string]SegmentSchema{}
}
fixedSegments[key] = segment
}
isLeaf = false
} else if key == "_self" {
isAccount = true
} else if key == "_metadata" {
err := json.Unmarshal(value, &account.Metadata)
if err != nil {
return err
}
} else if key == "_rules" {
err := json.Unmarshal(value, &account.Rules)
if err != nil {
return err
}
}
}
isAccount = isAccount || isLeaf
if isAccount {
s.Account = &account
}
s.FixedSegments = fixedSegments
s.VariableSegment = variableSegment

return nil
}

func (s *ChartOfAccounts) MarshalJSON() ([]byte, error) {
out := make(map[string]any)
for key, value := range map[string]SegmentSchema(*s) {
serialized, err := value.MarshalJSON()
if err != nil {
return nil, err
}
out[key] = json.RawMessage(serialized)
}
return json.Marshal(out)
}
func (s *SegmentSchema) MarshalJSON() ([]byte, error) {
out := make(map[string]any)
for key, value := range s.FixedSegments {
serialized, err := value.MarshalJSON()
if err != nil {
return nil, err
}
out[key] = json.RawMessage(serialized)

// if value.Fixed != nil {
// out[*value.Fixed] = json.RawMessage(serialized)
// } else if value.Label != nil {
// key := "$" + *value.Label
// out[key] = json.RawMessage(serialized)
// }
}
if s.VariableSegment != nil {
key := fmt.Sprintf("$%v", s.VariableSegment.Label)
serialized, err := s.VariableSegment.SegmentSchema.MarshalJSON()
if err != nil {
return nil, err
}
out[key] = json.RawMessage(serialized)
}
if s.Account != nil {
if s.Account.Metadata != nil {
out["_metadata"] = s.Account.Metadata
}
out["_rules"] = s.Account.Rules
}
return json.Marshal(out)
}

func findAccountSchema(fixedSegments map[string]SegmentSchema, variableSegment *VariableSegment, account []string) (*AccountSchema, error) {
nextSegment := account[0]
if segment, ok := fixedSegments[nextSegment]; ok {
if len(account) > 1 {
return findAccountSchema(segment.FixedSegments, segment.VariableSegment, account)
} else if segment.Account != nil {
return segment.Account, nil
} else {
return nil, errors.New("account is not allowed by the chart of accounts")
}
}
if variableSegment != nil {
matches, err := regexp.Match(variableSegment.Pattern, []byte(nextSegment))
if err != nil {
return nil, errors.New("invalid regex")
}
if matches {
return nil, errors.New("account is not allowed by the chart of accounts")
}
}
return nil, errors.New("account is not allowed by the chart of accounts")
}
func (c *ChartOfAccounts) FindAccountSchema(account string) (*AccountSchema, error) {
schema, err := findAccountSchema(map[string]SegmentSchema(*c), nil, strings.Split(account, ":"))
if err != nil {
if account == "world" {
return &AccountSchema{}, nil
}
return nil, err
}
return schema, nil
}

func (c *ChartOfAccounts) ValidatePosting(posting Posting) error {
source, err := c.FindAccountSchema(posting.Source)
if err != nil {
return err
}
destination, err := c.FindAccountSchema(posting.Destination)
if err != nil {
return err
}
if source.Rules.AllowedDestinations != nil && source.Rules.AllowedDestinations[posting.Destination] == nil {
return errors.New("destination is not allowed")
}
if destination.Rules.AllowedSources != nil && destination.Rules.AllowedSources[posting.Source] == nil {
return errors.New("source is not allowed")
}
return nil
}
97 changes: 97 additions & 0 deletions internal/chart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package ledger

import (
"encoding/json"
"fmt"

"testing"

"github.com/stretchr/testify/require"
)

func TestChartOfAccounts(t *testing.T) {
src := `{
"banks": {
"$iban": {
"_pattern": "*iban_pattern",
"main": {
"_rules": {
"allowedDestinations": {
"thing": true
}
}
},
"out": {
"_metadata": {
"key": "value"
}
},
"pending_out": {}
}
},
"users": {
"$userID": {
"_self": {},
"_pattern": "*user_pattern",
"main": {}
}
}
}`

expected := ChartOfAccounts{
"banks": {
VariableSegment: &VariableSegment{
Label: "iban",
Pattern: "*iban_pattern",
SegmentSchema: SegmentSchema{
FixedSegments: map[string]SegmentSchema{
"main": {
Account: &AccountSchema{
Rules: AccountRules{
AllowedDestinations: map[string]interface{}{
"thing": true,
},
},
},
},
"out": {
Account: &AccountSchema{
Metadata: map[string]string{
"key": "value",
},
},
},
"pending_out": {
Account: &AccountSchema{},
},
},
},
},
},
"users": {
VariableSegment: &VariableSegment{
Label: "userID",
Pattern: "*user_pattern",
SegmentSchema: SegmentSchema{
Account: &AccountSchema{},
FixedSegments: map[string]SegmentSchema{
"main": {
Account: &AccountSchema{},
},
},
},
},
},
}

var chart ChartOfAccounts
err := json.Unmarshal([]byte(src), &chart)
require.NoError(t, err)

require.Equal(t, expected, chart)

value, err := json.MarshalIndent(&chart, "", " ")
require.NoError(t, err)
fmt.Printf("%v\n", string(value))

}
12 changes: 12 additions & 0 deletions internal/controller/ledger/controller_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,18 @@ func (ctrl *DefaultController) createTransaction(ctx context.Context, store Stor
}
}

schema, err := store.FindSchema(ctx, parameters.SchemaVersion)
if err != nil {
return nil, err
}

for _, posting := range result.Postings {
err := schema.Chart.ValidatePosting(posting)
if err != nil {
return nil, err
}
}

transaction := ledger.NewTransaction().
WithPostings(result.Postings...).
WithMetadata(finalMetadata).
Expand Down
1 change: 1 addition & 0 deletions internal/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
)

type SchemaData struct {
Chart *ChartOfAccounts `json:"chart" bun:"chart"`
}

type Schema struct {
Expand Down
3 changes: 2 additions & 1 deletion internal/storage/bucket/migrations/41-add-schema/up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ do $$
ledger varchar,
version text not null,
created_at timestamp without time zone not null default now(),
chart jsonb not null,
primary key (ledger, version)
);

Expand All @@ -14,4 +15,4 @@ do $$
alter table logs
add column schema_version text;
end
$$;
$$;
Loading