payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

@@ -1,6 +1,8 @@
package srequest
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
)
@@ -23,8 +25,7 @@ type QuotePayment struct {
}
func (r *QuotePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil {
return err
}
@@ -43,7 +44,7 @@ type QuotePayments struct {
}
func (r *QuotePayments) Validate() error {
if err := r.PaymentBase.Validate(); err != nil {
if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil {
return err
}
if len(r.Intents) == 0 {
@@ -57,6 +58,20 @@ func (r *QuotePayments) Validate() error {
return nil
}
func validateQuoteIdempotency(previewOnly bool, idempotencyKey string) error {
key := strings.TrimSpace(idempotencyKey)
if previewOnly {
if key != "" {
return merrors.InvalidArgument("previewOnly requests must not include idempotencyKey", "idempotencyKey")
}
return nil
}
if key == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
return nil
}
type InitiatePayment struct {
PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent,omitempty"`

View File

@@ -0,0 +1,29 @@
package srequest
import "testing"
func TestValidateQuoteIdempotency(t *testing.T) {
t.Run("non-preview requires idempotency key", func(t *testing.T) {
if err := validateQuoteIdempotency(false, ""); err == nil {
t.Fatalf("expected error for empty idempotency key")
}
})
t.Run("preview rejects idempotency key", func(t *testing.T) {
if err := validateQuoteIdempotency(true, "idem-1"); err == nil {
t.Fatalf("expected error when preview request has idempotency key")
}
})
t.Run("preview accepts empty idempotency key", func(t *testing.T) {
if err := validateQuoteIdempotency(true, ""); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
t.Run("non-preview accepts idempotency key", func(t *testing.T) {
if err := validateQuoteIdempotency(false, "idem-1"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}

View File

@@ -2,6 +2,7 @@ package sresponse
import (
"net/http"
"strconv"
"strings"
"time"
@@ -11,9 +12,9 @@ import (
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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 {
@@ -96,7 +97,7 @@ type paymentResponse struct {
}
// PaymentQuote wraps a payment quote with refreshed access token.
func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *sharedv1.PaymentQuote, token *TokenData) http.HandlerFunc {
func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuoteResponse{
Quote: toPaymentQuote(quote),
IdempotencyKey: idempotencyKey,
@@ -105,7 +106,7 @@ func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *s
}
// PaymentQuotes wraps batch quotes with refreshed access token.
func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuotesResponse{
Quote: toPaymentQuotes(resp),
authResponse: authResponse{AccessToken: *token},
@@ -113,7 +114,7 @@ func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePayment
}
// Payments wraps a list of payments with refreshed access token.
func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token *TokenData) http.HandlerFunc {
func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(payments),
authResponse: authResponse{AccessToken: *token},
@@ -121,7 +122,7 @@ func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token
}
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(resp.GetPayments()),
Page: resp.GetPage(),
@@ -130,7 +131,7 @@ func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymen
}
// Payment wraps a payment with refreshed access token.
func PaymentResponse(logger mlogger.Logger, payment *sharedv1.Payment, token *TokenData) http.HandlerFunc {
func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentResponse{
Payment: toPayment(payment),
authResponse: authResponse{AccessToken: *token},
@@ -191,33 +192,20 @@ func toFxQuote(q *oraclev1.Quote) *FxQuote {
}
}
func toPaymentQuote(q *sharedv1.PaymentQuote) *PaymentQuote {
func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
if q == nil {
return nil
}
return &PaymentQuote{
QuoteRef: q.GetQuoteRef(),
DebitAmount: toMoney(q.GetDebitAmount()),
DebitSettlementAmount: toMoney(q.GetDebitSettlementAmount()),
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
DebitAmount: toMoney(q.GetPayerTotalDebitAmount()),
ExpectedSettlementAmount: toMoney(q.GetDestinationAmount()),
FeeLines: toFeeLines(q.GetFeeLines()),
FxQuote: toFxQuote(q.GetFxQuote()),
}
}
func toPaymentQuoteAggregate(q *sharedv1.PaymentQuoteAggregate) *PaymentQuoteAggregate {
if q == nil {
return nil
}
return &PaymentQuoteAggregate{
DebitAmounts: toMoneyList(q.GetDebitAmounts()),
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
}
}
func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes {
func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes {
if resp == nil {
return nil
}
@@ -233,12 +221,11 @@ func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes {
return &PaymentQuotes{
IdempotencyKey: resp.GetIdempotencyKey(),
QuoteRef: resp.GetQuoteRef(),
Aggregate: toPaymentQuoteAggregate(resp.GetAggregate()),
Quotes: quotes,
}
}
func toPayments(items []*sharedv1.Payment) []Payment {
func toPayments(items []*orchestrationv2.Payment) []Payment {
if len(items) == 0 {
return nil
}
@@ -254,22 +241,65 @@ func toPayments(items []*sharedv1.Payment) []Payment {
return result
}
func toPayment(p *sharedv1.Payment) *Payment {
func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
failureCode, failureReason := firstFailure(p.GetStepExecutions())
return &Payment{
PaymentRef: p.GetPaymentRef(),
IdempotencyKey: p.GetIdempotencyKey(),
State: enumJSONName(p.GetState().String()),
FailureCode: enumJSONName(p.GetFailureCode().String()),
FailureReason: p.GetFailureReason(),
LastQuote: toPaymentQuote(p.GetLastQuote()),
CreatedAt: p.GetCreatedAt().AsTime(),
Meta: p.GetMetadata(),
FailureCode: failureCode,
FailureReason: failureReason,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
CreatedAt: timestampAsTime(p.GetCreatedAt()),
Meta: paymentMeta(p),
IdempotencyKey: "",
}
}
func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) {
for _, step := range steps {
if step == nil || step.GetFailure() == nil {
continue
}
failure := step.GetFailure()
message := strings.TrimSpace(failure.GetMessage())
if message == "" {
message = strings.TrimSpace(failure.GetCode())
}
return enumJSONName(failure.GetCategory().String()), message
}
return "", ""
}
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))
}