fixed mntx payout sequence

This commit is contained in:
Stephan D
2026-03-04 02:46:51 +01:00
parent 8377b6b2af
commit 56bf49aa03
19 changed files with 385 additions and 121 deletions

View File

@@ -20,12 +20,15 @@ This service now supports Monetix “payout by card”.
Payload is built per Monetix spec: Payload is built per Monetix spec:
``` ```
{ {
"general": { "project_id": <int>, "payment_id": "<payout_id>", "signature": "<hmac>" }, "general": { "project_id": <int>, "payment_id": "<operation_ref>", "signature": "<hmac>" },
"customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? }, "customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? },
"payment": { amount: <minor_units>, currency: "<ISO-4217>" }, "payment": { amount: <minor_units>, currency: "<ISO-4217>" },
"card": { pan, year?, month?, card_holder } "card": { pan, year?, month?, card_holder }
} }
``` ```
Gateway request contract additionally requires `parent_payment_ref` as a first-class field
(separate from Monetix `payment_id`).
Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`. Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`.
## Callback handling ## Callback handling

View File

@@ -39,8 +39,6 @@ type gatewayClient struct {
logger mlogger.Logger logger mlogger.Logger
} }
const parentPaymentRefMetadataKey = "parent_payment_ref"
// New dials the Monetix gateway. // New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults() cfg.setDefaults()
@@ -106,7 +104,7 @@ func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPa
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError()) return nil, connectorError(resp.GetReceipt().GetError())
} }
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
} }
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
@@ -123,7 +121,7 @@ func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.C
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError()) return nil, connectorError(resp.GetReceipt().GetError())
} }
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), resp.GetReceipt())}, nil return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
} }
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
@@ -200,9 +198,10 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin
} }
func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} {
metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) metadata := sanitizeMetadata(req.GetMetadata())
params := map[string]interface{}{ params := map[string]interface{}{
"project_id": req.GetProjectId(), "project_id": req.GetProjectId(),
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
"customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
@@ -227,9 +226,10 @@ func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{}
} }
func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} {
metadata := metadataWithParentPaymentRef(req.GetMetadata(), req.GetPayoutId()) metadata := sanitizeMetadata(req.GetMetadata())
params := map[string]interface{}{ params := map[string]interface{}{
"project_id": req.GetProjectId(), "project_id": req.GetProjectId(),
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
"customer_id": strings.TrimSpace(req.GetCustomerId()), "customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
@@ -263,9 +263,10 @@ func moneyFromMinor(amount int64, currency string) *moneyv1.Money {
} }
} }
func payoutFromReceipt(payoutID, operationRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { func payoutFromReceipt(payoutID, operationRef, parentPaymentRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
state := &mntxv1.CardPayoutState{ state := &mntxv1.CardPayoutState{
PayoutId: fallbackNonEmpty(operationRef, payoutID), PayoutId: fallbackNonEmpty(operationRef, payoutID),
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
} }
if receipt == nil { if receipt == nil {
return state return state
@@ -288,9 +289,8 @@ func fallbackNonEmpty(values ...string) string {
return "" return ""
} }
func metadataWithParentPaymentRef(source map[string]string, parentPaymentRef string) map[string]string { func sanitizeMetadata(source map[string]string) map[string]string {
parentPaymentRef = strings.TrimSpace(parentPaymentRef) if len(source) == 0 {
if len(source) == 0 && parentPaymentRef == "" {
return nil return nil
} }
out := map[string]string{} out := map[string]string{}
@@ -301,9 +301,6 @@ func metadataWithParentPaymentRef(source map[string]string, parentPaymentRef str
} }
out[k] = strings.TrimSpace(value) out[k] = strings.TrimSpace(value)
} }
if parentPaymentRef != "" && strings.TrimSpace(out[parentPaymentRefMetadataKey]) == "" {
out[parentPaymentRefMetadataKey] = parentPaymentRef
}
if len(out) == 0 { if len(out) == 0 {
return nil return nil
} }

View File

@@ -22,6 +22,7 @@ func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPa
log.Info("Create card payout request received", log.Info("Create card payout request received",
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
zap.Int64("amount_minor", req.GetAmountMinor()), zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
@@ -49,6 +50,7 @@ func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.C
log.Info("Create card token payout request received", log.Info("Create card token payout request received",
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
zap.Int64("amount_minor", req.GetAmountMinor()), zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
@@ -122,6 +124,7 @@ func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayout
return req return req
} }
r.PayoutId = strings.TrimSpace(r.GetPayoutId()) r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
r.CustomerId = strings.TrimSpace(r.GetCustomerId()) r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
@@ -151,6 +154,7 @@ func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.
return req return req
} }
r.PayoutId = strings.TrimSpace(r.GetPayoutId()) r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
r.CustomerId = strings.TrimSpace(r.GetCustomerId()) r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())

View File

@@ -17,6 +17,9 @@ func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
return err return err
} }
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" { if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
} }

