docs format updated

This commit is contained in:
Stephan D
2026-03-13 01:28:51 +01:00
parent b4eb1437f6
commit f1840690e1
54 changed files with 677 additions and 195 deletions

View File

@@ -16,6 +16,8 @@ import (
"github.com/tech/sendico/pkg/mutil/mzap"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -24,7 +26,6 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const (
@@ -61,6 +62,10 @@ func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Accoun
if operationRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
}
paymentRef := strings.TrimSpace(query.Get("payment_ref"))
if paymentRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
}
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
if h != nil {
@@ -73,7 +78,18 @@ func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Accoun
return documentErrorResponse(a.logger, a.Name(), err)
}
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, paymentRef, op)
if payment, paymentErr := a.fetchPayment(r.Context(), orgRef.Hex(), paymentRef); paymentErr != nil {
a.logger.Warn(
"Failed to fetch payment snapshot for operation document",
zap.Error(paymentErr),
mzap.ObjRef("organization_ref", orgRef),
zap.String("payment_ref", paymentRef),
zap.String("operation_ref", operationRef),
)
} else {
enrichOperationDocumentRequestFromPayment(req, payment)
}
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
if err != nil {
@@ -263,6 +279,28 @@ func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, opera
return op, nil
}
func (a *PaymentAPI) fetchPayment(ctx context.Context, organizationRef, paymentRef string) (*orchestrationv2.Payment, error) {
if a.execution == nil {
return nil, merrors.Internal("payment execution client is not configured")
}
resp, err := a.execution.GetPayment(ctx, &orchestrationv2.GetPaymentRequest{
Meta: requestMeta(organizationRef, ""),
PaymentRef: strings.TrimSpace(paymentRef),
})
if err != nil {
return nil, err
}
if resp == nil {
return nil, merrors.NoData("payment orchestrator returned empty response")
}
if resp.GetPayment() == nil {
return nil, merrors.NoData("payment orchestrator returned empty payment")
}
return resp.GetPayment(), nil
}
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
for _, gw := range gateways {
@@ -313,13 +351,14 @@ func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService m
return &best
}
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, paymentRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
req := &documentsv1.GetOperationDocumentRequest{
OrganizationRef: strings.TrimSpace(organizationRef),
GatewayService: gatewayService,
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
PaymentRef: strings.TrimSpace(paymentRef),
OperationCode: strings.TrimSpace(op.GetType().String()),
OperationLabel: operationLabel(op.GetType()),
OperationLabel: strings.TrimSpace(op.GetType().String()),
OperationState: strings.TrimSpace(op.GetStatus().String()),
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
@@ -332,63 +371,94 @@ func operationDocumentRequest(organizationRef string, gatewayService mservice.Ty
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
}
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
req.FailureCode = firstNonEmpty(
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
failureCodeFromStatus(op.GetStatus()),
)
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
if isFailedOperationStatus(op.GetStatus()) {
req.FailureCode = strings.TrimSpace(op.GetStatus().String())
}
return req
}
func operationLabel(opType connectorv1.OperationType) string {
switch opType {
case connectorv1.OperationType_CREDIT:
return "Credit"
case connectorv1.OperationType_DEBIT:
return "Debit"
case connectorv1.OperationType_TRANSFER:
return "Transfer"
case connectorv1.OperationType_PAYOUT:
return "Payout"
case connectorv1.OperationType_FEE_ESTIMATE:
return "Fee Estimate"
case connectorv1.OperationType_FX:
return "FX"
case connectorv1.OperationType_GAS_TOPUP:
return "Gas Top Up"
default:
return strings.TrimSpace(opType.String())
}
func isFailedOperationStatus(status connectorv1.OperationStatus) bool {
return status == connectorv1.OperationStatus_OPERATION_FAILED || status == connectorv1.OperationStatus_OPERATION_CANCELLED
}
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
switch status {
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
return strings.TrimSpace(status.String())
default:
return ""
func enrichOperationDocumentRequestFromPayment(req *documentsv1.GetOperationDocumentRequest, payment *orchestrationv2.Payment) {
if req == nil || payment == nil {
return
}
req.PaymentRef = firstNonEmpty(strings.TrimSpace(req.GetPaymentRef()), strings.TrimSpace(payment.GetPaymentRef()))
req.ClientName = firstNonEmpty(strings.TrimSpace(req.GetClientName()), paymentClientName(payment))
}
func operationParamValue(params *structpb.Struct, keys ...string) string {
if params == nil {
func paymentClientName(payment *orchestrationv2.Payment) string {
if payment == nil {
return ""
}
values := params.AsMap()
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
return text
}
intent := payment.GetIntentSnapshot()
if intent == nil {
return ""
}
return ""
if customerDescription := strings.TrimSpace(intent.GetComment()); customerDescription != "" {
return customerDescription
}
return firstNonEmpty(
paymentEndpointClientName(intent.GetDestination()),
paymentEndpointClientName(intent.GetSource()),
)
}
func paymentEndpointClientName(endpoint *endpointv1.PaymentEndpoint) string {
if endpoint == nil {
return ""
}
method := endpoint.GetPaymentMethod()
if method == nil {
return ""
}
switch method.GetType() {
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
type cardMethodData struct {
FirstName string `bson:"firstName"`
LastName string `bson:"lastName"`
}
var payload cardMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return ""
}
return strings.TrimSpace(strings.Join([]string{
strings.TrimSpace(payload.FirstName),
strings.TrimSpace(payload.LastName),
}, " "))
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
type bankMethodData struct {
RecipientName string `bson:"recipientName"`
}
var payload bankMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return ""
}
return strings.TrimSpace(payload.RecipientName)
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
type ibanMethodData struct {
AccountHolder string `bson:"accountHolder"`
}
var payload ibanMethodData
if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
return ""
}
return strings.TrimSpace(payload.AccountHolder)
default:
return ""
}
}
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {

View File

@@ -0,0 +1,120 @@
package paymentapiimp
import (
"testing"
"github.com/tech/sendico/pkg/mservice"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestOperationDocumentRequest_UsesStructuredOperationFieldsOnly(t *testing.T) {
op := &connectorv1.Operation{
OperationRef: "pay-123:hop_1",
Type: connectorv1.OperationType_TRANSFER,
Status: connectorv1.OperationStatus_OPERATION_SUCCESS,
Money: &moneyv1.Money{
Amount: "100.50",
Currency: "USDT",
},
}
req := operationDocumentRequest("org-1", mservice.ChainGateway, "requested-op", "pay-123", op)
if req == nil {
t.Fatalf("expected request")
}
if got, want := req.GetPaymentRef(), "pay-123"; got != want {
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
}
if got := req.GetClientName(); got != "" {
t.Fatalf("expected empty client_name from operation-only request, got=%q", got)
}
if got := req.GetClientAddress(); got != "" {
t.Fatalf("expected empty client_address from operation-only request, got=%q", got)
}
}
func TestOperationDocumentRequest_FailureCodeFromStructuredStatus(t *testing.T) {
req := operationDocumentRequest("org-1", mservice.ChainGateway, "op", "pay-123", &connectorv1.Operation{
OperationRef: "pay-123:hop_1",
Type: connectorv1.OperationType_TRANSFER,
Status: connectorv1.OperationStatus_OPERATION_FAILED,
})
if req == nil {
t.Fatalf("expected request")
}
if got, want := req.GetFailureCode(), "OPERATION_FAILED"; got != want {
t.Fatalf("failure_code mismatch: got=%q want=%q", got, want)
}
}
func TestPaymentClientName_FromIntentComment(t *testing.T) {
payment := &orchestrationv2.Payment{
IntentSnapshot: &quotationv2.QuoteIntent{
Comment: "Jane Customer",
},
}
if got, want := paymentClientName(payment), "Jane Customer"; got != want {
t.Fatalf("paymentClientName mismatch: got=%q want=%q", got, want)
}
}
func TestPaymentClientName_FromStructuredCardEndpoint(t *testing.T) {
raw, err := bson.Marshal(map[string]string{
"firstName": "Jane",
"lastName": "Doe",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
payment := &orchestrationv2.Payment{
IntentSnapshot: &quotationv2.QuoteIntent{
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: raw,
},
},
},
},
}
if got, want := paymentClientName(payment), "Jane Doe"; got != want {
t.Fatalf("paymentClientName mismatch: got=%q want=%q", got, want)
}
}
func TestEnrichOperationDocumentRequestFromPayment_SetsClientName(t *testing.T) {
req := &documentsv1.GetOperationDocumentRequest{
OperationRef: "pay-123:hop_1",
}
payment := &orchestrationv2.Payment{
PaymentRef: "pay-123",
IntentSnapshot: &quotationv2.QuoteIntent{
Comment: "Client Name",
},
}
enrichOperationDocumentRequestFromPayment(req, payment)
if got, want := req.GetPaymentRef(), "pay-123"; got != want {
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
}
if got, want := req.GetClientName(), "Client Name"; got != want {
t.Fatalf("client_name mismatch: got=%q want=%q", got, want)
}
}
func TestOperationDocumentRequest_Compatibility(t *testing.T) {
var _ *documentsv1.GetOperationDocumentRequest = operationDocumentRequest("org-1", mservice.ChainGateway, "op", "pay-1", &connectorv1.Operation{})
}

View File

@@ -165,6 +165,10 @@ func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestration
return &orchestrationv2.ListPaymentsResponse{}, nil
}
func (*fakeExecutionClientForBatch) GetPayment(context.Context, *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) {
return &orchestrationv2.GetPaymentResponse{}, nil
}
func (*fakeExecutionClientForBatch) Close() error { return nil }
type fakeEnforcerForBatch struct {

View File

@@ -32,6 +32,7 @@ import (
type executionClient interface {
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
Close() error
}