mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
* wip: connector workbench * wip: improve workbench * feat(workbench): improve UI with sidebar, routing, and syntax highlighting - Replace connector dropdown with Slack-style sidebar for connector selection - Add URL-based routing with react-router-dom for browser navigation support - Add JSON syntax highlighting with react-syntax-highlighter - Rework State tab layout to list/detail view with filtering - Update terminology from "create" to "instantiate" for connectors - Fix duplicate selection bug in lists (ID generation collision) - Unify data table row heights across tabs - Increase code block font sizes for readability - Hide navigation tabs on homepage when no connector selected - Make CW logo clickable to return to home view Co-authored-by: Cursor <[email protected]> * fix(workbench): use textarea for PEM/certificate config fields Fields containing 'privateKey', 'certificate', or 'pem' in their name now use a textarea instead of single-line input, preserving newlines in PEM content that would otherwise be stripped. Co-authored-by: Cursor <[email protected]> * fix(workbench): address PR review issues - Fix data race on idCounter using atomic.Int64 - Fix path traversal vulnerability in introspect.go - Fix pointer comparison for DefaultAsset in baseline.go Co-authored-by: Cursor <[email protected]> * feat(workbench): add generic connector server for integration testing Expose the generic connector REST API from the workbench so staging services can install a "generic connector" pointing to the local workbench, enabling end-to-end integration testing. - Add /generic/* routes (accounts, balances, beneficiaries, transactions) - Return cached data from storage instead of making fresh API calls - Add connector selection and optional API key authentication - Add UI panel in Debug tab for configuration Co-authored-by: Cursor <[email protected]> * fix(workbench): use internal registry to avoid pkg dependency Change imports from pkg/registry to internal/connectors/plugins/registry so the workbench can be merged before the pkg refactoring. The internal registry will automatically delegate to pkg/registry once that refactor lands. Also adds light/dark mode toggle to the UI with system preference detection. Co-authored-by: Cursor <[email protected]> * fix(workbench): address PR review feedback - Add ctx.Done() checks in fetch loops to exit early on client disconnect - Refactor generic server to get connector directly instead of via context, providing clearer data flow as suggested in review Co-authored-by: Cursor <[email protected]> * fix(workbench): include built UI files for embed directive The //go:embed ui/dist/* directive requires files to exist at build time. Add exception to .gitignore and commit the built UI distribution files. Co-authored-by: Cursor <[email protected]> * fix(workbench): address golangci-lint errors - Remove unused globalDebugTransport variable - Remove unused parseIntParam function - Fix printf format for models.TaskType (use %v instead of %s) - Add error return value handling for various function calls Co-authored-by: Cursor <[email protected]> * fix: remove .vite cache folder from git Co-authored-by: Cursor <[email protected]> * fix(workbench): fix remaining errcheck issue Co-authored-by: Cursor <[email protected]> * fix(workbench): fix all remaining lint issues - Add error handling for Body.Close() calls - Remove unnecessary fmt.Sprintf wrapper - Simplify hex character check condition Co-authored-by: Cursor <[email protected]> * fix(workbench): fix errcheck in cmd/workbench.go Co-authored-by: Cursor <[email protected]> * chore: replace ui dist build artifacts with placeholder The //go:embed directive needs at least one file to exist. Instead of committing the entire built UI, commit only a .gitkeep placeholder. The server has fallback code that displays instructions when the full UI isn't available. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
455 lines
12 KiB
Go
455 lines
12 KiB
Go
package workbench
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/formancehq/payments/internal/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"`
|
|
ExternalAccounts []models.PSPAccount `json:"external_accounts"`
|
|
|
|
// Metadata
|
|
AccountCount int `json:"account_count"`
|
|
PaymentCount int `json:"payment_count"`
|
|
BalanceCount int `json:"balance_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"`
|
|
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()
|
|
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,
|
|
ExternalAccounts: externalAccounts,
|
|
AccountCount: len(accounts),
|
|
PaymentCount: len(payments),
|
|
BalanceCount: len(balances),
|
|
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()
|
|
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),
|
|
ExternalAccounts: m.compareAccounts(baseline.ExternalAccounts, currentExtAccounts),
|
|
}
|
|
|
|
diff.HasChanges = diff.Accounts.hasChanges() || diff.Payments.hasChanges() ||
|
|
diff.Balances.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 (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.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
|
|
}
|