payment button connected
This commit is contained in:
@@ -13,7 +13,7 @@ import (
|
|||||||
type paymentEngine interface {
|
type paymentEngine interface {
|
||||||
EnsureRepository(ctx context.Context) error
|
EnsureRepository(ctx context.Context) error
|
||||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, 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
|
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||||
Repository() storage.Repository
|
Repository() storage.Repository
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
|||||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
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)
|
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 {
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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})
|
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)
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
if err := requireNonNilIntent(intent); err != nil {
|
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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())
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
if err != nil {
|
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)
|
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,
|
OrgRef: orgRef,
|
||||||
OrgID: orgID,
|
OrgID: orgID,
|
||||||
Meta: req.GetMeta(),
|
Meta: req.GetMeta(),
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
QuoteRef: req.GetQuoteRef(),
|
QuoteRef: quoteRef,
|
||||||
IdempotencyKey: req.GetIdempotencyKey(),
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,8 +312,11 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
|||||||
if quoteSnapshot == nil {
|
if quoteSnapshot == nil {
|
||||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
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 err = store.Create(ctx, entity); err != nil {
|
||||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
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)
|
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{
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||||
Payment: toProtoPayment(entity),
|
Payment: toProtoPayment(entity),
|
||||||
})
|
})
|
||||||
@@ -355,7 +369,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
|
|||||||
if err := store.Update(ctx, payment); err != nil {
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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)})
|
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 {
|
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)})
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
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)
|
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{
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||||
Conversion: toProtoPayment(entity),
|
Conversion: toProtoPayment(entity),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,33 +103,40 @@ type quoteResolutionError struct {
|
|||||||
|
|
||||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
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 != "" {
|
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||||
quotesStore, err := ensureQuotesStore(s.storage)
|
quotesStore, err := ensureQuotesStore(s.storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
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) {
|
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) {
|
intent, err := recordIntentFromQuote(record)
|
||||||
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
quote := modelQuoteToProto(record.Quote)
|
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||||
if quote == nil {
|
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
}
|
||||||
|
quote, err := recordQuoteFromQuote(record)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
quote.QuoteRef = ref
|
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{
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
Meta: in.Meta,
|
Meta: in.Meta,
|
||||||
IdempotencyKey: in.IdempotencyKey,
|
IdempotencyKey: in.IdempotencyKey,
|
||||||
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
|||||||
}
|
}
|
||||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||||
if err != nil {
|
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 {
|
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{}},
|
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||||
OrgRef: org.Hex(),
|
OrgRef: org.Hex(),
|
||||||
OrgID: org,
|
OrgID: org,
|
||||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
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}}},
|
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||||
OrgRef: org.Hex(),
|
OrgRef: org.Hex(),
|
||||||
OrgID: org,
|
OrgID: org,
|
||||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
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) {
|
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||||
logger := mloggerfactory.NewLogger(false)
|
logger := mloggerfactory.NewLogger(false)
|
||||||
org := primitive.NewObjectID()
|
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 ---
|
// --- test doubles ---
|
||||||
|
|
||||||
type stubRepo struct {
|
type stubRepo struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
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/srequest"
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
"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 {
|
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
|
||||||
orgRef, err := a.oph.GetRef(r)
|
orgRef, err := a.oph.GetRef(r)
|
||||||
if err != nil {
|
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)
|
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)
|
resp, err := a.client.InitiatePayment(ctx, req)
|
||||||
if err != nil {
|
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)
|
return response.Auto(a.logger, a.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
@@ -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<String, dynamic> json) => _$PaymentResponseFromJson(json);
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => _$PaymentResponseToJson(this);
|
||||||
|
}
|
||||||
64
frontend/pshared/lib/provider/payment/provider.dart
Normal file
64
frontend/pshared/lib/provider/payment/provider.dart
Normal file
@@ -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> _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 = payment;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/pshared/lib/service/payment/service.dart
Normal file
36
frontend/pshared/lib/service/payment/service.dart
Normal file
@@ -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<Payment> pay(
|
||||||
|
String organizationRef,
|
||||||
|
String quotationRef, {
|
||||||
|
String? idempotencyKey,
|
||||||
|
Map<String, String>? 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
|
||||||
create: (_) => QuotationProvider(),
|
|
||||||
update: (context, orgnization, payment, wallet, flow, methods, provider) => provider!..update(orgnization, payment, wallet, flow, methods),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: const PaymentFormWidget(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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/methods/type.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.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/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/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
|
|
||||||
@@ -38,16 +42,12 @@ class PaymentPage extends StatefulWidget {
|
|||||||
class _PaymentPageState extends State<PaymentPage> {
|
class _PaymentPageState extends State<PaymentPage> {
|
||||||
late final TextEditingController _searchController;
|
late final TextEditingController _searchController;
|
||||||
late final FocusNode _searchFocusNode;
|
late final FocusNode _searchFocusNode;
|
||||||
late final PaymentFlowProvider _flowProvider;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchController = TextEditingController();
|
_searchController = TextEditingController();
|
||||||
_searchFocusNode = FocusNode();
|
_searchFocusNode = FocusNode();
|
||||||
_flowProvider = PaymentFlowProvider(
|
|
||||||
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
|
||||||
);
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
|
||||||
}
|
}
|
||||||
@@ -56,45 +56,30 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
_flowProvider.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializePaymentPage() {
|
void _initializePaymentPage() {
|
||||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||||
_handleWalletAutoSelection(methodsProvider);
|
_handleWalletAutoSelection(methodsProvider);
|
||||||
|
|
||||||
final recipient = context.read<RecipientsProvider>().currentObject;
|
|
||||||
_syncFlowProvider(
|
|
||||||
recipient: recipient,
|
|
||||||
methodsProvider: methodsProvider,
|
|
||||||
preferredType: widget.initialPaymentType,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSearchChanged(String query) {
|
void _handleSearchChanged(String query) {
|
||||||
context.read<RecipientsProvider>().setQuery(query);
|
context.read<RecipientsProvider>().setQuery(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleRecipientSelected(Recipient recipient) {
|
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
|
||||||
final recipientProvider = context.read<RecipientsProvider>();
|
final recipientProvider = context.read<RecipientsProvider>();
|
||||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
|
||||||
|
|
||||||
recipientProvider.setCurrentObject(recipient.id);
|
recipientProvider.setCurrentObject(recipient.id);
|
||||||
_flowProvider.reset(
|
|
||||||
recipient: recipient,
|
|
||||||
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
|
|
||||||
preferredType: widget.initialPaymentType,
|
|
||||||
);
|
|
||||||
_clearSearchField();
|
_clearSearchField();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleRecipientCleared() {
|
void _handleRecipientCleared(BuildContext context) {
|
||||||
final recipientProvider = context.read<RecipientsProvider>();
|
final recipientProvider = context.read<RecipientsProvider>();
|
||||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||||
|
|
||||||
recipientProvider.setCurrentObject(null);
|
recipientProvider.setCurrentObject(null);
|
||||||
_flowProvider.reset(
|
context.read<PaymentFlowProvider>().reset(
|
||||||
recipient: null,
|
recipient: null,
|
||||||
availableTypes: _availablePaymentTypes(null, methodsProvider),
|
availableTypes: _availablePaymentTypes(null, methodsProvider),
|
||||||
preferredType: widget.initialPaymentType,
|
preferredType: widget.initialPaymentType,
|
||||||
@@ -108,9 +93,13 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
context.read<RecipientsProvider>().setQuery('');
|
context.read<RecipientsProvider>().setQuery('');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSendPayment() {
|
void _handleSendPayment(BuildContext context) {
|
||||||
// TODO: Handle Payment logic
|
if (context.read<QuotationProvider>().isReady) {
|
||||||
PosthogService.paymentInitiated(method: _flowProvider.selectedType);
|
context.read<PaymentProvider>().pay();
|
||||||
|
PosthogService.paymentInitiated(
|
||||||
|
method: context.read<PaymentFlowProvider>().selectedType,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -120,27 +109,49 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
final recipient = recipientProvider.currentObject;
|
final recipient = recipientProvider.currentObject;
|
||||||
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
|
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
|
||||||
|
|
||||||
_syncFlowProvider(
|
return MultiProvider(
|
||||||
recipient: recipient,
|
providers: [
|
||||||
methodsProvider: methodsProvider,
|
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
||||||
preferredType: recipient != null ? widget.initialPaymentType : null,
|
create: (_) => PaymentFlowProvider(
|
||||||
);
|
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
|
||||||
|
),
|
||||||
return ChangeNotifierProvider.value(
|
update: (_, recipients, methods, flow) {
|
||||||
value: _flowProvider,
|
final currentRecipient = recipients.currentObject;
|
||||||
child: PaymentPageBody(
|
flow!.sync(
|
||||||
onBack: widget.onBack,
|
recipient: currentRecipient,
|
||||||
fallbackDestination: widget.fallbackDestination,
|
availableTypes: _availablePaymentTypes(currentRecipient, methods),
|
||||||
recipient: recipient,
|
preferredType: currentRecipient != null ? widget.initialPaymentType : null,
|
||||||
recipientProvider: recipientProvider,
|
);
|
||||||
methodsProvider: methodsProvider,
|
return flow;
|
||||||
availablePaymentTypes: availableTypes,
|
},
|
||||||
searchController: _searchController,
|
),
|
||||||
searchFocusNode: _searchFocusNode,
|
ChangeNotifierProvider(
|
||||||
onSearchChanged: _handleSearchChanged,
|
create: (_) => PaymentAmountProvider(),
|
||||||
onRecipientSelected: _handleRecipientSelected,
|
),
|
||||||
onRecipientCleared: _handleRecipientCleared,
|
ChangeNotifierProxyProvider5<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
||||||
onSend: _handleSendPayment,
|
create: (_) => QuotationProvider(),
|
||||||
|
update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods),
|
||||||
|
),
|
||||||
|
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
|
||||||
|
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<PaymentPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncFlowProvider({
|
|
||||||
required Recipient? recipient,
|
|
||||||
required PaymentMethodsProvider methodsProvider,
|
|
||||||
PaymentType? preferredType,
|
|
||||||
}) {
|
|
||||||
_flowProvider.sync(
|
|
||||||
recipient: recipient,
|
|
||||||
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
|
|
||||||
preferredType: preferredType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
MethodMap _availablePaymentTypes(
|
MethodMap _availablePaymentTypes(
|
||||||
Recipient? recipient,
|
Recipient? recipient,
|
||||||
PaymentMethodsProvider methodsProvider,
|
PaymentMethodsProvider methodsProvider,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
|
|||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
import 'package:pshared/provider/payment/flow.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/back_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||||
@@ -105,7 +105,7 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
availableTypes: availablePaymentTypes,
|
availableTypes: availablePaymentTypes,
|
||||||
),
|
),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
const PaymentFromWrappingWidget(),
|
const PaymentFormWidget(),
|
||||||
SizedBox(height: dimensions.paddingXXXLarge),
|
SizedBox(height: dimensions.paddingXXXLarge),
|
||||||
SendButton(onPressed: onSend),
|
SendButton(onPressed: onSend),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
|
|||||||
@@ -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<void> 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<void> identify(Account account) async =>
|
|
||||||
_amp().setUserId(account.id);
|
|
||||||
|
|
||||||
|
|
||||||
static Future<void> login(Account account) async =>
|
|
||||||
_logEvent(
|
|
||||||
'login',
|
|
||||||
userProperties: {
|
|
||||||
// 'email': account.email, TODO Add email into account
|
|
||||||
'locale': account.locale,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
static Future<void> logout() async => _logEvent("logout");
|
|
||||||
|
|
||||||
static Future<void> 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<void> registrationStarted(String method, String country) async =>
|
|
||||||
// _logEvent("registrationStarted", eventProperties: {"method": method, "country": country});
|
|
||||||
|
|
||||||
// static Future<void> registrationCompleted(String method, String country) async =>
|
|
||||||
// _logEvent("registrationCompleted", eventProperties: {"method": method, "country": country});
|
|
||||||
|
|
||||||
static Future<void> pageNotFound(String url) async =>
|
|
||||||
_logEvent("pageNotFound", eventProperties: {"url": url});
|
|
||||||
|
|
||||||
static Future<void> localeChanged(Locale locale) async =>
|
|
||||||
_logEvent("localeChanged", eventProperties: {"locale": locale.toString()});
|
|
||||||
|
|
||||||
static Future<void> localeMatched(String locale, bool haveRequested) async => //DO we need it?
|
|
||||||
_logEvent("localeMatched", eventProperties: {
|
|
||||||
"locale": locale,
|
|
||||||
"have_requested_locale": haveRequested
|
|
||||||
});
|
|
||||||
|
|
||||||
static Future<void> recipientAddStarted() async =>
|
|
||||||
_logEvent("recipientAddStarted");
|
|
||||||
|
|
||||||
static Future<void> recipientAddCompleted(
|
|
||||||
RecipientType type,
|
|
||||||
RecipientStatus status,
|
|
||||||
Set<PaymentType> methods,
|
|
||||||
) async {
|
|
||||||
_logEvent(
|
|
||||||
"recipientAddCompleted",
|
|
||||||
eventProperties: {
|
|
||||||
"methods": methods.map((m) => m.name).toList(),
|
|
||||||
"type": type.name,
|
|
||||||
"status": status.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> _paymentEvent(
|
|
||||||
String evt,
|
|
||||||
double amount,
|
|
||||||
double fee,
|
|
||||||
bool payerCoversFee,
|
|
||||||
PaymentType source,
|
|
||||||
PaymentType recpientPaymentMethod, {
|
|
||||||
String? message,
|
|
||||||
String? errorType,
|
|
||||||
Map<String, dynamic>? 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<void> 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<void> paymentStarted(double amount, double fee,
|
|
||||||
bool payerCoversFee, PaymentType source, PaymentType recpientPaymentMethod) async =>
|
|
||||||
_paymentEvent("paymentStarted", amount, fee, payerCoversFee, source, recpientPaymentMethod);
|
|
||||||
|
|
||||||
static Future<void> 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<void> 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<void> 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<void> supportOpened(String fromPage, String trigger) async =>
|
|
||||||
// _logEvent("supportOpened", eventProperties: {"from_page": fromPage, "trigger": trigger});
|
|
||||||
|
|
||||||
// static Future<void> supportMessageSent(String category, bool resolved) async =>
|
|
||||||
// _logEvent("supportMessageSent", eventProperties: {"category": category, "resolved": resolved});
|
|
||||||
|
|
||||||
|
|
||||||
static Future<void> 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<void> uiElementClicked(String elementName, String page, String uiSource) async =>
|
|
||||||
_logEvent("uiElementClicked", eventProperties: {
|
|
||||||
"element_name": elementName,
|
|
||||||
"page": page,
|
|
||||||
"uiSource": uiSource
|
|
||||||
});
|
|
||||||
|
|
||||||
static final Map<String, int> _stepStartTimes = {};
|
|
||||||
//TODO Consider it as part of payment flow or registration flow or adding recipient and rework accordingly
|
|
||||||
static Future<void> 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<void> 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<void> _logEvent(
|
|
||||||
String eventType, {
|
|
||||||
Map<String, dynamic>? eventProperties,
|
|
||||||
Map<String, dynamic>? userProperties,
|
|
||||||
}) async {
|
|
||||||
final event = BaseEvent(
|
|
||||||
eventType,
|
|
||||||
eventProperties: eventProperties,
|
|
||||||
userProperties: userProperties,
|
|
||||||
);
|
|
||||||
_amp().track(event);
|
|
||||||
print(event.toString()); //TODO delete when everything is ready
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user