Connect customer quotations and payment execution to Sendico (#50)
This commit is contained in:
1 parent
5d67c25c8e
commit
7a6510d1a8
45 files changed
+2696
-45
No files matched your search
@@ -0,0 +1,110 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Accounts exposes only dedicated, organization-owned customer ledgers tagged by
|
||||
// trusted platform provisioning. The integration login's personal/pooled ledgers
|
||||
// never become customer balances. Method bindings are provisioned on that ledger.
|
||||
func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Account, error) {
|
||||
if !validRef(subject) {
|
||||
return nil, payments.Unavailable
|
||||
}
|
||||
var currencies struct {
|
||||
Rows []currencyDTO `json:"currencies"`
|
||||
}
|
||||
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, ¤cies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scales := map[string]int{}
|
||||
for _, row := range currencies.Rows {
|
||||
if row.Scale != nil && *row.Scale >= 0 && *row.Scale <= 8 {
|
||||
scales[row.Code] = *row.Scale
|
||||
}
|
||||
}
|
||||
var data struct {
|
||||
Accounts []accountDTO `json:"accounts"`
|
||||
}
|
||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts := []payments.Account{}
|
||||
for _, row := range data.Accounts {
|
||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||
continue
|
||||
}
|
||||
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
||||
return nil, payments.Unavailable
|
||||
}
|
||||
scale, ok := scales[row.Currency]
|
||||
if !ok {
|
||||
return nil, payments.Unsupported
|
||||
}
|
||||
var balance struct {
|
||||
Value balanceDTO `json:"balance"`
|
||||
}
|
||||
if err := c.paymentRequest(ctx, http.MethodGet, root+"/"+row.ID+"/balance", nil, &balance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := mapAccount(row, balance.Value, scale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
func (c *Client) Quote(ctx context.Context, subject string, r payments.Request, accounts []payments.Account) (payments.Quote, error) {
|
||||
mapped, err := mapIntent(subject, r, accounts)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
var response struct {
|
||||
Quote paymentQuoteDTO `json:"quote"`
|
||||
Subject string `json:"externalSubjectRef"`
|
||||
}
|
||||
err = c.paymentRequest(ctx, http.MethodPost, "/payments/quote/"+c.config.OrganizationID, quoteRequestDTO{Key: "sxchnge:quote:" + bson.NewObjectID().Hex(), Intent: mapped.wire}, &response)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
if response.Subject != subject {
|
||||
return payments.Quote{}, payments.Unavailable
|
||||
}
|
||||
return mapPaymentQuote(response.Quote, mapped.request, mapped.source, mapped.destination)
|
||||
}
|
||||
func (c *Client) Execute(ctx context.Context, quote, key string) (payments.RemoteOperation, error) {
|
||||
if !safePaymentRef(quote) {
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
var result struct {
|
||||
Payment paymentDTO `json:"payment"`
|
||||
}
|
||||
payload := struct {
|
||||
Quote string `json:"quoteRef"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}{quote, key}
|
||||
err := c.paymentRequest(ctx, http.MethodPost, "/payments/by-quote/"+c.config.OrganizationID, payload, &result)
|
||||
if err != nil {
|
||||
return payments.RemoteOperation{}, err
|
||||
}
|
||||
return mapPayment(result.Payment)
|
||||
}
|
||||
func (c *Client) Payment(ctx context.Context, id string) (payments.RemoteOperation, error) {
|
||||
if !safePaymentRef(id) {
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
var result struct {
|
||||
Payment paymentDTO `json:"payment"`
|
||||
}
|
||||
err := c.paymentRequest(ctx, http.MethodGet, "/payments/by-ref/"+id, nil, &result)
|
||||
if err != nil {
|
||||
return payments.RemoteOperation{}, err
|
||||
}
|
||||
return mapPayment(result.Payment)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
)
|
||||
|
||||
const maxMinor int64 = 9007199254740991
|
||||
|
||||
func safePaymentRef(value string) bool {
|
||||
if len(value) == 0 || len(value) > 200 {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Decimal parsing never rounds provider values or converts money through floats.
|
||||
func minorMoney(m moneyDTO, currency string, scale int) (int64, error) {
|
||||
if m.Currency != currency || scale < 0 || scale > 8 {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
parts := strings.Split(m.Amount, ".")
|
||||
if len(parts) > 2 || parts[0] == "" {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
digits := parts[0]
|
||||
if len(parts) == 2 {
|
||||
if parts[1] == "" || len(parts[1]) > scale {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
digits += parts[1] + strings.Repeat("0", scale-len(parts[1]))
|
||||
} else {
|
||||
digits += strings.Repeat("0", scale)
|
||||
}
|
||||
for _, r := range digits {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
}
|
||||
value, err := strconv.ParseInt(digits, 10, 64)
|
||||
if err != nil || value > maxMinor {
|
||||
return 0, payments.Unavailable
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func majorMoney(minor int64, scale int) string {
|
||||
digits := strconv.FormatInt(minor, 10)
|
||||
if scale == 0 {
|
||||
return digits
|
||||
}
|
||||
if len(digits) <= scale {
|
||||
digits = strings.Repeat("0", scale+1-len(digits)) + digits
|
||||
}
|
||||
return digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
|
||||
}
|
||||
func mapPaymentQuote(dto paymentQuoteDTO, r payments.Request, source, destination payments.Account) (payments.Quote, error) {
|
||||
if !dto.Executable || dto.State != "executable" {
|
||||
switch dto.BlockReason {
|
||||
case "limit_blocked":
|
||||
return payments.Quote{}, payments.LimitExceeded
|
||||
case "amount_too_small", "amount_too_large":
|
||||
return payments.Quote{}, payments.InvalidAmount
|
||||
default:
|
||||
return payments.Quote{}, payments.Unsupported
|
||||
}
|
||||
}
|
||||
if !safePaymentRef(dto.ID) || !dto.Expires.After(time.Now()) {
|
||||
return payments.Quote{}, payments.ExpiredQuote
|
||||
}
|
||||
debit, err := minorMoney(dto.Amounts.Debit, source.Currency, source.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
principal, err := minorMoney(dto.Amounts.Principal, source.Currency, source.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
credit, err := minorMoney(dto.Amounts.Credit, destination.Currency, destination.Precision)
|
||||
if err != nil {
|
||||
return payments.Quote{}, err
|
||||
}
|
||||
if principal != r.Amount || debit < principal || credit <= 0 {
|
||||
return payments.Quote{}, payments.Unavailable
|
||||
}
|
||||
if r.Kind != "topUp" && debit > source.Available {
|
||||
return payments.Quote{}, payments.InsufficientFunds
|
||||
}
|
||||
num, den := big.NewInt(credit), big.NewInt(principal)
|
||||
num.Mul(num, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(source.Precision)), nil))
|
||||
den.Mul(den, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(destination.Precision)), nil))
|
||||
gcd := new(big.Int).GCD(nil, nil, num, den)
|
||||
num.Quo(num, gcd)
|
||||
den.Quo(den, gcd)
|
||||
if !num.IsInt64() || !den.IsInt64() || num.Int64() > maxMinor || den.Int64() > maxMinor {
|
||||
return payments.Quote{}, payments.Unsupported
|
||||
}
|
||||
return payments.Quote{ID: dto.ID, Request: r, Fee: debit - principal, Debit: debit, Credit: credit, ExpiresAt: dto.Expires, RateNumerator: num.Int64(), RateDenominator: den.Int64()}, nil
|
||||
}
|
||||
func mapPayment(dto paymentDTO) (payments.RemoteOperation, error) {
|
||||
if !safePaymentRef(dto.ID) || !safePaymentRef(dto.Quote.ID) || dto.Created.IsZero() {
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
status := "pending"
|
||||
switch dto.State {
|
||||
case "orchestration_state_settled":
|
||||
status = "completed"
|
||||
case "orchestration_state_failed":
|
||||
status = "failed"
|
||||
case "orchestration_state_created", "orchestration_state_executing", "orchestration_state_needs_attention":
|
||||
default:
|
||||
return payments.RemoteOperation{}, payments.Unavailable
|
||||
}
|
||||
return payments.RemoteOperation{ID: dto.ID, QuoteRef: dto.Quote.ID, Status: status, CreatedAt: dto.Created}, nil
|
||||
}
|
||||
|
||||
func accountByID(accounts []payments.Account, id string) (payments.Account, error) {
|
||||
for _, a := range accounts {
|
||||
if a.ID == id {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return payments.Account{}, payments.NotFound
|
||||
}
|
||||
func ledgerEndpoint(id string) endpointDTO {
|
||||
return endpointDTO{Type: "ledger", Data: map[string]string{"account_ref": id}}
|
||||
}
|
||||
func methodEndpoint(id string) endpointDTO {
|
||||
return endpointDTO{Type: "paymentMethod", Data: map[string]string{"ref": id}}
|
||||
}
|
||||
|
||||
type mappedIntent struct {
|
||||
wire paymentIntentDTO
|
||||
request payments.Request
|
||||
source, destination payments.Account
|
||||
}
|
||||
|
||||
func mapIntent(subject string, r payments.Request, accounts []payments.Account) (mappedIntent, error) {
|
||||
source, err := accountByID(accounts, r.Source)
|
||||
if err != nil {
|
||||
return mappedIntent{}, err
|
||||
}
|
||||
if r.Amount <= 0 || r.Amount > maxMinor {
|
||||
return mappedIntent{}, payments.InvalidAmount
|
||||
}
|
||||
intent := paymentIntentDTO{Subject: subject, Source: ledgerEndpoint(source.ID), Amount: moneyDTO{Amount: majorMoney(r.Amount, source.Precision), Currency: source.Currency}, FeeTreatment: "add_to_source"}
|
||||
destination := source
|
||||
switch r.Kind {
|
||||
case "payout":
|
||||
r.Destination = ""
|
||||
if source.PayoutMethod == "" || r.Recipient != source.PayoutMethod {
|
||||
return mappedIntent{}, payments.InvalidRecipient
|
||||
}
|
||||
intent.Kind = "payout"
|
||||
intent.Destination = methodEndpoint(source.PayoutMethod)
|
||||
// Store the safe provisioned display label, never present an opaque method ID.
|
||||
r.Recipient = source.PayoutLabel
|
||||
case "exchange":
|
||||
r.Recipient = ""
|
||||
destination, err = accountByID(accounts, r.Destination)
|
||||
if err != nil {
|
||||
return mappedIntent{}, err
|
||||
}
|
||||
if source.ID == destination.ID || source.Currency == destination.Currency {
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
intent.Kind = "fx_conversion"
|
||||
intent.Destination = ledgerEndpoint(destination.ID)
|
||||
case "topUp":
|
||||
r.Destination = ""
|
||||
r.Recipient = ""
|
||||
if source.FundingMethod == "" {
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
intent.Kind = "internal_transfer"
|
||||
intent.Source = methodEndpoint(source.FundingMethod)
|
||||
intent.Destination = ledgerEndpoint(source.ID)
|
||||
default:
|
||||
return mappedIntent{}, payments.Unsupported
|
||||
}
|
||||
|
||||
return mappedIntent{intent, r, source, destination}, nil
|
||||
}
|
||||
|
||||
func mapAccount(row accountDTO, balance balanceDTO, scale int) (payments.Account, error) {
|
||||
if balance.ID != row.ID {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
minor, err := minorMoney(balance.Money, row.Currency, scale)
|
||||
if err != nil {
|
||||
return payments.Account{}, err
|
||||
}
|
||||
account := payments.Account{ID: row.ID, Currency: row.Currency, Precision: scale, Available: minor, PayoutMethod: row.Metadata["sxchnge_payout_method_ref"], PayoutLabel: row.Metadata["sxchnge_payout_label"], FundingMethod: row.Metadata["sxchnge_funding_method_ref"]}
|
||||
for _, ref := range []string{account.PayoutMethod, account.FundingMethod} {
|
||||
if ref != "" && !validRef(ref) {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
}
|
||||
if account.PayoutMethod != "" && (strings.TrimSpace(account.PayoutLabel) == "" || len(account.PayoutLabel) > 140) {
|
||||
return payments.Account{}, payments.Unavailable
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPaymentPublicWireAndExactMoney(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ledger := "507f1f77bcf86cd799439015"
|
||||
method := "507f1f77bcf86cd799439016"
|
||||
calls := 0
|
||||
acknowledge := true
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NotEmpty(t, r.Header.Get("Authorization"))
|
||||
var result any
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/payments/fx/currencies/" + org:
|
||||
result = map[string]any{"currencies": []any{map[string]any{"code": "EUR", "accountingScale": 2}}}
|
||||
case "/api/v1/ledger_accounts/" + org:
|
||||
result = map[string]any{"accounts": []any{
|
||||
map[string]any{"ledgerAccountRef": source, "organizationRef": org, "ownerRef": source, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE"},
|
||||
map[string]any{"ledgerAccountRef": ledger, "organizationRef": org, "currency": "EUR", "accountType": "ACCOUNT_TYPE_ASSET", "role": "ACCOUNT_ROLE_OPERATING", "status": "ACCOUNT_STATUS_ACTIVE", "metadata": map[string]string{"sxchnge_subject_ref": subject, "sxchnge_payout_method_ref": method, "sxchnge_payout_label": "Saved bank ending 1234"}},
|
||||
}}
|
||||
case "/api/v1/ledger_accounts/" + org + "/" + ledger + "/balance":
|
||||
result = map[string]any{"balance": map[string]any{"ledgerAccountRef": ledger, "balance": moneyDTO{"75.00", "EUR"}}}
|
||||
case "/api/v1/payments/quote/" + org:
|
||||
var request quoteRequestDTO
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
require.Equal(t, subject, request.Intent.Subject)
|
||||
require.Equal(t, "20.01", request.Intent.Amount.Amount)
|
||||
require.Equal(t, ledger, request.Intent.Source.Data["account_ref"])
|
||||
require.Equal(t, method, request.Intent.Destination.Data["ref"])
|
||||
confirmedSubject := subject
|
||||
if !acknowledge {
|
||||
confirmedSubject = ""
|
||||
}
|
||||
result = map[string]any{"externalSubjectRef": confirmedSubject, "quote": paymentQuoteDTO{ID: "quote:1", State: "executable", Executable: true, Expires: time.Now().Add(time.Minute), Amounts: quoteAmountsDTO{Principal: moneyDTO{"20.01", "EUR"}, Debit: moneyDTO{"21.01", "EUR"}, Credit: moneyDTO{"20.01", "EUR"}}}}
|
||||
case "/api/v1/payments/by-quote/" + org:
|
||||
calls++
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
require.Equal(t, "stable-execution", payload["idempotencyKey"])
|
||||
require.Equal(t, "quote:1", payload["quoteRef"])
|
||||
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_needs_attention", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||
case "/api/v1/payments/by-ref/payment:1":
|
||||
result = map[string]any{"payment": paymentDTO{ID: "payment:1", State: "orchestration_state_settled", Quote: paymentQuoteDTO{ID: "quote:1"}, Created: time.Now()}}
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": result}))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
accounts, err := client.Accounts(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 1)
|
||||
require.EqualValues(t, 7500, accounts[0].Available)
|
||||
q, err := client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 100, q.Fee)
|
||||
require.EqualValues(t, 2101, q.Debit)
|
||||
require.Equal(t, "Saved bank ending 1234", q.Request.Recipient)
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: source, Amount: 2001, Recipient: method}, accounts)
|
||||
require.ErrorIs(t, err, payments.NotFound)
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: "foreign-method"}, accounts)
|
||||
require.ErrorIs(t, err, payments.InvalidRecipient)
|
||||
acknowledge = false
|
||||
_, err = client.Quote(ctx, subject, payments.Request{Kind: "payout", Source: ledger, Amount: 2001, Recipient: method}, accounts)
|
||||
require.ErrorIs(t, err, payments.Unavailable)
|
||||
acknowledge = true
|
||||
op, err := client.Execute(ctx, q.ID, "stable-execution")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "pending", op.Status)
|
||||
require.Equal(t, 1, calls)
|
||||
op, err = client.Payment(ctx, op.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "completed", op.Status)
|
||||
}
|
||||
func TestPaymentMappingRejectsUnsafeOrUnexecutableValues(t *testing.T) {
|
||||
for _, amount := range []string{"1e3", "0.001", "-2", "NaN", "90071992547409.92", ""} {
|
||||
_, err := minorMoney(moneyDTO{amount, "EUR"}, "EUR", 2)
|
||||
require.Error(t, err, amount)
|
||||
}
|
||||
_, err := minorMoney(moneyDTO{"10.00", "USD"}, "EUR", 2)
|
||||
require.Error(t, err)
|
||||
_, err = mapPayment(paymentDTO{ID: "p", State: "new_unknown_state", Quote: paymentQuoteDTO{ID: "q"}, Created: time.Now()})
|
||||
require.Error(t, err)
|
||||
_, err = mapPaymentQuote(paymentQuoteDTO{State: "blocked", BlockReason: "limit_blocked"}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||
require.ErrorIs(t, err, payments.LimitExceeded)
|
||||
_, err = mapPaymentQuote(paymentQuoteDTO{State: "executable", Executable: false}, payments.Request{}, payments.Account{}, payments.Account{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/payments"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type moneyDTO struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
type endpointDTO struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// Explicit wire fields avoid coupling local domain models to provider JSON.
|
||||
type quoteRequestDTO struct {
|
||||
Key string `json:"idempotencyKey"`
|
||||
Intent paymentIntentDTO `json:"intent"`
|
||||
}
|
||||
type paymentIntentDTO struct {
|
||||
Kind string `json:"kind"`
|
||||
Source endpointDTO `json:"source"`
|
||||
Destination endpointDTO `json:"destination"`
|
||||
Amount moneyDTO `json:"amount"`
|
||||
Subject string `json:"external_subject_ref"`
|
||||
FeeTreatment string `json:"fee_treatment"`
|
||||
}
|
||||
type quoteAmountsDTO struct {
|
||||
Principal moneyDTO `json:"sourcePrincipal"`
|
||||
Debit moneyDTO `json:"sourceDebitTotal"`
|
||||
Credit moneyDTO `json:"destinationSettlement"`
|
||||
}
|
||||
type paymentQuoteDTO struct {
|
||||
ID string `json:"quoteRef"`
|
||||
State string `json:"state"`
|
||||
Executable bool `json:"executable"`
|
||||
BlockReason string `json:"blockReason"`
|
||||
Expires time.Time `json:"expiresAt"`
|
||||
Amounts quoteAmountsDTO `json:"amounts"`
|
||||
}
|
||||
type paymentDTO struct {
|
||||
ID string `json:"paymentRef"`
|
||||
State string `json:"state"`
|
||||
Quote paymentQuoteDTO `json:"lastQuote"`
|
||||
Created time.Time `json:"createdAt"`
|
||||
}
|
||||
type accountDTO struct {
|
||||
ID string `json:"ledgerAccountRef"`
|
||||
ScopeRef string `json:"organizationRef"`
|
||||
Owner string `json:"ownerRef"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"accountType"`
|
||||
LedgerRole string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
type currencyDTO struct {
|
||||
Code string `json:"code"`
|
||||
Scale *int `json:"accountingScale"`
|
||||
}
|
||||
type balanceDTO struct {
|
||||
ID string `json:"ledgerAccountRef"`
|
||||
Money moneyDTO `json:"balance"`
|
||||
}
|
||||
|
||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "encode Sendico payment request")
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "create Sendico payment request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := *c.http
|
||||
client.Timeout = 30 * time.Second
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "Sendico payment request failed")
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return payments.Unavailable
|
||||
}
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
d := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024+1))
|
||||
if err = d.Decode(&envelope); err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment envelope")
|
||||
}
|
||||
if envelope.Status != "success" {
|
||||
return payments.Unavailable
|
||||
}
|
||||
var extra any
|
||||
if d.Decode(&extra) != io.EOF {
|
||||
return payments.Unavailable
|
||||
}
|
||||
if err = json.Unmarshal(envelope.Data, dst); err != nil {
|
||||
return merrors.OpaqueWrap(payments.Unavailable, err, "decode Sendico payment payload")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user