From 1b0ed2f69939d9b8f33a669b085d26b4e334eee8 Mon Sep 17 00:00:00 2001 From: thierrycoopman Date: Thu, 28 May 2026 10:28:57 +0200 Subject: [PATCH 1/3] feat(payments): EN-622 EN-618 scaffold orders/conversions commands Adds `fctl payments orders` and `fctl payments conversions` (list + get) aligned with the upcoming payments v3.3 `GET /v3/orders` and `GET /v3/conversions` endpoints. Filter flags, pagination, JSON output, help screens, and renderers ship today; `Run()` returns a clear sentinel error until formance-sdk-go/v3 exposes the new operations (see EN-1012), at which point each stub becomes a one-line wire-up to `Payments.V3.{ListOrders,GetOrder,ListConversions,GetConversion}`. Per-package inline helpers follow the existing cmd/payments convention; cross-package DRY is left as a future optimization. --- cmd/payments/conversions/list.go | 215 +++++++++++++++++++++++++ cmd/payments/conversions/root.go | 18 +++ cmd/payments/conversions/show.go | 104 +++++++++++++ cmd/payments/orders/list.go | 259 +++++++++++++++++++++++++++++++ cmd/payments/orders/root.go | 18 +++ cmd/payments/orders/show.go | 134 ++++++++++++++++ cmd/payments/root.go | 4 + 7 files changed, 752 insertions(+) create mode 100644 cmd/payments/conversions/list.go create mode 100644 cmd/payments/conversions/root.go create mode 100644 cmd/payments/conversions/show.go create mode 100644 cmd/payments/orders/list.go create mode 100644 cmd/payments/orders/root.go create mode 100644 cmd/payments/orders/show.go diff --git a/cmd/payments/conversions/list.go b/cmd/payments/conversions/list.go new file mode 100644 index 00000000..f4f6692d --- /dev/null +++ b/cmd/payments/conversions/list.go @@ -0,0 +1,215 @@ +package conversions + +import ( + "fmt" + "math/big" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "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 + createdAtFromFlag string + createdAtToFlag 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", + createdAtFromFlag: "created-at-from", + createdAtToFlag: "created-at-to", + } +} + +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.WithStringFlag(c.createdAtFromFlag, "", "Include only conversions created at or after this RFC3339 instant"), + fctl.WithStringFlag(c.createdAtToFlag, "", "Include only conversions created at or before this RFC3339 instant"), + fctl.WithCursorFlag(), + fctl.WithPageSizeFlag(), + fctl.WithController[*ListStore](c), + ) +} + +// buildQuery translates the CLI filter flags into a V3QueryBuilder body +// (free-form map[string]any) using the same $match / $and pattern the +// ledger uses today (see cmd/ledger/accounts/list.go). +func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) { + 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("connectorID", fctl.GetString(cmd, c.connectorIDFlag)) + addMatch("reference", fctl.GetString(cmd, c.referenceFlag)) + addMatch("status", fctl.GetString(cmd, c.statusFlag)) + addMatch("sourceAsset", fctl.GetString(cmd, c.sourceAssetFlag)) + addMatch("destinationAsset", fctl.GetString(cmd, c.destinationAssetFlag)) + + from, err := fctl.GetDateTime(cmd, c.createdAtFromFlag) + if err != nil { + return nil, fmt.Errorf("parsing --%s: %w", c.createdAtFromFlag, err) + } + if from != nil { + matches = append(matches, map[string]any{"$gte": map[string]any{"createdAt": from.Format(time.RFC3339Nano)}}) + } + to, err := fctl.GetDateTime(cmd, c.createdAtToFlag) + if err != nil { + return nil, fmt.Errorf("parsing --%s: %w", c.createdAtToFlag, err) + } + if to != nil { + matches = append(matches, map[string]any{"$lte": map[string]any{"createdAt": to.Format(time.RFC3339Nano)}}) + } + + if len(matches) == 0 { + return nil, nil + } + return map[string]any{"$and": matches}, nil +} + +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, err := c.buildQuery(cmd) + if err != nil { + return nil, err + } + 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) + _ = stackClient + + // TODO(EN-622): wire to stackClient.Payments.V3.ListConversions(cmd.Context(), operations.V3ListConversionsRequest{ + // PageSize: fctl.Ptr(int64(pageSize)), + // Cursor: fctl.Ptr(cursor), + // V3QueryBuilder: query, + // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). + // On success, map the response into c.store.Conversions (already aligned with V3Conversion) + // and c.store.Cursor (use fctl.Cursor{HasMore, PageSize, Next, Previous}). + return nil, fmt.Errorf("conversions.list: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-622)") +} + +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/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..729d00be --- /dev/null +++ b/cmd/payments/conversions/show.go @@ -0,0 +1,104 @@ +package conversions + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "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]) + _ = stackClient + + // TODO(EN-618): wire to stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ + // ConversionID: args[0], + // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). + // On success, map the response Data into c.store.Conversion. + return nil, fmt.Errorf("conversions.get: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-618)") +} + +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..10670c97 --- /dev/null +++ b/cmd/payments/orders/list.go @@ -0,0 +1,259 @@ +package orders + +import ( + "fmt" + "math/big" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "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 + createdAtFromFlag string + createdAtToFlag 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", + createdAtFromFlag: "created-at-from", + createdAtToFlag: "created-at-to", + } +} + +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.WithStringFlag(c.createdAtFromFlag, "", "Include only orders created at or after this RFC3339 instant"), + fctl.WithStringFlag(c.createdAtToFlag, "", "Include only orders created at or before this RFC3339 instant"), + fctl.WithCursorFlag(), + fctl.WithPageSizeFlag(), + fctl.WithController[*ListStore](c), + ) +} + +// buildQuery translates the CLI filter flags into a V3QueryBuilder body +// (free-form map[string]any) matching the $match / $and pattern that the +// ledger uses today (see cmd/ledger/accounts/list.go). +func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) { + 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("connectorID", 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("sourceAsset", fctl.GetString(cmd, c.sourceAssetFlag)) + addMatch("destinationAsset", fctl.GetString(cmd, c.destinationAssetFlag)) + + from, err := fctl.GetDateTime(cmd, c.createdAtFromFlag) + if err != nil { + return nil, fmt.Errorf("parsing --%s: %w", c.createdAtFromFlag, err) + } + if from != nil { + matches = append(matches, map[string]any{"$gte": map[string]any{"createdAt": from.Format(time.RFC3339Nano)}}) + } + to, err := fctl.GetDateTime(cmd, c.createdAtToFlag) + if err != nil { + return nil, fmt.Errorf("parsing --%s: %w", c.createdAtToFlag, err) + } + if to != nil { + matches = append(matches, map[string]any{"$lte": map[string]any{"createdAt": to.Format(time.RFC3339Nano)}}) + } + + if len(matches) == 0 { + return nil, nil + } + return map[string]any{"$and": matches}, nil +} + +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, err := c.buildQuery(cmd) + if err != nil { + return nil, err + } + 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) + _ = stackClient + + // TODO(EN-622): wire to stackClient.Payments.V3.ListOrders(cmd.Context(), operations.V3ListOrdersRequest{ + // PageSize: fctl.Ptr(int64(pageSize)), + // Cursor: fctl.Ptr(cursor), + // V3QueryBuilder: query, + // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). + // On success, map the response into c.store.Orders (already aligned with V3Order) + // and c.store.Cursor (use fctl.Cursor{HasMore, PageSize, Next, Previous}). + return nil, fmt.Errorf("orders.list: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-622)") +} + +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/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..1d62c1d4 --- /dev/null +++ b/cmd/payments/orders/show.go @@ -0,0 +1,134 @@ +package orders + +import ( + "fmt" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "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]) + _ = stackClient + + // TODO(EN-618): wire to stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{ + // OrderID: args[0], + // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). + // On success, map the response Data into c.store.Order. + return nil, fmt.Errorf("orders.get: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-618)") +} + +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 470ad521..abf61feb 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" "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(), ), ) From 29f76810c00564dd6100b77765f1b4cb7f53a4d9 Mon Sep 17 00:00:00 2001 From: thierrycoopman Date: Fri, 29 May 2026 11:16:36 +0200 Subject: [PATCH 2/3] docs(payments): revisit orders/conversions stubs for SDK v4 reality Payments v3.3 endpoints shipped in formance-sdk-go v4.0.0 (2026-05-29) as a breaking major: pkg/models/components was removed and the models were split into per-domain packages (pkg/models/payments, ledger, wallets, ...). The PR's original "awaiting v3.x release" framing no longer holds. Updates each of the 4 stubs (orders list/get, conversions list/get) to: - block on EN-1012 (fctl-wide migration to formance-sdk-go/v4) instead of a non-existent v3 release - carry the precise v4 paste-in wiring with the right import paths (operations + paymentsmodels), request shape (RequestBody not V3QueryBuilder), response shape (V3OrdersCursorResponse.Cursor / V3GetOrderResponse.V3Order, etc.), and mapping caveats (typed-string enum casts, V3Metadata field name, V3OrderAdjustmentRaw empty struct) No functional change; Run() still returns a clear sentinel error and go build / go vet stay green. Co-authored-by: Cursor --- cmd/payments/conversions/list.go | 42 ++++++++++++++++++++++------ cmd/payments/conversions/show.go | 30 ++++++++++++++++---- cmd/payments/orders/list.go | 47 ++++++++++++++++++++++++++------ cmd/payments/orders/show.go | 31 +++++++++++++++++---- 4 files changed, 124 insertions(+), 26 deletions(-) diff --git a/cmd/payments/conversions/list.go b/cmd/payments/conversions/list.go index f4f6692d..eebe2bcf 100644 --- a/cmd/payments/conversions/list.go +++ b/cmd/payments/conversions/list.go @@ -160,14 +160,40 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) _ = stackClient - // TODO(EN-622): wire to stackClient.Payments.V3.ListConversions(cmd.Context(), operations.V3ListConversionsRequest{ - // PageSize: fctl.Ptr(int64(pageSize)), - // Cursor: fctl.Ptr(cursor), - // V3QueryBuilder: query, - // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). - // On success, map the response into c.store.Conversions (already aligned with V3Conversion) - // and c.store.Cursor (use fctl.Cursor{HasMore, PageSize, Next, Previous}). - return nil, fmt.Errorf("conversions.list: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-622)") + // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments + // v3.3 endpoints shipped in v4.0.0 as a breaking major: pkg/models/components + // was removed and the models were split into per-domain packages (e.g. + // pkg/models/payments). Until that migration lands, this stub stays in place. + // + // Ready-to-paste wiring (replace this block and the return below): + // + // import ( + // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" + // ) + // + // res, err := stackClient.Payments.V3.ListConversions(cmd.Context(), operations.V3ListConversionsRequest{ + // RequestBody: query, // map[string]any, $and/$match + // Cursor: fctl.Ptr(cursor), // *string + // PageSize: fctl.Ptr(int64(pageSize)),// *int64 + // }) + // if err != nil { + // return nil, err + // } + // cur := res.V3ConversionsCursorResponse.Cursor + // c.store.Conversions = fctl.Map(cur.Data, toConversion) // toConversion: paymentsmodels.V3Conversion -> Conversion + // c.store.Cursor = fctl.Cursor{ + // HasMore: cur.HasMore, + // PageSize: cur.PageSize, // already int64 + // Next: cur.Next, + // Previous: cur.Previous, + // } + // return c, nil + // + // Mapping notes (paymentsmodels.V3Conversion -> local Conversion): + // - typed-string status enum; cast with string(cv.V3ConversionStatusEnum) + // - metadata field on V3Conversion is V3Metadata (not Metadata) + return nil, fmt.Errorf("conversions.list: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") } func (c *ListController) Render(cmd *cobra.Command, _ []string) error { diff --git a/cmd/payments/conversions/show.go b/cmd/payments/conversions/show.go index 729d00be..59e205dd 100644 --- a/cmd/payments/conversions/show.go +++ b/cmd/payments/conversions/show.go @@ -62,11 +62,31 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.show conversionID=%q", args[0]) _ = stackClient - // TODO(EN-618): wire to stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ - // ConversionID: args[0], - // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). - // On success, map the response Data into c.store.Conversion. - return nil, fmt.Errorf("conversions.get: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-618)") + // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments + // v3.3 endpoints shipped in v4.0.0 as a breaking major (pkg/models/components + // removed, models split into per-domain packages). Until that migration + // lands, this stub stays in place. + // + // Ready-to-paste wiring (replace this block and the return below): + // + // import ( + // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" + // ) + // + // res, err := stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ + // ConversionID: args[0], + // }) + // if err != nil { + // return nil, err + // } + // c.store.Conversion = toConversion(res.V3GetConversionResponse.V3Conversion) // .V3Conversion is the data payload + // return c, nil + // + // Mapping notes (paymentsmodels.V3Conversion -> local Conversion): same + // caveats as conversions.list — cast string(cv.V3ConversionStatusEnum) and + // read the metadata from V3Metadata (not Metadata). + return nil, fmt.Errorf("conversions.get: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") } func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { diff --git a/cmd/payments/orders/list.go b/cmd/payments/orders/list.go index 10670c97..e0df0f83 100644 --- a/cmd/payments/orders/list.go +++ b/cmd/payments/orders/list.go @@ -194,14 +194,45 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) _ = stackClient - // TODO(EN-622): wire to stackClient.Payments.V3.ListOrders(cmd.Context(), operations.V3ListOrdersRequest{ - // PageSize: fctl.Ptr(int64(pageSize)), - // Cursor: fctl.Ptr(cursor), - // V3QueryBuilder: query, - // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). - // On success, map the response into c.store.Orders (already aligned with V3Order) - // and c.store.Cursor (use fctl.Cursor{HasMore, PageSize, Next, Previous}). - return nil, fmt.Errorf("orders.list: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-622)") + // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments + // v3.3 endpoints shipped in v4.0.0 as a breaking major: pkg/models/components + // was removed and the models were split into per-domain packages (e.g. + // pkg/models/payments). Until that migration lands, this stub stays in place. + // + // Ready-to-paste wiring (replace this block and the return below): + // + // import ( + // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" + // ) + // + // res, err := stackClient.Payments.V3.ListOrders(cmd.Context(), operations.V3ListOrdersRequest{ + // RequestBody: query, // map[string]any, $and/$match + // Cursor: fctl.Ptr(cursor), // *string + // PageSize: fctl.Ptr(int64(pageSize)),// *int64 + // }) + // if err != nil { + // return nil, err + // } + // cur := res.V3OrdersCursorResponse.Cursor + // c.store.Orders = fctl.Map(cur.Data, toOrder) // toOrder: paymentsmodels.V3Order -> Order + // c.store.Cursor = fctl.Cursor{ + // HasMore: cur.HasMore, + // PageSize: cur.PageSize, // already int64 + // Next: cur.Next, + // Previous: cur.Previous, + // } + // return c, nil + // + // Mapping notes (paymentsmodels.V3Order -> local Order): + // - typed-string enums; cast with: + // string(o.V3OrderDirectionEnum), string(o.V3OrderStatusEnum), + // string(o.V3OrderTypeEnum), string(o.V3TimeInForceEnum) + // - metadata field on V3Order is V3Metadata (not Metadata) + // - V3OrderAdjustment.Raw is *paymentsmodels.V3OrderAdjustmentRaw (empty + // struct in v4.0.0); leave OrderAdjustment.Raw nil or drop it from the + // store when wiring + return nil, fmt.Errorf("orders.list: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") } func (c *ListController) Render(cmd *cobra.Command, _ []string) error { diff --git a/cmd/payments/orders/show.go b/cmd/payments/orders/show.go index 1d62c1d4..2a06ec97 100644 --- a/cmd/payments/orders/show.go +++ b/cmd/payments/orders/show.go @@ -62,11 +62,32 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.show orderID=%q", args[0]) _ = stackClient - // TODO(EN-618): wire to stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{ - // OrderID: args[0], - // }) once formance-sdk-go/v3 exposes payments v3.3 endpoints (see EN-1012). - // On success, map the response Data into c.store.Order. - return nil, fmt.Errorf("orders.get: not wired yet - awaiting formance-sdk-go release with payments v3.3 (EN-618)") + // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments + // v3.3 endpoints shipped in v4.0.0 as a breaking major (pkg/models/components + // removed, models split into per-domain packages). Until that migration + // lands, this stub stays in place. + // + // Ready-to-paste wiring (replace this block and the return below): + // + // import ( + // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" + // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" + // ) + // + // res, err := stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{ + // OrderID: args[0], + // }) + // if err != nil { + // return nil, err + // } + // c.store.Order = toOrder(res.V3GetOrderResponse.V3Order) // .V3Order is the data payload + // return c, nil + // + // Mapping notes (paymentsmodels.V3Order -> local Order): same caveats as + // orders.list — cast typed-string enums (V3OrderDirectionEnum, etc.), use + // V3Metadata (not Metadata), and V3OrderAdjustment.Raw is an empty struct + // in v4.0.0 (leave OrderAdjustment.Raw nil or drop it). + return nil, fmt.Errorf("orders.get: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") } func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { From b29d94fb90a2ddfcfabc063a06376678ed1dbf22 Mon Sep 17 00:00:00 2001 From: thierrycoopman Date: Thu, 4 Jun 2026 17:07:45 +0200 Subject: [PATCH 3/3] feat(payments): EN-622 EN-618 wire orders/conversions to SDK v4 Replace the awaiting-SDK stubs with real calls now that fctl is on formance-sdk-go/v4 (payments v3.3 endpoints): ListOrders/GetOrder/ ListConversions/GetConversion, with toOrder/toOrderAdjustment/toConversion mappers (enum casting, V3Metadata, nullable big.Int amounts). - Query filter keys use the payments snake_case column names; the endpoint whitelists them, so camelCase was rejected with a VALIDATION error. - Drop the unsupported --created-at-from/to flags (no created_at filter key). - V3 query endpoints take a query body (first page) or an opaque cursor (subsequent pages), never both. - Add stdlib unit tests for the mappers and the query builder. --- cmd/payments/conversions/list.go | 131 ++++++++++----------- cmd/payments/conversions/list_test.go | 74 ++++++++++++ cmd/payments/conversions/show.go | 40 +++---- cmd/payments/orders/list.go | 159 ++++++++++++++------------ cmd/payments/orders/list_test.go | 92 +++++++++++++++ cmd/payments/orders/show.go | 41 +++---- 6 files changed, 338 insertions(+), 199 deletions(-) create mode 100644 cmd/payments/conversions/list_test.go create mode 100644 cmd/payments/orders/list_test.go diff --git a/cmd/payments/conversions/list.go b/cmd/payments/conversions/list.go index eebe2bcf..39af96b9 100644 --- a/cmd/payments/conversions/list.go +++ b/cmd/payments/conversions/list.go @@ -8,6 +8,9 @@ import ( "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" ) @@ -48,8 +51,6 @@ type ListController struct { statusFlag string sourceAssetFlag string destinationAssetFlag string - createdAtFromFlag string - createdAtToFlag string } var _ fctl.Controller[*ListStore] = (*ListController)(nil) @@ -64,8 +65,6 @@ func NewListController() *ListController { statusFlag: "status", sourceAssetFlag: "source-asset", destinationAssetFlag: "destination-asset", - createdAtFromFlag: "created-at-from", - createdAtToFlag: "created-at-to", } } @@ -83,18 +82,16 @@ func NewListCommand() *cobra.Command { 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.WithStringFlag(c.createdAtFromFlag, "", "Include only conversions created at or after this RFC3339 instant"), - fctl.WithStringFlag(c.createdAtToFlag, "", "Include only conversions created at or before this RFC3339 instant"), fctl.WithCursorFlag(), fctl.WithPageSizeFlag(), fctl.WithController[*ListStore](c), ) } -// buildQuery translates the CLI filter flags into a V3QueryBuilder body -// (free-form map[string]any) using the same $match / $and pattern the -// ledger uses today (see cmd/ledger/accounts/list.go). -func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) { +// 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 == "" { @@ -102,31 +99,16 @@ func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) } matches = append(matches, map[string]any{"$match": map[string]any{field: val}}) } - addMatch("connectorID", fctl.GetString(cmd, c.connectorIDFlag)) + addMatch("connector_id", fctl.GetString(cmd, c.connectorIDFlag)) addMatch("reference", fctl.GetString(cmd, c.referenceFlag)) addMatch("status", fctl.GetString(cmd, c.statusFlag)) - addMatch("sourceAsset", fctl.GetString(cmd, c.sourceAssetFlag)) - addMatch("destinationAsset", fctl.GetString(cmd, c.destinationAssetFlag)) - - from, err := fctl.GetDateTime(cmd, c.createdAtFromFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", c.createdAtFromFlag, err) - } - if from != nil { - matches = append(matches, map[string]any{"$gte": map[string]any{"createdAt": from.Format(time.RFC3339Nano)}}) - } - to, err := fctl.GetDateTime(cmd, c.createdAtToFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", c.createdAtToFlag, err) - } - if to != nil { - matches = append(matches, map[string]any{"$lte": map[string]any{"createdAt": to.Format(time.RFC3339Nano)}}) - } + addMatch("source_asset", fctl.GetString(cmd, c.sourceAssetFlag)) + addMatch("destination_asset", fctl.GetString(cmd, c.destinationAssetFlag)) if len(matches) == 0 { - return nil, nil + return nil } - return map[string]any{"$and": matches}, nil + return map[string]any{"$and": matches} } func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { @@ -145,10 +127,7 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, fmt.Errorf("conversions require Payments >= v3.%d (stack reports %s)", conversionsMinMinor, c.PaymentsVersion.Raw) } - query, err := c.buildQuery(cmd) - if err != nil { - return nil, err - } + query := c.buildQuery(cmd) cursor, err := fctl.GetCursor(cmd) if err != nil { return nil, err @@ -158,42 +137,54 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) - _ = stackClient - - // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments - // v3.3 endpoints shipped in v4.0.0 as a breaking major: pkg/models/components - // was removed and the models were split into per-domain packages (e.g. - // pkg/models/payments). Until that migration lands, this stub stays in place. - // - // Ready-to-paste wiring (replace this block and the return below): - // - // import ( - // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - // ) - // - // res, err := stackClient.Payments.V3.ListConversions(cmd.Context(), operations.V3ListConversionsRequest{ - // RequestBody: query, // map[string]any, $and/$match - // Cursor: fctl.Ptr(cursor), // *string - // PageSize: fctl.Ptr(int64(pageSize)),// *int64 - // }) - // if err != nil { - // return nil, err - // } - // cur := res.V3ConversionsCursorResponse.Cursor - // c.store.Conversions = fctl.Map(cur.Data, toConversion) // toConversion: paymentsmodels.V3Conversion -> Conversion - // c.store.Cursor = fctl.Cursor{ - // HasMore: cur.HasMore, - // PageSize: cur.PageSize, // already int64 - // Next: cur.Next, - // Previous: cur.Previous, - // } - // return c, nil - // - // Mapping notes (paymentsmodels.V3Conversion -> local Conversion): - // - typed-string status enum; cast with string(cv.V3ConversionStatusEnum) - // - metadata field on V3Conversion is V3Metadata (not Metadata) - return nil, fmt.Errorf("conversions.list: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") + + // 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 { 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/show.go b/cmd/payments/conversions/show.go index 59e205dd..3b85517c 100644 --- a/cmd/payments/conversions/show.go +++ b/cmd/payments/conversions/show.go @@ -7,6 +7,7 @@ import ( "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" @@ -60,33 +61,18 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable } pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.show conversionID=%q", args[0]) - _ = stackClient - - // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments - // v3.3 endpoints shipped in v4.0.0 as a breaking major (pkg/models/components - // removed, models split into per-domain packages). Until that migration - // lands, this stub stays in place. - // - // Ready-to-paste wiring (replace this block and the return below): - // - // import ( - // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - // ) - // - // res, err := stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ - // ConversionID: args[0], - // }) - // if err != nil { - // return nil, err - // } - // c.store.Conversion = toConversion(res.V3GetConversionResponse.V3Conversion) // .V3Conversion is the data payload - // return c, nil - // - // Mapping notes (paymentsmodels.V3Conversion -> local Conversion): same - // caveats as conversions.list — cast string(cv.V3ConversionStatusEnum) and - // read the metadata from V3Metadata (not Metadata). - return nil, fmt.Errorf("conversions.get: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") + + 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 { diff --git a/cmd/payments/orders/list.go b/cmd/payments/orders/list.go index e0df0f83..662dbdff 100644 --- a/cmd/payments/orders/list.go +++ b/cmd/payments/orders/list.go @@ -8,6 +8,9 @@ import ( "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" ) @@ -74,8 +77,6 @@ type ListController struct { typeFlag string sourceAssetFlag string destinationAssetFlag string - createdAtFromFlag string - createdAtToFlag string } var _ fctl.Controller[*ListStore] = (*ListController)(nil) @@ -94,8 +95,6 @@ func NewListController() *ListController { typeFlag: "type", sourceAssetFlag: "source-asset", destinationAssetFlag: "destination-asset", - createdAtFromFlag: "created-at-from", - createdAtToFlag: "created-at-to", } } @@ -115,18 +114,16 @@ func NewListCommand() *cobra.Command { 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.WithStringFlag(c.createdAtFromFlag, "", "Include only orders created at or after this RFC3339 instant"), - fctl.WithStringFlag(c.createdAtToFlag, "", "Include only orders created at or before this RFC3339 instant"), fctl.WithCursorFlag(), fctl.WithPageSizeFlag(), fctl.WithController[*ListStore](c), ) } -// buildQuery translates the CLI filter flags into a V3QueryBuilder body -// (free-form map[string]any) matching the $match / $and pattern that the -// ledger uses today (see cmd/ledger/accounts/list.go). -func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) { +// 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 == "" { @@ -134,33 +131,18 @@ func (c *ListController) buildQuery(cmd *cobra.Command) (map[string]any, error) } matches = append(matches, map[string]any{"$match": map[string]any{field: val}}) } - addMatch("connectorID", fctl.GetString(cmd, c.connectorIDFlag)) + 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("sourceAsset", fctl.GetString(cmd, c.sourceAssetFlag)) - addMatch("destinationAsset", fctl.GetString(cmd, c.destinationAssetFlag)) - - from, err := fctl.GetDateTime(cmd, c.createdAtFromFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", c.createdAtFromFlag, err) - } - if from != nil { - matches = append(matches, map[string]any{"$gte": map[string]any{"createdAt": from.Format(time.RFC3339Nano)}}) - } - to, err := fctl.GetDateTime(cmd, c.createdAtToFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", c.createdAtToFlag, err) - } - if to != nil { - matches = append(matches, map[string]any{"$lte": map[string]any{"createdAt": to.Format(time.RFC3339Nano)}}) - } + addMatch("source_asset", fctl.GetString(cmd, c.sourceAssetFlag)) + addMatch("destination_asset", fctl.GetString(cmd, c.destinationAssetFlag)) if len(matches) == 0 { - return nil, nil + return nil } - return map[string]any{"$and": matches}, nil + return map[string]any{"$and": matches} } func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { @@ -179,10 +161,7 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, fmt.Errorf("orders require Payments >= v3.%d (stack reports %s)", ordersMinMinor, c.PaymentsVersion.Raw) } - query, err := c.buildQuery(cmd) - if err != nil { - return nil, err - } + query := c.buildQuery(cmd) cursor, err := fctl.GetCursor(cmd) if err != nil { return nil, err @@ -192,47 +171,79 @@ func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, e return nil, err } pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) - _ = stackClient - // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments - // v3.3 endpoints shipped in v4.0.0 as a breaking major: pkg/models/components - // was removed and the models were split into per-domain packages (e.g. - // pkg/models/payments). Until that migration lands, this stub stays in place. - // - // Ready-to-paste wiring (replace this block and the return below): - // - // import ( - // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - // ) - // - // res, err := stackClient.Payments.V3.ListOrders(cmd.Context(), operations.V3ListOrdersRequest{ - // RequestBody: query, // map[string]any, $and/$match - // Cursor: fctl.Ptr(cursor), // *string - // PageSize: fctl.Ptr(int64(pageSize)),// *int64 - // }) - // if err != nil { - // return nil, err - // } - // cur := res.V3OrdersCursorResponse.Cursor - // c.store.Orders = fctl.Map(cur.Data, toOrder) // toOrder: paymentsmodels.V3Order -> Order - // c.store.Cursor = fctl.Cursor{ - // HasMore: cur.HasMore, - // PageSize: cur.PageSize, // already int64 - // Next: cur.Next, - // Previous: cur.Previous, - // } - // return c, nil - // - // Mapping notes (paymentsmodels.V3Order -> local Order): - // - typed-string enums; cast with: - // string(o.V3OrderDirectionEnum), string(o.V3OrderStatusEnum), - // string(o.V3OrderTypeEnum), string(o.V3TimeInForceEnum) - // - metadata field on V3Order is V3Metadata (not Metadata) - // - V3OrderAdjustment.Raw is *paymentsmodels.V3OrderAdjustmentRaw (empty - // struct in v4.0.0); leave OrderAdjustment.Raw nil or drop it from the - // store when wiring - return nil, fmt.Errorf("orders.list: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") + // 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 { 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/show.go b/cmd/payments/orders/show.go index 2a06ec97..6b226ffb 100644 --- a/cmd/payments/orders/show.go +++ b/cmd/payments/orders/show.go @@ -7,6 +7,7 @@ import ( "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" @@ -60,34 +61,18 @@ func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable } pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.show orderID=%q", args[0]) - _ = stackClient - - // TODO(EN-1012): wire once fctl migrates to formance-sdk-go/v4. The payments - // v3.3 endpoints shipped in v4.0.0 as a breaking major (pkg/models/components - // removed, models split into per-domain packages). Until that migration - // lands, this stub stays in place. - // - // Ready-to-paste wiring (replace this block and the return below): - // - // import ( - // operations "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - // paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - // ) - // - // res, err := stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{ - // OrderID: args[0], - // }) - // if err != nil { - // return nil, err - // } - // c.store.Order = toOrder(res.V3GetOrderResponse.V3Order) // .V3Order is the data payload - // return c, nil - // - // Mapping notes (paymentsmodels.V3Order -> local Order): same caveats as - // orders.list — cast typed-string enums (V3OrderDirectionEnum, etc.), use - // V3Metadata (not Metadata), and V3OrderAdjustment.Raw is an empty struct - // in v4.0.0 (leave OrderAdjustment.Raw nil or drop it). - return nil, fmt.Errorf("orders.get: blocked until fctl migrates to formance-sdk-go/v4 (payments v3.3 shipped in v4.0.0 as a breaking major; see EN-1012)") + + 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 {