move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View File

@@ -0,0 +1,62 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type accountData struct {
model.AccountPublic `json:",inline"`
IsAnonymous bool `json:"isAnonymous"`
}
type accountResponse struct {
authResponse `json:",inline"`
Account accountData `json:"account"`
}
func _createAccount(account *model.Account, isAnonymous bool) *accountData {
return &accountData{
AccountPublic: account.AccountPublic,
IsAnonymous: isAnonymous,
}
}
func _toAccount(account *model.Account, orgRef bson.ObjectID) *accountData {
return _createAccount(account, model.AccountIsAnonymous(&account.UserDataBase, orgRef))
}
func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
type accountsResponse struct {
authResponse `json:",inline"`
Accounts []accountData `json:"accounts"`
}
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
// Convert each account to its public representation.
publicAccounts := make([]accountData, len(accounts))
for i, a := range accounts {
publicAccounts[i] = *_toAccount(&a, orgRef)
}
return response.Ok(
logger,
&accountsResponse{
Accounts: publicAccounts,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -0,0 +1,5 @@
package sresponse
type authResponse struct {
AccessToken TokenData `json:"accessToken"`
}

View File

@@ -0,0 +1,15 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func BadRPassword(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
logger.Info("Failed password validation check", zap.Error(err))
return response.BadRequest(logger, source, "invalid_request", err.Error())
}

View File

@@ -0,0 +1,24 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type dzoneResponse struct {
authResponse `json:",inline"`
DZone model.DZone `json:"dzone"`
}
func DZone(logger mlogger.Logger, dzone *model.DZone, accessToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&dzoneResponse{
DZone: *dzone,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -0,0 +1,16 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
)
type fileUpladed struct {
URL string `json:"url"`
}
func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc {
return response.Ok(logger, &fileUpladed{URL: url})
}

View File

@@ -0,0 +1,21 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type invitationResp struct {
Invitation model.PublicInvitation `json:"invitation"`
}
func Invitation(logger mlogger.Logger, invitation *model.PublicInvitation) http.HandlerFunc {
return response.Ok(logger, &invitationResp{Invitation: *invitation})
}
func Invitations(logger mlogger.Logger, invitations []model.Invitation) http.HandlerFunc {
return response.Ok(logger, invitations)
}

View File

@@ -0,0 +1,126 @@
package sresponse
import (
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
type ledgerAccount struct {
model.Describable `bson:",inline" json:",inline"`
LedgerAccountRef string `json:"ledgerAccountRef"`
OrganizationRef string `json:"organizationRef"`
OwnerRef string `json:"ownerRef,omitempty"`
AccountCode string `json:"accountCode"`
AccountType string `json:"accountType"`
Currency string `json:"currency"`
Status string `json:"status"`
AllowNegative bool `json:"allowNegative"`
Role string `json:"role"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
type ledgerAccountsResponse struct {
authResponse `json:",inline"`
Accounts []ledgerAccount `json:"accounts"`
}
type ledgerAccountResponse struct {
authResponse `json:",inline"`
Account ledgerAccount `json:"account"`
}
type ledgerMoney struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type ledgerBalance struct {
LedgerAccountRef string `json:"ledgerAccountRef"`
Balance *ledgerMoney `json:"balance,omitempty"`
Version int64 `json:"version"`
LastUpdated time.Time `json:"lastUpdated,omitempty"`
}
type ledgerBalanceResponse struct {
authResponse `json:",inline"`
Balance ledgerBalance `json:"balance"`
}
func LedgerAccounts(logger mlogger.Logger, accounts []*ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc {
dto := make([]ledgerAccount, 0, len(accounts))
for _, acc := range accounts {
dto = append(dto, toLedgerAccount(acc))
}
return response.Ok(logger, ledgerAccountsResponse{
Accounts: dto,
authResponse: authResponse{AccessToken: *accessToken},
})
}
func LedgerAccountCreated(logger mlogger.Logger, account *ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc {
return response.Created(logger, ledgerAccountResponse{
Account: toLedgerAccount(account),
authResponse: authResponse{AccessToken: *accessToken},
})
}
func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, ledgerBalanceResponse{
Balance: toLedgerBalance(resp),
authResponse: authResponse{AccessToken: *accessToken},
})
}
func toLedgerAccount(acc *ledgerv1.LedgerAccount) ledgerAccount {
if acc == nil {
return ledgerAccount{}
}
return ledgerAccount{
Describable: model.Describable{
Name: acc.GetDescribable().GetName(),
Description: acc.GetDescribable().Description,
},
LedgerAccountRef: acc.GetLedgerAccountRef(),
OrganizationRef: acc.GetOrganizationRef(),
OwnerRef: acc.GetOwnerRef(),
AccountCode: acc.GetAccountCode(),
AccountType: acc.GetAccountType().String(),
Currency: acc.GetCurrency(),
Status: acc.GetStatus().String(),
AllowNegative: acc.GetAllowNegative(),
Role: acc.GetRole().String(),
Metadata: acc.GetMetadata(),
CreatedAt: acc.GetCreatedAt().AsTime(),
UpdatedAt: acc.GetUpdatedAt().AsTime(),
}
}
func toLedgerBalance(resp *ledgerv1.BalanceResponse) ledgerBalance {
if resp == nil {
return ledgerBalance{}
}
return ledgerBalance{
LedgerAccountRef: resp.GetLedgerAccountRef(),
Balance: toLedgerMoney(resp.GetBalance()),
Version: resp.GetVersion(),
LastUpdated: resp.GetLastUpdated().AsTime(),
}
}
func toLedgerMoney(m *moneyv1.Money) *ledgerMoney {
if m == nil {
return nil
}
return &ledgerMoney{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}
}

View File

@@ -0,0 +1,27 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type loginResponse struct {
accountResponse
RefreshToken TokenData `json:"refreshToken"`
}
func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&loginResponse{
accountResponse: accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
RefreshToken: *refreshToken,
},
)
}

View File

@@ -0,0 +1,29 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type pendingLoginResponse struct {
Account accountResponse `json:"account"`
PendingToken TokenData `json:"pendingToken"`
Target string `json:"target"`
}
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc {
return response.Accepted(
logger,
&pendingLoginResponse{
Account: accountResponse{
Account: *_createAccount(account, false),
authResponse: authResponse{},
},
PendingToken: *pendingToken,
Target: target,
},
)
}

View File

@@ -0,0 +1,16 @@
package sresponse
import (
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func toMoney(m *moneyv1.Money) *paymenttypes.Money {
if m == nil {
return nil
}
return &paymenttypes.Money{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}
}

View File

@@ -0,0 +1,49 @@
package sresponse
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
)
type DynamicResponse[T any] struct {
authResponse `json:",inline"`
Items []T
// FieldName is the JSON key to use for the items.
FieldName string
}
func (dr DynamicResponse[T]) MarshalJSON() ([]byte, error) {
// Create a temporary map to hold the keys and values.
m := map[string]any{
dr.FieldName: dr.Items,
"accessToken": dr.AccessToken,
}
return json.Marshal(m)
}
type handler = func(logger mlogger.Logger, data any) http.HandlerFunc
func objectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type, handler handler) http.HandlerFunc {
resp := &DynamicResponse[T]{
Items: items,
authResponse: authResponse{AccessToken: *accessToken},
FieldName: resource,
}
return handler(logger, resp)
}
func ObjectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return objectsAuth(logger, items, accessToken, resource, response.Ok)
}
func ObjectAuth[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return ObjectsAuth(logger, []T{*item}, accessToken, resource)
}
func ObjectAuthCreated[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc {
return objectsAuth(logger, []T{*item}, accessToken, resource, response.Created)
}

View File

@@ -0,0 +1,35 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type organizationsResponse struct {
authResponse `json:",inline"`
Organizations []model.Organization `json:"organizations"`
}
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
return Organizations(logger, []model.Organization{*organization}, accessToken)
}
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, organizationsResponse{
Organizations: organizations,
authResponse: authResponse{AccessToken: *accessToken},
})
}
type organizationPublicResponse struct {
Organizations []model.OrganizationBase `json:"organizations"`
}
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
return response.Ok(logger, organizationPublicResponse{
[]model.OrganizationBase{*organization},
})
}

View File

@@ -0,0 +1,389 @@
package sresponse
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/types/known/timestamppb"
)
type FeeLine struct {
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
Amount *paymenttypes.Money `json:"amount,omitempty"`
LineType string `json:"lineType,omitempty"`
Side string `json:"side,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type FxQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
BaseCurrency string `json:"baseCurrency,omitempty"`
QuoteCurrency string `json:"quoteCurrency,omitempty"`
Side string `json:"side,omitempty"`
Price string `json:"price,omitempty"`
BaseAmount *paymenttypes.Money `json:"baseAmount,omitempty"`
QuoteAmount *paymenttypes.Money `json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
PricedAtUnixMs int64 `json:"pricedAtUnixMs,omitempty"`
Provider string `json:"provider,omitempty"`
RateRef string `json:"rateRef,omitempty"`
Firm bool `json:"firm,omitempty"`
}
type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
IntentRef string `json:"intentRef,omitempty"`
Amounts *QuoteAmounts `json:"amounts,omitempty"`
Fees *QuoteFees `json:"fees,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
}
type QuoteAmounts struct {
SourcePrincipal *paymenttypes.Money `json:"sourcePrincipal,omitempty"`
SourceDebitTotal *paymenttypes.Money `json:"sourceDebitTotal,omitempty"`
DestinationSettlement *paymenttypes.Money `json:"destinationSettlement,omitempty"`
}
type QuoteFees struct {
Lines []FeeLine `json:"lines,omitempty"`
}
type PaymentQuotes struct {
IdempotencyKey string `json:"idempotencyKey,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
Items []PaymentQuote `json:"items,omitempty"`
}
type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
Operations []PaymentOperation `json:"operations,omitempty"`
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type paymentQuoteResponse struct {
authResponse `json:",inline"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Quote *PaymentQuote `json:"quote"`
}
type paymentQuotesResponse struct {
authResponse `json:",inline"`
Quote *PaymentQuotes `json:"quote"`
}
type paymentsResponse struct {
authResponse `json:",inline"`
Payments []Payment `json:"payments"`
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
}
type paymentResponse struct {
authResponse `json:",inline"`
Payment *Payment `json:"payment"`
}
// PaymentQuote wraps a payment quote with refreshed access token.
func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuoteResponse{
Quote: toPaymentQuote(quote),
IdempotencyKey: idempotencyKey,
authResponse: authResponse{AccessToken: *token},
})
}
// PaymentQuotes wraps batch quotes with refreshed access token.
func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuotesResponse{
Quote: toPaymentQuotes(resp),
authResponse: authResponse{AccessToken: *token},
})
}
// Payments wraps a list of payments with refreshed access token.
func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(payments),
authResponse: authResponse{AccessToken: *token},
})
}
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(resp.GetPayments()),
Page: resp.GetPage(),
authResponse: authResponse{AccessToken: *token},
})
}
// Payment wraps a payment with refreshed access token.
func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentResponse{
Payment: toPayment(payment),
authResponse: authResponse{AccessToken: *token},
})
}
func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
if len(lines) == 0 {
return nil
}
result := make([]FeeLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, FeeLine{
LedgerAccountRef: line.GetLedgerAccountRef(),
Amount: toMoney(line.GetMoney()),
LineType: enumJSONName(line.GetLineType().String()),
Side: enumJSONName(line.GetSide().String()),
Meta: line.GetMeta(),
})
}
if len(result) == 0 {
return nil
}
return result
}
func toFxQuote(q *oraclev1.Quote) *FxQuote {
if q == nil {
return nil
}
pair := q.GetPair()
pricedAtUnixMs := int64(0)
if ts := q.GetPricedAt(); ts != nil {
pricedAtUnixMs = ts.AsTime().UnixMilli()
}
base := ""
quote := ""
if pair != nil {
base = pair.GetBase()
quote = pair.GetQuote()
}
return &FxQuote{
QuoteRef: q.GetQuoteRef(),
BaseCurrency: base,
QuoteCurrency: quote,
Side: enumJSONName(q.GetSide().String()),
Price: q.GetPrice().GetValue(),
BaseAmount: toMoney(q.GetBaseAmount()),
QuoteAmount: toMoney(q.GetQuoteAmount()),
ExpiresAtUnixMs: q.GetExpiresAtUnixMs(),
PricedAtUnixMs: pricedAtUnixMs,
Provider: q.GetProvider(),
RateRef: q.GetRateRef(),
Firm: q.GetFirm(),
}
}
func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
if q == nil {
return nil
}
amounts := toQuoteAmounts(q)
fees := toQuoteFees(q.GetFeeLines())
return &PaymentQuote{
QuoteRef: q.GetQuoteRef(),
IntentRef: strings.TrimSpace(q.GetIntentRef()),
Amounts: amounts,
Fees: fees,
FxQuote: toFxQuote(q.GetFxQuote()),
}
}
func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes {
if resp == nil {
return nil
}
items := make([]PaymentQuote, 0, len(resp.GetQuotes()))
for _, quote := range resp.GetQuotes() {
if dto := toPaymentQuote(quote); dto != nil {
items = append(items, *dto)
}
}
if len(items) == 0 {
items = nil
}
return &PaymentQuotes{
IdempotencyKey: resp.GetIdempotencyKey(),
QuoteRef: resp.GetQuoteRef(),
Items: items,
}
}
func toQuoteAmounts(q *quotationv2.PaymentQuote) *QuoteAmounts {
if q == nil {
return nil
}
amounts := &QuoteAmounts{
SourcePrincipal: toMoney(q.GetTransferPrincipalAmount()),
SourceDebitTotal: toMoney(q.GetPayerTotalDebitAmount()),
DestinationSettlement: toMoney(q.GetDestinationAmount()),
}
if amounts.SourcePrincipal == nil && amounts.SourceDebitTotal == nil && amounts.DestinationSettlement == nil {
return nil
}
return amounts
}
func toQuoteFees(lines []*feesv1.DerivedPostingLine) *QuoteFees {
feeLines := toFeeLines(lines)
if len(feeLines) == 0 {
return nil
}
return &QuoteFees{Lines: feeLines}
}
func toPayments(items []*orchestrationv2.Payment) []Payment {
if len(items) == 0 {
return nil
}
result := make([]Payment, 0, len(items))
for _, item := range items {
if p := toPayment(item); p != nil {
result = append(result, *p)
}
}
if len(result) == 0 {
return nil
}
return result
}
func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
operations := toUserVisibleOperations(p.GetStepExecutions())
failureCode, failureReason := firstFailure(operations)
return &Payment{
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
CreatedAt: timestampAsTime(p.GetCreatedAt()),
Meta: paymentMeta(p),
IdempotencyKey: "",
}
}
func firstFailure(operations []PaymentOperation) (string, string) {
for _, op := range operations {
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {
continue
}
return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason)
}
return "", ""
}
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
if len(steps) == 0 {
return nil
}
ops := make([]PaymentOperation, 0, len(steps))
for _, step := range steps {
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue
}
ops = append(ops, toPaymentOperation(step))
}
if len(ops) == 0 {
return nil
}
return ops
}
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
}
failure := step.GetFailure()
if failure == nil {
return op
}
op.FailureCode = enumJSONName(failure.GetCategory().String())
op.FailureReason = strings.TrimSpace(failure.GetMessage())
if op.FailureReason == "" {
op.FailureReason = strings.TrimSpace(failure.GetCode())
}
return op
}
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
switch visibility {
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT:
return false
default:
return true
}
}
func paymentMeta(p *orchestrationv2.Payment) map[string]string {
if p == nil {
return nil
}
meta := make(map[string]string)
if quotationRef := strings.TrimSpace(p.GetQuotationRef()); quotationRef != "" {
meta["quotationRef"] = quotationRef
}
if clientPaymentRef := strings.TrimSpace(p.GetClientPaymentRef()); clientPaymentRef != "" {
meta["clientPaymentRef"] = clientPaymentRef
}
if version := p.GetVersion(); version > 0 {
meta["version"] = strconv.FormatUint(version, 10)
}
if len(meta) == 0 {
return nil
}
return meta
}
func timestampAsTime(ts *timestamppb.Timestamp) time.Time {
if ts == nil {
return time.Time{}
}
return ts.AsTime()
}
func enumJSONName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,136 @@
package sresponse
import (
"testing"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
steps := []*orchestrationv2.StepExecution{
{
StepRef: "hidden",
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
},
{
StepRef: "user",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
},
{
StepRef: "unspecified",
StepCode: "hop.4.card_payout.observe",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED,
},
{
StepRef: "backoffice",
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
},
}
ops := toUserVisibleOperations(steps)
if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
}
if got, want := ops[0].StepRef, "user"; got != want {
t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want)
}
if got, want := ops[1].StepRef, "unspecified"; got != want {
t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-1",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
StepExecutions: []*orchestrationv2.StepExecution{
{
StepRef: "hidden_failed",
StepCode: "edge.1_2.ledger.debit",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
Message: "internal hold release failure",
},
},
{
StepRef: "user_failed",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN,
Message: "card declined",
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if got, want := dto.FailureCode, "failure_chain"; got != want {
t.Fatalf("failure_code mismatch: got=%q want=%q", got, want)
}
if got, want := dto.FailureReason, "card declined"; got != want {
t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want)
}
if len(dto.Operations) != 1 {
t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations))
}
if got, want := dto.Operations[0].StepRef, "user_failed"; got != want {
t.Fatalf("visible operation mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-2",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
StepExecutions: []*orchestrationv2.StepExecution{
{
StepRef: "hidden_failed",
StepCode: "edge.1_2.ledger.release",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
Failure: &orchestrationv2.Failure{
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
Message: "backoffice only failure",
},
},
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if got := dto.FailureCode; got != "" {
t.Fatalf("expected empty failure_code, got=%q", got)
}
if got := dto.FailureReason; got != "" {
t.Fatalf("expected empty failure_reason, got=%q", got)
}
if len(dto.Operations) != 0 {
t.Fatalf("expected no visible operations, got=%d", len(dto.Operations))
}
}
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote(&quotationv2.PaymentQuote{
QuoteRef: "quote-1",
IntentRef: "intent-1",
})
if dto == nil {
t.Fatal("expected non-nil quote dto")
}
if got, want := dto.QuoteRef, "quote-1"; got != want {
t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want)
}
if got, want := dto.IntentRef, "intent-1"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -0,0 +1,45 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type permissionsDescription struct {
Roles []model.RoleDescription `json:"roles"`
Policies []model.PolicyDescription `json:"policies"`
}
type permissionsData struct {
Roles []model.Role `json:"roles"`
Policies []model.RolePolicy `json:"policies"`
Permissions []model.Permission `json:"permissions"`
}
type permissionsResponse struct {
authResponse `json:",inline"`
Descriptions permissionsDescription `json:"descriptions"`
Permissions permissionsData `json:"permissions"`
}
func Permisssions(logger mlogger.Logger,
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
accessToken *TokenData,
) http.HandlerFunc {
return response.Ok(logger, permissionsResponse{
Descriptions: permissionsDescription{
Roles: rolesDescs,
Policies: policiesDescs,
},
Permissions: permissionsData{
Roles: roles,
Policies: policies,
Permissions: permissions,
},
authResponse: authResponse{AccessToken: *accessToken},
})
}

View File

@@ -0,0 +1,14 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/model"
emodel "github.com/tech/sendico/server/interface/model"
)
type (
HandlerFunc = func(r *http.Request) http.HandlerFunc
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc
)

