Skip to content

Commit

Permalink
Merge pull request #122 from formancehq/feat/currency-cloud-transfer-…
Browse files Browse the repository at this point in the history
…payouts

feat(currencycloud): add transfer/payout creation
  • Loading branch information
paul-nicolas authored Oct 14, 2024
2 parents 7a07fdb + d3fee8b commit 165d61d
Show file tree
Hide file tree
Showing 12 changed files with 769 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type Client interface {
GetBeneficiaries(ctx context.Context, page int, pageSize int) ([]*Beneficiary, int, error)
GetContactID(ctx context.Context, accountID string) (*Contact, error)
GetTransactions(ctx context.Context, page int, pageSize int, updatedAtFrom time.Time) ([]Transaction, int, error)
InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error)
InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error)
}

type apiTransport struct {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http"
"net/url"
"strings"

"github.com/formancehq/payments/internal/models"
)

type Contact struct {
Expand Down Expand Up @@ -43,7 +45,8 @@ func (c *client) GetContactID(ctx context.Context, accountID string) (*Contact,
}

if len(res.Contacts) == 0 {
return nil, fmt.Errorf("no contact found for account %s", accountID)
return nil, fmt.Errorf("no contact found for account %s: %w", accountID, models.ErrInvalidRequest)
}

return res.Contacts[0], nil
}
81 changes: 81 additions & 0 deletions internal/connectors/plugins/public/currencycloud/client/payouts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)

type PayoutRequest struct {
OnBehalfOf string `json:"on_behalf_of"`
BeneficiaryID string `json:"beneficiary_id"`
Currency string `json:"currency"`
Amount json.Number `json:"amount"`
Reference string `json:"reference"`
UniqueRequestID string `json:"unique_request_id"`
}

func (pr *PayoutRequest) ToFormData() url.Values {
form := url.Values{}
form.Set("on_behalf_of", pr.OnBehalfOf)
form.Set("beneficiary_id", pr.BeneficiaryID)
form.Set("currency", pr.Currency)
form.Set("amount", pr.Amount.String())
form.Set("reference", pr.Reference)
if pr.UniqueRequestID != "" {
form.Set("unique_request_id", pr.UniqueRequestID)
}

return form
}

type PayoutResponse struct {
ID string `json:"id"`
Amount json.Number `json:"amount"`
BeneficiaryID string `json:"beneficiary_id"`
Currency string `json:"currency"`
Reference string `json:"reference"`
Status string `json:"status"`
Reason string `json:"reason"`
CreatorContactID string `json:"creator_contact_id"`
PaymentType string `json:"payment_type"`
TransferredAt string `json:"transferred_at"`
PaymentDate string `json:"payment_date"`
FailureReason string `json:"failure_reason"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UniqueRequestID string `json:"unique_request_id"`
}

func (c *client) InitiatePayout(ctx context.Context, payoutRequest *PayoutRequest) (*PayoutResponse, error) {
// TODO(polo): metrics
// f := connectors.ClientMetrics(ctx, "currencycloud", "initiate_payout")
// now := time.Now()
// defer f(ctx, now)

if err := c.ensureLogin(ctx); err != nil {
return nil, err
}

form := payoutRequest.ToFormData()

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.buildEndpoint("v2/payments/create"), strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

var payoutResponse PayoutResponse
var errRes currencyCloudError
_, err = c.httpClient.Do(req, &payoutResponse, &errRes)
if err != nil {
return nil, fmt.Errorf("failed to create payout: %w, %w", err, errRes.Error())
}

return &payoutResponse, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package client

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)

type TransferRequest struct {
SourceAccountID string `json:"source_account_id"`
DestinationAccountID string `json:"destination_account_id"`
Currency string `json:"currency"`
Amount json.Number `json:"amount"`
Reason string `json:"reason,omitempty"`
UniqueRequestID string `json:"unique_request_id,omitempty"`
}

func (tr *TransferRequest) ToFormData() url.Values {
form := url.Values{}
form.Set("source_account_id", tr.SourceAccountID)
form.Set("destination_account_id", tr.DestinationAccountID)
form.Set("currency", tr.Currency)
form.Set("amount", fmt.Sprintf("%v", tr.Amount))
if tr.Reason != "" {
form.Set("reason", tr.Reason)
}
if tr.UniqueRequestID != "" {
form.Set("unique_request_id", tr.UniqueRequestID)
}

return form
}

type TransferResponse struct {
ID string `json:"id"`
ShortReference string `json:"short_reference"`
SourceAccountID string `json:"source_account_id"`
DestinationAccountID string `json:"destination_account_id"`
Currency string `json:"currency"`
Amount json.Number `json:"amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt time.Time `json:"completed_at"`
CreatorAccountID string `json:"creator_account_id"`
CreatorContactID string `json:"creator_contact_id"`
Reason string `json:"reason"`
UniqueRequestID string `json:"unique_request_id"`
}

