callbacks service draft
This commit is contained in:
@@ -58,7 +58,7 @@ func (i *Imp) Start() error {
|
||||
if broker != nil {
|
||||
opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker))
|
||||
}
|
||||
svc, err := orchestrator.NewService(logger, repo, opts...)
|
||||
svc, err := orchestrator.NewService(logger, repo, producer, opts...)
|
||||
i.service = svc
|
||||
return svc, err
|
||||
}
|
||||
|
||||
@@ -238,6 +238,18 @@ func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, gr
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.statuses.Publish(ctx, paymentStatusPublishInput{
|
||||
Payment: payment,
|
||||
PreviousState: agg.StateUnspecified,
|
||||
CurrentState: payment.State,
|
||||
OccurredAt: s.nowUTC(),
|
||||
Event: "created",
|
||||
}); err != nil {
|
||||
s.logger.Warn("Failed to publish created payment status update",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
for i := range payment.StepExecutions {
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
@@ -64,6 +65,7 @@ type Dependencies struct {
|
||||
Query pquery.Service
|
||||
Mapper prmap.Mapper
|
||||
Observer oobs.Observer
|
||||
Producer msg.Producer
|
||||
|
||||
RetryPolicy ssched.RetryPolicy
|
||||
Now func() time.Time
|
||||
|
||||
@@ -87,6 +87,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen
|
||||
func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) {
|
||||
logger := s.logger
|
||||
expectedVersion := payment.Version
|
||||
previousAggregateState := payment.State
|
||||
|
||||
scheduled, err := s.scheduler.Schedule(ssched.Input{
|
||||
Steps: graph.Steps,
|
||||
@@ -146,6 +147,22 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr
|
||||
zap.Uint64("version", payment.Version),
|
||||
zap.String("state", string(payment.State)),
|
||||
)
|
||||
if aggChanged && payment.State != previousAggregateState {
|
||||
if err := s.statuses.Publish(ctx, paymentStatusPublishInput{
|
||||
Payment: payment,
|
||||
PreviousState: previousAggregateState,
|
||||
CurrentState: payment.State,
|
||||
OccurredAt: s.nowUTC(),
|
||||
Event: "state_changed",
|
||||
}); err != nil {
|
||||
logger.Warn("Failed to publish payment status update",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("from_state", string(previousAggregateState)),
|
||||
zap.String("to_state", string(payment.State)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return payment, true, len(scheduled.Runnable) == 0, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type svc struct {
|
||||
query pquery.Service
|
||||
mapper prmap.Mapper
|
||||
observer oobs.Observer
|
||||
statuses paymentStatusPublisher
|
||||
|
||||
retryPolicy ssched.RetryPolicy
|
||||
now func() time.Time
|
||||
@@ -106,6 +107,7 @@ func newService(deps Dependencies) (Service, error) {
|
||||
query: query,
|
||||
mapper: firstMapper(deps.Mapper, logger),
|
||||
observer: observer,
|
||||
statuses: newPaymentStatusPublisher(logger, deps.Producer),
|
||||
|
||||
retryPolicy: deps.RetryPolicy,
|
||||
now: deps.Now,
|
||||
|
||||
@@ -3,6 +3,7 @@ package psvc
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
menv "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
@@ -76,6 +78,83 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) {
|
||||
assertTimelineHasEvent(t, timeline.Items, "settled")
|
||||
}
|
||||
|
||||
func TestExecutePayment_PublishesStatusUpdates(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-status", "intent-status", buildLedgerRoute()))
|
||||
|
||||
resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-status"),
|
||||
QuotationRef: "quote-status",
|
||||
ClientPaymentRef: "client-status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||
}
|
||||
if resp.GetPayment() == nil {
|
||||
t.Fatal("expected payment in response")
|
||||
}
|
||||
|
||||
type publishedEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
type publishedData struct {
|
||||
PaymentRef string `json:"payment_ref"`
|
||||
State string `json:"state"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
msgs := env.producer.Messages()
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("expected published status updates")
|
||||
}
|
||||
|
||||
seenCreated := false
|
||||
seenSettled := false
|
||||
for i := range msgs {
|
||||
if got, want := msgs[i].Subject, "payment_orchestrator_updated"; got != want {
|
||||
t.Fatalf("subject mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
|
||||
var outer publishedEnvelope
|
||||
if err := json.Unmarshal(msgs[i].Data, &outer); err != nil {
|
||||
t.Fatalf("failed to unmarshal published envelope[%d]: %v", i, err)
|
||||
}
|
||||
if strings.TrimSpace(outer.EventID) == "" {
|
||||
t.Fatalf("expected non-empty event_id at %d", i)
|
||||
}
|
||||
if got, want := outer.Type, paymentStatusEventType; got != want {
|
||||
t.Fatalf("event type mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
|
||||
var inner publishedData
|
||||
if err := json.Unmarshal(outer.Data, &inner); err != nil {
|
||||
t.Fatalf("failed to unmarshal published data[%d]: %v", i, err)
|
||||
}
|
||||
if got, want := inner.PaymentRef, resp.GetPayment().GetPaymentRef(); got != want {
|
||||
t.Fatalf("payment_ref mismatch at %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
if inner.Event == "created" && inner.State == string(agg.StateCreated) {
|
||||
seenCreated = true
|
||||
}
|
||||
if inner.State == string(agg.StateSettled) {
|
||||
seenSettled = true
|
||||
}
|
||||
}
|
||||
|
||||
if !seenCreated {
|
||||
t.Fatal("expected created payment status update")
|
||||
}
|
||||
if !seenSettled {
|
||||
t.Fatal("expected settled payment status update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
@@ -282,6 +361,7 @@ type testEnv struct {
|
||||
repo *memoryRepo
|
||||
quotes *memoryQuoteStore
|
||||
observer oobs.Observer
|
||||
producer *capturingProducer
|
||||
orgID bson.ObjectID
|
||||
}
|
||||
|
||||
@@ -306,11 +386,13 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (
|
||||
Guard: script,
|
||||
})
|
||||
|
||||
producer := &capturingProducer{}
|
||||
svc, err := New(Dependencies{
|
||||
QuoteStore: quotes,
|
||||
Repository: repo,
|
||||
Executors: registry,
|
||||
Observer: observer,
|
||||
Producer: producer,
|
||||
RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2},
|
||||
MaxTicks: 20,
|
||||
})
|
||||
@@ -322,10 +404,46 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (
|
||||
repo: repo,
|
||||
quotes: quotes,
|
||||
observer: observer,
|
||||
producer: producer,
|
||||
orgID: bson.NewObjectID(),
|
||||
}
|
||||
}
|
||||
|
||||
type capturedMessage struct {
|
||||
Subject string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type capturingProducer struct {
|
||||
mu sync.Mutex
|
||||
items []capturedMessage
|
||||
}
|
||||
|
||||
func (p *capturingProducer) SendMessage(envelope menv.Envelope) error {
|
||||
if envelope == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := envelope.Serialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.items = append(p.items, capturedMessage{
|
||||
Subject: envelope.GetSignature().ToString(),
|
||||
Data: append([]byte(nil), data...),
|
||||
})
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *capturingProducer) Messages() []capturedMessage {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]capturedMessage, len(p.items))
|
||||
copy(out, p.items)
|
||||
return out
|
||||
}
|
||||
|
||||
type scriptedExecutors struct {
|
||||
handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
paymentStatusEventType = "payment.status.updated"
|
||||
paymentStatusEventSender = "payments.orchestrator.v2"
|
||||
)
|
||||
|
||||
type paymentStatusPublisher interface {
|
||||
Publish(ctx context.Context, in paymentStatusPublishInput) error
|
||||
}
|
||||
|
||||
type paymentStatusPublishInput struct {
|
||||
Payment *agg.Payment
|
||||
PreviousState agg.State
|
||||
CurrentState agg.State
|
||||
OccurredAt time.Time
|
||||
Event string
|
||||
}
|
||||
|
||||
type noopPaymentStatusPublisher struct{}
|
||||
|
||||
func (noopPaymentStatusPublisher) Publish(_ context.Context, _ paymentStatusPublishInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type brokerPaymentStatusPublisher struct {
|
||||
logger mlogger.Logger
|
||||
producer msg.Producer
|
||||
}
|
||||
|
||||
type callbackEventEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
ClientID string `json:"client_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type paymentStatusEventData struct {
|
||||
OrganizationRef string `json:"organization_ref"`
|
||||
PaymentRef string `json:"payment_ref"`
|
||||
QuotationRef string `json:"quotation_ref"`
|
||||
ClientPaymentRef string `json:"client_payment_ref,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
State string `json:"state"`
|
||||
PreviousState string `json:"previous_state,omitempty"`
|
||||
Version uint64 `json:"version"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
type rawEnvelope struct {
|
||||
timestamp time.Time
|
||||
messageID uuid.UUID
|
||||
data []byte
|
||||
sender string
|
||||
signature model.NotificationEvent
|
||||
}
|
||||
|
||||
func newPaymentStatusPublisher(logger mlogger.Logger, producer msg.Producer) paymentStatusPublisher {
|
||||
if producer == nil {
|
||||
return noopPaymentStatusPublisher{}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &brokerPaymentStatusPublisher{
|
||||
logger: logger.Named("status_publisher"),
|
||||
producer: producer,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *brokerPaymentStatusPublisher) Publish(_ context.Context, in paymentStatusPublishInput) error {
|
||||
if in.Payment == nil {
|
||||
return nil
|
||||
}
|
||||
payment := in.Payment
|
||||
|
||||
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||
if paymentRef == "" || payment.OrganizationRef.IsZero() {
|
||||
p.logger.Warn("Skipping payment status publish due to missing identifiers",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("organization_ref", payment.OrganizationRef.Hex()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
occurredAt := in.OccurredAt.UTC()
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
eventName := strings.TrimSpace(in.Event)
|
||||
if eventName == "" {
|
||||
eventName = "state_changed"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(paymentStatusEventData{
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
PaymentRef: paymentRef,
|
||||
QuotationRef: strings.TrimSpace(payment.QuotationRef),
|
||||
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
|
||||
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
|
||||
State: string(in.CurrentState),
|
||||
PreviousState: normalizePreviousState(in.PreviousState, in.CurrentState),
|
||||
Version: payment.Version,
|
||||
IsTerminal: isTerminalState(in.CurrentState),
|
||||
Event: eventName,
|
||||
})
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "payment status publish: marshal body failed")
|
||||
}
|
||||
|
||||
message, err := json.Marshal(callbackEventEnvelope{
|
||||
EventID: buildPaymentStatusEventID(paymentRef, payment.Version, in.CurrentState),
|
||||
Type: paymentStatusEventType,
|
||||
ClientID: payment.OrganizationRef.Hex(),
|
||||
OccurredAt: occurredAt,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Data: body,
|
||||
})
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "payment status publish: marshal envelope failed")
|
||||
}
|
||||
|
||||
signature := model.NewNotification(mservice.PaymentOrchestrator, nm.NAUpdated)
|
||||
envelope := &rawEnvelope{
|
||||
timestamp: occurredAt,
|
||||
messageID: uuid.New(),
|
||||
data: message,
|
||||
sender: paymentStatusEventSender,
|
||||
signature: signature,
|
||||
}
|
||||
|
||||
if err := p.producer.SendMessage(envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePreviousState(previous, current agg.State) string {
|
||||
if previous == current || previous == agg.StateUnspecified {
|
||||
return ""
|
||||
}
|
||||
return string(previous)
|
||||
}
|
||||
|
||||
func isTerminalState(state agg.State) bool {
|
||||
switch state {
|
||||
case agg.StateSettled, agg.StateNeedsAttention, agg.StateFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildPaymentStatusEventID(paymentRef string, version uint64, state agg.State) string {
|
||||
return paymentRef + ":" + strconv.FormatUint(version, 10) + ":" + string(state)
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) Serialize() ([]byte, error) {
|
||||
return append([]byte(nil), e.data...), nil
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetTimeStamp() time.Time {
|
||||
return e.timestamp
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetMessageId() uuid.UUID {
|
||||
return e.messageID
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetData() []byte {
|
||||
return append([]byte(nil), e.data...)
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetSender() string {
|
||||
return e.sender
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) GetSignature() model.NotificationEvent {
|
||||
return e.signature
|
||||
}
|
||||
|
||||
func (e *rawEnvelope) Wrap(data []byte) ([]byte, error) {
|
||||
e.data = append([]byte(nil), data...)
|
||||
return e.Serialize()
|
||||
}
|
||||
|
||||
var _ me.Envelope = (*rawEnvelope)(nil)
|
||||
@@ -23,6 +23,7 @@ type Service struct {
|
||||
repo storage.Repository
|
||||
v2 psvc.Service
|
||||
paymentRepo prepo.Repository
|
||||
producer msg.Producer
|
||||
|
||||
ledgerClient ledgerclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
@@ -35,14 +36,15 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService constructs the v2 orchestrator service.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) (*Service, error) {
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) (*Service, error) {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("service"),
|
||||
repo: repo,
|
||||
logger: logger.Named("service"),
|
||||
repo: repo,
|
||||
producer: producer,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -58,6 +60,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
GatewayInvokeResolver: svc.gatewayInvokeResolver,
|
||||
GatewayRegistry: svc.gatewayRegistry,
|
||||
CardGatewayRoutes: svc.cardGatewayRoutes,
|
||||
Producer: svc.producer,
|
||||
})
|
||||
svc.startExternalRuntime()
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
@@ -30,6 +31,7 @@ type v2RuntimeDeps struct {
|
||||
GatewayInvokeResolver GatewayInvokeResolver
|
||||
GatewayRegistry GatewayRegistry
|
||||
CardGatewayRoutes map[string]CardGatewayRoute
|
||||
Producer msg.Producer
|
||||
}
|
||||
|
||||
func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, runtimeDeps v2RuntimeDeps) (psvc.Service, prepo.Repository, error) {
|
||||
@@ -76,6 +78,7 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
|
||||
Query: query,
|
||||
Observer: observer,
|
||||
Executors: executors,
|
||||
Producer: runtimeDeps.Producer,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Orchestration v2 disabled: service init failed", zap.Error(err))
|
||||
|
||||
Reference in New Issue
Block a user