Connect customer quotations and payment execution to Sendico (#50)

This commit is contained in:
Stephan D committed 2026-10-16 09:57:28 +02:00
1 parent 5d67c25c8e
commit 7a6510d1a8
45 files changed
+2696 -45

No files matched your search

+7
View File
@@ -43,3 +43,10 @@ contracts; local customers retain no platform tenancy. See
`go test -race ./...` and `go vet ./...` validate this module. Tests require Docker
for disposable Mongo replica sets. Root `make test` and `make lint-backend` also
validate `shared`. See [source provenance](../PROVENANCE.md).
## Customer quotations and payments
With `api.sendico`, runtime composes the customer-scoped payment service and
public HTTP provider adapter. Flutter backend mode selects this path automatically;
missing provider configuration never returns demo money. See
[contracts, provisioning, authorization and rollout](../../docs/payments.md).
+2
View File
@@ -7,6 +7,7 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/storage"
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5"
@@ -19,6 +20,7 @@ type Dependencies struct {
Auth *flow.Service
Factors *twofactor.Service
Identity *identity.Service
Payments *payments.Service
Token middleware.TokenConfig
Signature middleware.Signature
LoginLimits middleware.LoginRateLimits
+2
View File
@@ -6,6 +6,7 @@ import (
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
identity "git.sendico.io/tech/sxchnge/product/identity/http/v1"
paymenthttp "git.sendico.io/tech/sxchnge/product/payments/http/v1"
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
"git.sendico.io/tech/sxchnge/shared/errors"
)
@@ -24,5 +25,6 @@ func Install(scope api.Scope, deps *api.Dependencies) error {
factors.Install(routes, deps.Factors, scope.Logger)
}
identity.Install(routes, deps.Identity, scope.Logger)
paymenthttp.Install(routes, deps.Payments, scope.Logger)
return nil
}
+9
View File
@@ -10,6 +10,7 @@ import (
"git.sendico.io/tech/sxchnge/product/customers"
"git.sendico.io/tech/sxchnge/product/identity"
"git.sendico.io/tech/sxchnge/product/notifications"
"git.sendico.io/tech/sxchnge/product/payments"
"git.sendico.io/tech/sxchnge/product/providers/sendico"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
@@ -69,6 +70,11 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
}
deps.Identity = &identity.Service{Store: bindings, Provider: provider, Logger: logger.Named("identity")}
deps.Auth.Facts = deps.Identity
paymentStore, err := payments.OpenStore(db)
if err != nil {
return nil, nil, err
}
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
logger.Info("Sendico sign-up facts integration enabled")
}
records, err := factorstore.Open(logger, db)
@@ -80,6 +86,9 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
return nil, nil, err
}
}
if deps.Payments != nil {
deps.Payments.Factors = deps.Factors
}
if config.Sendico != nil && deps.Auth == nil {
return nil, nil, merrors.InvalidArgument("Sendico sign-up facts require notifications configuration")
}
+10
View File
@@ -147,3 +147,13 @@ func (s *Service) SubmitVerification(ctx context.Context, a *customer.Account, k
s.Logger.Info("Identity proofs accepted for review", zap.String("account_ref", a.ID.Hex()), zap.String("submission_ref", v.SubmissionID))
return v, nil
}
// Subject resolves the authenticated customer's confirmed provider binding.
// Only server-side services may use this resource; clients cannot select it.
func (s *Service) Subject(ctx context.Context, a *customer.Account) (string, error) {
b, err := s.binding(ctx, a)
if err != nil {
return "", err
}
return b.SubjectRef, nil
}
+84
View File
@@ -0,0 +1,84 @@
package paymenthttp
import (
"git.sendico.io/tech/sxchnge/product/payments"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
"time"
)
type requestDTO struct {
Kind string `json:"kind"`
Source string `json:"source"`
Destination string `json:"destination"`
Recipient string `json:"recipient"`
Amount int64 `json:"amount"`
}
func (d requestDTO) model() payments.Request {
return payments.Request{Kind: d.Kind, Source: d.Source, Destination: d.Destination, Recipient: d.Recipient, Amount: d.Amount}
}
func fromRequest(r payments.Request) requestDTO {
return requestDTO{r.Kind, r.Source, r.Destination, r.Recipient, r.Amount}
}
type quoteDTO struct {
ID string `json:"id"`
Request requestDTO `json:"request"`
Fee int64 `json:"fee"`
Debit int64 `json:"debit"`
Credit int64 `json:"credit"`
ExpiresAt time.Time `json:"expiresAt"`
Numerator int64 `json:"rateNumerator"`
Denominator int64 `json:"rateDenominator"`
}
func fromQuote(q payments.Quote) quoteDTO {
return quoteDTO{q.ID, fromRequest(q.Request), q.Fee, q.Debit, q.Credit, q.ExpiresAt, q.RateNumerator, q.RateDenominator}
}
type operationDTO struct {
ID string `json:"id"`
Quote quoteDTO `json:"quote"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
func fromOperation(o payments.Operation) operationDTO {
return operationDTO{o.ID, fromQuote(o.Quote), o.Status, o.CreatedAt}
}
type accountDTO struct {
ID string `json:"id"`
Currency string `json:"currency"`
Precision int `json:"precision"`
Available int64 `json:"available"`
PayoutMethod string `json:"payoutMethod"`
PayoutLabel string `json:"payoutLabel"`
CanFund bool `json:"canFund"`
}
type snapshotDTO struct {
Accounts []accountDTO `json:"accounts"`
Operations []operationDTO `json:"operations"`
}
func fromSnapshot(s payments.Snapshot) snapshotDTO {
v := snapshotDTO{Accounts: []accountDTO{}, Operations: []operationDTO{}}
for _, a := range s.Accounts {
v.Accounts = append(v.Accounts, accountDTO{a.ID, a.Currency, a.Precision, a.Available, a.PayoutMethod, a.PayoutLabel, a.FundingMethod != ""})
}
for i := len(s.Operations) - 1; i >= 0; i-- {
v.Operations = append(v.Operations, fromOperation(s.Operations[i]))
}
return v
}
type challengeDTO struct {
ID string `json:"challengeId"`
Target string `json:"target"`
ExpiresAt time.Time `json:"expiresAt"`
ResendAt time.Time `json:"resendAt"`
}
func fromChallenge(c factor.Challenge) challengeDTO {
return challengeDTO{c.ID, c.Target, c.ExpiresAt, c.ResendAt}
}
+164
View File
@@ -0,0 +1,164 @@
package paymenthttp
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/payments"
verification "git.sendico.io/tech/sxchnge/product/verification/store"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/http/response"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
mservice "git.sendico.io/tech/sxchnge/shared/service"
)
type Registrar interface {
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
}
type router struct {
service *payments.Service
logger mlogger.Logger
}
func Install(reg Registrar, s *payments.Service, logger mlogger.Logger) {
h := &router{s, logger.Named("payment_http")}
reg.AccountHandler(mservice.Accounts, "/wallet/profile", api.Get, h.profile)
reg.AccountHandler(mservice.Accounts, "/wallet", api.Get, h.fetch)
reg.AccountHandler(mservice.Accounts, "/wallet/quote", api.Post, h.quote)
reg.AccountHandler(mservice.Accounts, "/wallet/submit", api.Post, h.submit)
reg.AccountHandler(mservice.Accounts, "/wallet/submission", api.Post, h.reconcile)
reg.AccountHandler(mservice.Accounts, "/wallet/challenge", api.Post, h.challenge)
reg.AccountHandler(mservice.Accounts, "/wallet/resend", api.Post, h.resend)
reg.AccountHandler(mservice.Accounts, "/wallet/verify", api.Post, h.verify)
}
func decode(r *http.Request, dst any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
d.DisallowUnknownFields()
if d.Decode(dst) != nil {
return payments.InvalidAmount
}
var extra any
if d.Decode(&extra) != io.EOF {
return payments.InvalidAmount
}
return nil
}
func (h *router) result(data any, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err == nil {
response.Ok(h.logger, data)(w, r)
return
}
code := "unavailable"
status := http.StatusServiceUnavailable
var f payments.Failure
if errors.As(err, &f) {
code = string(f)
if f != payments.Unavailable && f != payments.Uncertain {
status = http.StatusBadRequest
}
}
switch {
case errors.Is(err, verification.ErrTokenExpired):
code = "factorExpired"
status = 400
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
code = "factorLocked"
status = 400
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrUnauthorized):
code = "factorInvalid"
status = 400
}
if throttle, ok := verification.Throttle(err); ok {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(throttle.RetryAfter.Seconds()))))
code = "factorCooldown"
status = 429
}
response.Error(h.logger, mservice.Accounts, status, code, "Unable to complete wallet operation")(w, r)
}
}
func (h *router) fetch(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
v, e := h.service.Fetch(r.Context(), a)
return h.result(fromSnapshot(v), e)
}
func (h *router) quote(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d requestDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Quote(r.Context(), a, d.model())
return h.result(fromQuote(v), e)
}
type actionDTO struct {
Quote string `json:"quoteId"`
Key string `json:"idempotencyKey"`
Challenge string `json:"challengeId"`
Code string `json:"code"`
}
func (h *router) submit(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Submit(r.Context(), a, d.Quote, d.Key)
return h.result(fromOperation(v), e)
}
func (h *router) reconcile(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Reconcile(r.Context(), a, d.Key)
if v == nil {
return h.result(nil, e)
}
return h.result(fromOperation(*v), e)
}
func requestIP(r *http.Request) string {
if ip := ipguard.ClientIP(r); ip != nil {
return ip.String()
}
return "unresolved"
}
func (h *router) challenge(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Challenge(r.Context(), a, d.Quote, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
v, e := h.service.Resend(r.Context(), a, d.Quote, d.Challenge, d.Key, requestIP(r))
return h.result(fromChallenge(v), e)
}
func (h *router) verify(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
var d actionDTO
if e := decode(r, &d); e != nil {
return h.result(nil, e)
}
e := h.service.Authorize(r.Context(), a, d.Quote, d.Challenge, d.Code, d.Key)
return h.result(struct {
Key string `json:"idempotencyKey"`
}{d.Key}, e)
}
func (h *router) profile(_ *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
return h.result(struct {
Locale string `json:"locale"`
}{a.Locale}, nil)
}
@@ -0,0 +1,36 @@
package paymenthttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestUnconfiguredWalletCannotReturnDemoFunds(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
request := httptest.NewRequest(http.MethodGet, "/accounts/wallet", nil)
recorder := httptest.NewRecorder()
h.fetch(request, a, nil)(recorder, request)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
require.NotContains(t, recorder.Body.String(), "7500")
}
func TestQuoteRejectsUnknownFieldsAndTrailingRequests(t *testing.T) {
h := &router{logger: zap.NewNop()}
a := &customer.Account{}
a.ID = bson.NewObjectID()
for _, body := range []string{`{"kind":"payout","subject":"foreign"}`, `{"amount":10} {"amount":20}`, `{"amount":1.2}`} {
request := httptest.NewRequest(http.MethodPost, "/accounts/wallet/quote", strings.NewReader(body))
recorder := httptest.NewRecorder()
h.quote(request, a, nil)(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package payments owns customer quotation ownership and durable execution intent.
// Money and settlement remain authoritative in the provider.
package payments
import (
"context"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/shared/database/record"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Account struct {
ID, Currency string
Precision int
Available int64
PayoutMethod, PayoutLabel, FundingMethod string
}
type Request struct {
Kind, Source, Destination, Recipient string
Amount int64
}
type Quote struct {
ID string
Request Request
Fee, Debit, Credit int64
ExpiresAt time.Time
RateNumerator, RateDenominator int64
}
type Operation struct {
ID string
Quote Quote
Status string
CreatedAt time.Time
}
type Snapshot struct {
Accounts []Account
Operations []Operation
}
type Record struct {
storable.Base `bson:",inline"`
Customer bson.ObjectID `bson:"customer"`
Namespace string `bson:"namespace"`
Subject string `bson:"subject"`
Quote Quote `bson:"quote"`
ProviderQuote string `bson:"providerQuote"`
Key string `bson:"key"`
Payment string `bson:"payment"`
}
func (*Record) Collection() string { return "payment_quotes" }
type Provider interface {
Namespace() string
Accounts(context.Context, string) ([]Account, error)
Quote(context.Context, string, Request, []Account) (Quote, error)
Execute(context.Context, string, string) (RemoteOperation, error)
Payment(context.Context, string) (RemoteOperation, error)
}
type RemoteOperation struct {
ID, QuoteRef, Status string
CreatedAt time.Time
}
type Identity interface {
Subject(context.Context, *customer.Account) (string, error)
}
type Failure string
func (e Failure) Error() string { return string(e) }
const (
Unavailable Failure = "unavailable"
InvalidAmount Failure = "invalidAmount"
InvalidRecipient Failure = "invalidRecipient"
InsufficientFunds Failure = "insufficientFunds"
LimitExceeded Failure = "limitExceeded"
ExpiredQuote Failure = "expiredQuote"
Unsupported Failure = "unsupported"
NotFound Failure = "notFound"
Uncertain Failure = "uncertain"
FactorRequired Failure = "factorRequired"
)
+237
View File
@@ -0,0 +1,237 @@
package payments
import (
"context"
"errors"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
verification "git.sendico.io/tech/sxchnge/product/verification/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Service struct {
Store Store
Provider Provider
Identity Identity
Factors twofactor.API
Logger mlogger.Logger
}
func (s *Service) ready() error {
if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil {
return Unavailable
}
return nil
}
func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) {
if err := s.ready(); err != nil {
return nil, err
}
if a == nil || a.ID.IsZero() {
return nil, NotFound
}
ref, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, NotFound
}
r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace())
if errors.Is(err, merrors.ErrNoData) {
return nil, NotFound
}
if err != nil {
return nil, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return nil, err
}
if subject != r.Subject {
return nil, Unavailable
}
return r, nil
}
func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) {
if err := s.ready(); err != nil {
return Quote{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Quote{}, err
}
accounts, err := s.Provider.Accounts(ctx, subject)
if err != nil {
return Quote{}, err
}
q, err := s.Provider.Quote(ctx, subject, request, accounts)
if err != nil {
return Quote{}, err
}
providerRef := q.ID
q.ID = bson.NewObjectID().Hex()
r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef}
r.SetID(mustID(q.ID))
if err = s.Store.Create(ctx, r); err != nil {
return Quote{}, err
}
s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind))
return q, nil
}
func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value }
func operationScope(r *Record) factor.Scope {
return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID}
}
func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip})
}
func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return factor.Challenge{}, err
}
if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" {
return factor.Challenge{}, ExpiredQuote
}
if !validKey(key) {
return factor.Challenge{}, InvalidAmount
}
return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge)
}
func validKey(key string) bool {
if len(key) < 16 || len(key) > 128 {
return false
}
for _, c := range key {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}
// Authorize consumes the challenge atomically with a durable execution intent.
// No upstream I/O occurs inside the transaction. Replaying the same authorization
// recovers a lost response without reusing the one-use verification code.
func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error {
r, err := s.record(ctx, a, id)
if err != nil {
return err
}
if !validKey(key) {
return InvalidAmount
}
if r.Key != "" {
if r.Key == key {
return nil
}
return ExpiredQuote
}
return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error {
if !time.Now().Before(r.Quote.ExpiresAt) {
return ExpiredQuote
}
return s.Store.Authorize(tx, r, key)
})
}
func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) {
var remote RemoteOperation
var err error
if r.Payment != "" {
remote, err = s.Provider.Payment(ctx, r.Payment)
} else {
remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex())
}
if err != nil {
s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID))
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown")
}
if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) {
return Operation{}, Uncertain
}
if r.Payment == "" {
if err = s.Store.Attach(ctx, r, remote.ID); err != nil {
return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference")
}
}
s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status))
return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil
}
func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) {
r, err := s.record(ctx, a, id)
if err != nil {
return Operation{}, err
}
if !validKey(key) || r.Key == "" || r.Key != key {
return Operation{}, FactorRequired
}
return s.execute(ctx, r)
}
// Reconcile replays the already authorized durable intent with the same provider
// key. A timeout or an empty upstream list is never treated as proof of absence.
func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) {
if err := s.ready(); err != nil {
return nil, err
}
if !validKey(key) {
return nil, NotFound
}
r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
return nil, err
}
r, err = s.record(ctx, a, r.ID.Hex())
if err != nil {
return nil, err
}
op, err := s.execute(ctx, r)
return &op, err
}
func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) {
if err := s.ready(); err != nil {
return Snapshot{}, err
}
subject, err := s.Identity.Subject(ctx, a)
if err != nil {
return Snapshot{}, err
}
rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace())
if err != nil {
return Snapshot{}, err
}
result := Snapshot{Operations: []Operation{}}
for _, r := range rows {
if r.Subject != subject {
return Snapshot{}, Unavailable
}
op, err := s.execute(ctx, &r)
if errors.Is(err, Uncertain) {
// A durable authorization with an unknown provider outcome remains
// pending. It must not hide unrelated balances or report failure.
op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt}
} else if err != nil {
return Snapshot{}, err
}
result.Operations = append(result.Operations, op)
}
result.Accounts, err = s.Provider.Accounts(ctx, subject)
if err != nil {
return Snapshot{}, err
}
return result, nil
}
+189
View File
@@ -0,0 +1,189 @@
package payments
import (
"context"
"sync"
"testing"
"time"
customer "git.sendico.io/tech/sxchnge/product/customers/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type memoryStore struct {
mu sync.Mutex
records map[bson.ObjectID]Record
}
func (s *memoryStore) Create(_ context.Context, r *Record) error {
s.mu.Lock()
defer s.mu.Unlock()
s.records[r.ID] = *r
return nil
}
func (s *memoryStore) Get(_ context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.records[id]
if !ok || r.Customer != customer || r.Namespace != namespace {
return nil, merrors.ErrNoData
}
return &r, nil
}
func (s *memoryStore) ByKey(_ context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key == key {
return &r, nil
}
}
return nil, merrors.ErrNoData
}
func (s *memoryStore) List(_ context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows := []Record{}
for _, r := range s.records {
if r.Customer == customer && r.Namespace == namespace && r.Key != "" {
rows = append(rows, r)
}
}
return rows, nil
}
func (s *memoryStore) Authorize(_ context.Context, r *Record, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
if current.Key != "" {
return Uncertain
}
current.Key = key
s.records[r.ID] = current
return nil
}
func (s *memoryStore) Attach(_ context.Context, r *Record, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.records[r.ID]
current.Payment = id
s.records[r.ID] = current
return nil
}
type provider struct {
mu sync.Mutex
accepted int
lost bool
status string
key string
namespace string
}
func (p *provider) Namespace() string { return p.namespace }
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
}
func (p *provider) Quote(_ context.Context, _ string, r Request, _ []Account) (Quote, error) {
return Quote{ID: "provider-quote", Request: r, ExpiresAt: time.Now().Add(time.Minute)}, nil
}
func (p *provider) Execute(_ context.Context, quote, key string) (RemoteOperation, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.key == "" {
p.accepted++
p.key = key
}
if p.key != key {
return RemoteOperation{}, Unavailable
}
if p.lost {
p.lost = false
return RemoteOperation{}, Unavailable
}
return RemoteOperation{ID: "payment", QuoteRef: quote, Status: p.status, CreatedAt: time.Now()}, nil
}
func (p *provider) Payment(context.Context, string) (RemoteOperation, error) {
return RemoteOperation{ID: "payment", QuoteRef: "provider-quote", Status: p.status, CreatedAt: time.Now()}, nil
}
type identity struct{}
func (identity) Subject(context.Context, *customer.Account) (string, error) { return "subject", nil }
type factors struct {
calls int
subject bson.ObjectID
scope factor.Scope
}
func (f *factors) Begin(_ context.Context, r factor.Request) (factor.Challenge, error) {
f.subject = r.Subject
f.scope = r.Scope
return factor.Challenge{ID: "challenge"}, nil
}
func (f *factors) Resend(ctx context.Context, r factor.Request, _ string) (factor.Challenge, error) {
return f.Begin(ctx, r)
}
func (f *factors) Complete(ctx context.Context, subject bson.ObjectID, scope factor.Scope, id, code string, action func(context.Context) error) error {
if subject != f.subject || scope != f.scope || id != "challenge" || code != "123456" {
return FactorRequired
}
f.calls++
return action(ctx)
}
func TestCustomerExecutionOwnershipAndLostResponse(t *testing.T) {
ctx := context.Background()
a := &customer.Account{}
a.ID = bson.NewObjectID()
other := &customer.Account{}
other.ID = bson.NewObjectID()
store := &memoryStore{records: map[bson.ObjectID]Record{}}
p := &provider{lost: true, status: "pending", namespace: "provider"}
f := &factors{}
s := &Service{Store: store, Provider: p, Identity: identity{}, Factors: f, Logger: zap.NewNop()}
q, err := s.Quote(ctx, a, Request{Kind: "payout", Source: "ledger", Amount: 1000})
require.NoError(t, err)
_, err = s.Submit(ctx, a, q.ID, "unapproved-key-123")
require.ErrorIs(t, err, FactorRequired)
require.Zero(t, p.accepted)
_, err = s.Challenge(ctx, other, q.ID, "")
require.ErrorIs(t, err, NotFound)
_, err = s.Challenge(ctx, a, q.ID, "")
require.NoError(t, err)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "bad-code", "authorized-key-123"), FactorRequired)
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.NoError(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "authorized-key-123"))
require.Equal(t, 1, f.calls)
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, Uncertain)
op, err := s.Reconcile(ctx, other, "authorized-key-123")
require.NoError(t, err)
require.Nil(t, op)
op, err = s.Reconcile(ctx, a, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "pending", op.Status)
require.Equal(t, 1, p.accepted)
p.status = "completed"
snapshot, err := s.Fetch(ctx, a)
require.NoError(t, err)
require.Equal(t, "completed", snapshot.Operations[0].Status)
require.ErrorIs(t, s.Authorize(ctx, a, q.ID, "challenge", "123456", "different-key-1234"), ExpiredQuote)
// A changed provider namespace cannot execute the old quote.
p.namespace = "rotated-provider"
_, err = s.Submit(ctx, a, q.ID, "authorized-key-123")
require.ErrorIs(t, err, NotFound)
}
func TestExpiredQuoteCannotAuthorize(t *testing.T) {
a := &customer.Account{}
a.ID = bson.NewObjectID()
r := Record{Customer: a.ID, Namespace: "p", Subject: "subject", Quote: Quote{ID: bson.NewObjectID().Hex(), ExpiresAt: time.Now().Add(-time.Second)}}
r.SetID(mustID(r.Quote.ID))
s := &Service{Store: &memoryStore{records: map[bson.ObjectID]Record{r.ID: r}}, Provider: &provider{namespace: "p"}, Identity: identity{}, Factors: &factors{}, Logger: zap.NewNop()}
_, err := s.Challenge(context.Background(), a, r.Quote.ID, "")
require.ErrorIs(t, err, ExpiredQuote)
}
+88
View File
@@ -0,0 +1,88 @@
package payments
import (
"context"
db "git.sendico.io/tech/sxchnge/shared/database"
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Store interface {
Create(context.Context, *Record) error
Get(context.Context, bson.ObjectID, bson.ObjectID, string) (*Record, error)
ByKey(context.Context, bson.ObjectID, string, string) (*Record, error)
List(context.Context, bson.ObjectID, string) ([]Record, error)
Authorize(context.Context, *Record, string) error
Attach(context.Context, *Record, string) error
}
type mongoStore struct{ records repository.Repository }
func OpenStore(factory db.Factory) (Store, error) {
d, err := deps.ResolveDependencies(factory)
if err != nil {
return nil, err
}
r := repository.CreateRepository(source.Majority(d.Database), "payment_quotes")
err = r.CreateIndex(&ri.Definition{Name: "customer_execution_key", Unique: true, Keys: []ri.Key{{Field: "customer", Sort: ri.Asc}, {Field: "namespace", Sort: ri.Asc}, {Field: "key", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("key"), builder.Gt, "")})
if err != nil {
return nil, err
}
if err = r.CreateIndex(&ri.Definition{Name: "provider_quote_unique", Unique: true, Keys: []ri.Key{{Field: "namespace", Sort: ri.Asc}, {Field: "providerQuote", Sort: ri.Asc}}, PartialFilter: repository.Query().Comparison(repository.Field("providerQuote"), builder.Gt, "")}); err != nil {
return nil, err
}
return &mongoStore{r}, nil
}
func scope(customer bson.ObjectID, namespace string) repository.FilterQuery {
return repository.Query().Filter(repository.Field("customer"), customer).Filter(repository.Field("namespace"), namespace)
}
func (s *mongoStore) Create(ctx context.Context, r *Record) error {
return s.records.Insert(ctx, r, nil)
}
func (s *mongoStore) Get(ctx context.Context, id, customer bson.ObjectID, namespace string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.IDField(), id), r)
return r, err
}
func (s *mongoStore) ByKey(ctx context.Context, customer bson.ObjectID, namespace, key string) (*Record, error) {
r := &Record{}
err := s.records.FindOneByFilter(ctx, scope(customer, namespace).Filter(repository.Field("key"), key), r)
return r, err
}
func (s *mongoStore) List(ctx context.Context, customer bson.ObjectID, namespace string) ([]Record, error) {
var rows []Record
err := s.records.FindAllByFilter(ctx, scope(customer, namespace).Comparison(repository.Field("key"), builder.Gt, "").Sort(repository.Field("createdAt"), true), &rows)
return rows, err
}
func (s *mongoStore) Authorize(ctx context.Context, r *Record, key string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("key"), ""), repository.Patch().Set(repository.Field("key"), key), &Record{})
if err != nil {
return err
}
if !matched {
return Uncertain
}
return nil
}
func (s *mongoStore) Attach(ctx context.Context, r *Record, id string) error {
matched, err := s.records.PatchOneByFilter(ctx, scope(r.Customer, r.Namespace).Filter(repository.IDField(), r.ID).Filter(repository.Field("payment"), ""), repository.Patch().Set(repository.Field("payment"), id), &Record{})
if err != nil {
return err
}
if matched {
return nil
}
current, err := s.Get(ctx, r.ID, r.Customer, r.Namespace)
if err != nil {
return err
}
if current.Payment != id {
return Uncertain
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package payments
import (
"context"
"net/url"
"testing"
"time"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestDurableAuthorizationTransactionAndCustomerIsolation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
u, err := url.Parse(uri)
require.NoError(t, err)
q := u.Query()
q.Set("directConnection", "true")
u.RawQuery = q.Encode()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "payments"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
store, err := OpenStore(factory)
require.NoError(t, err)
customer := bson.NewObjectID()
r := &Record{Customer: customer, Namespace: "provider", Subject: "subject"}
r.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, r))
_, err = store.Get(ctx, r.ID, bson.NewObjectID(), r.Namespace)
require.ErrorIs(t, err, merrors.ErrNoData)
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
require.NoError(t, store.Authorize(tx, r, "authorized-key-123"))
return nil, Uncertain
})
require.ErrorIs(t, err, Uncertain)
stored, err := store.Get(ctx, r.ID, customer, r.Namespace)
require.NoError(t, err)
require.Empty(t, stored.Key)
require.NoError(t, store.Authorize(ctx, r, "authorized-key-123"))
require.ErrorIs(t, store.Authorize(ctx, r, "other-key-123456"), Uncertain)
another := &Record{Customer: customer, Namespace: r.Namespace}
another.SetID(bson.NewObjectID())
require.NoError(t, store.Create(ctx, another))
require.ErrorIs(t, store.Authorize(ctx, another, "authorized-key-123"), merrors.ErrDataConflict)
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.NoError(t, store.Attach(ctx, r, "payment-1"))
require.ErrorIs(t, store.Attach(ctx, r, "different-payment"), Uncertain)
reopened, err := OpenStore(factory)
require.NoError(t, err)
stored, err = reopened.ByKey(ctx, customer, r.Namespace, "authorized-key-123")
require.NoError(t, err)
require.Equal(t, "payment-1", stored.Payment)
_, err = reopened.ByKey(ctx, customer, "other-provider", "authorized-key-123")
require.ErrorIs(t, err, merrors.ErrNoData)
}
@@ -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, &currencies); 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
}
+1 -1
View File
@@ -87,7 +87,7 @@ func validScope(scope model.Scope) bool {
return false
}
switch scope.Purpose {
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
case vmodel.PurposeLogin, vmodel.PurposePayment, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
return true
}
return false
@@ -21,6 +21,7 @@ const (
PurposeFactorPreference VerificationPurpose = "factor_preference"
PurposeFactorProof VerificationPurpose = "factor_proof"
PurposePayout VerificationPurpose = "payout"
PurposePayment VerificationPurpose = "payment"
)
func VPToString(code VerificationPurpose) string {
@@ -45,6 +46,8 @@ func VPFromString(s string) (VerificationPurpose, error) {
return PurposeFactorPreference, nil
case string(PurposeFactorProof):
return PurposeFactorProof, nil
case string(PurposePayment):
return PurposePayment, nil
case string(PurposePayout):
return PurposePayout, nil
default: