Skip to content
Open
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
232 changes: 232 additions & 0 deletions cmd/payments/conversions/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package conversions

import (
"fmt"
"math/big"
"time"

"github.com/pterm/pterm"
"github.com/spf13/cobra"

"github.com/formancehq/formance-sdk-go/v4/pkg/models/operations"
paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments"

"github.com/formancehq/fctl/v3/cmd/payments/versions"
fctl "github.com/formancehq/fctl/v3/pkg"
)

const conversionsMinMinor = 3

type Conversion struct {
ID string `json:"id"`
ConnectorID string `json:"connectorID"`
Provider string `json:"provider"`
Reference string `json:"reference"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
SourceAsset string `json:"sourceAsset"`
DestinationAsset string `json:"destinationAsset"`
SourceAmount *big.Int `json:"sourceAmount"`
DestinationAmount *big.Int `json:"destinationAmount,omitempty"`
Fee *big.Int `json:"fee,omitempty"`
FeeAsset *string `json:"feeAsset,omitempty"`
Status string `json:"status"`
SourceAccountID *string `json:"sourceAccountID,omitempty"`
DestinationAccountID *string `json:"destinationAccountID,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Error *string `json:"error,omitempty"`
}

type ListStore struct {
Conversions []Conversion `json:"conversions"`
Cursor fctl.Cursor `json:"cursor"`
}

type ListController struct {
PaymentsVersion versions.Version
store *ListStore

connectorIDFlag string
referenceFlag string
statusFlag string
sourceAssetFlag string
destinationAssetFlag string
}

var _ fctl.Controller[*ListStore] = (*ListController)(nil)

func (c *ListController) SetVersion(version versions.Version) { c.PaymentsVersion = version }

func NewListController() *ListController {
return &ListController{
store: &ListStore{Conversions: []Conversion{}},
connectorIDFlag: "connector-id",
referenceFlag: "reference",
statusFlag: "status",
sourceAssetFlag: "source-asset",
destinationAssetFlag: "destination-asset",
}
}

func (c *ListController) GetStore() *ListStore { return c.store }

func NewListCommand() *cobra.Command {
c := NewListController()
return fctl.NewCommand("list",
fctl.WithAliases("ls", "l"),
fctl.WithArgs(cobra.ExactArgs(0)),
fctl.WithValidArgsFunction(cobra.NoFileCompletions),
fctl.WithShortDescription("List currency conversions ingested from exchange-style connectors"),
fctl.WithStringFlag(c.connectorIDFlag, "", "Filter by connector ID"),
fctl.WithStringFlag(c.referenceFlag, "", "Filter by PSP-assigned reference"),
fctl.WithStringFlag(c.statusFlag, "", "Filter by status (PENDING|COMPLETED|FAILED|UNKNOWN)"),
fctl.WithStringFlag(c.sourceAssetFlag, "", "Filter by source asset (e.g. USD/2)"),
fctl.WithStringFlag(c.destinationAssetFlag, "", "Filter by destination asset (e.g. USDC/6)"),
fctl.WithCursorFlag(),
fctl.WithPageSizeFlag(),
fctl.WithController[*ListStore](c),
)
}

// buildQuery turns the filter flags into a $match/$and query body. Keys are the
// payments storage column names (snake_case): the endpoint whitelists them and
// rejects anything else with a VALIDATION error.
func (c *ListController) buildQuery(cmd *cobra.Command) map[string]any {
matches := make([]map[string]any, 0)
addMatch := func(field, val string) {
if val == "" {
return
}
matches = append(matches, map[string]any{"$match": map[string]any{field: val}})
}
addMatch("connector_id", fctl.GetString(cmd, c.connectorIDFlag))
addMatch("reference", fctl.GetString(cmd, c.referenceFlag))
addMatch("status", fctl.GetString(cmd, c.statusFlag))
addMatch("source_asset", fctl.GetString(cmd, c.sourceAssetFlag))
addMatch("destination_asset", fctl.GetString(cmd, c.destinationAssetFlag))

if len(matches) == 0 {
return nil
}
return map[string]any{"$and": matches}
}