View File

@@ -0,0 +1,27 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
)
type resultAuth struct {
authResponse `json:",inline"`
response.Result `json:",inline"`
}
func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, &resultAuth{
Result: response.Result{Result: true},
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Accepted(logger, &resultAuth{
Result: response.Result{Result: false},
authResponse: authResponse{AccessToken: *accessToken},
})
}

View File

@@ -0,0 +1,16 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
func SignUp(logger mlogger.Logger, account *model.Account) http.HandlerFunc {
return response.Ok(
logger,
&account.AccountBase,
)
}

View File

@@ -0,0 +1,23 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
)
type SignupAvailability struct {
Login string `json:"login"`
Available bool `json:"available"`
}
func SignUpAvailability(logger mlogger.Logger, login string, available bool) http.HandlerFunc {
return response.Ok(
logger,
SignupAvailability{
Login: login,
Available: available,
},
)
}

View File

@@ -0,0 +1,8 @@
package sresponse
import "time"
type TokenData struct {
Token string `json:"token"`
Expiration time.Time `json:"expiration"`
}

View File

@@ -0,0 +1,292 @@
package sresponse
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
type walletAsset struct {
Chain string `json:"chain"`
TokenSymbol string `json:"tokenSymbol"`
ContractAddress string `json:"contractAddress"`
}
type wallet struct {
WalletRef string `json:"walletRef"`
OrganizationRef string `json:"organizationRef"`
OwnerRef string `json:"ownerRef"`
Asset walletAsset `json:"asset"`
DepositAddress string `json:"depositAddress"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type walletsResponse struct {
authResponse `json:",inline"`
Wallets []wallet `json:"wallets"`
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
}
type walletBalance struct {
Available *paymenttypes.Money `json:"available,omitempty"`
PendingInbound *paymenttypes.Money `json:"pendingInbound,omitempty"`
PendingOutbound *paymenttypes.Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
}
type walletBalanceResponse struct {
authResponse `json:",inline"`
Balance walletBalance `json:"balance"`
}
func Wallets(logger mlogger.Logger, resp *chainv1.ListManagedWalletsResponse, accessToken *TokenData) http.HandlerFunc {
dto := walletsResponse{
Page: resp.GetPage(),
authResponse: authResponse{AccessToken: *accessToken},
}
dto.Wallets = make([]wallet, 0, len(resp.GetWallets()))
for _, w := range resp.GetWallets() {
dto.Wallets = append(dto.Wallets, toWallet(w))
}
return response.Ok(logger, dto)
}
func WalletBalance(logger mlogger.Logger, bal *chainv1.WalletBalance, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, walletBalanceResponse{
Balance: toWalletBalance(bal),
authResponse: authResponse{AccessToken: *accessToken},
})
}
func toWallet(w *chainv1.ManagedWallet) wallet {
if w == nil {
return wallet{}
}
asset := w.GetAsset()
chain := ""
token := ""
contract := ""
if asset != nil {
chain = chainNetworkValue(asset.GetChain())
token = asset.GetTokenSymbol()
contract = asset.GetContractAddress()
}
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{
WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(),
OwnerRef: w.GetOwnerRef(),
Asset: walletAsset{
Chain: chain,
TokenSymbol: token,
ContractAddress: contract,
},
DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(),
Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()),
}
}
func toWalletBalance(b *chainv1.WalletBalance) walletBalance {
if b == nil {
return walletBalance{}
}
return walletBalance{
Available: toMoney(b.GetAvailable()),
PendingInbound: toMoney(b.GetPendingInbound()),
PendingOutbound: toMoney(b.GetPendingOutbound()),
CalculatedAt: tsToString(b.GetCalculatedAt()),
}
}
func tsToString(ts *timestamppb.Timestamp) string {
if ts == nil {
return ""
}
return ts.AsTime().UTC().Format(time.RFC3339)
}
func chainNetworkValue(chain chainv1.ChainNetwork) string {
name := chain.String()
if !strings.HasPrefix(name, "CHAIN_NETWORK_") {
return "unspecified"
}
trimmed := strings.TrimPrefix(name, "CHAIN_NETWORK_")
if trimmed == "" {
return "unspecified"
}
return strings.ToLower(trimmed)
}
// WalletsFromAccounts converts connector accounts to wallet response format.
// Used when querying multiple gateways via discovery.
func WalletsFromAccounts(logger mlogger.Logger, accounts []*connectorv1.Account, accessToken *TokenData) http.HandlerFunc {
dto := walletsResponse{
authResponse: authResponse{AccessToken: *accessToken},
}
dto.Wallets = make([]wallet, 0, len(accounts))
for _, acc := range accounts {
if acc == nil {
continue
}
dto.Wallets = append(dto.Wallets, accountToWallet(acc))
}
return response.Ok(logger, dto)
}
func accountToWallet(acc *connectorv1.Account) wallet {
if acc == nil {
return wallet{}
}
// Extract wallet details from provider details
details := map[string]interface{}{}
if acc.GetProviderDetails() != nil {
details = acc.GetProviderDetails().AsMap()
}
walletRef := ""
if ref := acc.GetRef(); ref != nil {
walletRef = strings.TrimSpace(ref.GetAccountId())
}
if v := stringFromDetails(details, "wallet_ref"); v != "" {
walletRef = v
}
organizationRef := stringFromDetails(details, "organization_ref")
ownerRef := strings.TrimSpace(acc.GetOwnerRef())
if v := stringFromDetails(details, "owner_ref"); v != "" {
ownerRef = v
}
chain := stringFromDetails(details, "network")
tokenSymbol := stringFromDetails(details, "token_symbol")
contractAddress := stringFromDetails(details, "contract_address")
depositAddress := stringFromDetails(details, "deposit_address")
name := ""
if d := acc.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(acc.GetLabel())
}
if name == "" {
name = walletRef
}
var description *string
if d := acc.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
status := acc.GetState().String()
// Convert connector state to wallet status format
switch acc.GetState() {
case connectorv1.AccountState_ACCOUNT_ACTIVE:
status = "MANAGED_WALLET_ACTIVE"
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
status = "MANAGED_WALLET_SUSPENDED"
case connectorv1.AccountState_ACCOUNT_CLOSED:
status = "MANAGED_WALLET_CLOSED"
}
return wallet{
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Asset: walletAsset{
Chain: chain,
TokenSymbol: tokenSymbol,
ContractAddress: contractAddress,
},
DepositAddress: depositAddress,
Status: status,
Name: name,
Description: description,
CreatedAt: tsToString(acc.GetCreatedAt()),
UpdatedAt: tsToString(acc.GetUpdatedAt()),
}
}
func stringFromDetails(details map[string]interface{}, key string) string {
if details == nil {
return ""
}
if value, ok := details[key]; ok {
return strings.TrimSpace(fmt.Sprint(value))
}
return ""
}
// WalletBalanceFromConnector converts connector balance to wallet balance response format.
// Used when querying gateways via discovery.
func WalletBalanceFromConnector(logger mlogger.Logger, bal *connectorv1.Balance, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, walletBalanceResponse{
Balance: connectorBalanceToWalletBalance(bal),
authResponse: authResponse{AccessToken: *accessToken},
})
}
func connectorBalanceToWalletBalance(b *connectorv1.Balance) walletBalance {
if b == nil {
return walletBalance{}
}
return walletBalance{
Available: connectorMoneyToModel(b.GetAvailable()),
PendingInbound: connectorMoneyToModel(b.GetPendingInbound()),
PendingOutbound: connectorMoneyToModel(b.GetPendingOutbound()),
CalculatedAt: tsToString(b.GetCalculatedAt()),
}
}
func connectorMoneyToModel(m *moneyv1.Money) *paymenttypes.Money {
if m == nil {
return nil
}
return &paymenttypes.Money{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}
}

View File

@@ -0,0 +1,57 @@
package ws
import (
"net/http"
api "github.com/tech/sendico/pkg/api/http"
r "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
func respond(logger mlogger.Logger, conn *websocket.Conn, messageType, apiStatus, requestID string, data any) {
message := ws.Message{
BaseResponse: r.BaseResponse{
Status: apiStatus,
Data: data,
},
ID: requestID,
MessageType: messageType,
}
if err := websocket.JSON.Send(conn, message); err != nil {
logger.Warn("Failed to send error message", zap.Error(err), zap.Any("message", message))
}
}
func errorf(logger mlogger.Logger, messageType, requestID string, conn *websocket.Conn, resp r.ErrorResponse) {
logger.Debug(
"Writing error sresponse",
zap.String("error", resp.Error),
zap.String("details", resp.Details),
zap.Int("code", resp.Code),
)
respond(logger, conn, messageType, api.MSError, requestID, &resp)
}
func Ok(logger mlogger.Logger, requestID string, data any) ws.ResponseHandler {
res := func(messageType string, conn *websocket.Conn) {
logger.Debug("Successfully executed request", zap.Any("sresponse", data))
respond(logger, conn, messageType, api.MSSuccess, requestID, data)
}
return res
}
func Internal(logger mlogger.Logger, requestID string, err error) ws.ResponseHandler {
res := func(messageType string, conn *websocket.Conn) {
errorf(logger, messageType, requestID, conn,
r.ErrorResponse{
Error: "internal_error",
Details: err.Error(),
Code: http.StatusInternalServerError,
})
}
return res
}