package payments import ( "context" "errors" "time" customer "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/product/twofactor" factor "git.sendico.io/tech/sxchnge/product/twofactor/model" verification "git.sendico.io/tech/sxchnge/product/verification/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 Service struct { Store Store Provider Provider Identity Identity Factors twofactor.API Logger mlogger.Logger } func (s *Service) ready() error { if s == nil || s.Store == nil || s.Provider == nil || s.Identity == nil || s.Factors == nil { return Unavailable } return nil } func (s *Service) record(ctx context.Context, a *customer.Account, id string) (*Record, error) { if err := s.ready(); err != nil { return nil, err } if a == nil || a.ID.IsZero() { return nil, NotFound } ref, err := bson.ObjectIDFromHex(id) if err != nil { return nil, NotFound } r, err := s.Store.Get(ctx, ref, a.ID, s.Provider.Namespace()) if errors.Is(err, merrors.ErrNoData) { return nil, NotFound } if err != nil { return nil, err } subject, err := s.Identity.Subject(ctx, a) if err != nil { return nil, err } if subject != r.Subject { return nil, Unavailable } return r, nil } func (s *Service) Quote(ctx context.Context, a *customer.Account, request Request) (Quote, error) { if err := s.ready(); err != nil { return Quote{}, err } subject, err := s.Identity.Subject(ctx, a) if err != nil { return Quote{}, err } accounts, err := s.Provider.Accounts(ctx, subject) if err != nil { return Quote{}, err } q, err := s.Provider.Quote(ctx, subject, request, accounts) if err != nil { return Quote{}, err } providerRef := q.ID q.ID = bson.NewObjectID().Hex() r := &Record{Customer: a.ID, Namespace: s.Provider.Namespace(), Subject: subject, Quote: q, ProviderQuote: providerRef} r.SetID(mustID(q.ID)) if err = s.Store.Create(ctx, r); err != nil { return Quote{}, err } s.Logger.Info("Customer quotation stored", zap.String("quote_ref", q.ID), zap.String("kind", request.Kind)) return q, nil } func mustID(id string) bson.ObjectID { value, _ := bson.ObjectIDFromHex(id); return value } func operationScope(r *Record) factor.Scope { return factor.Scope{Purpose: verification.PurposePayment, ContextRef: r.Quote.ID} } func (s *Service) Challenge(ctx context.Context, a *customer.Account, id, ip string) (factor.Challenge, error) { r, err := s.record(ctx, a, id) if err != nil { return factor.Challenge{}, err } if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" { return factor.Challenge{}, ExpiredQuote } return s.Factors.Begin(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: "payment:" + r.Quote.ID, IP: ip}) } func (s *Service) Resend(ctx context.Context, a *customer.Account, id, challenge, key, ip string) (factor.Challenge, error) { r, err := s.record(ctx, a, id) if err != nil { return factor.Challenge{}, err } if !time.Now().Before(r.Quote.ExpiresAt) || r.Key != "" { return factor.Challenge{}, ExpiredQuote } if !validKey(key) { return factor.Challenge{}, InvalidAmount } return s.Factors.Resend(ctx, factor.Request{Subject: a.ID, Scope: operationScope(r), Deadline: r.Quote.ExpiresAt, IdempotencyKey: key, IP: ip}, challenge) } func validKey(key string) bool { if len(key) < 16 || len(key) > 128 { return false } for _, c := range key { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { return false } } return true } // Authorize consumes the challenge atomically with a durable execution intent. // No upstream I/O occurs inside the transaction. Replaying the same authorization // recovers a lost response without reusing the one-use verification code. func (s *Service) Authorize(ctx context.Context, a *customer.Account, id, challenge, code, key string) error { r, err := s.record(ctx, a, id) if err != nil { return err } if !validKey(key) { return InvalidAmount } if r.Key != "" { if r.Key == key { return nil } return ExpiredQuote } return s.Factors.Complete(ctx, a.ID, operationScope(r), challenge, code, func(tx context.Context) error { if !time.Now().Before(r.Quote.ExpiresAt) { return ExpiredQuote } return s.Store.Authorize(tx, r, key) }) } func (s *Service) execute(ctx context.Context, r *Record) (Operation, error) { var remote RemoteOperation var err error if r.Payment != "" { remote, err = s.Provider.Payment(ctx, r.Payment) } else { remote, err = s.Provider.Execute(ctx, r.ProviderQuote, "sxchnge:payment:"+r.ID.Hex()) } if err != nil { s.Logger.Warn("Payment execution needs reconciliation", zap.String("quote_ref", r.Quote.ID)) return Operation{}, merrors.OpaqueWrap(Uncertain, err, "provider payment outcome unknown") } if remote.QuoteRef != r.ProviderQuote || remote.ID == "" || (r.Payment != "" && r.Payment != remote.ID) { return Operation{}, Uncertain } if r.Payment == "" { if err = s.Store.Attach(ctx, r, remote.ID); err != nil { return Operation{}, merrors.OpaqueWrap(Uncertain, err, "persist provider payment reference") } } s.Logger.Info("Payment execution reconciled", zap.String("quote_ref", r.Quote.ID), zap.String("payment_ref", remote.ID), zap.String("state", remote.Status)) return Operation{ID: r.Quote.ID, Quote: r.Quote, Status: remote.Status, CreatedAt: remote.CreatedAt}, nil } func (s *Service) Submit(ctx context.Context, a *customer.Account, id, key string) (Operation, error) { r, err := s.record(ctx, a, id) if err != nil { return Operation{}, err } if !validKey(key) || r.Key == "" || r.Key != key { return Operation{}, FactorRequired } return s.execute(ctx, r) } // Reconcile replays the already authorized durable intent with the same provider // key. A timeout or an empty upstream list is never treated as proof of absence. func (s *Service) Reconcile(ctx context.Context, a *customer.Account, key string) (*Operation, error) { if err := s.ready(); err != nil { return nil, err } if !validKey(key) { return nil, NotFound } r, err := s.Store.ByKey(ctx, a.ID, s.Provider.Namespace(), key) if errors.Is(err, merrors.ErrNoData) { return nil, nil } if err != nil { return nil, err } r, err = s.record(ctx, a, r.ID.Hex()) if err != nil { return nil, err } op, err := s.execute(ctx, r) return &op, err } func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, error) { if err := s.ready(); err != nil { return Snapshot{}, err } subject, err := s.Identity.Subject(ctx, a) if err != nil { return Snapshot{}, err } rows, err := s.Store.List(ctx, a.ID, s.Provider.Namespace()) if err != nil { return Snapshot{}, err } result := Snapshot{Operations: []Operation{}} for _, r := range rows { if r.Subject != subject { return Snapshot{}, Unavailable } op, err := s.execute(ctx, &r) if errors.Is(err, Uncertain) { // A durable authorization with an unknown provider outcome remains // pending. It must not hide unrelated balances or report failure. op = Operation{ID: r.Quote.ID, Quote: r.Quote, Status: "pending", CreatedAt: r.CreatedAt} } else if err != nil { return Snapshot{}, err } result.Operations = append(result.Operations, op) } result.Accounts, err = s.Provider.Accounts(ctx, subject) if err != nil { return Snapshot{}, err } return result, nil }