View File

@@ -28,6 +28,11 @@ func TestValidateCardPayoutRequest_Errors(t *testing.T) {
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" }, mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
expected: "missing_operation_ref", expected: "missing_operation_ref",
}, },
{
name: "missing_parent_payment_ref",
mutate: func(r *mntxv1.CardPayoutRequest) { r.ParentPaymentRef = "" },
expected: "missing_parent_payment_ref",
},
{ {
name: "both_operation_and_payout_identity", name: "both_operation_and_payout_identity",
mutate: func(r *mntxv1.CardPayoutRequest) { mutate: func(r *mntxv1.CardPayoutRequest) {

View File

@@ -39,8 +39,6 @@ type cardPayoutProcessor struct {
perTxMinAmountMinorByCurrency map[string]int64 perTxMinAmountMinorByCurrency map[string]int64
} }
const parentPaymentRefMetadataKey = "parent_payment_ref"
func mergePayoutStateWithExisting(state, existing *model.CardPayout) { func mergePayoutStateWithExisting(state, existing *model.CardPayout) {
if state == nil || existing == nil { if state == nil || existing == nil {
return return
@@ -59,7 +57,7 @@ func mergePayoutStateWithExisting(state, existing *model.CardPayout) {
if state.IntentRef == "" { if state.IntentRef == "" {
state.IntentRef = existing.IntentRef state.IntentRef = existing.IntentRef
} }
if state.PaymentRef == "" { if existing.PaymentRef != "" {
state.PaymentRef = existing.PaymentRef state.PaymentRef = existing.PaymentRef
} }
} }
@@ -72,47 +70,6 @@ func findOperationRef(operationRef, payoutID string) string {
return strings.TrimSpace(payoutID) return strings.TrimSpace(payoutID)
} }
func parentPaymentRefFromOperationRef(operationRef string) string {
ref := strings.TrimSpace(operationRef)
if ref == "" {
return ""
}
if idx := strings.Index(ref, ":hop_"); idx > 0 {
return ref[:idx]
}
if idx := strings.Index(ref, ":"); idx > 0 {
return ref[:idx]
}
return ""
}
func parentPaymentRefFromMetadata(metadata map[string]string) string {
if len(metadata) == 0 {
return ""
}
return strings.TrimSpace(metadata[parentPaymentRefMetadataKey])
}
func resolveParentPaymentRef(payoutID, operationRef string, metadata map[string]string) string {
if parent := parentPaymentRefFromMetadata(metadata); parent != "" {
return parent
}
payoutRef := strings.TrimSpace(payoutID)
opRef := strings.TrimSpace(operationRef)
// Legacy callers may pass parent payment ref via payout_id and a distinct operation_ref.
if payoutRef != "" && (opRef == "" || payoutRef != opRef) {
return payoutRef
}
if parent := parentPaymentRefFromOperationRef(opRef); parent != "" {
return parent
}
if payoutRef != "" {
return payoutRef
}
return opRef
}
func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) {
if p == nil || state == nil { if p == nil || state == nil {
return nil, nil return nil, nil
@@ -129,23 +86,6 @@ func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state
return nil, err return nil, err
} }
} }
// Legacy mode may still map operation ref to payout/payment ref.
if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef == "" || paymentRef != opRef {
return nil, nil
}
}
if paymentRef := strings.TrimSpace(state.PaymentRef); paymentRef != "" {
existing, err := p.store.Payouts().FindByPaymentID(ctx, paymentRef)
if err == nil {
if existing != nil {
return existing, nil
}
}
if !errors.Is(err, merrors.ErrNoData) {
if err != nil {
return nil, err
}
}
} }
return nil, nil return nil, nil
} }
@@ -310,7 +250,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
req = sanitizeCardPayoutRequest(req) req = sanitizeCardPayoutRequest(req)
operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId())
parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef())
p.logger.Info("Submitting card payout", p.logger.Info("Submitting card payout",
zap.String("parent_payment_ref", parentPaymentRef), zap.String("parent_payment_ref", parentPaymentRef),
@@ -435,7 +375,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
req = sanitizeCardTokenPayoutRequest(req) req = sanitizeCardTokenPayoutRequest(req)
operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId())
parentPaymentRef := resolveParentPaymentRef(req.GetPayoutId(), operationRef, req.GetMetadata()) parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef())
p.logger.Info("Submitting card token payout", p.logger.Info("Submitting card token payout",
zap.String("parent_payment_ref", parentPaymentRef), zap.String("parent_payment_ref", parentPaymentRef),
@@ -609,7 +549,7 @@ func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mnt
} }
id := strings.TrimSpace(payoutID) id := strings.TrimSpace(payoutID)
p.logger.Info("Card payout status requested", zap.String("operation_or_payout_id", id)) p.logger.Info("Card payout status requested", zap.String("operation_ref", id))
if id == "" { if id == "" {
p.logger.Warn("Payout status requested with empty payout_id") p.logger.Warn("Payout status requested with empty payout_id")
@@ -622,11 +562,8 @@ func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mnt
return nil, err return nil, err
} }
if state == nil || errors.Is(err, merrors.ErrNoData) { if state == nil || errors.Is(err, merrors.ErrNoData) {
state, err = p.store.Payouts().FindByPaymentID(ctx, id) p.logger.Warn("Payout status not found", zap.String("operation_ref", id))
if err != nil || state == nil { return nil, merrors.NoData("payout not found")
p.logger.Warn("Payout status not found", zap.String("operation_or_payout_id", id), zap.Error(err))
return nil, merrors.NoData("payout not found")
}
} }
p.logger.Info("Card payout status resolved", p.logger.Info("Card payout status resolved",

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"testing" "testing"
@@ -45,8 +46,9 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
repo := newMockRepository() repo := newMockRepository()
repo.payouts.Save(&model.CardPayout{ repo.payouts.Save(&model.CardPayout{
PaymentRef: "payout-1", PaymentRef: "payment-parent-1",
CreatedAt: existingCreated, OperationRef: "payout-1",
CreatedAt: existingCreated,
}) })
httpClient := &http.Client{ httpClient := &http.Client{
@@ -227,3 +229,173 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
t.Fatalf("expected success status in model, got %v", state.Status) t.Fatalf("expected success status in model, got %v", state.Status)
} }
} }
func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparately(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var callN int
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
callN++
resp := monetix.APIResponse{}
resp.Operation.RequestID = fmt.Sprintf("req-%d", callN)
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
parentPaymentRef := "payment-parent-1"
op1 := parentPaymentRef + ":hop_4_card_payout_send"
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
req1 := validCardPayoutRequest()
req1.PayoutId = ""
req1.OperationRef = op1
req1.IdempotencyKey = "idem-1"
req1.ParentPaymentRef = parentPaymentRef
req1.CardPan = "2204310000002456"
req2 := validCardPayoutRequest()
req2.PayoutId = ""
req2.OperationRef = op2
req2.IdempotencyKey = "idem-2"
req2.ParentPaymentRef = parentPaymentRef
req2.CardPan = "2204320000009754"
if _, err := processor.Submit(context.Background(), req1); err != nil {
t.Fatalf("first submit failed: %v", err)
}
if _, err := processor.Submit(context.Background(), req2); err != nil {
t.Fatalf("second submit failed: %v", err)
}
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
if err != nil || first == nil {
t.Fatalf("expected first operation stored, err=%v", err)
}
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
if err != nil || second == nil {
t.Fatalf("expected second operation stored, err=%v", err)
}
if got, want := first.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("first parent payment ref mismatch: got=%q want=%q", got, want)
}
if got, want := second.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
}
if got, want := first.OperationRef, op1; got != want {
t.Fatalf("first operation ref mismatch: got=%q want=%q", got, want)
}
if got, want := second.OperationRef, op2; got != want {
t.Fatalf("second operation ref mismatch: got=%q want=%q", got, want)
}
if first.ProviderPaymentID == "" || second.ProviderPaymentID == "" {
t.Fatalf("expected provider payment ids for both operations")
}
if first.ProviderPaymentID == second.ProviderPaymentID {
t.Fatalf("expected different provider payment ids, got=%q", first.ProviderPaymentID)
}
}
func TestCardPayoutProcessor_ProcessCallback_UpdatesMatchingOperationWithinSameParent(t *testing.T) {
cfg := monetix.Config{
SecretKey: "secret",
StatusSuccess: "success",
StatusProcessing: "processing",
AllowedCurrencies: []string{"RUB"},
}
parentPaymentRef := "payment-parent-1"
op1 := parentPaymentRef + ":hop_4_card_payout_send"
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
now := time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)
repo := newMockRepository()
repo.payouts.Save(&model.CardPayout{
PaymentRef: parentPaymentRef,
OperationRef: op1,
Status: model.PayoutStatusWaiting,
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now.Add(-time.Minute),
})
repo.payouts.Save(&model.CardPayout{
PaymentRef: parentPaymentRef,
OperationRef: op2,
Status: model.PayoutStatusWaiting,
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now.Add(-time.Minute),
})
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: now},
repo,
&http.Client{},
nil,
)
cb := baseCallback()
cb.Payment.ID = op2
cb.Payment.Status = "success"
cb.Operation.Status = "success"
cb.Operation.Code = "0"
cb.Operation.Provider.PaymentID = "provider-op-2"
cb.Payment.Sum.Currency = "RUB"
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
if err != nil {
t.Fatalf("failed to sign callback: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
status, err := processor.ProcessCallback(context.Background(), payload)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status ok, got %d", status)
}
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
if err != nil || first == nil {
t.Fatalf("expected first operation present, err=%v", err)
}
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
if err != nil || second == nil {
t.Fatalf("expected second operation present, err=%v", err)
}
if got, want := first.Status, model.PayoutStatusWaiting; got != want {
t.Fatalf("first operation status mismatch: got=%v want=%v", got, want)
}
if got, want := second.Status, model.PayoutStatusSuccess; got != want {
t.Fatalf("second operation status mismatch: got=%v want=%v", got, want)
}
if got, want := second.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -16,6 +16,9 @@ func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg mone
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
return err return err
} }
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" { if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
} }

