diff --git a/cmd/payments/conversions/list.go b/cmd/payments/conversions/list.go new file mode 100644 index 00000000..39af96b9 --- /dev/null +++ b/cmd/payments/conversions/list.go @@ -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) + 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 +} diff --git a/cmd/payments/conversions/list_test.go b/cmd/payments/conversions/list_test.go new file mode 100644 index 00000000..25c3a289 --- /dev/null +++ b/cmd/payments/conversions/list_test.go @@ -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) + } +} diff --git a/cmd/payments/conversions/root.go b/cmd/payments/conversions/root.go new file mode 100644 index 00000000..20a464fa --- /dev/null +++ b/cmd/payments/conversions/root.go @@ -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(), + ), + ) +} diff --git a/cmd/payments/conversions/show.go b/cmd/payments/conversions/show.go new file mode 100644 index 00000000..3b85517c --- /dev/null +++ b/cmd/payments/conversions/show.go @@ -0,0 +1,110 @@ +package conversions + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + "github.com/formancehq/go-libs/v4/metadata" + + "github.com/formancehq/fctl/v3/cmd/payments/versions" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ShowStore struct { + Conversion *Conversion `json:"conversion"` +} + +type ShowController struct { + PaymentsVersion versions.Version + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func (c *ShowController) SetVersion(version versions.Version) { c.PaymentsVersion = version } + +func NewShowController() *ShowController { + return &ShowController{store: &ShowStore{}} +} + +func (c *ShowController) GetStore() *ShowStore { return c.store } + +func NewShowCommand() *cobra.Command { + c := NewShowController() + return fctl.NewCommand("get ", + fctl.WithAliases("sh", "s"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithShortDescription("Get a single conversion by its Formance ID"), + fctl.WithController[*ShowStore](c), + ) +} + +func (c *ShowController) Run(cmd *cobra.Command, args []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, args, 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) + } + + pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.show conversionID=%q", args[0]) + + res, err := stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ConversionID: args[0]}) + if err != nil { + return nil, err + } + if res.V3GetConversionResponse == nil { + return nil, fmt.Errorf("conversions.get: empty response (status %d)", res.StatusCode) + } + + conversion := toConversion(res.V3GetConversionResponse.V3Conversion) + c.store.Conversion = &conversion + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + if c.store.Conversion == nil { + fctl.Println("No conversion data.") + return nil + } + cv := c.store.Conversion + out := cmd.OutOrStdout() + + fctl.Section.WithWriter(out).Println("Information") + info := pterm.TableData{ + {pterm.LightCyan("ID"), cv.ID}, + {pterm.LightCyan("Reference"), cv.Reference}, + {pterm.LightCyan("ConnectorID"), cv.ConnectorID}, + {pterm.LightCyan("Provider"), cv.Provider}, + {pterm.LightCyan("Status"), cv.Status}, + {pterm.LightCyan("SourceAsset"), cv.SourceAsset}, + {pterm.LightCyan("DestinationAsset"), cv.DestinationAsset}, + {pterm.LightCyan("SourceAmount"), bigIntString(cv.SourceAmount)}, + {pterm.LightCyan("DestinationAmount"), bigIntString(cv.DestinationAmount)}, + {pterm.LightCyan("Fee"), bigIntString(cv.Fee)}, + {pterm.LightCyan("FeeAsset"), strDeref(cv.FeeAsset)}, + {pterm.LightCyan("SourceAccountID"), strDeref(cv.SourceAccountID)}, + {pterm.LightCyan("DestinationAccountID"), strDeref(cv.DestinationAccountID)}, + {pterm.LightCyan("CreatedAt"), cv.CreatedAt.Format(time.RFC3339)}, + {pterm.LightCyan("UpdatedAt"), cv.UpdatedAt.Format(time.RFC3339)}, + {pterm.LightCyan("Error"), strDeref(cv.Error)}, + } + if err := pterm.DefaultTable.WithWriter(out).WithData(info).Render(); err != nil { + return err + } + + return fctl.PrintMetadata(out, metadata.Metadata(cv.Metadata)) +} diff --git a/cmd/payments/orders/list.go b/cmd/payments/orders/list.go new file mode 100644 index 00000000..662dbdff --- /dev/null +++ b/cmd/payments/orders/list.go @@ -0,0 +1,301 @@ +package orders + +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 ordersMinMinor = 3 + +type Order struct { + ID string `json:"id"` + ConnectorID string `json:"connectorID"` + Provider string `json:"provider"` + Reference string `json:"reference"` + ClientOrderID *string `json:"clientOrderID,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Direction string `json:"direction"` + SourceAsset string `json:"sourceAsset"` + DestinationAsset string `json:"destinationAsset"` + Type string `json:"type"` + Status string `json:"status"` + BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"` + BaseQuantityFilled *big.Int `json:"baseQuantityFilled,omitempty"` + LimitPrice *big.Int `json:"limitPrice,omitempty"` + StopPrice *big.Int `json:"stopPrice,omitempty"` + TimeInForce string `json:"timeInForce"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Fee *big.Int `json:"fee,omitempty"` + FeeAsset *string `json:"feeAsset,omitempty"` + AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"` + QuoteAmount *big.Int `json:"quoteAmount,omitempty"` + QuoteAsset *string `json:"quoteAsset,omitempty"` + PriceAsset *string `json:"priceAsset,omitempty"` + SourceAccountID *string `json:"sourceAccountID,omitempty"` + DestinationAccountID *string `json:"destinationAccountID,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Adjustments []OrderAdjustment `json:"adjustments,omitempty"` + Error *string `json:"error,omitempty"` +} + +type OrderAdjustment struct { + ID string `json:"id"` + Reference string `json:"reference"` + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` + BaseQuantityFilled *big.Int `json:"baseQuantityFilled,omitempty"` + Fee *big.Int `json:"fee,omitempty"` + FeeAsset *string `json:"feeAsset,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +type ListStore struct { + Orders []Order `json:"orders"` + Cursor fctl.Cursor `json:"cursor"` +} + +type ListController struct { + PaymentsVersion versions.Version + store *ListStore + + connectorIDFlag string + referenceFlag string + directionFlag string + statusFlag string + typeFlag 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{Orders: []Order{}}, + connectorIDFlag: "connector-id", + referenceFlag: "reference", + directionFlag: "direction", + statusFlag: "status", + typeFlag: "type", + 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 orders 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.directionFlag, "", "Filter by order direction (BUY|SELL|UNKNOWN)"), + fctl.WithStringFlag(c.statusFlag, "", "Filter by status (PENDING|OPEN|PARTIALLY_FILLED|FILLED|CANCELLED|FAILED|EXPIRED|UNKNOWN)"), + fctl.WithStringFlag(c.typeFlag, "", "Filter by order type (MARKET|LIMIT|STOP|STOP_LIMIT|TWAP|VWAP|PEG|BLOCK|RFQ|TRAILING_STOP|TRAILING_STOP_LIMIT|TAKE_PROFIT|TAKE_PROFIT_LIMIT|LIMIT_MAKER|UNKNOWN)"), + fctl.WithStringFlag(c.sourceAssetFlag, "", "Filter by source asset (e.g. USD/2)"), + fctl.WithStringFlag(c.destinationAssetFlag, "", "Filter by destination asset (e.g. BTC/8)"), + 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("direction", fctl.GetString(cmd, c.directionFlag)) + addMatch("status", fctl.GetString(cmd, c.statusFlag)) + addMatch("type", fctl.GetString(cmd, c.typeFlag)) + 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, ordersMinMinor) { + return nil, fmt.Errorf("orders require Payments >= v3.%d (stack reports %s)", ordersMinMinor, c.PaymentsVersion.Raw) + } + + query := c.buildQuery(cmd) + cursor, err := fctl.GetCursor(cmd) + if err != nil { + return nil, err + } + pageSize, err := fctl.GetPageSize(cmd) + if err != nil { + return nil, err + } + pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.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.V3ListOrdersRequest{} + if cursor != "" { + req.Cursor = fctl.Ptr(cursor) + } else { + req.RequestBody = query + req.PageSize = fctl.Ptr(int64(pageSize)) + } + + res, err := stackClient.Payments.V3.ListOrders(cmd.Context(), req) + if err != nil { + return nil, err + } + if res.V3OrdersCursorResponse == nil { + return nil, fmt.Errorf("orders.list: empty response (status %d)", res.StatusCode) + } + + cur := res.V3OrdersCursorResponse.Cursor + pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.list received=%d hasMore=%v", len(cur.Data), cur.HasMore) + c.store.Orders = fctl.Map(cur.Data, toOrder) + c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous} + return c, nil +} + +// toOrder maps an SDK V3Order onto the local store type, casting the typed +// string enums and reading metadata from the SDK's V3Metadata field. +func toOrder(o paymentsmodels.V3Order) Order { + return Order{ + ID: o.ID, + ConnectorID: o.ConnectorID, + Provider: o.Provider, + Reference: o.Reference, + ClientOrderID: o.ClientOrderID, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + Direction: string(o.V3OrderDirectionEnum), + SourceAsset: o.SourceAsset, + DestinationAsset: o.DestinationAsset, + Type: string(o.V3OrderTypeEnum), + Status: string(o.V3OrderStatusEnum), + BaseQuantityOrdered: o.BaseQuantityOrdered, + BaseQuantityFilled: o.BaseQuantityFilled, + LimitPrice: o.LimitPrice, + StopPrice: o.StopPrice, + TimeInForce: string(o.V3TimeInForceEnum), + ExpiresAt: o.ExpiresAt, + Fee: o.Fee, + FeeAsset: o.FeeAsset, + AverageFillPrice: o.AverageFillPrice, + QuoteAmount: o.QuoteAmount, + QuoteAsset: o.QuoteAsset, + PriceAsset: o.PriceAsset, + SourceAccountID: o.SourceAccountID, + DestinationAccountID: o.DestinationAccountID, + Metadata: o.V3Metadata, + Adjustments: fctl.Map(o.Adjustments, toOrderAdjustment), + Error: o.Error, + } +} + +func toOrderAdjustment(a paymentsmodels.V3OrderAdjustment) OrderAdjustment { + return OrderAdjustment{ + ID: a.ID, + Reference: a.Reference, + CreatedAt: a.CreatedAt, + Status: string(a.V3OrderStatusEnum), + BaseQuantityFilled: a.BaseQuantityFilled, + Fee: a.Fee, + FeeAsset: a.FeeAsset, + Metadata: a.V3Metadata, + } +} + +func (c *ListController) Render(cmd *cobra.Command, _ []string) error { + tableData := fctl.Map(c.store.Orders, func(o Order) []string { + return []string{ + o.ID, + o.Reference, + o.Provider, + o.Direction, + o.Type, + o.Status, + o.SourceAsset, + o.DestinationAsset, + bigIntString(o.BaseQuantityOrdered), + bigIntString(o.BaseQuantityFilled), + o.CreatedAt.Format(time.RFC3339), + } + }) + tableData = fctl.Prepend(tableData, []string{ + "ID", "Reference", "Provider", "Direction", "Type", "Status", + "SourceAsset", "DestinationAsset", "BaseQuantityOrdered", "BaseQuantityFilled", "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 +} + +// timeRFC3339 formats t as RFC3339, or "" when t is nil. +func timeRFC3339(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(time.RFC3339) +} diff --git a/cmd/payments/orders/list_test.go b/cmd/payments/orders/list_test.go new file mode 100644 index 00000000..5c4a6b5e --- /dev/null +++ b/cmd/payments/orders/list_test.go @@ -0,0 +1,92 @@ +package orders + +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.directionFlag, c.statusFlag, + c.typeFlag, 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: "FILLED", 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": "FILLED"}}, + {"$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 TestToOrder(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + got := toOrder(paymentsmodels.V3Order{ + ID: "ord_1", + ConnectorID: "conn_1", + Provider: "coinbaseprime", + Reference: "ref-1", + CreatedAt: now, + UpdatedAt: now, + V3OrderDirectionEnum: paymentsmodels.V3OrderDirectionEnum("BUY"), + V3OrderStatusEnum: paymentsmodels.V3OrderStatusEnum("FILLED"), + V3OrderTypeEnum: paymentsmodels.V3OrderTypeEnum("LIMIT"), + V3TimeInForceEnum: paymentsmodels.V3TimeInForceEnum("GOOD_UNTIL_CANCELLED"), + SourceAsset: "USD/2", + DestinationAsset: "BTC/8", + BaseQuantityOrdered: big.NewInt(100), + V3Metadata: map[string]string{"k": "v"}, + Adjustments: []paymentsmodels.V3OrderAdjustment{{ + ID: "adj_1", + V3OrderStatusEnum: paymentsmodels.V3OrderStatusEnum("PARTIALLY_FILLED"), + V3Metadata: map[string]string{"a": "b"}, + }}, + }) + + // The enums are typed strings on the SDK side and must surface as plain strings. + for _, tc := range []struct{ name, got, want string }{ + {"ID", got.ID, "ord_1"}, + {"Direction", got.Direction, "BUY"}, + {"Status", got.Status, "FILLED"}, + {"Type", got.Type, "LIMIT"}, + {"TimeInForce", got.TimeInForce, "GOOD_UNTIL_CANCELLED"}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } + if got.BaseQuantityOrdered.Cmp(big.NewInt(100)) != 0 { + t.Errorf("BaseQuantityOrdered = %v, want 100", got.BaseQuantityOrdered) + } + if !reflect.DeepEqual(got.Metadata, map[string]string{"k": "v"}) { + t.Errorf("Metadata = %v", got.Metadata) + } + if len(got.Adjustments) != 1 || got.Adjustments[0].Status != "PARTIALLY_FILLED" { + t.Fatalf("adjustments not mapped: %+v", got.Adjustments) + } + if !reflect.DeepEqual(got.Adjustments[0].Metadata, map[string]string{"a": "b"}) { + t.Errorf("adjustment metadata = %v", got.Adjustments[0].Metadata) + } +} diff --git a/cmd/payments/orders/root.go b/cmd/payments/orders/root.go new file mode 100644 index 00000000..9c79a627 --- /dev/null +++ b/cmd/payments/orders/root.go @@ -0,0 +1,18 @@ +package orders + +import ( + "github.com/spf13/cobra" + + fctl "github.com/formancehq/fctl/v3/pkg" +) + +func NewOrdersCommand() *cobra.Command { + return fctl.NewCommand("orders", + fctl.WithAliases("o"), + fctl.WithShortDescription("Manage orders (read-only) ingested from exchange-style connectors"), + fctl.WithChildCommands( + NewListCommand(), + NewShowCommand(), + ), + ) +} diff --git a/cmd/payments/orders/show.go b/cmd/payments/orders/show.go new file mode 100644 index 00000000..6b226ffb --- /dev/null +++ b/cmd/payments/orders/show.go @@ -0,0 +1,140 @@ +package orders + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + "github.com/formancehq/go-libs/v4/metadata" + + "github.com/formancehq/fctl/v3/cmd/payments/versions" + fctl "github.com/formancehq/fctl/v3/pkg" +) + +type ShowStore struct { + Order *Order `json:"order"` +} + +type ShowController struct { + PaymentsVersion versions.Version + store *ShowStore +} + +var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) + +func (c *ShowController) SetVersion(version versions.Version) { c.PaymentsVersion = version } + +func NewShowController() *ShowController { + return &ShowController{store: &ShowStore{}} +} + +func (c *ShowController) GetStore() *ShowStore { return c.store } + +func NewShowCommand() *cobra.Command { + c := NewShowController() + return fctl.NewCommand("get ", + fctl.WithAliases("sh", "s"), + fctl.WithArgs(cobra.ExactArgs(1)), + fctl.WithValidArgsFunction(cobra.NoFileCompletions), + fctl.WithShortDescription("Get a single order by its Formance ID, including its full adjustments history"), + fctl.WithController[*ShowStore](c), + ) +} + +func (c *ShowController) Run(cmd *cobra.Command, args []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, args, c); err != nil { + return nil, err + } + if !c.PaymentsVersion.IsAtLeast(versions.V3, ordersMinMinor) { + return nil, fmt.Errorf("orders require Payments >= v3.%d (stack reports %s)", ordersMinMinor, c.PaymentsVersion.Raw) + } + + pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.show orderID=%q", args[0]) + + res, err := stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{OrderID: args[0]}) + if err != nil { + return nil, err + } + if res.V3GetOrderResponse == nil { + return nil, fmt.Errorf("orders.get: empty response (status %d)", res.StatusCode) + } + + order := toOrder(res.V3GetOrderResponse.V3Order) + c.store.Order = &order + return c, nil +} + +func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { + if c.store.Order == nil { + fctl.Println("No order data.") + return nil + } + o := c.store.Order + out := cmd.OutOrStdout() + + fctl.Section.WithWriter(out).Println("Information") + info := pterm.TableData{ + {pterm.LightCyan("ID"), o.ID}, + {pterm.LightCyan("Reference"), o.Reference}, + {pterm.LightCyan("ConnectorID"), o.ConnectorID}, + {pterm.LightCyan("Provider"), o.Provider}, + {pterm.LightCyan("ClientOrderID"), strDeref(o.ClientOrderID)}, + {pterm.LightCyan("Direction"), o.Direction}, + {pterm.LightCyan("Type"), o.Type}, + {pterm.LightCyan("Status"), o.Status}, + {pterm.LightCyan("TimeInForce"), o.TimeInForce}, + {pterm.LightCyan("SourceAsset"), o.SourceAsset}, + {pterm.LightCyan("DestinationAsset"), o.DestinationAsset}, + {pterm.LightCyan("BaseQuantityOrdered"), bigIntString(o.BaseQuantityOrdered)}, + {pterm.LightCyan("BaseQuantityFilled"), bigIntString(o.BaseQuantityFilled)}, + {pterm.LightCyan("LimitPrice"), bigIntString(o.LimitPrice)}, + {pterm.LightCyan("StopPrice"), bigIntString(o.StopPrice)}, + {pterm.LightCyan("AverageFillPrice"), bigIntString(o.AverageFillPrice)}, + {pterm.LightCyan("QuoteAmount"), bigIntString(o.QuoteAmount)}, + {pterm.LightCyan("QuoteAsset"), strDeref(o.QuoteAsset)}, + {pterm.LightCyan("PriceAsset"), strDeref(o.PriceAsset)}, + {pterm.LightCyan("Fee"), bigIntString(o.Fee)}, + {pterm.LightCyan("FeeAsset"), strDeref(o.FeeAsset)}, + {pterm.LightCyan("SourceAccountID"), strDeref(o.SourceAccountID)}, + {pterm.LightCyan("DestinationAccountID"), strDeref(o.DestinationAccountID)}, + {pterm.LightCyan("ExpiresAt"), timeRFC3339(o.ExpiresAt)}, + {pterm.LightCyan("CreatedAt"), o.CreatedAt.Format(time.RFC3339)}, + {pterm.LightCyan("UpdatedAt"), o.UpdatedAt.Format(time.RFC3339)}, + {pterm.LightCyan("Error"), strDeref(o.Error)}, + } + if err := pterm.DefaultTable.WithWriter(out).WithData(info).Render(); err != nil { + return err + } + + if err := fctl.PrintMetadata(out, metadata.Metadata(o.Metadata)); err != nil { + return err + } + + if len(o.Adjustments) == 0 { + return nil + } + fctl.Section.WithWriter(out).Println("Adjustments") + adj := fctl.Map(o.Adjustments, func(a OrderAdjustment) []string { + return []string{ + a.CreatedAt.Format(time.RFC3339), + a.Status, + bigIntString(a.BaseQuantityFilled), + bigIntString(a.Fee), + strDeref(a.FeeAsset), + a.ID, + } + }) + adj = fctl.Prepend(adj, []string{"CreatedAt", "Status", "BaseQuantityFilled", "Fee", "FeeAsset", "ID"}) + return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(adj).Render() +} diff --git a/cmd/payments/root.go b/cmd/payments/root.go index 775b5ced..6f834506 100644 --- a/cmd/payments/root.go +++ b/cmd/payments/root.go @@ -6,6 +6,8 @@ import ( "github.com/formancehq/fctl/v3/cmd/payments/accounts" "github.com/formancehq/fctl/v3/cmd/payments/bankaccounts" "github.com/formancehq/fctl/v3/cmd/payments/connectors" + "github.com/formancehq/fctl/v3/cmd/payments/conversions" + "github.com/formancehq/fctl/v3/cmd/payments/orders" paymentsmodels "github.com/formancehq/fctl/v3/cmd/payments/payments" "github.com/formancehq/fctl/v3/cmd/payments/pools" "github.com/formancehq/fctl/v3/cmd/payments/tasks" @@ -23,6 +25,8 @@ func NewCommand() *cobra.Command { bankaccounts.NewBankAccountsCommand(), accounts.NewAccountsCommand(), pools.NewPoolsCommand(), + orders.NewOrdersCommand(), + conversions.NewConversionsCommand(), tasks.NewTasksCommand(), ), )