payment button connected + supported payment by quote reference #168
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -103,33 +103,40 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
|
||||
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote := modelQuoteToProto(record.Quote)
|
||||
if quote == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
return quote, nil
|
||||
return quote, intent, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return quote, nil
|
||||
return quote, in.Intent, nil
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Intents) > 0 {
|
||||
if len(record.Intents) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intents[0]), nil
|
||||
}
|
||||
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intent), nil
|
||||
}
|
||||
|
||||
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
if record.Quote != nil {
|
||||
return modelQuoteToProto(record.Quote), nil
|
||||
}
|
||||
if len(record.Quotes) > 0 {
|
||||
if len(record.Quotes) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return modelQuoteToProto(record.Quotes[0]), nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -110,6 +110,35 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quote == nil || quote.GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected quote_ref q1, got %#v", quote)
|
||||
}
|
||||
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
@@ -140,6 +169,42 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
store := newHelperPaymentStore()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
}, WithClock(clockpkg.NewSystem()))
|
||||
svc.ensureHandlers()
|
||||
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
IdempotencyKey: "k1",
|
||||
}
|
||||
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("initiate by quote_ref failed: %v", err)
|
||||
}
|
||||
if resp == nil || resp.GetPayment() == nil {
|
||||
t.Fatalf("expected payment response")
|
||||
}
|
||||
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected intent amount to be resolved from quote")
|
||||
}
|
||||
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected last quote_ref to be set from stored quote")
|
||||
}
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
type stubRepo struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
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/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<PaymentPage> {
|
||||
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<PaymentPage> {
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_flowProvider.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializePaymentPage() {
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
_handleWalletAutoSelection(methodsProvider);
|
||||
|
||||
final recipient = context.read<RecipientsProvider>().currentObject;
|
||||
_syncFlowProvider(
|
||||
recipient: recipient,
|
||||
methodsProvider: methodsProvider,
|
||||
preferredType: widget.initialPaymentType,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSearchChanged(String query) {
|
||||
context.read<RecipientsProvider>().setQuery(query);
|
||||
}
|
||||
|
||||
void _handleRecipientSelected(Recipient recipient) {
|
||||
void _handleRecipientSelected(BuildContext context, Recipient recipient) {
|
||||
final recipientProvider = context.read<RecipientsProvider>();
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
|
||||
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<RecipientsProvider>();
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
|
||||
recipientProvider.setCurrentObject(null);
|
||||
_flowProvider.reset(
|
||||
context.read<PaymentFlowProvider>().reset(
|
||||
recipient: null,
|
||||
availableTypes: _availablePaymentTypes(null, methodsProvider),
|
||||
preferredType: widget.initialPaymentType,
|
||||
@@ -108,9 +93,13 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
context.read<RecipientsProvider>().setQuery('');
|
||||
}
|
||||
|
||||
void _handleSendPayment() {
|
||||
// TODO: Handle Payment logic
|
||||
PosthogService.paymentInitiated(method: _flowProvider.selectedType);
|
||||
void _handleSendPayment(BuildContext context) {
|
||||
if (context.read<QuotationProvider>().isReady) {
|
||||
context.read<PaymentProvider>().pay();
|
||||
PosthogService.paymentInitiated(
|
||||
method: context.read<PaymentFlowProvider>().selectedType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,27 +109,49 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
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<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
||||
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<OrganizationsProvider, PaymentAmountProvider, WalletsProvider, PaymentFlowProvider, PaymentMethodsProvider, QuotationProvider>(
|
||||
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(
|
||||
Recipient? recipient,
|
||||
PaymentMethodsProvider methodsProvider,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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