package identity import ( "context" "errors" "time" customer "git.sendico.io/tech/sxchnge/product/customers/model" merrors "git.sendico.io/tech/sxchnge/shared/errors" mlogger "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type Provider interface { Namespace() string Register(context.Context, string) (string, error) Submit(context.Context, string, string, Facts) (Receipt, error) } type Service struct { Store Store Provider Provider Logger mlogger.Logger } func unavailable() error { return merrors.ServiceUnavailable("sign-up facts unavailable") } // Ensure completes only after a provider receipt has been persisted. Registration // has no idempotency contract, so its durable claim is never automatically expired. func (s *Service) Ensure(ctx context.Context, a *customer.Account) (result error) { if a == nil || a.ID.IsZero() { return merrors.InvalidArgument("customer required") } started := time.Now() requestRef := "" outcome := "unavailable" defer func() { requests.WithLabelValues(outcome).Inc() duration.Observe(time.Since(started).Seconds()) fields := []zap.Field{zap.String("account_ref", a.ID.Hex()), zap.String("request_ref", requestRef), zap.String("outcome", outcome)} if result != nil { s.Logger.Warn("Sign-up facts handoff incomplete", fields...) } else { s.Logger.Info("Sign-up facts handoff completed", fields...) } }() namespace := s.Provider.Namespace() b, err := s.Store.Get(ctx, a.ID) if errors.Is(err, merrors.ErrNoData) { b = &Binding{Namespace: namespace, RegistrationRequest: bson.NewObjectID().Hex(), Facts: Facts{FullName: a.Name, Email: a.Login, CustomerRef: a.ID.Hex()}} b.SetID(a.ID) if err = s.Store.Create(ctx, b); err != nil { if !errors.Is(err, merrors.ErrDataConflict) { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist sign-up facts") } b, err = s.Store.Get(ctx, a.ID) } } if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load sign-up facts") } requestRef = b.RegistrationRequest if b.Namespace != namespace { outcome = "attention_required" return merrors.FailedPrecondition("sign-up provider binding changed") } if b.Receipt != nil { outcome = "replayed" return nil } if b.SubjectRef == "" { claimed, err := s.Store.Claim(ctx, a.ID, namespace) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "claim individual registration") } if !claimed { // Another request may have completed while this request was claiming. b, err = s.Store.Get(ctx, a.ID) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "reload sign-up facts") } if b.SubjectRef == "" { if b.RegisteringAt.Add(30 * time.Second).After(time.Now()) { return unavailable() } outcome = "attention_required" return merrors.FailedPrecondition("individual registration requires reconciliation") } } else { subject, err := s.Provider.Register(ctx, b.RegistrationRequest) if err != nil { outcome = "attention_required" return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "individual registration requires reconciliation") } // Persist even if the browser left while Sendico accepted registration. saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) err = s.Store.Bind(saveCtx, a.ID, namespace, subject) cancel() if err != nil { outcome = "attention_required" s.Logger.Warn("Registered individual binding needs reconciliation", zap.String("account_ref", a.ID.Hex()), zap.String("subject_ref", subject)) return merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "persist individual binding") } b.SubjectRef = subject } } receipt, err := s.Provider.Submit(ctx, b.SubjectRef, sourceRef(a.ID), b.Facts) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "submit sign-up facts") } if receipt.SubjectRef != b.SubjectRef || receipt.SubmissionRef == "" || receipt.AcceptedAt.IsZero() { return unavailable() } if err = s.Store.Accept(ctx, a.ID, namespace, receipt); err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "persist facts receipt") } outcome = "accepted" return nil } // Ready checks confirmed intake without external I/O inside activation transactions. func (s *Service) Ready(ctx context.Context, id bson.ObjectID) error { b, err := s.Store.Get(ctx, id) if err != nil { return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "load facts receipt") } if b.Namespace != s.Provider.Namespace() || b.Receipt == nil { return unavailable() } return nil }