188 lines
5.9 KiB
Go
188 lines
5.9 KiB
Go
package quotation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/tech/sendico/payments/storage"
|
|
"github.com/tech/sendico/payments/storage/model"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
|
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
func validateMetaAndOrgRef(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) {
|
|
if meta == nil {
|
|
return "", bson.NilObjectID, merrors.InvalidArgument("meta is required")
|
|
}
|
|
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
|
if orgRef == "" {
|
|
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required")
|
|
}
|
|
orgID, err := bson.ObjectIDFromHex(orgRef)
|
|
if err != nil {
|
|
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
|
|
}
|
|
return orgRef, orgID, nil
|
|
}
|
|
|
|
func requireNonNilIntent(intent *sharedv1.PaymentIntent) error {
|
|
if intent == nil {
|
|
return merrors.InvalidArgument("intent is required")
|
|
}
|
|
if intent.GetAmount() == nil {
|
|
return merrors.InvalidArgument("intent.amount is required")
|
|
}
|
|
if strings.TrimSpace(intent.GetSettlementCurrency()) == "" {
|
|
return merrors.InvalidArgument("intent.settlement_currency is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
|
|
if repo == nil {
|
|
return nil, errStorageUnavailable
|
|
}
|
|
store := repo.Quotes()
|
|
if store == nil {
|
|
return nil, errStorageUnavailable
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
type quoteResolutionInput struct {
|
|
OrgRef string
|
|
OrgID bson.ObjectID
|
|
Meta *sharedv1.RequestMeta
|
|
Intent *sharedv1.PaymentIntent
|
|
QuoteRef string
|
|
IdempotencyKey string
|
|
}
|
|
|
|
type quoteResolutionError struct {
|
|
code string
|
|
err error
|
|
}
|
|
|
|
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
|
|
|
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
|
|
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
|
quotesStore, err := ensureQuotesStore(s.storage)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrQuoteNotFound) {
|
|
return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
|
}
|
|
return nil, nil, nil, err
|
|
}
|
|
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
|
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
|
}
|
|
intent, err := recordIntentFromQuote(record)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
|
return nil, 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, nil, err
|
|
}
|
|
quote.QuoteRef = ref
|
|
plan, err := recordPlanFromQuote(record)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return quote, intent, plan, nil
|
|
}
|
|
|
|
if in.Intent == nil {
|
|
return nil, nil, nil, merrors.InvalidArgument("intent is required")
|
|
}
|
|
req := "ationv1.QuotePaymentRequest{
|
|
Meta: in.Meta,
|
|
IdempotencyKey: in.IdempotencyKey,
|
|
Intent: in.Intent,
|
|
PreviewOnly: false,
|
|
}
|
|
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
plan, err := s.buildPaymentPlan(ctx, in.OrgID, in.Intent, in.IdempotencyKey, quote)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return quote, in.Intent, plan, nil
|
|
}
|
|
|
|
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.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) (*sharedv1.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 recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) {
|
|
if record == nil {
|
|
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
|
}
|
|
if len(record.Plans) > 0 {
|
|
if len(record.Plans) != 1 {
|
|
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
|
}
|
|
return cloneStoredPaymentPlan(record.Plans[0]), nil
|
|
}
|
|
if record.Plan != nil {
|
|
return cloneStoredPaymentPlan(record.Plan), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.PaymentQuote) *model.Payment {
|
|
entity := &model.Payment{}
|
|
entity.SetID(bson.NewObjectID())
|
|
entity.SetOrganizationRef(orgID)
|
|
entity.PaymentRef = entity.GetID().Hex()
|
|
entity.IdempotencyKey = idempotencyKey
|
|
entity.State = model.PaymentStateAccepted
|
|
entity.Intent = intentFromProto(intent)
|
|
entity.Metadata = cloneMetadata(metadata)
|
|
entity.LastQuote = quoteSnapshotToModel(quote)
|
|
entity.Normalize()
|
|
return entity
|
|
}
|