View File

@@ -28,6 +28,11 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" }, mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
expected: "missing_operation_ref", expected: "missing_operation_ref",
}, },
{
name: "missing_parent_payment_ref",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.ParentPaymentRef = "" },
expected: "missing_parent_payment_ref",
},
{ {
name: "both_operation_and_payout_identity", name: "both_operation_and_payout_identity",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { mutate: func(r *mntxv1.CardTokenPayoutRequest) {

View File

@@ -69,18 +69,18 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
if err != nil { if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
} }
parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref"))
payoutID := strings.TrimSpace(reader.String("payout_id")) payoutID := operationIDForRequest(operationRef)
payoutID = operationIDForRequest(payoutID, operationRef, idempotencyKey)
if strings.TrimSpace(reader.String("card_token")) != "" { if strings.TrimSpace(reader.String("card_token")) != "" {
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency)) resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency))
if err != nil { if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
} }
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
} }
cr := buildCardPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency) cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)
resp, err := s.CreateCardPayout(ctx, cr) resp, err := s.CreateCardPayout(ctx, cr)
if err != nil { if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
@@ -117,6 +117,7 @@ func mntxOperationParams() []*connectorv1.OperationParamSpec {
{Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false}, {Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false},
{Key: "project_id", Type: connectorv1.ParamType_INT, Required: false}, {Key: "project_id", Type: connectorv1.ParamType_INT, Required: false},
{Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false}, {Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false},
@@ -167,24 +168,12 @@ func currencyFromOperation(op *connectorv1.Operation) string {
return strings.ToUpper(currency) return strings.ToUpper(currency)
} }
func operationIDForRequest(payoutID, operationRef, idempotencyKey string) string { func operationIDForRequest(operationRef string) string {
if ref := strings.TrimSpace(operationRef); ref != "" { return strings.TrimSpace(operationRef)
return ref
}
if ref := strings.TrimSpace(payoutID); ref != "" {
return ref
}
return strings.TrimSpace(idempotencyKey)
} }
func metadataFromReader(reader params.Reader) map[string]string { func metadataFromReader(reader params.Reader) map[string]string {
metadata := reader.StringMap("metadata") metadata := reader.StringMap("metadata")
if metadata == nil {
metadata = map[string]string{}
}
if parentRef := strings.TrimSpace(reader.String("payout_id")); parentRef != "" && strings.TrimSpace(metadata[parentPaymentRefMetadataKey]) == "" {
metadata[parentPaymentRefMetadataKey] = parentRef
}
if len(metadata) == 0 { if len(metadata) == 0 {
return nil return nil
} }
@@ -192,7 +181,7 @@ func metadataFromReader(reader params.Reader) map[string]string {
} }
func buildCardTokenPayoutRequestFromParams(reader params.Reader, func buildCardTokenPayoutRequestFromParams(reader params.Reader,
payoutID, idempotencyKey, operationRef, intentRef string, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
operationRef = strings.TrimSpace(operationRef) operationRef = strings.TrimSpace(operationRef)
payoutID = strings.TrimSpace(payoutID) payoutID = strings.TrimSpace(payoutID)
@@ -201,6 +190,7 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader,
} }
req := &mntxv1.CardTokenPayoutRequest{ req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID, PayoutId: payoutID,
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
ProjectId: readerInt64(reader, "project_id"), ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
@@ -226,7 +216,7 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader,
} }
func buildCardPayoutRequestFromParams(reader params.Reader, func buildCardPayoutRequestFromParams(reader params.Reader,
payoutID, idempotencyKey, operationRef, intentRef string, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
amountMinor int64, currency string) *mntxv1.CardPayoutRequest { amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
operationRef = strings.TrimSpace(operationRef) operationRef = strings.TrimSpace(operationRef)
payoutID = strings.TrimSpace(payoutID) payoutID = strings.TrimSpace(payoutID)
@@ -235,6 +225,7 @@ func buildCardPayoutRequestFromParams(reader params.Reader,
} }
return &mntxv1.CardPayoutRequest{ return &mntxv1.CardPayoutRequest{
PayoutId: payoutID, PayoutId: payoutID,
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
ProjectId: readerInt64(reader, "project_id"), ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")), CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),

View File

@@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"strings"
"time" "time"
"github.com/tech/sendico/gateway/mntx/storage/model" "github.com/tech/sendico/gateway/mntx/storage/model"
@@ -22,7 +23,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *
} }
return &model.CardPayout{ return &model.CardPayout{
PaymentRef: p.PayoutId, PaymentRef: strings.TrimSpace(p.GetParentPaymentRef()),
OperationRef: p.GetOperationRef(), OperationRef: p.GetOperationRef(),
IntentRef: p.GetIntentRef(), IntentRef: p.GetIntentRef(),
IdempotencyKey: p.GetIdempotencyKey(), IdempotencyKey: p.GetIdempotencyKey(),
@@ -42,6 +43,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *
func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState { func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState {
return &mntxv1.CardPayoutState{ return &mntxv1.CardPayoutState{
PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef), PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef),
ParentPaymentRef: m.PaymentRef,
ProjectId: m.ProjectID, ProjectId: m.ProjectID,
CustomerId: m.CustomerID, CustomerId: m.CustomerID,
AmountMinor: m.AmountMinor, AmountMinor: m.AmountMinor,

View File

@@ -36,6 +36,7 @@ func testMonetixConfig() monetix.Config {
func validCardPayoutRequest() *mntxv1.CardPayoutRequest { func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
return &mntxv1.CardPayoutRequest{ return &mntxv1.CardPayoutRequest{
PayoutId: "payout-1", PayoutId: "payout-1",
ParentPaymentRef: "payment-parent-1",
CustomerId: "cust-1", CustomerId: "cust-1",
CustomerFirstName: "Jane", CustomerFirstName: "Jane",
CustomerLastName: "Doe", CustomerLastName: "Doe",
@@ -52,6 +53,7 @@ func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest { func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
return &mntxv1.CardTokenPayoutRequest{ return &mntxv1.CardTokenPayoutRequest{
PayoutId: "payout-1", PayoutId: "payout-1",
ParentPaymentRef: "payment-parent-1",
CustomerId: "cust-1", CustomerId: "cust-1",
CustomerFirstName: "Jane", CustomerFirstName: "Jane",
CustomerLastName: "Doe", CustomerLastName: "Doe",

View File

@@ -85,6 +85,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
if token := strings.TrimSpace(card.Token); token != "" { if token := strings.TrimSpace(card.Token); token != "" {
resp, createErr := client.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{ resp, createErr := client.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{
ProjectId: projectID, ProjectId: projectID,
ParentPaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
CustomerId: customer.id, CustomerId: customer.id,
CustomerFirstName: customer.firstName, CustomerFirstName: customer.firstName,
CustomerMiddleName: customer.middleName, CustomerMiddleName: customer.middleName,
@@ -122,6 +123,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
} }
resp, createErr := client.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{ resp, createErr := client.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{
ProjectId: projectID, ProjectId: projectID,
ParentPaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
CustomerId: customer.id, CustomerId: customer.id,
CustomerFirstName: customer.firstName, CustomerFirstName: customer.firstName,
CustomerMiddleName: customer.middleName, CustomerMiddleName: customer.middleName,
@@ -538,9 +540,6 @@ func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string
out = map[string]string{} out = map[string]string{}
} }
if payment != nil { if payment != nil {
if parentPaymentRef := strings.TrimSpace(payment.PaymentRef); parentPaymentRef != "" {
out[settlementMetadataParentPaymentRef] = parentPaymentRef
}
if quoteRef := firstNonEmpty( if quoteRef := firstNonEmpty(
strings.TrimSpace(payment.QuotationRef), strings.TrimSpace(payment.QuotationRef),
strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)),

View File

@@ -143,8 +143,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(discovery.RailCardPayout); got != want { if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(discovery.RailCardPayout); got != want {
t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want)
} }
if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-1"; got != want { if got, want := payoutReq.GetParentPaymentRef(), "payment-1"; got != want {
t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) t.Fatalf("parent_payment_ref mismatch: got=%q want=%q", got, want)
} }
if len(out.StepExecution.ExternalRefs) != 3 { if len(out.StepExecution.ExternalRefs) != 3 {
t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs)) t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs))
@@ -275,8 +275,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t
if got, want := payoutReq.GetMetadata()[batchmeta.MetaPayoutTargetRef], "recipient-2"; got != want { if got, want := payoutReq.GetMetadata()[batchmeta.MetaPayoutTargetRef], "recipient-2"; got != want {
t.Fatalf("target_ref metadata mismatch: got=%q want=%q", got, want) t.Fatalf("target_ref metadata mismatch: got=%q want=%q", got, want)
} }
if got, want := payoutReq.GetMetadata()[settlementMetadataParentPaymentRef], "payment-2"; got != want { if got, want := payoutReq.GetParentPaymentRef(), "payment-2"; got != want {
t.Fatalf("parent_payment_ref metadata mismatch: got=%q want=%q", got, want) t.Fatalf("parent_payment_ref mismatch: got=%q want=%q", got, want)
} }
} }
@@ -319,3 +319,140 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresGatewayRegistry(t *
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }
func TestGatewayCardPayoutExecutor_ExecuteCardPayout_BatchChildrenUseDistinctOperationRefsAndSharedParent(t *testing.T) {
orgID := bson.NewObjectID()
var payoutReqs []*mntxv1.CardPayoutRequest
executor := &gatewayCardPayoutExecutor{
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
Rail: discovery.RailCardPayout,
InvokeURI: "grpc://mntx-gateway:50051",
IsEnabled: true,
},
},
},
dialClient: func(_ context.Context, _ string) (mntxclient.Client, error) {
return &mntxclient.Fake{
CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
payoutReqs = append(payoutReqs, req)
return &mntxv1.CardPayoutResponse{
Payout: &mntxv1.CardPayoutState{
PayoutId: req.GetOperationRef(),
OperationRef: req.GetOperationRef(),
},
}, nil
},
}, nil
},
}
payment := &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-3",
IdempotencyKey: "idem-3",
QuotationRef: "quote-3",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-3",
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Pan: "2200700142860161",
ExpMonth: 3,
ExpYear: 2030,
},
},
Customer: &model.Customer{
ID: "cust-3",
FirstName: "Stephan",
LastName: "Deshevikh",
IP: "198.51.100.10",
},
Amount: &paymenttypes.Money{
Amount: "1.000000",
Currency: "USDT",
},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &paymenttypes.Money{
Amount: "76.50",
Currency: "RUB",
},
QuoteRef: "quote-3",
},
}
firstReq := sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "hop_4_card_payout_send",
StepCode: "hop.4.card_payout.send",
Action: discovery.RailOperationSend,
Rail: discovery.RailCardPayout,
Gateway: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
},
StepExecution: agg.StepExecution{
StepRef: "hop_4_card_payout_send",
StepCode: "hop.4.card_payout.send",
Attempt: 1,
},
}
secondReq := sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "hop_4_card_payout_send_2",
StepCode: "hop.4.card_payout.send",
Action: discovery.RailOperationSend,
Rail: discovery.RailCardPayout,
Gateway: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
Metadata: map[string]string{
batchmeta.MetaPayoutTargetRef: "recipient-2",
batchmeta.MetaAmount: "150",
batchmeta.MetaCurrency: "RUB",
batchmeta.MetaCardPan: "2200700142860162",
batchmeta.MetaCardExpMonth: "4",
batchmeta.MetaCardExpYear: "2030",
},
},
StepExecution: agg.StepExecution{
StepRef: "hop_4_card_payout_send_2",
StepCode: "hop.4.card_payout.send",
Attempt: 1,
},
}
if _, err := executor.ExecuteCardPayout(context.Background(), firstReq); err != nil {
t.Fatalf("first ExecuteCardPayout returned error: %v", err)
}
if _, err := executor.ExecuteCardPayout(context.Background(), secondReq); err != nil {
t.Fatalf("second ExecuteCardPayout returned error: %v", err)
}
if got, want := len(payoutReqs), 2; got != want {
t.Fatalf("submitted request count mismatch: got=%d want=%d", got, want)
}
if got, want := payoutReqs[0].GetOperationRef(), "payment-3:hop_4_card_payout_send"; got != want {
t.Fatalf("first operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := payoutReqs[1].GetOperationRef(), "payment-3:hop_4_card_payout_send_2"; got != want {
t.Fatalf("second operation_ref mismatch: got=%q want=%q", got, want)
}
if payoutReqs[0].GetPayoutId() != "" || payoutReqs[1].GetPayoutId() != "" {
t.Fatalf("expected empty payout_id for both child operations")
}
if got, want := payoutReqs[0].GetParentPaymentRef(), "payment-3"; got != want {
t.Fatalf("first parent_payment_ref mismatch: got=%q want=%q", got, want)
}
if got, want := payoutReqs[1].GetParentPaymentRef(), "payment-3"; got != want {
t.Fatalf("second parent_payment_ref mismatch: got=%q want=%q", got, want)
}
if payoutReqs[0].GetCardPan() == payoutReqs[1].GetCardPan() {
t.Fatalf("expected different destination cards across child operations")
}
}

