mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
* fix(coinbaseprime): avoid refetching payments on every account fetch TASK_FETCH_PAYMENTS was a child of TASK_FETCH_ACCOUNTS, so payments were re-fetched from scratch on every account sync cycle. Moving it to a top-level sibling task makes it run independently on its own schedule instead of being triggered by every account fetch. Backport of fix from PR #657 (fd62842). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(coinbaseprime): fix cursor reset causing full refetch on every cycle Without advanceCursor, when Coinbase returns an empty next_cursor at end-of-pagination the state was reset to an empty cursor, causing the next run to refetch all transactions from the beginning. - Extract paymentsState into shared incrementalState in state.go - Introduce advanceCursor: keep the previous cursor when next_cursor is empty so the pagination marker is never wiped Backport of fix from PR #657 (fd62842). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * test(coinbaseprime): add unit tests for advanceCursor Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]>
267 lines
7.8 KiB
Go
267 lines
7.8 KiB
Go
package coinbaseprime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/formancehq/go-libs/v3/currency"
|
|
"github.com/formancehq/payments/ee/plugins/coinbaseprime/client"
|
|
"github.com/formancehq/payments/internal/models"
|
|
)
|
|
|
|
type TransferEndpointType string
|
|
|
|
const (
|
|
// CoinbasePrime transfer types
|
|
transferTypeWallet TransferEndpointType = "WALLET"
|
|
transferTypePaymentMethod TransferEndpointType = "PAYMENT_METHOD"
|
|
transferTypeAddress TransferEndpointType = "ADDRESS"
|
|
transferTypeOther TransferEndpointType = "OTHER"
|
|
transferTypeMultipleAddress TransferEndpointType = "MULTIPLE_ADDRESSES"
|
|
transferTypeCounterpartyID TransferEndpointType = "COUNTERPARTY_ID"
|
|
)
|
|
|
|
func (p *Plugin) fetchNextPayments(ctx context.Context, req models.FetchNextPaymentsRequest) (models.FetchNextPaymentsResponse, error) {
|
|
var oldState incrementalState
|
|
if req.State != nil {
|
|
if err := json.Unmarshal(req.State, &oldState); err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
}
|
|
|
|
response, err := p.client.GetTransactions(ctx, oldState.Cursor, req.PageSize)
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
|
|
payments := make([]models.PSPPayment, 0, len(response.Transactions))
|
|
for _, tx := range response.Transactions {
|
|
payment, err := p.transactionToPayment(tx)
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, fmt.Errorf("failed to convert transaction %s: %w", tx.ID, err)
|
|
}
|
|
if payment == nil {
|
|
continue
|
|
}
|
|
payments = append(payments, *payment)
|
|
}
|
|
|
|
newState := incrementalState{Cursor: advanceCursor(oldState.Cursor, response.Pagination.NextCursor)}
|
|
payload, err := json.Marshal(newState)
|
|
if err != nil {
|
|
return models.FetchNextPaymentsResponse{}, err
|
|
}
|
|
|
|
return models.FetchNextPaymentsResponse{
|
|
Payments: payments,
|
|
NewState: payload,
|
|
HasMore: response.Pagination.HasNext,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) transactionToPayment(tx client.Transaction) (*models.PSPPayment, error) {
|
|
asset, precision, ok := p.resolveAssetAndPrecision(tx.Symbol)
|
|
if !ok {
|
|
p.logger.Infof("skipping transaction %s: unsupported currency %q", tx.ID, tx.Symbol)
|
|
return nil, nil
|
|
}
|
|
|
|
raw, err := json.Marshal(tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
paymentType := transactionTypeToPaymentType(tx.Type)
|
|
status := transactionStatusToPaymentStatus(tx.Status)
|
|
|
|
amount, err := currency.GetAmountWithPrecisionFromString(tx.Amount, precision)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse amount: %w", err)
|
|
}
|
|
|
|
metadata := buildTransactionMetadata(tx)
|
|
|
|
payment := models.PSPPayment{
|
|
Reference: tx.ID,
|
|
CreatedAt: tx.CreatedAt,
|
|
Type: paymentType,
|
|
Amount: amount,
|
|
Asset: asset,
|
|
Scheme: models.PAYMENT_SCHEME_OTHER,
|
|
Status: status,
|
|
Metadata: metadata,
|
|
Raw: raw,
|
|
}
|
|
|
|
sourceAccountReference, destinationAccountReference := resolveAccountReferences(tx)
|
|
payment.SourceAccountReference = sourceAccountReference
|
|
payment.DestinationAccountReference = destinationAccountReference
|
|
|
|
return &payment, nil
|
|
}
|
|
|
|
func (p *Plugin) resolveAssetAndPrecision(symbol string) (string, int, bool) {
|
|
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
|
|
|
// Resolve network-scoped symbols (e.g. "BASEUSDC") to their base symbol ("USDC")
|
|
if base, ok := p.networkSymbols[symbol]; ok {
|
|
symbol = base
|
|
}
|
|
|
|
precision, err := currency.GetPrecision(p.currencies, symbol)
|
|
if err != nil {
|
|
return "", 0, false
|
|
}
|
|
|
|
asset := currency.FormatAsset(p.currencies, symbol)
|
|
return asset, precision, true
|
|
}
|
|
|
|
func buildTransactionMetadata(tx client.Transaction) map[string]string {
|
|
metadata := make(map[string]string)
|
|
|
|
// Core identifiers
|
|
if tx.WalletID != "" {
|
|
metadata["wallet_id"] = tx.WalletID
|
|
}
|
|
if tx.PortfolioID != "" {
|
|
metadata["portfolio_id"] = tx.PortfolioID
|
|
}
|
|
metadata["type"] = tx.Type
|
|
metadata["status"] = tx.Status
|
|
|
|
// Fees
|
|
hasFees := (tx.Fees != "" && tx.Fees != "0") || (tx.NetworkFees != "" && tx.NetworkFees != "0")
|
|
if tx.Fees != "" && tx.Fees != "0" {
|
|
metadata["fees"] = tx.Fees
|
|
}
|
|
if tx.FeeSymbol != "" && hasFees {
|
|
metadata["fee_symbol"] = tx.FeeSymbol
|
|
}
|
|
if tx.NetworkFees != "" && tx.NetworkFees != "0" {
|
|
metadata["network_fees"] = tx.NetworkFees
|
|
}
|
|
|
|
// Network & blockchain
|
|
if tx.Network != "" {
|
|
metadata["network"] = tx.Network
|
|
}
|
|
if len(tx.BlockchainIDs) > 0 {
|
|
metadata["blockchain_ids"] = strings.Join(tx.BlockchainIDs, ",")
|
|
}
|
|
|
|
// Timestamps
|
|
if tx.CompletedAt != nil {
|
|
metadata["completed_at"] = tx.CompletedAt.Format(time.RFC3339)
|
|
}
|
|
|
|
// Addresses
|
|
sourceAddress := ""
|
|
if tx.TransferFrom != nil && tx.TransferFrom.Address != "" {
|
|
sourceAddress = tx.TransferFrom.Address
|
|
} else if tx.SourceAddress != "" {
|
|
sourceAddress = tx.SourceAddress
|
|
}
|
|
if sourceAddress != "" {
|
|
metadata["source_address"] = sourceAddress
|
|
}
|
|
|
|
depositAddress := ""
|
|
if tx.TransferTo != nil && tx.TransferTo.Address != "" {
|
|
depositAddress = tx.TransferTo.Address
|
|
} else if tx.DepositAddress != "" {
|
|
depositAddress = tx.DepositAddress
|
|
}
|
|
if depositAddress != "" {
|
|
metadata["deposit_address"] = depositAddress
|
|
}
|
|
|
|
if tx.ExternalTxID != "" {
|
|
metadata["external_tx_id"] = tx.ExternalTxID
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
func isTransferType(t string, expected TransferEndpointType) bool {
|
|
return strings.ToUpper(t) == string(expected)
|
|
}
|
|
|
|
func resolveAccountReferences(tx client.Transaction) (*string, *string) {
|
|
var source, dest *string
|
|
|
|
// Use transfer_from/transfer_to only when type is WALLET
|
|
if tx.TransferFrom != nil && tx.TransferFrom.Value != "" && isTransferType(tx.TransferFrom.Type, transferTypeWallet) {
|
|
v := tx.TransferFrom.Value
|
|
source = &v
|
|
}
|
|
if tx.TransferTo != nil && tx.TransferTo.Value != "" && isTransferType(tx.TransferTo.Type, transferTypeWallet) {
|
|
v := tx.TransferTo.Value
|
|
dest = &v
|
|
}
|
|
|
|
// Fallback to walletID based on payment direction
|
|
if tx.WalletID != "" {
|
|
switch transactionTypeToPaymentType(tx.Type) {
|
|
case models.PAYMENT_TYPE_PAYIN:
|
|
if dest == nil {
|
|
v := tx.WalletID
|
|
dest = &v
|
|
}
|
|
case models.PAYMENT_TYPE_PAYOUT:
|
|
if source == nil {
|
|
v := tx.WalletID
|
|
source = &v
|
|
}
|
|
}
|
|
}
|
|
|
|
return source, dest
|
|
}
|
|
|
|
func transactionTypeToPaymentType(txType string) models.PaymentType {
|
|
switch strings.ToUpper(txType) {
|
|
case "DEPOSIT",
|
|
"COINBASE_DEPOSIT", "COINBASE_REFUND", "REWARD",
|
|
"DEPOSIT_ADJUSTMENT", "CLAIM_REWARDS":
|
|
return models.PAYMENT_TYPE_PAYIN
|
|
case "WITHDRAWAL", "SWEEP_WITHDRAWAL",
|
|
"PROXY_WITHDRAWAL", "BILLING_WITHDRAWAL", "WITHDRAWAL_ADJUSTMENT", "SLASH":
|
|
return models.PAYMENT_TYPE_PAYOUT
|
|
case "CONVERSION", "INTERNAL_DEPOSIT", "INTERNAL_WITHDRAWAL", "SWEEP_DEPOSIT", "PROXY_DEPOSIT",
|
|
"STAKE", "RESTAKE", "PORTFOLIO_STAKE", "UNSTAKE", "PORTFOLIO_UNSTAKE":
|
|
return models.PAYMENT_TYPE_TRANSFER
|
|
case "KEY_REGISTRATION", "COMPLETE_UNBONDING", "WITHDRAW_UNBONDED", "STAKE_ACCOUNT_CREATE", "CHANGE_VALIDATOR",
|
|
"DELEGATION", "UNDELEGATION", "REMOVE_AUTHORIZED_PARTY", "STAKE_AUTHORIZE_WITH_SEED", "VOTE_AUTHORIZE", "ONCHAIN_TRANSACTION":
|
|
return models.PAYMENT_TYPE_OTHER
|
|
default:
|
|
return models.PAYMENT_TYPE_OTHER
|
|
}
|
|
}
|
|
|
|
func transactionStatusToPaymentStatus(status string) models.PaymentStatus {
|
|
switch strings.ToUpper(status) {
|
|
case "TRANSACTION_PENDING", "TRANSACTION_CREATED", "TRANSACTION_REQUESTED",
|
|
"TRANSACTION_APPROVED", "TRANSACTION_GASSING", "TRANSACTION_GASSED",
|
|
"TRANSACTION_PROVISIONED", "TRANSACTION_PLANNED", "TRANSACTION_PROCESSING",
|
|
"TRANSACTION_RESTORED", "TRANSACTION_IMPORT_PENDING", "TRANSACTION_DELAYED",
|
|
"TRANSACTION_BROADCASTING", "TRANSACTION_CONSTRUCTED":
|
|
return models.PAYMENT_STATUS_PENDING
|
|
case "TRANSACTION_DONE", "TRANSACTION_IMPORTED":
|
|
return models.PAYMENT_STATUS_SUCCEEDED
|
|
case "TRANSACTION_CANCELLED":
|
|
return models.PAYMENT_STATUS_CANCELLED
|
|
case "TRANSACTION_EXPIRED":
|
|
return models.PAYMENT_STATUS_EXPIRED
|
|
case "TRANSACTION_FAILED", "TRANSACTION_REJECTED":
|
|
return models.PAYMENT_STATUS_FAILED
|
|
case "OTHER_TRANSACTION_STATUS":
|
|
return models.PAYMENT_STATUS_OTHER
|
|
default:
|
|
return models.PAYMENT_STATUS_UNKNOWN
|
|
}
|
|
}
|