Files
zeekay 7d727874d5 chore: rebrand Formance Payments -> Hanzo Payments (hanzo-fi)
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments
  (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins).
  External dep kept: formancehq/go-libs.
- plugins: each ce/plugins/* consumed a *published* pkg/domain; added local
  'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every
  standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too.
- branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added),
  goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage),
  proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl.
- CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml +
  cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet).
- kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type
  client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image.
- verified: go build ./... (default + -tags ee) + all 20 nested modules green.
2026-07-05 13:08:55 -07:00

536 lines
15 KiB
Go

package workbench
import (
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"github.com/hanzo-fi/payments/pkg/domain/models"
)
// Baseline represents a saved snapshot of connector output for comparison.
type Baseline struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Provider string `json:"provider"`
// Captured data
Accounts []models.PSPAccount `json:"accounts"`
Payments []models.PSPPayment `json:"payments"`
Balances []models.PSPBalance `json:"balances"`
Orders []models.PSPOrder `json:"orders"`
Conversions []models.PSPConversion `json:"conversions"`
ExternalAccounts []models.PSPAccount `json:"external_accounts"`
// Metadata
AccountCount int `json:"account_count"`
PaymentCount int `json:"payment_count"`
BalanceCount int `json:"balance_count"`
OrderCount int `json:"order_count"`
ConversionCount int `json:"conversion_count"`
ExternalAccountCount int `json:"external_account_count"`
}
// BaselineDiff represents differences between baseline and current data.
type BaselineDiff struct {
Timestamp time.Time `json:"timestamp"`
BaselineID string `json:"baseline_id"`
HasChanges bool `json:"has_changes"`
Accounts DataDiff `json:"accounts"`
Payments DataDiff `json:"payments"`
Balances DataDiff `json:"balances"`
Orders DataDiff `json:"orders"`
Conversions DataDiff `json:"conversions"`
ExternalAccounts DataDiff `json:"external_accounts"`
Summary string `json:"summary"`
}
// DataDiff represents differences in a specific data type.
type DataDiff struct {
BaselineCount int `json:"baseline_count"`
CurrentCount int `json:"current_count"`
Added []string `json:"added,omitempty"` // References of added items
Removed []string `json:"removed,omitempty"` // References of removed items
Modified []ItemDiff `json:"modified,omitempty"`
}
// ItemDiff represents differences in a single item.
type ItemDiff struct {
Reference string `json:"reference"`
Changes []Change `json:"changes"`
}
// Change represents a single field change.
type Change struct {
Field string `json:"field"`
OldValue interface{} `json:"old_value"`
NewValue interface{} `json:"new_value"`
}
// BaselineManager manages baselines and comparisons.
type BaselineManager struct {
mu sync.RWMutex
// Saved baselines
baselines map[string]*Baseline
// Reference to storage for current data
storage *MemoryStorage
provider string
}
// NewBaselineManager creates a new baseline manager.
func NewBaselineManager(provider string, storage *MemoryStorage) *BaselineManager {
return &BaselineManager{
baselines: make(map[string]*Baseline),
storage: storage,
provider: provider,
}
}
// SaveBaseline saves current data as a baseline.
func (m *BaselineManager) SaveBaseline(name string) (*Baseline, error) {
m.mu.Lock()
defer m.mu.Unlock()
accounts := m.storage.GetAccounts()
payments := m.storage.GetPayments()
balances := m.storage.GetBalances()
orders := m.storage.GetOrders()
conversions := m.storage.GetConversions()
externalAccounts := m.storage.GetExternalAccounts()
baseline := &Baseline{
ID: fmt.Sprintf("baseline-%d", time.Now().UnixNano()),
Name: name,
CreatedAt: time.Now(),
Provider: m.provider,
Accounts: accounts,
Payments: payments,
Balances: balances,
Orders: orders,
Conversions: conversions,
ExternalAccounts: externalAccounts,
AccountCount: len(accounts),
PaymentCount: len(payments),
BalanceCount: len(balances),
OrderCount: len(orders),
ConversionCount: len(conversions),
ExternalAccountCount: len(externalAccounts),
}
m.baselines[baseline.ID] = baseline
return baseline, nil
}
// GetBaseline returns a baseline by ID.
func (m *BaselineManager) GetBaseline(id string) *Baseline {
m.mu.RLock()
defer m.mu.RUnlock()
return m.baselines[id]
}
// ListBaselines returns all baselines.
func (m *BaselineManager) ListBaselines() []*Baseline {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Baseline
for _, b := range m.baselines {
result = append(result, b)
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result
}
// DeleteBaseline deletes a baseline.
func (m *BaselineManager) DeleteBaseline(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.baselines[id]; !exists {
return fmt.Errorf("baseline not found: %s", id)
}
delete(m.baselines, id)
return nil
}
// CompareWithCurrent compares a baseline with current data.
func (m *BaselineManager) CompareWithCurrent(baselineID string) (*BaselineDiff, error) {
m.mu.RLock()
baseline, exists := m.baselines[baselineID]
m.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("baseline not found: %s", baselineID)
}
currentAccounts := m.storage.GetAccounts()
currentPayments := m.storage.GetPayments()
currentBalances := m.storage.GetBalances()
currentOrders := m.storage.GetOrders()
currentConversions := m.storage.GetConversions()
currentExtAccounts := m.storage.GetExternalAccounts()
diff := &BaselineDiff{
Timestamp: time.Now(),
BaselineID: baselineID,
Accounts: m.compareAccounts(baseline.Accounts, currentAccounts),
Payments: m.comparePayments(baseline.Payments, currentPayments),
Balances: m.compareBalances(baseline.Balances, currentBalances),
Orders: m.compareOrders(baseline.Orders, currentOrders),
Conversions: m.compareConversions(baseline.Conversions, currentConversions),
ExternalAccounts: m.compareAccounts(baseline.ExternalAccounts, currentExtAccounts),
}
diff.HasChanges = diff.Accounts.hasChanges() || diff.Payments.hasChanges() ||
diff.Balances.hasChanges() || diff.Orders.hasChanges() ||
diff.Conversions.hasChanges() || diff.ExternalAccounts.hasChanges()
diff.Summary = m.generateSummary(diff)
return diff, nil
}
func (m *BaselineManager) compareAccounts(baseline, current []models.PSPAccount) DataDiff {
diff := DataDiff{
BaselineCount: len(baseline),
CurrentCount: len(current),
}
baselineMap := make(map[string]models.PSPAccount)
for _, acc := range baseline {
baselineMap[acc.Reference] = acc
}
currentMap := make(map[string]models.PSPAccount)
for _, acc := range current {
currentMap[acc.Reference] = acc
}
// Find added and modified
for ref, curr := range currentMap {
if base, exists := baselineMap[ref]; exists {
// Check for modifications
changes := m.compareAccount(base, curr)
if len(changes) > 0 {
diff.Modified = append(diff.Modified, ItemDiff{
Reference: ref,
Changes: changes,
})
}
} else {
diff.Added = append(diff.Added, ref)
}
}
// Find removed
for ref := range baselineMap {
if _, exists := currentMap[ref]; !exists {
diff.Removed = append(diff.Removed, ref)
}
}
sort.Strings(diff.Added)
sort.Strings(diff.Removed)
return diff
}
func (m *BaselineManager) compareAccount(base, curr models.PSPAccount) []Change {
var changes []Change
// Compare DefaultAsset by value, not pointer address
if (base.DefaultAsset == nil) != (curr.DefaultAsset == nil) ||
(base.DefaultAsset != nil && curr.DefaultAsset != nil && *base.DefaultAsset != *curr.DefaultAsset) {
var oldVal, newVal interface{}
if base.DefaultAsset != nil {
oldVal = *base.DefaultAsset
}
if curr.DefaultAsset != nil {
newVal = *curr.DefaultAsset
}
changes = append(changes, Change{Field: "default_asset", OldValue: oldVal, NewValue: newVal})
}
if (base.Name == nil) != (curr.Name == nil) || (base.Name != nil && curr.Name != nil && *base.Name != *curr.Name) {
var oldVal, newVal interface{}
if base.Name != nil {
oldVal = *base.Name
}
if curr.Name != nil {
newVal = *curr.Name
}
changes = append(changes, Change{Field: "name", OldValue: oldVal, NewValue: newVal})
}
return changes
}
func (m *BaselineManager) comparePayments(baseline, current []models.PSPPayment) DataDiff {
diff := DataDiff{
BaselineCount: len(baseline),
CurrentCount: len(current),
}
baselineMap := make(map[string]models.PSPPayment)
for _, p := range baseline {
baselineMap[p.Reference] = p
}
currentMap := make(map[string]models.PSPPayment)
for _, p := range current {
currentMap[p.Reference] = p
}
// Find added and modified
for ref, curr := range currentMap {
if base, exists := baselineMap[ref]; exists {
changes := m.comparePayment(base, curr)
if len(changes) > 0 {
diff.Modified = append(diff.Modified, ItemDiff{
Reference: ref,
Changes: changes,
})
}
} else {
diff.Added = append(diff.Added, ref)
}
}
// Find removed
for ref := range baselineMap {
if _, exists := currentMap[ref]; !exists {
diff.Removed = append(diff.Removed, ref)
}
}
sort.Strings(diff.Added)
sort.Strings(diff.Removed)
return diff
}
func (m *BaselineManager) comparePayment(base, curr models.PSPPayment) []Change {
var changes []Change
if base.Amount.Cmp(curr.Amount) != 0 {
changes = append(changes, Change{Field: "amount", OldValue: base.Amount.String(), NewValue: curr.Amount.String()})
}
if base.Asset != curr.Asset {
changes = append(changes, Change{Field: "asset", OldValue: base.Asset, NewValue: curr.Asset})
}
if base.Status != curr.Status {
changes = append(changes, Change{Field: "status", OldValue: base.Status, NewValue: curr.Status})
}
if base.Type != curr.Type {
changes = append(changes, Change{Field: "type", OldValue: base.Type, NewValue: curr.Type})
}
return changes
}
func (m *BaselineManager) compareBalances(baseline, current []models.PSPBalance) DataDiff {
diff := DataDiff{
BaselineCount: len(baseline),
CurrentCount: len(current),
}
// Create key for balance (account + asset)
balanceKey := func(b models.PSPBalance) string {
return b.AccountReference + ":" + b.Asset
}
baselineMap := make(map[string]models.PSPBalance)
for _, b := range baseline {
baselineMap[balanceKey(b)] = b
}
currentMap := make(map[string]models.PSPBalance)
for _, b := range current {
currentMap[balanceKey(b)] = b
}
// Find added and modified
for key, curr := range currentMap {
if base, exists := baselineMap[key]; exists {
changes := m.compareBalance(base, curr)
if len(changes) > 0 {
diff.Modified = append(diff.Modified, ItemDiff{
Reference: key,
Changes: changes,
})
}
} else {
diff.Added = append(diff.Added, key)
}
}
// Find removed
for key := range baselineMap {
if _, exists := currentMap[key]; !exists {
diff.Removed = append(diff.Removed, key)
}
}
sort.Strings(diff.Added)
sort.Strings(diff.Removed)
return diff
}
func (m *BaselineManager) compareBalance(base, curr models.PSPBalance) []Change {
var changes []Change
if base.Amount.Cmp(curr.Amount) != 0 {
changes = append(changes, Change{Field: "amount", OldValue: base.Amount.String(), NewValue: curr.Amount.String()})
}
return changes
}
func (m *BaselineManager) compareOrders(baseline, current []models.PSPOrder) DataDiff {
diff := DataDiff{BaselineCount: len(baseline), CurrentCount: len(current)}
baselineMap := make(map[string]models.PSPOrder)
for _, o := range baseline {
baselineMap[o.Reference] = o
}
for _, curr := range current {
if base, exists := baselineMap[curr.Reference]; exists {
var changes []Change
if base.Status != curr.Status {
changes = append(changes, Change{Field: "status", OldValue: base.Status.String(), NewValue: curr.Status.String()})
}
if len(changes) > 0 {
diff.Modified = append(diff.Modified, ItemDiff{Reference: curr.Reference, Changes: changes})
}
delete(baselineMap, curr.Reference)
} else {
diff.Added = append(diff.Added, curr.Reference)
}
}
for ref := range baselineMap {
diff.Removed = append(diff.Removed, ref)
}
sort.Strings(diff.Added)
sort.Strings(diff.Removed)
return diff
}
func (m *BaselineManager) compareConversions(baseline, current []models.PSPConversion) DataDiff {
diff := DataDiff{BaselineCount: len(baseline), CurrentCount: len(current)}
baselineMap := make(map[string]models.PSPConversion)
for _, c := range baseline {
baselineMap[c.Reference] = c
}
for _, curr := range current {
if base, exists := baselineMap[curr.Reference]; exists {
var changes []Change
if base.Status != curr.Status {
changes = append(changes, Change{Field: "status", OldValue: base.Status.String(), NewValue: curr.Status.String()})
}
if len(changes) > 0 {
diff.Modified = append(diff.Modified, ItemDiff{Reference: curr.Reference, Changes: changes})
}
delete(baselineMap, curr.Reference)
} else {
diff.Added = append(diff.Added, curr.Reference)
}
}
for ref := range baselineMap {
diff.Removed = append(diff.Removed, ref)
}
sort.Strings(diff.Added)
sort.Strings(diff.Removed)
return diff
}
func (d DataDiff) hasChanges() bool {
return len(d.Added) > 0 || len(d.Removed) > 0 || len(d.Modified) > 0
}
func (m *BaselineManager) generateSummary(diff *BaselineDiff) string {
var parts []string
if diff.Accounts.hasChanges() {
parts = append(parts, fmt.Sprintf("Accounts: +%d/-%d/~%d",
len(diff.Accounts.Added), len(diff.Accounts.Removed), len(diff.Accounts.Modified)))
}
if diff.Payments.hasChanges() {
parts = append(parts, fmt.Sprintf("Payments: +%d/-%d/~%d",
len(diff.Payments.Added), len(diff.Payments.Removed), len(diff.Payments.Modified)))
}
if diff.Balances.hasChanges() {
parts = append(parts, fmt.Sprintf("Balances: +%d/-%d/~%d",
len(diff.Balances.Added), len(diff.Balances.Removed), len(diff.Balances.Modified)))
}
if diff.Orders.hasChanges() {
parts = append(parts, fmt.Sprintf("Orders: +%d/-%d/~%d",
len(diff.Orders.Added), len(diff.Orders.Removed), len(diff.Orders.Modified)))
}
if diff.Conversions.hasChanges() {
parts = append(parts, fmt.Sprintf("Conversions: +%d/-%d/~%d",
len(diff.Conversions.Added), len(diff.Conversions.Removed), len(diff.Conversions.Modified)))
}
if diff.ExternalAccounts.hasChanges() {
parts = append(parts, fmt.Sprintf("ExtAccounts: +%d/-%d/~%d",
len(diff.ExternalAccounts.Added), len(diff.ExternalAccounts.Removed), len(diff.ExternalAccounts.Modified)))
}
if len(parts) == 0 {
return "No changes detected"
}
return join(parts, " | ")
}
func join(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for i := 1; i < len(parts); i++ {
result += sep + parts[i]
}
return result
}
// Clear clears all baselines.
func (m *BaselineManager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.baselines = make(map[string]*Baseline)
}
// Export exports a baseline as JSON.
func (m *BaselineManager) Export(baselineID string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
baseline, exists := m.baselines[baselineID]
if !exists {
return nil, fmt.Errorf("baseline not found: %s", baselineID)
}
return json.MarshalIndent(baseline, "", " ")
}
// Import imports a baseline from JSON.
func (m *BaselineManager) Import(data []byte) (*Baseline, error) {
var baseline Baseline
if err := json.Unmarshal(data, &baseline); err != nil {
return nil, err
}
// Generate new ID to avoid conflicts
baseline.ID = fmt.Sprintf("imported-%d", time.Now().UnixNano())
m.mu.Lock()
m.baselines[baseline.ID] = &baseline
m.mu.Unlock()
return &baseline, nil
}