callbacks service draft

This commit is contained in:
Stephan D
2026-02-28 10:10:26 +01:00
parent b7900d3beb
commit 0f28f2d088
71 changed files with 5212 additions and 446 deletions

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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))