func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) {
_, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd)
if err != nil {
return nil, err
}
stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile)
if err != nil {
return nil, err
}
if err := versions.GetPaymentsVersion(cmd, nil, c); err != nil {
return nil, err
}
if !c.PaymentsVersion.IsAtLeast(versions.V3, conversionsMinMinor) {
return nil, fmt.Errorf("conversions require Payments >= v3.%d (stack reports %s)", conversionsMinMinor, c.PaymentsVersion.Raw)
}

query := c.buildQuery(cmd)
cursor, err := fctl.GetCursor(cmd)
Comment on lines +130 to +131
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate --status before sending the request.

--status is currently forwarded as-is; typos only fail at API time. Add a local enum check (and optional normalization) to fail fast with a clear CLI error.

Suggested patch
 import (
 	"fmt"
 	"math/big"
+	"strings"
 	"time"
@@
 	query := c.buildQuery(cmd)
+	status := strings.ToUpper(strings.TrimSpace(fctl.GetString(cmd, c.statusFlag)))
+	if status != "" {
+		switch status {
+		case "PENDING", "COMPLETED", "FAILED", "UNKNOWN":
+			if err := cmd.Flags().Set(c.statusFlag, status); err != nil {
+				return nil, err
+			}
+		default:
+			return nil, fmt.Errorf("invalid --status %q (allowed: PENDING|COMPLETED|FAILED|UNKNOWN)", status)
+		}
+	}
+	query = c.buildQuery(cmd)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/payments/conversions/list.go` around lines 130 - 131, The CLI currently
forwards the --status value directly (see usage around buildQuery(cmd) and
fctl.GetCursor(cmd)); add a local enum of allowed status values (e.g., pending,
completed, failed) and validate/normalize the status flag before calling
buildQuery or making the API request, returning a clear CLI error if the value
is invalid; ensure you read the flag from the same source used by buildQuery
(the cmd or query struct), perform optional case normalization, and only attach
the status to the outgoing query when it passes validation.

if err != nil {
return nil, err
}
pageSize, err := fctl.GetPageSize(cmd)
if err != nil {
return nil, err
}
pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize)

// The V3 query endpoints accept either a query body (first page) or an
// opaque cursor (subsequent pages), never both.
req := operations.V3ListConversionsRequest{}
if cursor != "" {
req.Cursor = fctl.Ptr(cursor)
} else {
req.RequestBody = query
req.PageSize = fctl.Ptr(int64(pageSize))
}

res, err := stackClient.Payments.V3.ListConversions(cmd.Context(), req)
if err != nil {
return nil, err
}
if res.V3ConversionsCursorResponse == nil {
return nil, fmt.Errorf("conversions.list: empty response (status %d)", res.StatusCode)
}

cur := res.V3ConversionsCursorResponse.Cursor
pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list received=%d hasMore=%v", len(cur.Data), cur.HasMore)
c.store.Conversions = fctl.Map(cur.Data, toConversion)
c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous}
return c, nil
}

// toConversion maps an SDK V3Conversion onto the local nil-safe store type.
// The status enum is a typed string and metadata lives under V3Metadata.
func toConversion(cv paymentsmodels.V3Conversion) Conversion {
return Conversion{
ID: cv.ID,
ConnectorID: cv.ConnectorID,
Provider: cv.Provider,
Reference: cv.Reference,
CreatedAt: cv.CreatedAt,
UpdatedAt: cv.UpdatedAt,
SourceAsset: cv.SourceAsset,
DestinationAsset: cv.DestinationAsset,
SourceAmount: cv.SourceAmount,
DestinationAmount: cv.DestinationAmount,
Fee: cv.Fee,
FeeAsset: cv.FeeAsset,
Status: string(cv.V3ConversionStatusEnum),
SourceAccountID: cv.SourceAccountID,
DestinationAccountID: cv.DestinationAccountID,
Metadata: cv.V3Metadata,
Error: cv.Error,
}
}

func (c *ListController) Render(cmd *cobra.Command, _ []string) error {
tableData := fctl.Map(c.store.Conversions, func(cv Conversion) []string {
return []string{
cv.ID,
cv.Reference,
cv.Provider,
cv.Status,
cv.SourceAsset,
cv.DestinationAsset,
bigIntString(cv.SourceAmount),
bigIntString(cv.DestinationAmount),
cv.CreatedAt.Format(time.RFC3339),
}
})
tableData = fctl.Prepend(tableData, []string{
"ID", "Reference", "Provider", "Status",
"SourceAsset", "DestinationAsset", "SourceAmount", "DestinationAmount", "CreatedAt",
})
if err := pterm.DefaultTable.
WithHasHeader().
WithWriter(cmd.OutOrStdout()).
WithData(tableData).
Render(); err != nil {
return err
}
return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor)
}

// bigIntString returns the decimal string of b, or "" when b is nil.
func bigIntString(b *big.Int) string {
if b == nil {
return ""
}
return b.String()
}

// strDeref returns the value of s, or "" when s is nil.
func strDeref(s *string) string {
if s == nil {
return ""
}
return *s
}
74 changes: 74 additions & 0 deletions cmd/payments/conversions/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package conversions

import (
"math/big"
"reflect"
"testing"
"time"

"github.com/spf13/cobra"

paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments"
)

func TestBuildQuery(t *testing.T) {
c := NewListController()
cmd := &cobra.Command{}
for _, f := range []string{
c.connectorIDFlag, c.referenceFlag, c.statusFlag,
c.sourceAssetFlag, c.destinationAssetFlag,
} {
cmd.Flags().String(f, "", "")
}

if got := c.buildQuery(cmd); got != nil {
t.Fatalf("no flags set: want nil query body, got %v", got)
}

for flag, val := range map[string]string{c.statusFlag: "COMPLETED", c.sourceAssetFlag: "USD/2"} {
if err := cmd.Flags().Set(flag, val); err != nil {
t.Fatalf("set --%s: %v", flag, err)
}
}

// Keys must be the snake_case storage columns; the endpoint rejects anything else.
want := map[string]any{"$and": []map[string]any{
{"$match": map[string]any{"status": "COMPLETED"}},
{"$match": map[string]any{"source_asset": "USD/2"}},
}}
if got := c.buildQuery(cmd); !reflect.DeepEqual(got, want) {
t.Fatalf("buildQuery mismatch:\n got=%v\nwant=%v", got, want)
}
}

func TestToConversion(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
got := toConversion(paymentsmodels.V3Conversion{
ID: "cv_1",
ConnectorID: "conn_1",
Provider: "coinbaseprime",
Reference: "ref-1",
CreatedAt: now,
UpdatedAt: now,
V3ConversionStatusEnum: paymentsmodels.V3ConversionStatusEnum("COMPLETED"),
SourceAsset: "USD/2",
DestinationAsset: "USDC/6",
SourceAmount: big.NewInt(1000),
DestinationAmount: big.NewInt(999),
V3Metadata: map[string]string{"k": "v"},
})

if got.ID != "cv_1" {
t.Errorf("ID = %q", got.ID)
}
// Status is a typed-string enum on the SDK and must surface as a plain string.
if got.Status != "COMPLETED" {
t.Errorf("Status = %q, want COMPLETED", got.Status)
}
if got.SourceAmount.Cmp(big.NewInt(1000)) != 0 || got.DestinationAmount.Cmp(big.NewInt(999)) != 0 {
t.Errorf("amounts mismatch: src=%v dst=%v", got.SourceAmount, got.DestinationAmount)
}
if !reflect.DeepEqual(got.Metadata, map[string]string{"k": "v"}) {
t.Errorf("Metadata = %v", got.Metadata)
}
}
18 changes: 18 additions & 0 deletions cmd/payments/conversions/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package conversions

import (
"github.com/spf13/cobra"

fctl "github.com/formancehq/fctl/v3/pkg"
)

func NewConversionsCommand() *cobra.Command {
return fctl.NewCommand("conversions",
fctl.WithAliases("cv"),
fctl.WithShortDescription("Manage currency conversions (read-only) ingested from exchange-style connectors"),
fctl.WithChildCommands(
NewListCommand(),
NewShowCommand(),
),
)
}
Loading
Loading