View File

@@ -15,9 +15,8 @@ import (
) )
const ( const (
settlementMetadataQuoteRef = "quote_ref" settlementMetadataQuoteRef = "quote_ref"
settlementMetadataOutgoingLeg = "outgoing_leg" settlementMetadataOutgoingLeg = "outgoing_leg"
settlementMetadataParentPaymentRef = "parent_payment_ref"
) )
type gatewayProviderSettlementExecutor struct { type gatewayProviderSettlementExecutor struct {

View File

@@ -21,7 +21,7 @@ enum PayoutStatus {
// Request to initiate a Monetix card payout. // Request to initiate a Monetix card payout.
message CardPayoutRequest { message CardPayoutRequest {
string payout_id = 1; // internal payout id, mapped to Monetix payment_id string payout_id = 1; // alternate operation id mapped to Monetix payment_id
int64 project_id = 2; // optional override; defaults to configured project id int64 project_id = 2; // optional override; defaults to configured project id
string customer_id = 3; string customer_id = 3;
string customer_first_name = 4; string customer_first_name = 4;
@@ -40,9 +40,10 @@ message CardPayoutRequest {
uint32 card_exp_month = 17; uint32 card_exp_month = 17;
string card_holder = 18; string card_holder = 18;
map<string, string> metadata = 30; map<string, string> metadata = 30;
string operation_ref = 31; string operation_ref = 31; // preferred operation id mapped to Monetix payment_id
string idempotency_key = 32; string idempotency_key = 32;
string intent_ref = 33; string intent_ref = 33;
string parent_payment_ref = 34;
} }
// Persisted payout state for retrieval and status updates. // Persisted payout state for retrieval and status updates.
@@ -61,6 +62,7 @@ message CardPayoutState {
string operation_ref = 12; string operation_ref = 12;
string idempotency_key = 13; string idempotency_key = 13;
string intent_ref = 14; string intent_ref = 14;
string parent_payment_ref = 15;
} }
// Response returned immediately after submitting a payout to Monetix. // Response returned immediately after submitting a payout to Monetix.
@@ -97,7 +99,7 @@ message ListGatewayInstancesResponse {
// Request to initiate a token-based card payout. // Request to initiate a token-based card payout.
message CardTokenPayoutRequest { message CardTokenPayoutRequest {
string payout_id = 1; string payout_id = 1; // alternate operation id
int64 project_id = 2; int64 project_id = 2;
string customer_id = 3; string customer_id = 3;
@@ -119,9 +121,10 @@ message CardTokenPayoutRequest {
string card_holder = 16; string card_holder = 16;
string masked_pan = 17; string masked_pan = 17;
map<string, string> metadata = 30; map<string, string> metadata = 30;
string operation_ref = 31; string operation_ref = 31; // preferred operation id
string idempotency_key = 32; string idempotency_key = 32;
string intent_ref = 33; string intent_ref = 33;
string parent_payment_ref = 34;
} }
// Response returned immediately after submitting a token payout to Monetix. // Response returned immediately after submitting a token payout to Monetix.

View File

@@ -24,6 +24,7 @@ import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pshared/utils/payment/fx_helpers.dart';
class QuotationIntentBuilder { class QuotationIntentBuilder {
static const String _settlementCurrency = 'RUB'; static const String _settlementCurrency = 'RUB';

View File

@@ -26,6 +26,7 @@ import 'package:pshared/service/payment/quotation.dart';
import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pshared/utils/payment/quote_helpers.dart';
import 'package:pshared/utils/exception.dart'; import 'package:pshared/utils/exception.dart';
class QuotationProvider extends ChangeNotifier { class QuotationProvider extends ChangeNotifier {
static final _logger = Logger('provider.payment.quotation'); static final _logger = Logger('provider.payment.quotation');
Resource<PaymentQuote> _quotation = Resource( Resource<PaymentQuote> _quotation = Resource(

0
t.log Normal file
View File