mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
773 lines
27 KiB
Go
773 lines
27 KiB
Go
package models
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/pointer"
|
|
"github.com/hanzo-fi/payments/pkg/domain/assets"
|
|
errorsutils "github.com/hanzo-fi/payments/pkg/domain/errors"
|
|
"github.com/gibson042/canonicaljson-go"
|
|
)
|
|
|
|
// PSPOrder represents an order from a Payment Service Provider (exchange).
|
|
// This is the internal struct used by plugins to communicate order data.
|
|
type PSPOrder struct {
|
|
// PSP order reference. Should be unique within the connector.
|
|
Reference string
|
|
|
|
// Client-assigned order ID used by the exchange for placement idempotency.
|
|
// The caller provides this when submitting the order; the exchange deduplicates
|
|
// retried submissions using it. Formance stores it for traceability only — our
|
|
// storage dedup keys on Reference (the exchange-assigned ID), not this field.
|
|
// Exchange-specific names: Coinbase client_order_id, Kraken cl_ord_id, Binance clientOrderId.
|
|
ClientOrderID string
|
|
|
|
// Order creation date
|
|
CreatedAt time.Time
|
|
|
|
// Order direction: BUY or SELL
|
|
Direction OrderDirection
|
|
|
|
// Source asset (what you're trading from)
|
|
// For BUY orders: the quote currency (e.g., EUR in BTC/EUR)
|
|
// For SELL orders: the base currency (e.g., BTC in BTC/EUR)
|
|
SourceAsset string
|
|
|
|
// Target asset (what you're trading to)
|
|
// For BUY orders: the base currency (e.g., BTC in BTC/EUR)
|
|
// For SELL orders: the quote currency (e.g., EUR in BTC/EUR)
|
|
DestinationAsset string
|
|
|
|
// Order type: MARKET or LIMIT
|
|
Type OrderType
|
|
|
|
// Order status
|
|
Status OrderStatus
|
|
|
|
// Base quantity ordered (in base asset units, using integer representation)
|
|
BaseQuantityOrdered *big.Int
|
|
|
|
// Base quantity filled (in base asset units, using integer representation)
|
|
BaseQuantityFilled *big.Int
|
|
|
|
// Limit price for LIMIT orders (optional, using integer representation)
|
|
LimitPrice *big.Int
|
|
|
|
// Stop price for STOP_LIMIT orders (optional, using integer representation)
|
|
StopPrice *big.Int
|
|
|
|
// Time in force
|
|
TimeInForce TimeInForce
|
|
|
|
// Expiration time for GTD orders
|
|
ExpiresAt *time.Time
|
|
|
|
// Quote amount filled (in quote asset units, e.g. USD cents).
|
|
// For BUY: how much quote currency was spent. For SELL: how much was received.
|
|
// This is the exact filled_value from the exchange, parsed at quote precision.
|
|
QuoteAmount *big.Int
|
|
|
|
// Quote asset with precision (e.g. "USD/2")
|
|
QuoteAsset string
|
|
|
|
// Fee charged for the order (using integer representation, in quote currency)
|
|
Fee *big.Int
|
|
|
|
// Fee asset with precision (e.g. "USD/2")
|
|
FeeAsset *string
|
|
|
|
// Average fill price (using integer representation, analytics only)
|
|
AverageFillPrice *big.Int
|
|
|
|
// Price asset with precision for interpreting price fields (analytics only)
|
|
PriceAsset *string
|
|
|
|
// Account references (wallet UUIDs) for source and destination.
|
|
// BUY: source = quote wallet (USD), destination = base wallet (crypto)
|
|
// SELL: source = base wallet (crypto), destination = quote wallet (USD)
|
|
SourceAccountReference *string
|
|
DestinationAccountReference *string
|
|
|
|
// Additional metadata
|
|
Metadata map[string]string
|
|
|
|
// PSP response in raw format
|
|
Raw json.RawMessage
|
|
}
|
|
|
|
func (o *PSPOrder) Validate() error {
|
|
if o.Reference == "" {
|
|
return errorsutils.NewWrappedError(errors.New("missing order reference"), ErrValidation)
|
|
}
|
|
|
|
if o.CreatedAt.IsZero() {
|
|
return errorsutils.NewWrappedError(errors.New("missing order createdAt"), ErrValidation)
|
|
}
|
|
|
|
if o.Direction == ORDER_DIRECTION_UNKNOWN {
|
|
return errorsutils.NewWrappedError(errors.New("missing order direction"), ErrValidation)
|
|
}
|
|
|
|
if !assets.IsValid(o.SourceAsset) {
|
|
return errorsutils.NewWrappedError(errors.New("invalid order source asset"), ErrValidation)
|
|
}
|
|
|
|
if !assets.IsValid(o.DestinationAsset) {
|
|
return errorsutils.NewWrappedError(errors.New("invalid order target asset"), ErrValidation)
|
|
}
|
|
|
|
if o.Type == ORDER_TYPE_UNKNOWN {
|
|
return errorsutils.NewWrappedError(errors.New("missing order type"), ErrValidation)
|
|
}
|
|
|
|
if o.Status == ORDER_STATUS_UNKNOWN {
|
|
return errorsutils.NewWrappedError(errors.New("missing order status"), ErrValidation)
|
|
}
|
|
|
|
if o.BaseQuantityOrdered == nil {
|
|
return errorsutils.NewWrappedError(errors.New("missing order base quantity ordered"), ErrValidation)
|
|
}
|
|
|
|
if o.Raw == nil {
|
|
return errorsutils.NewWrappedError(errors.New("missing order raw"), ErrValidation)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Order represents a trading order in Formance.
|
|
type Order struct {
|
|
// Unique Order ID generated from order information
|
|
ID OrderID `json:"id"`
|
|
|
|
// Related Connector ID
|
|
ConnectorID ConnectorID `json:"connectorID"`
|
|
|
|
// PSP order reference
|
|
Reference string `json:"reference"`
|
|
|
|
// Client-assigned order ID for exchange-side placement idempotency (not used for storage dedup).
|
|
ClientOrderID string `json:"clientOrderID,omitempty"`
|
|
|
|
// Order creation date
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// Last update date
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
// Order direction: BUY or SELL
|
|
Direction OrderDirection `json:"direction"`
|
|
|
|
// Source asset
|
|
SourceAsset string `json:"sourceAsset"`
|
|
|
|
// Target asset
|
|
DestinationAsset string `json:"destinationAsset"`
|
|
|
|
// Order type: MARKET or LIMIT
|
|
Type OrderType `json:"type"`
|
|
|
|
// Order status
|
|
Status OrderStatus `json:"status"`
|
|
|
|
// Base quantity ordered (using integer representation)
|
|
BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"`
|
|
|
|
// Base quantity filled (using integer representation)
|
|
BaseQuantityFilled *big.Int `json:"baseQuantityFilled"`
|
|
|
|
// Limit price for LIMIT orders (optional)
|
|
LimitPrice *big.Int `json:"limitPrice,omitempty"`
|
|
|
|
// Stop price for STOP_LIMIT orders (optional)
|
|
StopPrice *big.Int `json:"stopPrice,omitempty"`
|
|
|
|
// Time in force
|
|
TimeInForce TimeInForce `json:"timeInForce"`
|
|
|
|
// Expiration time for GTD orders
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
|
|
// Quote amount filled (in quote asset units, integer)
|
|
QuoteAmount *big.Int `json:"quoteAmount,omitempty"`
|
|
|
|
// Quote asset with precision (e.g. "USD/2")
|
|
QuoteAsset string `json:"quoteAsset,omitempty"`
|
|
|
|
// Fee charged (in quote currency, integer)
|
|
Fee *big.Int `json:"fee,omitempty"`
|
|
|
|
// Fee asset with precision (e.g. "USD/2")
|
|
FeeAsset *string `json:"feeAsset,omitempty"`
|
|
|
|
// Average fill price (analytics, integer at priceAsset precision)
|
|
AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"`
|
|
|
|
// Price asset with precision for interpreting price fields (analytics)
|
|
PriceAsset *string `json:"priceAsset,omitempty"`
|
|
|
|
// Account references (Formance AccountIDs built from wallet UUID + connectorID)
|
|
SourceAccountID *AccountID `json:"sourceAccountID"`
|
|
DestinationAccountID *AccountID `json:"destinationAccountID"`
|
|
|
|
// Additional metadata
|
|
Metadata map[string]string `json:"metadata"`
|
|
|
|
// Related adjustments (status changes)
|
|
Adjustments []OrderAdjustment `json:"adjustments"`
|
|
}
|
|
|
|
func (o Order) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(&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 OrderDirection `json:"direction"`
|
|
SourceAsset string `json:"sourceAsset"`
|
|
DestinationAsset string `json:"destinationAsset"`
|
|
Type OrderType `json:"type"`
|
|
Status OrderStatus `json:"status"`
|
|
BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"`
|
|
BaseQuantityFilled *big.Int `json:"baseQuantityFilled"`
|
|
LimitPrice *big.Int `json:"limitPrice,omitempty"`
|
|
StopPrice *big.Int `json:"stopPrice,omitempty"`
|
|
TimeInForce TimeInForce `json:"timeInForce"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
QuoteAmount *big.Int `json:"quoteAmount,omitempty"`
|
|
QuoteAsset string `json:"quoteAsset,omitempty"`
|
|
Fee *big.Int `json:"fee,omitempty"`
|
|
FeeAsset *string `json:"feeAsset,omitempty"`
|
|
AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"`
|
|
PriceAsset *string `json:"priceAsset,omitempty"`
|
|
SourceAccountID *string `json:"sourceAccountID"`
|
|
DestinationAccountID *string `json:"destinationAccountID"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
Adjustments []OrderAdjustment `json:"adjustments"`
|
|
}{
|
|
ID: o.ID.String(),
|
|
ConnectorID: o.ConnectorID.String(),
|
|
Provider: ToV3Provider(o.ConnectorID.Provider),
|
|
Reference: o.Reference,
|
|
ClientOrderID: o.ClientOrderID,
|
|
CreatedAt: o.CreatedAt,
|
|
UpdatedAt: o.UpdatedAt,
|
|
Direction: o.Direction,
|
|
SourceAsset: o.SourceAsset,
|
|
DestinationAsset: o.DestinationAsset,
|
|
Type: o.Type,
|
|
Status: o.Status,
|
|
BaseQuantityOrdered: o.BaseQuantityOrdered,
|
|
BaseQuantityFilled: o.BaseQuantityFilled,
|
|
LimitPrice: o.LimitPrice,
|
|
StopPrice: o.StopPrice,
|
|
TimeInForce: o.TimeInForce,
|
|
ExpiresAt: o.ExpiresAt,
|
|
QuoteAmount: o.QuoteAmount,
|
|
QuoteAsset: o.QuoteAsset,
|
|
Fee: o.Fee,
|
|
FeeAsset: o.FeeAsset,
|
|
AverageFillPrice: o.AverageFillPrice,
|
|
PriceAsset: o.PriceAsset,
|
|
SourceAccountID: o.SourceAccountID.StringPtr(),
|
|
DestinationAccountID: o.DestinationAccountID.StringPtr(),
|
|
Metadata: o.Metadata,
|
|
Adjustments: o.Adjustments,
|
|
})
|
|
}
|
|
|
|
func (o *Order) UnmarshalJSON(data []byte) error {
|
|
var aux struct {
|
|
ID string `json:"id"`
|
|
ConnectorID string `json:"connectorID"`
|
|
Reference string `json:"reference"`
|
|
ClientOrderID string `json:"clientOrderID,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Direction OrderDirection `json:"direction"`
|
|
SourceAsset string `json:"sourceAsset"`
|
|
DestinationAsset string `json:"destinationAsset"`
|
|
Type OrderType `json:"type"`
|
|
Status OrderStatus `json:"status"`
|
|
BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"`
|
|
BaseQuantityFilled *big.Int `json:"baseQuantityFilled"`
|
|
LimitPrice *big.Int `json:"limitPrice,omitempty"`
|
|
StopPrice *big.Int `json:"stopPrice,omitempty"`
|
|
TimeInForce TimeInForce `json:"timeInForce"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
QuoteAmount *big.Int `json:"quoteAmount,omitempty"`
|
|
QuoteAsset string `json:"quoteAsset,omitempty"`
|
|
Fee *big.Int `json:"fee,omitempty"`
|
|
FeeAsset *string `json:"feeAsset,omitempty"`
|
|
AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"`
|
|
PriceAsset *string `json:"priceAsset,omitempty"`
|
|
SourceAccountID *string `json:"sourceAccountID,omitempty"`
|
|
DestinationAccountID *string `json:"destinationAccountID,omitempty"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
Adjustments []OrderAdjustment `json:"adjustments"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := OrderIDFromString(aux.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
connectorID, err := ConnectorIDFromString(aux.ConnectorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
o.ID = id
|
|
o.ConnectorID = connectorID
|
|
o.Reference = aux.Reference
|
|
o.ClientOrderID = aux.ClientOrderID
|
|
o.CreatedAt = aux.CreatedAt
|
|
o.UpdatedAt = aux.UpdatedAt
|
|
o.Direction = aux.Direction
|
|
o.SourceAsset = aux.SourceAsset
|
|
o.DestinationAsset = aux.DestinationAsset
|
|
o.Type = aux.Type
|
|
o.Status = aux.Status
|
|
o.BaseQuantityOrdered = aux.BaseQuantityOrdered
|
|
o.BaseQuantityFilled = aux.BaseQuantityFilled
|
|
o.LimitPrice = aux.LimitPrice
|
|
o.StopPrice = aux.StopPrice
|
|
o.TimeInForce = aux.TimeInForce
|
|
o.ExpiresAt = aux.ExpiresAt
|
|
o.QuoteAmount = aux.QuoteAmount
|
|
o.QuoteAsset = aux.QuoteAsset
|
|
o.Fee = aux.Fee
|
|
o.FeeAsset = aux.FeeAsset
|
|
o.AverageFillPrice = aux.AverageFillPrice
|
|
o.PriceAsset = aux.PriceAsset
|
|
if aux.SourceAccountID != nil {
|
|
id, err := AccountIDFromString(*aux.SourceAccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.SourceAccountID = &id
|
|
} else {
|
|
o.SourceAccountID = nil
|
|
}
|
|
if aux.DestinationAccountID != nil {
|
|
id, err := AccountIDFromString(*aux.DestinationAccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.DestinationAccountID = &id
|
|
} else {
|
|
o.DestinationAccountID = nil
|
|
}
|
|
o.Metadata = aux.Metadata
|
|
o.Adjustments = aux.Adjustments
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToPSPOrder converts an Order to a PSPOrder for sending to the plugin
|
|
func ToPSPOrder(order *Order) PSPOrder {
|
|
return PSPOrder{
|
|
Reference: order.Reference,
|
|
CreatedAt: order.CreatedAt,
|
|
Direction: order.Direction,
|
|
SourceAsset: order.SourceAsset,
|
|
DestinationAsset: order.DestinationAsset,
|
|
Type: order.Type,
|
|
Status: order.Status,
|
|
BaseQuantityOrdered: order.BaseQuantityOrdered,
|
|
BaseQuantityFilled: order.BaseQuantityFilled,
|
|
LimitPrice: order.LimitPrice,
|
|
StopPrice: order.StopPrice,
|
|
TimeInForce: order.TimeInForce,
|
|
ExpiresAt: order.ExpiresAt,
|
|
QuoteAmount: order.QuoteAmount,
|
|
QuoteAsset: order.QuoteAsset,
|
|
Fee: order.Fee,
|
|
FeeAsset: order.FeeAsset,
|
|
AverageFillPrice: order.AverageFillPrice,
|
|
PriceAsset: order.PriceAsset,
|
|
SourceAccountReference: order.SourceAccountID.Ref(),
|
|
DestinationAccountReference: order.DestinationAccountID.Ref(),
|
|
Metadata: order.Metadata,
|
|
// Order itself does not carry the PSP raw payload — it lives on
|
|
// the adjustments list (each adjustment snapshots the PSP response
|
|
// at that moment). For round-trip purposes (Order → PSPOrder →
|
|
// Validate()), use the latest adjustment's Raw: it represents the
|
|
// order's current state.
|
|
Raw: latestAdjustmentRaw(order.Adjustments),
|
|
}
|
|
}
|
|
|
|
// latestAdjustmentRaw returns the Raw payload of the most recent
|
|
// adjustment by CreatedAt. FromPSPOrderToOrder appends at least one
|
|
// adjustment on every PSP observation, so a well-formed Order has a
|
|
// non-empty list.
|
|
func latestAdjustmentRaw(adjustments []OrderAdjustment) json.RawMessage {
|
|
if len(adjustments) == 0 {
|
|
return nil
|
|
}
|
|
latest := adjustments[0]
|
|
for _, a := range adjustments[1:] {
|
|
if !a.CreatedAt.Before(latest.CreatedAt) {
|
|
latest = a
|
|
}
|
|
}
|
|
return latest.Raw
|
|
}
|
|
|
|
// FromPSPOrderToOrder converts a PSPOrder to an Order.
|
|
// observedAt should be workflow.Now() in production (Temporal deterministic clock).
|
|
func FromPSPOrderToOrder(from PSPOrder, connectorID ConnectorID, observedAt time.Time) (Order, error) {
|
|
if err := from.Validate(); err != nil {
|
|
return Order{}, err
|
|
}
|
|
|
|
o := Order{
|
|
ID: OrderID{
|
|
Reference: from.Reference,
|
|
ConnectorID: connectorID,
|
|
},
|
|
ConnectorID: connectorID,
|
|
Reference: from.Reference,
|
|
ClientOrderID: from.ClientOrderID,
|
|
CreatedAt: from.CreatedAt,
|
|
UpdatedAt: observedAt,
|
|
Direction: from.Direction,
|
|
SourceAsset: from.SourceAsset,
|
|
DestinationAsset: from.DestinationAsset,
|
|
Type: from.Type,
|
|
Status: from.Status,
|
|
BaseQuantityOrdered: from.BaseQuantityOrdered,
|
|
BaseQuantityFilled: from.BaseQuantityFilled,
|
|
LimitPrice: from.LimitPrice,
|
|
StopPrice: from.StopPrice,
|
|
TimeInForce: from.TimeInForce,
|
|
ExpiresAt: from.ExpiresAt,
|
|
QuoteAmount: from.QuoteAmount,
|
|
QuoteAsset: from.QuoteAsset,
|
|
Fee: from.Fee,
|
|
FeeAsset: from.FeeAsset,
|
|
AverageFillPrice: from.AverageFillPrice,
|
|
PriceAsset: from.PriceAsset,
|
|
SourceAccountID: NewAccountID(from.SourceAccountReference, connectorID),
|
|
DestinationAccountID: NewAccountID(from.DestinationAccountReference, connectorID),
|
|
Metadata: from.Metadata,
|
|
}
|
|
|
|
o.Adjustments = append(o.Adjustments, FromPSPOrderToOrderAdjustment(from, connectorID, observedAt))
|
|
|
|
return o, nil
|
|
}
|
|
|
|
// FromPSPOrders converts a slice of PSPOrders to Orders.
|
|
// observedAt should be workflow.Now() in production (Temporal deterministic clock).
|
|
//
|
|
// When the same OrderID appears more than once (e.g. multiple lifecycle events
|
|
// for one order in a single batch), only the entry with the most recent
|
|
// UpdatedAt is kept. Last-seen wins on a tie, which preserves chronological
|
|
// order within a same-observedAt batch. The discarded entry's adjustments are
|
|
// prepended to the newer record so the full lifecycle is recorded without
|
|
// triggering a duplicate-row conflict on upsert.
|
|
func FromPSPOrders(from []PSPOrder, connectorID ConnectorID, observedAt time.Time) ([]Order, error) {
|
|
// indexByID maps an order ID to its position in orders so duplicates can be
|
|
// merged in place. orders preserves first-seen input order — ranging over a
|
|
// map to build the result would make the output order non-deterministic.
|
|
indexByID := make(map[OrderID]int, len(from))
|
|
orders := make([]Order, 0, len(from))
|
|
for _, o := range from {
|
|
order, err := FromPSPOrderToOrder(o, connectorID, observedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx, dup := indexByID[order.ID]
|
|
if !dup {
|
|
indexByID[order.ID] = len(orders)
|
|
orders = append(orders, order)
|
|
continue
|
|
}
|
|
existing := orders[idx]
|
|
var newest, oldest Order
|
|
if existing.CreatedAt.After(order.CreatedAt) {
|
|
newest, oldest = existing, order
|
|
} else {
|
|
newest, oldest = order, existing
|
|
}
|
|
// FromPSPOrderToOrder always creates a single order with a single adjustment
|
|
// after combining we want to keep the older record as the first adjustment
|
|
newest.Adjustments = append(oldest.Adjustments, newest.Adjustments...)
|
|
orders[idx] = newest
|
|
}
|
|
|
|
return orders, nil
|
|
}
|
|
|
|
// OrderAdjustment represents a status change or update to an order
|
|
type OrderAdjustment struct {
|
|
// Unique adjustment ID
|
|
ID OrderAdjustmentID `json:"id"`
|
|
|
|
// Reference from PSP
|
|
Reference string `json:"reference"`
|
|
|
|
// Adjustment creation time
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// Status at this adjustment
|
|
Status OrderStatus `json:"status"`
|
|
|
|
// Base quantity filled at this point
|
|
BaseQuantityFilled *big.Int `json:"baseQuantityFilled,omitempty"`
|
|
|
|
// Fee at this point
|
|
Fee *big.Int `json:"fee,omitempty"`
|
|
|
|
// Fee asset
|
|
FeeAsset *string `json:"feeAsset,omitempty"`
|
|
|
|
// Additional metadata
|
|
Metadata map[string]string `json:"metadata"`
|
|
|
|
// Raw PSP response
|
|
Raw json.RawMessage `json:"raw"`
|
|
}
|
|
|
|
func (oa *OrderAdjustment) IdempotencyKey() string {
|
|
return IdempotencyKey(oa.ID)
|
|
}
|
|
|
|
func (oa OrderAdjustment) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(&struct {
|
|
ID string `json:"id"`
|
|
Reference string `json:"reference"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Status OrderStatus `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"`
|
|
Raw json.RawMessage `json:"raw"`
|
|
}{
|
|
ID: oa.ID.String(),
|
|
Reference: oa.Reference,
|
|
CreatedAt: oa.CreatedAt,
|
|
Status: oa.Status,
|
|
BaseQuantityFilled: oa.BaseQuantityFilled,
|
|
Fee: oa.Fee,
|
|
FeeAsset: oa.FeeAsset,
|
|
Metadata: oa.Metadata,
|
|
Raw: oa.Raw,
|
|
})
|
|
}
|
|
|
|
func (oa *OrderAdjustment) UnmarshalJSON(data []byte) error {
|
|
var aux struct {
|
|
ID string `json:"id"`
|
|
Reference string `json:"reference"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Status OrderStatus `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"`
|
|
Raw json.RawMessage `json:"raw"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := OrderAdjustmentIDFromString(aux.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
oa.ID = id
|
|
oa.Reference = aux.Reference
|
|
oa.CreatedAt = aux.CreatedAt
|
|
oa.Status = aux.Status
|
|
oa.BaseQuantityFilled = aux.BaseQuantityFilled
|
|
oa.Fee = aux.Fee
|
|
oa.FeeAsset = aux.FeeAsset
|
|
oa.Metadata = aux.Metadata
|
|
oa.Raw = aux.Raw
|
|
|
|
return nil
|
|
}
|
|
|
|
// OrderAdjustmentID uniquely identifies an order adjustment.
|
|
// CreatedAt is intentionally excluded — it represents observation time (variable),
|
|
// not identity. Mutable fields (BaseQuantityFilled, Fee, FeeAsset) are included
|
|
// so that same-status changes (e.g., PARTIALLY_FILLED at different fill levels)
|
|
// produce distinct adjustments.
|
|
type OrderAdjustmentID struct {
|
|
OrderID OrderID
|
|
Reference string
|
|
Status OrderStatus
|
|
BaseQuantityFilled *big.Int
|
|
Fee *big.Int
|
|
FeeAsset *string
|
|
}
|
|
|
|
func (oaid OrderAdjustmentID) String() string {
|
|
data, err := canonicaljson.Marshal(oaid)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)
|
|
}
|
|
|
|
func OrderAdjustmentIDFromString(value string) (OrderAdjustmentID, error) {
|
|
ret := OrderAdjustmentID{}
|
|
data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(value)
|
|
if err != nil {
|
|
return ret, err
|
|
}
|
|
err = canonicaljson.Unmarshal(data, &ret)
|
|
if err != nil {
|
|
return ret, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (oaid OrderAdjustmentID) Value() (driver.Value, error) {
|
|
return oaid.String(), nil
|
|
}
|
|
|
|
func (oaid *OrderAdjustmentID) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return errors.New("order adjustment id is nil")
|
|
}
|
|
|
|
if s, err := driver.String.ConvertValue(value); err == nil {
|
|
if v, ok := s.(string); ok {
|
|
id, err := OrderAdjustmentIDFromString(v)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse order adjustment id %s: %v", v, err)
|
|
}
|
|
*oaid = id
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("failed to scan order adjustment id: %v", value)
|
|
}
|
|
|
|
// FromPSPOrderToOrderAdjustment creates an OrderAdjustment from a PSPOrder.
|
|
// observedAt should be workflow.Now() in production (Temporal deterministic clock).
|
|
func FromPSPOrderToOrderAdjustment(from PSPOrder, connectorID ConnectorID, observedAt time.Time) OrderAdjustment {
|
|
orderID := OrderID{
|
|
Reference: from.Reference,
|
|
ConnectorID: connectorID,
|
|
}
|
|
|
|
return OrderAdjustment{
|
|
ID: OrderAdjustmentID{
|
|
OrderID: orderID,
|
|
Reference: from.Reference,
|
|
Status: from.Status,
|
|
BaseQuantityFilled: from.BaseQuantityFilled,
|
|
Fee: from.Fee,
|
|
FeeAsset: from.FeeAsset,
|
|
},
|
|
Reference: from.Reference,
|
|
CreatedAt: observedAt,
|
|
Status: from.Status,
|
|
BaseQuantityFilled: from.BaseQuantityFilled,
|
|
Fee: from.Fee,
|
|
FeeAsset: from.FeeAsset,
|
|
Metadata: from.Metadata,
|
|
Raw: from.Raw,
|
|
}
|
|
}
|
|
|
|
// OrderExpanded includes order with its current status
|
|
type OrderExpanded struct {
|
|
Order Order
|
|
Status OrderStatus
|
|
Error error
|
|
}
|
|
|
|
func (oe OrderExpanded) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(&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 OrderDirection `json:"direction"`
|
|
SourceAsset string `json:"sourceAsset"`
|
|
DestinationAsset string `json:"destinationAsset"`
|
|
Type OrderType `json:"type"`
|
|
Status string `json:"status"`
|
|
BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"`
|
|
BaseQuantityFilled *big.Int `json:"baseQuantityFilled"`
|
|
LimitPrice *big.Int `json:"limitPrice,omitempty"`
|
|
StopPrice *big.Int `json:"stopPrice,omitempty"`
|
|
TimeInForce TimeInForce `json:"timeInForce"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
QuoteAmount *big.Int `json:"quoteAmount,omitempty"`
|
|
QuoteAsset string `json:"quoteAsset,omitempty"`
|
|
Fee *big.Int `json:"fee,omitempty"`
|
|
FeeAsset *string `json:"feeAsset,omitempty"`
|
|
AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"`
|
|
PriceAsset *string `json:"priceAsset,omitempty"`
|
|
SourceAccountID *string `json:"sourceAccountID"`
|
|
DestinationAccountID *string `json:"destinationAccountID"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
Adjustments []OrderAdjustment `json:"adjustments"`
|
|
Error *string `json:"error,omitempty"`
|
|
}{
|
|
ID: oe.Order.ID.String(),
|
|
ConnectorID: oe.Order.ConnectorID.String(),
|
|
Provider: ToV3Provider(oe.Order.ConnectorID.Provider),
|
|
Reference: oe.Order.Reference,
|
|
ClientOrderID: oe.Order.ClientOrderID,
|
|
CreatedAt: oe.Order.CreatedAt,
|
|
UpdatedAt: oe.Order.UpdatedAt,
|
|
Direction: oe.Order.Direction,
|
|
SourceAsset: oe.Order.SourceAsset,
|
|
DestinationAsset: oe.Order.DestinationAsset,
|
|
Type: oe.Order.Type,
|
|
Status: oe.Status.String(),
|
|
BaseQuantityOrdered: oe.Order.BaseQuantityOrdered,
|
|
BaseQuantityFilled: oe.Order.BaseQuantityFilled,
|
|
LimitPrice: oe.Order.LimitPrice,
|
|
StopPrice: oe.Order.StopPrice,
|
|
TimeInForce: oe.Order.TimeInForce,
|
|
ExpiresAt: oe.Order.ExpiresAt,
|
|
QuoteAmount: oe.Order.QuoteAmount,
|
|
QuoteAsset: oe.Order.QuoteAsset,
|
|
Fee: oe.Order.Fee,
|
|
FeeAsset: oe.Order.FeeAsset,
|
|
AverageFillPrice: oe.Order.AverageFillPrice,
|
|
PriceAsset: oe.Order.PriceAsset,
|
|
SourceAccountID: oe.Order.SourceAccountID.StringPtr(),
|
|
DestinationAccountID: oe.Order.DestinationAccountID.StringPtr(),
|
|
Metadata: oe.Order.Metadata,
|
|
Adjustments: oe.Order.Adjustments,
|
|
Error: func() *string {
|
|
if oe.Error == nil {
|
|
return nil
|
|
}
|
|
return pointer.For(oe.Error.Error())
|
|
}(),
|
|
})
|
|
}
|