func (c *client) InitiateTransfer(ctx context.Context, transferRequest *TransferRequest) (*TransferResponse, error) {
// TODO(polo): metrics
// f := connectors.ClientMetrics(ctx, "currencycloud", "initiate_transfer")
// now := time.Now()
// defer f(ctx, now)

if err := c.ensureLogin(ctx); err != nil {
return nil, err
}

form := transferRequest.ToFormData()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.buildEndpoint("v2/transfers/create"), strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

var res TransferResponse
var errRes currencyCloudError
_, err = c.httpClient.Do(req, &res, &errRes)
if err != nil {
return nil, fmt.Errorf("failed to create transfer: %w, %w", err, errRes.Error())
}

return &res, nil
}
4 changes: 3 additions & 1 deletion internal/connectors/plugins/public/currencycloud/payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ func matchTransactionStatus(transactionStatus string) models.PaymentStatus {
switch transactionStatus {
case "completed":
return models.PAYMENT_STATUS_SUCCEEDED
case "pending":
case "pending", "ready_to_send":
return models.PAYMENT_STATUS_PENDING
case "deleted":
return models.PAYMENT_STATUS_FAILED
case "cancelled":
return models.PAYMENT_STATUS_CANCELLED
}
return models.PAYMENT_STATUS_OTHER
}
88 changes: 88 additions & 0 deletions internal/connectors/plugins/public/currencycloud/payouts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package currencycloud

import (
"context"
"encoding/json"
"fmt"

"github.com/formancehq/payments/internal/connectors/plugins/currency"
"github.com/formancehq/payments/internal/connectors/plugins/public/currencycloud/client"
"github.com/formancehq/payments/internal/models"
)

func (p *Plugin) validatePayoutRequest(pi models.PSPPaymentInitiation) error {
if pi.SourceAccount == nil {
return fmt.Errorf("source account is required: %w", models.ErrInvalidRequest)
}

if pi.DestinationAccount == nil {
return fmt.Errorf("destination account is required: %w", models.ErrInvalidRequest)
}

return nil
}

func (p *Plugin) createPayout(ctx context.Context, pi models.PSPPaymentInitiation) (models.PSPPayment, error) {
if err := p.validatePayoutRequest(pi); err != nil {
return models.PSPPayment{}, err
}

curr, precision, err := currency.GetCurrencyAndPrecisionFromAsset(supportedCurrenciesWithDecimal, pi.Asset)
if err != nil {
return models.PSPPayment{}, fmt.Errorf("failed to get currency and precision from asset: %v: %w", err, models.ErrInvalidRequest)
}

amount, err := currency.GetStringAmountFromBigIntWithPrecision(pi.Amount, precision)
if err != nil {
return models.PSPPayment{}, fmt.Errorf("failed to get string amount from big int: %v: %w", err, models.ErrInvalidRequest)
}

contact, err := p.client.GetContactID(ctx, pi.SourceAccount.Reference)
if err != nil {
return models.PSPPayment{}, err
}

resp, err := p.client.InitiatePayout(ctx, &client.PayoutRequest{
OnBehalfOf: contact.ID,
BeneficiaryID: pi.DestinationAccount.Reference,
Currency: curr,
Amount: json.Number(amount),
Reference: pi.Description,
UniqueRequestID: pi.Reference,
})
if err != nil {
return models.PSPPayment{}, err
}

return translatePayoutToPayment(resp, pi.SourceAccount.Reference)
}

func translatePayoutToPayment(from *client.PayoutResponse, sourceAccountReference string) (models.PSPPayment, error) {
raw, err := json.Marshal(from)
if err != nil {
return models.PSPPayment{}, err
}

precision, ok := supportedCurrenciesWithDecimal[from.Currency]
if !ok {
return models.PSPPayment{}, nil
}

amount, err := currency.GetAmountWithPrecisionFromString(from.Amount.String(), precision)
if err != nil {
return models.PSPPayment{}, err
}

return models.PSPPayment{
Reference: from.ID,
CreatedAt: from.CreatedAt,
Type: models.PAYMENT_TYPE_PAYOUT,
Amount: amount,
Asset: currency.FormatAsset(supportedCurrenciesWithDecimal, from.Currency),
Scheme: models.PAYMENT_SCHEME_OTHER,
Status: matchTransactionStatus(from.Status),
SourceAccountReference: &sourceAccountReference,
DestinationAccountReference: &from.BeneficiaryID,
Raw: raw,
}, nil
}
Loading

0 comments on commit 165d61d

Please sign in to comment.