diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go index 359a32d..bd7f233 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go @@ -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) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index 4f40e5a..a1941e9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" @@ -61,7 +62,13 @@ 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), + mzap.ObjRef("org_ref", orgID), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.String("kind", intent.GetKind().String()), + ) } return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote}) @@ -79,7 +86,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1. if req == nil { return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) } - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta()) if err != nil { return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } @@ -101,7 +108,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1. Intent: intent, PreviewOnly: req.GetPreviewOnly(), } - quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq) + quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq) if err != nil { return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } @@ -132,11 +139,14 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1. ExpiresAt: expiresAt, } record.SetID(primitive.NewObjectID()) - record.SetOrganizationRef(orgID) + record.SetOrganizationRef(orgRef) if err := quotesStore.Create(ctx, record); err != nil { return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex())) + h.logger.Info("Stored payment quotes", + zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef), + zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)), + ) } return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ @@ -158,7 +168,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator if req == nil { return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) } - _, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + _, orgRef, err := validateMetaAndOrgRef(req.GetMeta()) if err != nil { return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } @@ -175,7 +185,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator if err != nil { return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } - record, err := quotesStore.GetByRef(ctx, orgID, quoteRef) + record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef) if err != nil { if errors.Is(err, storage.ErrQuoteNotFound) { return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) @@ -213,14 +223,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator quoteProto.QuoteRef = quoteRef perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents)) - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil { + if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil { payments = append(payments, toProtoPayment(existing)) continue } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) } - entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto) + entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto) if err = store.Create(ctx, entity); err != nil { if errors.Is(err, storage.ErrDuplicatePayment) { return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) @@ -235,6 +245,13 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator payments = append(payments, toProtoPayment(entity)) } + h.logger.Info( + "Payments initiated", + mzap.ObjRef("org_ref", orgRef), + zap.String("quote_ref", quoteRef), + zap.String("idempotency_key", idempotencyKey), + zap.Int("payment_count", len(payments)), + ) return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments}) } @@ -255,13 +272,31 @@ 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 { return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) } + h.logger.Debug( + "Initiate payment request accepted", + zap.String("org_ref", orgID.Hex()), + zap.String("idempotency_key", idempotencyKey), + zap.String("quote_ref", quoteRef), + zap.Bool("has_intent", hasIntent), + ) store, err := ensurePaymentsStore(h.engine.Repository()) if err != nil { @@ -269,18 +304,24 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv } if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { - h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex())) + h.logger.Debug( + "idempotent payment request reused", + zap.String("payment_ref", existing.PaymentRef), + zap.String("org_ref", orgID.Hex()), + zap.String("idempotency_key", idempotencyKey), + zap.String("quote_ref", quoteRef), + ) return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)}) } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { 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 +342,17 @@ 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) + } + h.logger.Debug( + "Payment quote resolved", + zap.String("org_ref", orgID.Hex()), + zap.String("quote_ref", quoteRef), + zap.Bool("quote_ref_used", quoteRef != ""), + ) - 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 +365,14 @@ 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()), + zap.String("quote_ref", quoteSnapshot.GetQuoteRef()), + zap.String("idempotency_key", idempotencyKey), + ) return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ Payment: toProtoPayment(entity), }) @@ -355,7 +412,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 +453,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 +496,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), }) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index 3b8ba32..b0ead12 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -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 { diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index f33f03d..4908b82 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -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 { diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 3ed91d1..544e411 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mutil/mzap" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" @@ -20,7 +21,7 @@ import ( func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc { orgRef, err := a.oph.GetRef(r) if err != nil { - a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) + a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r)) return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) } @@ -76,7 +77,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to resp, err := a.client.InitiatePayment(ctx, req) if err != nil { - a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) return response.Auto(a.logger, a.Name(), err) } diff --git a/frontend/pshared/lib/api/responses/payment/payment.dart b/frontend/pshared/lib/api/responses/payment/payment.dart new file mode 100644 index 0000000..b5b5d3e --- /dev/null +++ b/frontend/pshared/lib/api/responses/payment/payment.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/base.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/payment/payment.dart'; + +part 'payment.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class PaymentResponse extends BaseAuthorizedResponse { + + final PaymentDTO payment; + + const PaymentResponse({required super.accessToken, required this.payment}); + + factory PaymentResponse.fromJson(Map json) => _$PaymentResponseFromJson(json); + @override + Map toJson() => _$PaymentResponseToJson(this); +} diff --git a/frontend/pshared/lib/provider/payment/provider.dart b/frontend/pshared/lib/provider/payment/provider.dart new file mode 100644 index 0000000..6afd032 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/payment/service.dart'; + + +class PaymentProvider extends ChangeNotifier { + late OrganizationsProvider _organization; + late QuotationProvider _quotation; + + Resource _payment = Resource(data: null, isLoading: false, error: null); + bool _isLoaded = false; + + void update(OrganizationsProvider organization, QuotationProvider quotation) { + _quotation = quotation; + _organization = organization; + } + + Payment? get payment => _payment.data; + bool get isLoading => _payment.isLoading; + Exception? get error => _payment.error; + bool get isReady => _isLoaded && !_payment.isLoading && _payment.error == null; + + void _setResource(Resource payment) { + _payment = payment; + notifyListeners(); + } + + Future pay({String? idempotencyKey, Map? metadata}) async { + if (!_organization.isOrganizationSet) throw StateError('Organization is not set'); + if (!_quotation.isReady) throw StateError('Quotation is not ready'); + final quoteRef = _quotation.quotation?.quoteRef; + if (quoteRef == null || quoteRef.isEmpty) { + throw StateError('Quotation reference is not set'); + } + + _setResource(_payment.copyWith(isLoading: true, error: null)); + try { + final response = await PaymentService.pay( + _organization.current.id, + quoteRef, + idempotencyKey: idempotencyKey, + metadata: metadata, + ); + _isLoaded = true; + _setResource(_payment.copyWith(data: response, isLoading: false, error: null)); + } catch (e) { + _setResource(_payment.copyWith( + data: null, + error: e is Exception ? e : Exception(e.toString()), + isLoading: false, + )); + } + return _payment.data; + } + + void reset() { + _setResource(Resource(data: null, isLoading: false, error: null)); + _isLoaded = false; + } +} diff --git a/frontend/pshared/lib/service/payment/service.dart b/frontend/pshared/lib/service/payment/service.dart new file mode 100644 index 0000000..b8e0a27 --- /dev/null +++ b/frontend/pshared/lib/service/payment/service.dart @@ -0,0 +1,36 @@ +import 'package:logging/logging.dart'; + +import 'package:uuid/uuid.dart'; + +import 'package:pshared/api/requests/payment/initiate.dart'; +import 'package:pshared/api/responses/payment/payment.dart'; +import 'package:pshared/data/mapper/payment/payment_response.dart'; +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/services.dart'; + + +class PaymentService { + static final _logger = Logger('service.payment'); + static const String _objectType = Services.payments; + + static Future pay( + String organizationRef, + String quotationRef, { + String? idempotencyKey, + Map? metadata, + }) async { + _logger.fine('Executing payment for quotation $quotationRef in $organizationRef'); + final request = InitiatePaymentRequest( + idempotencyKey: idempotencyKey ?? Uuid().v4(), + quoteRef: quotationRef, + metadata: metadata, + ); + final response = await AuthorizationService.getPOSTResponse( + _objectType, + '/by-quote/$organizationRef', + request.toJson(), + ); + return PaymentResponse.fromJson(response).payment.toDomain(); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/widget.dart deleted file mode 100644 index 2e7e89f..0000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/widget.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:provider/provider.dart'; - -import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/payment/amount.dart'; -import 'package:pshared/provider/payment/flow.dart'; -import 'package:pshared/provider/payment/quotation.dart'; -import 'package:pshared/provider/payment/wallets.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; - -import 'package:pweb/pages/dashboard/payouts/form.dart'; - - -class PaymentFromWrappingWidget extends StatelessWidget { - const PaymentFromWrappingWidget({super.key}); - - @override - Widget build(BuildContext context) => MultiProvider( - providers: [ - ChangeNotifierProvider( - create: (_) => PaymentAmountProvider(), - ), - ChangeNotifierProxyProvider5( - create: (_) => QuotationProvider(), - update: (context, orgnization, payment, wallet, flow, methods, provider) => provider!..update(orgnization, payment, wallet, flow, methods), - ), - ], - child: const PaymentFormWidget(), - ); -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 98aa77b..2c10623 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -8,7 +8,11 @@ import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -38,16 +42,12 @@ class PaymentPage extends StatefulWidget { class _PaymentPageState extends State { late final TextEditingController _searchController; late final FocusNode _searchFocusNode; - late final PaymentFlowProvider _flowProvider; @override void initState() { super.initState(); _searchController = TextEditingController(); _searchFocusNode = FocusNode(); - _flowProvider = PaymentFlowProvider( - initialType: widget.initialPaymentType ?? PaymentType.bankAccount, - ); WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); } @@ -56,45 +56,30 @@ class _PaymentPageState extends State { void dispose() { _searchController.dispose(); _searchFocusNode.dispose(); - _flowProvider.dispose(); super.dispose(); } void _initializePaymentPage() { final methodsProvider = context.read(); _handleWalletAutoSelection(methodsProvider); - - final recipient = context.read().currentObject; - _syncFlowProvider( - recipient: recipient, - methodsProvider: methodsProvider, - preferredType: widget.initialPaymentType, - ); } void _handleSearchChanged(String query) { context.read().setQuery(query); } - void _handleRecipientSelected(Recipient recipient) { + void _handleRecipientSelected(BuildContext context, Recipient recipient) { final recipientProvider = context.read(); - final methodsProvider = context.read(); - recipientProvider.setCurrentObject(recipient.id); - _flowProvider.reset( - recipient: recipient, - availableTypes: _availablePaymentTypes(recipient, methodsProvider), - preferredType: widget.initialPaymentType, - ); _clearSearchField(); } - void _handleRecipientCleared() { + void _handleRecipientCleared(BuildContext context) { final recipientProvider = context.read(); final methodsProvider = context.read(); recipientProvider.setCurrentObject(null); - _flowProvider.reset( + context.read().reset( recipient: null, availableTypes: _availablePaymentTypes(null, methodsProvider), preferredType: widget.initialPaymentType, @@ -108,9 +93,13 @@ class _PaymentPageState extends State { context.read().setQuery(''); } - void _handleSendPayment() { - // TODO: Handle Payment logic - PosthogService.paymentInitiated(method: _flowProvider.selectedType); + void _handleSendPayment(BuildContext context) { + if (context.read().isReady) { + context.read().pay(); + PosthogService.paymentInitiated( + method: context.read().selectedType, + ); + } } @override @@ -120,27 +109,49 @@ class _PaymentPageState extends State { final recipient = recipientProvider.currentObject; final availableTypes = _availablePaymentTypes(recipient, methodsProvider); - _syncFlowProvider( - recipient: recipient, - methodsProvider: methodsProvider, - preferredType: recipient != null ? widget.initialPaymentType : null, - ); - - return ChangeNotifierProvider.value( - value: _flowProvider, - child: PaymentPageBody( - onBack: widget.onBack, - fallbackDestination: widget.fallbackDestination, - recipient: recipient, - recipientProvider: recipientProvider, - methodsProvider: methodsProvider, - availablePaymentTypes: availableTypes, - searchController: _searchController, - searchFocusNode: _searchFocusNode, - onSearchChanged: _handleSearchChanged, - onRecipientSelected: _handleRecipientSelected, - onRecipientCleared: _handleRecipientCleared, - onSend: _handleSendPayment, + return MultiProvider( + providers: [ + ChangeNotifierProxyProvider2( + create: (_) => PaymentFlowProvider( + initialType: widget.initialPaymentType ?? PaymentType.bankAccount, + ), + update: (_, recipients, methods, flow) { + final currentRecipient = recipients.currentObject; + flow!.sync( + recipient: currentRecipient, + availableTypes: _availablePaymentTypes(currentRecipient, methods), + preferredType: currentRecipient != null ? widget.initialPaymentType : null, + ); + return flow; + }, + ), + ChangeNotifierProvider( + create: (_) => PaymentAmountProvider(), + ), + ChangeNotifierProxyProvider5( + create: (_) => QuotationProvider(), + update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods), + ), + ChangeNotifierProxyProvider2( + create: (_) => PaymentProvider(), + update: (_, organization, quotation, provider) => provider!..update(organization, quotation), + ), + ], + child: Builder( + builder: (innerContext) => PaymentPageBody( + onBack: widget.onBack, + fallbackDestination: widget.fallbackDestination, + recipient: recipient, + recipientProvider: recipientProvider, + methodsProvider: methodsProvider, + availablePaymentTypes: availableTypes, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onSearchChanged: _handleSearchChanged, + onRecipientSelected: (selected) => _handleRecipientSelected(innerContext, selected), + onRecipientCleared: () => _handleRecipientCleared(innerContext), + onSend: () => _handleSendPayment(innerContext), + ), ), ); } @@ -155,18 +166,6 @@ class _PaymentPageState extends State { } } - void _syncFlowProvider({ - required Recipient? recipient, - required PaymentMethodsProvider methodsProvider, - PaymentType? preferredType, - }) { - _flowProvider.sync( - recipient: recipient, - availableTypes: _availablePaymentTypes(recipient, methodsProvider), - preferredType: preferredType, - ); - } - MethodMap _availablePaymentTypes( Recipient? recipient, PaymentMethodsProvider methodsProvider, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart index d0ff6f5..2352d0d 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -8,7 +8,7 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/payment/flow.dart'; -import 'package:pweb/pages/dashboard/payouts/widget.dart'; +import 'package:pweb/pages/dashboard/payouts/form.dart'; import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; import 'package:pweb/pages/payment_methods/payment_page/header.dart'; import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; @@ -105,7 +105,7 @@ class PaymentPageContent extends StatelessWidget { availableTypes: availablePaymentTypes, ), SizedBox(height: dimensions.paddingLarge), - const PaymentFromWrappingWidget(), + const PaymentFormWidget(), SizedBox(height: dimensions.paddingXXXLarge), SendButton(onPressed: onSend), SizedBox(height: dimensions.paddingLarge), diff --git a/frontend/pweb/lib/services/amplitude.dart b/frontend/pweb/lib/services/amplitude.dart deleted file mode 100644 index c1384f4..0000000 --- a/frontend/pweb/lib/services/amplitude.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:amplitude_flutter/amplitude.dart'; -import 'package:amplitude_flutter/configuration.dart'; -import 'package:amplitude_flutter/constants.dart' as amp; -import 'package:amplitude_flutter/events/base_event.dart'; -import 'package:flutter/widgets.dart'; -import 'package:pshared/models/account/account.dart'; -import 'package:pshared/models/payment/type.dart'; -import 'package:pshared/models/recipient/status.dart'; -import 'package:pshared/models/recipient/type.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; - - -class AmplitudeService { - static late Amplitude _analytics; - - static Amplitude _amp() => _analytics; - - static Future initialize() async { - _analytics = Amplitude(Configuration( - apiKey: '12345', //TODO define through App Contants - serverZone: amp.ServerZone.eu, //TODO define through App Contants - )); - await _analytics.isBuilt; - } - - static Future identify(Account account) async => - _amp().setUserId(account.id); - - - static Future login(Account account) async => - _logEvent( - 'login', - userProperties: { - // 'email': account.email, TODO Add email into account - 'locale': account.locale, - }, - ); - - static Future logout() async => _logEvent("logout"); - - static Future pageOpened(PayoutDestination page, {String? path, String? uiSource}) async { - return _logEvent("pageOpened", eventProperties: { - "page": page, - if (path != null) "path": path, - if (uiSource != null) "uiSource": uiSource, - }); - } - - //TODO Add when registration is ready. User properties {user_id, registration_date, has_wallet (true/false), wallet_balance (should concider loggin it as: 0 / <100 / 100–500 / 500+), preferred_method (Wallet/Card/Bank/IBAN), total_transactions, total_amount, last_payout_date, last_login_date , marketing_source} - - // static Future registrationStarted(String method, String country) async => - // _logEvent("registrationStarted", eventProperties: {"method": method, "country": country}); - - // static Future registrationCompleted(String method, String country) async => - // _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country}); - - static Future pageNotFound(String url) async => - _logEvent("pageNotFound", eventProperties: {"url": url}); - - static Future localeChanged(Locale locale) async => - _logEvent("localeChanged", eventProperties: {"locale": locale.toString()}); - - static Future localeMatched(String locale, bool haveRequested) async => //DO we need it? - _logEvent("localeMatched", eventProperties: { - "locale": locale, - "have_requested_locale": haveRequested - }); - - static Future recipientAddStarted() async => - _logEvent("recipientAddStarted"); - - static Future recipientAddCompleted( - RecipientType type, - RecipientStatus status, - Set methods, - ) async { - _logEvent( - "recipientAddCompleted", - eventProperties: { - "methods": methods.map((m) => m.name).toList(), - "type": type.name, - "status": status.name, - }, - ); - } - - static Future _paymentEvent( - String evt, - double amount, - double fee, - bool payerCoversFee, - PaymentType source, - PaymentType recpientPaymentMethod, { - String? message, - String? errorType, - Map? extraProps, - }) async { - final props = { - "amount": amount, - "fee": fee, - "feeCoveredBy": payerCoversFee ? 'payer' : 'recipient', - "source": source, - "recipient_method": recpientPaymentMethod, - if (message != null) "message": message, - if (errorType != null) "error_type": errorType, - if (extraProps != null) ...extraProps, - }; - return _logEvent(evt, eventProperties: props); - } - - static Future paymentPrepared(double amount, double fee, - bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async => - _paymentEvent("paymentPrepared", amount, fee, payerCoversFee, source, recpientPaymentMethod); - //TODO Rework paymentStarted (do i need all those properties or is the event enough? Mb properties should be passed at paymentPrepared) - static Future paymentStarted(double amount, double fee, - bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async => - _paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod); - - static Future paymentFailed(double amount, double fee, bool payerCoversFee, - PaymentType source, PaymentType recpientPaymentMethod, String errorType, String message) async => - _paymentEvent("paymentFailed", amount, fee, payerCoversFee, source, recpientPaymentMethod, - errorType: errorType, message: message); - - static Future paymentError(double amount, double fee, bool payerCoversFee, - PaymentType source,PaymentType recpientPaymentMethod, String message) async => - _paymentEvent("paymentError", amount, fee, payerCoversFee, source, recpientPaymentMethod, - message: message); - - static Future paymentSuccess({ - required double amount, - required double fee, - required bool payerCoversFee, - required PaymentType source, - required PaymentType recpientPaymentMethod, - required String transactionId, - String? comment, - required int durationMs, - }) async { - return _paymentEvent( - "paymentSuccess", - amount, - fee, - payerCoversFee, - source, - recpientPaymentMethod, - message: comment, - extraProps: { - "transaction_id": transactionId, - "duration_ms": durationMs, //How do i calculate duration here? - "\$revenue": amount, //How do i calculate revenue here? - "\$revenueType": "payment", //Do we need to get revenue type? - }, - ); - } - - //TODO add when support is ready - // static Future supportOpened(String fromPage, String trigger) async => - // _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger}); - - // static Future supportMessageSent(String category, bool resolved) async => - // _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved}); - - - static Future walletTopUp(double amount, PaymentType method) async => - _logEvent("walletTopUp", eventProperties: {"amount": amount, "method": method}); - - - //TODO Decide do we need uiElementClicked or pageOpened is enough? - static Future uiElementClicked(String elementName, String page, String uiSource) async => - _logEvent("uiElementClicked", eventProperties: { - "element_name": elementName, - "page": page, - "uiSource": uiSource - }); - - static final Map _stepStartTimes = {}; - //TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly - static Future stepStarted(String stepName, {String? context}) async { - _stepStartTimes[stepName] = DateTime.now().millisecondsSinceEpoch; - return _logEvent("stepStarted", eventProperties: { - "step_name": stepName, - if (context != null) "context": context, - }); - } - - static Future stepCompleted(String stepName, bool success) async { - final now = DateTime.now().millisecondsSinceEpoch; - final start = _stepStartTimes[stepName] ?? now; - final duration = now - start; - return _logEvent("stepCompleted", eventProperties: { - "step_name": stepName, - "duration_ms": duration, - "success": success - }); - } - - static Future _logEvent( - String eventType, { - Map? eventProperties, - Map? userProperties, - }) async { - final event = BaseEvent( - eventType, - eventProperties: eventProperties, - userProperties: userProperties, - ); - _amp().track(event); - print(event.toString()); //TODO delete when everything is ready - } -} \ No newline at end of file