payment button connected
This commit is contained in:
@@ -13,7 +13,7 @@ import (
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
|
||||
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||
Repository() storage.Repository
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||
@@ -255,8 +255,19 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
hasIntent := intent != nil
|
||||
hasQuote := quoteRef != ""
|
||||
switch {
|
||||
case !hasIntent && !hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
|
||||
case hasIntent && hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
|
||||
}
|
||||
if hasIntent {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
@@ -275,12 +286,12 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: orgRef,
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
Intent: intent,
|
||||
QuoteRef: req.GetQuoteRef(),
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -301,8 +312,11 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
if quoteSnapshot == nil {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
if err := requireNonNilIntent(resolvedIntent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
@@ -315,7 +329,7 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
|
||||
h.logger.Info("Payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", resolvedIntent.GetKind().String()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(entity),
|
||||
})
|
||||
@@ -355,7 +369,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
@@ -396,7 +410,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
@@ -439,7 +453,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||
Conversion: toProtoPayment(entity),
|
||||
})
|
||||
|
||||
@@ -103,33 +103,40 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
|
||||
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote := modelQuoteToProto(record.Quote)
|
||||
if quote == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
return quote, nil
|
||||
return quote, intent, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return quote, nil
|
||||
return quote, in.Intent, nil
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Intents) > 0 {
|
||||
if len(record.Intents) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intents[0]), nil
|
||||
}
|
||||
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intent), nil
|
||||
}
|
||||
|
||||
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
if record.Quote != nil {
|
||||
return modelQuoteToProto(record.Quote), nil
|
||||
}
|
||||
if len(record.Quotes) > 0 {
|
||||
if len(record.Quotes) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return modelQuoteToProto(record.Quotes[0]), nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -110,6 +110,35 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quote == nil || quote.GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected quote_ref q1, got %#v", quote)
|
||||
}
|
||||
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
@@ -140,6 +169,42 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
store := newHelperPaymentStore()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
}, WithClock(clockpkg.NewSystem()))
|
||||
svc.ensureHandlers()
|
||||
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
IdempotencyKey: "k1",
|
||||
}
|
||||
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("initiate by quote_ref failed: %v", err)
|
||||
}
|
||||
if resp == nil || resp.GetPayment() == nil {
|
||||
t.Fatalf("expected payment response")
|
||||
}
|
||||
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected intent amount to be resolved from quote")
|
||||
}
|
||||
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected last quote_ref to be set from stored quote")
|
||||
}
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
type stubRepo struct {
|
||||
|
||||
Reference in New Issue
Block a user