package callbacksimp import ( "context" "encoding/json" "net/http" "net/url" "strings" "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) type callbackWriteResponse struct { AccessToken sresponse.TokenData `json:"accessToken"` Callbacks []model.Callback `json:"callbacks"` GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"` } func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc { organizationRef, err := a.Oph.GetRef(r) if err != nil { a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r)) return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err) } var callback model.Callback if err := json.NewDecoder(r.Body).Decode(&callback); err != nil { a.Logger.Warn("Failed to decode callback payload", zap.Error(err)) return response.BadPayload(a.Logger, a.Name(), err) } generatedSecret, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, true) if err != nil { return response.Auto(a.Logger, a.Name(), err) } if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &callback); err != nil { a.Logger.Warn("Failed to create callback", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } return a.callbackResponse(&callback, accessToken, generatedSecret, true) } func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc { var input model.Callback if err := json.NewDecoder(r.Body).Decode(&input); err != nil { a.Logger.Warn("Failed to decode callback payload", zap.Error(err)) return response.BadPayload(a.Logger, a.Name(), err) } callbackRef := *input.GetID() if callbackRef.IsZero() { return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback id is required", "id")) } var existing model.Callback if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil { a.Logger.Warn("Failed to fetch callback before update", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } mergeCallbackMutable(&existing, &input) generatedSecret, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, true) if err != nil { return response.Auto(a.Logger, a.Name(), err) } if err := a.DB.Update(r.Context(), *account.GetID(), &existing); err != nil { a.Logger.Warn("Failed to update callback", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } return a.callbackResponse(&existing, accessToken, generatedSecret, false) } func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc { callbackRef, err := a.Cph.GetRef(r) if err != nil { a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r)) return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err) } var callback model.Callback if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil { a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 { return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks") } secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef) if err != nil { a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } callback.RetryPolicy.SecretRef = secretRef if err := a.DB.Update(r.Context(), *account.GetID(), &callback); err != nil { a.Logger.Warn("Failed to persist rotated callback secret reference", zap.Error(err)) return response.Auto(a.Logger, a.Name(), err) } return a.callbackResponse(&callback, accessToken, generatedSecret, false) } func (a *CallbacksAPI) normalizeAndPrepare( ctx context.Context, callback *model.Callback, organizationRef bson.ObjectID, allowSecretGeneration bool, ) (string, error) { if callback == nil { return "", merrors.InvalidArgument("callback payload is required") } if organizationRef.IsZero() { return "", merrors.InvalidArgument("organization reference is required", "organizationRef") } callback.SetOrganizationRef(organizationRef) callback.Name = strings.TrimSpace(callback.Name) callback.Description = trimDescription(callback.Description) callback.ClientID = strings.TrimSpace(callback.ClientID) if callback.ClientID == "" { callback.ClientID = organizationRef.Hex() } callback.URL = strings.TrimSpace(callback.URL) if callback.URL == "" { return "", merrors.InvalidArgument("url is required", "url") } if err := validateCallbackURL(callback.URL); err != nil { return "", err } if callback.Name == "" { callback.Name = callback.URL } status, err := normalizeStatus(callback.Status, a.config.DefaultStatus) if err != nil { return "", err } callback.Status = status callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes) callback.RetryPolicy.MinDelayMS = defaultInt(callback.RetryPolicy.MinDelayMS, defaultRetryMinDelayMS) callback.RetryPolicy.MaxDelayMS = defaultInt(callback.RetryPolicy.MaxDelayMS, defaultRetryMaxDelayMS) if callback.RetryPolicy.MaxDelayMS < callback.RetryPolicy.MinDelayMS { callback.RetryPolicy.MaxDelayMS = callback.RetryPolicy.MinDelayMS } callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts) callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS) callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers) mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode) if err != nil { return "", err } callback.RetryPolicy.SigningMode = mode callback.RetryPolicy.SecretRef = strings.TrimSpace(callback.RetryPolicy.SecretRef) switch callback.RetryPolicy.SigningMode { case model.CallbackSigningModeNone: callback.RetryPolicy.SecretRef = "" return "", nil case model.CallbackSigningModeHMACSHA256: if callback.RetryPolicy.SecretRef != "" { return "", nil } if !allowSecretGeneration { return "", merrors.InvalidArgument("secretRef is required for hmac_sha256 callbacks", "retryPolicy.secretRef") } if callback.GetID().IsZero() { callback.SetID(bson.NewObjectID()) } secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID()) if err != nil { return "", err } callback.RetryPolicy.SecretRef = secretRef return generatedSecret, nil default: return "", merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode") } } func (a *CallbacksAPI) callbackResponse( callback *model.Callback, accessToken *sresponse.TokenData, generatedSecret string, created bool, ) http.HandlerFunc { if callback == nil || accessToken == nil { return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response")) } resp := callbackWriteResponse{ AccessToken: *accessToken, Callbacks: []model.Callback{*callback}, GeneratedSigningSecret: generatedSecret, } if created { return response.Created(a.Logger, resp) } return response.Ok(a.Logger, resp) } func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) { candidate := strings.ToLower(strings.TrimSpace(string(raw))) if candidate == "" { candidate = strings.ToLower(strings.TrimSpace(string(fallback))) } switch candidate { case "", "active", "enabled": return model.CallbackStatusActive, nil case "disabled", "inactive": return model.CallbackStatusDisabled, nil default: return "", merrors.InvalidArgument("unsupported callback status", "status") } } func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) { mode := strings.ToLower(strings.TrimSpace(string(raw))) switch mode { case "", "none": return model.CallbackSigningModeNone, nil case "hmac_sha256", "hmac-sha256", "hmac": return model.CallbackSigningModeHMACSHA256, nil default: return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode") } } func normalizeEventTypes(eventTypes []string, defaults []string) []string { if len(eventTypes) == 0 { return normalizeEventTypes(defaults, nil) } seen := make(map[string]struct{}, len(eventTypes)) out := make([]string, 0, len(eventTypes)) for _, eventType := range eventTypes { value := strings.TrimSpace(eventType) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } if len(out) == 0 { if len(defaults) > 0 { return normalizeEventTypes(defaults, nil) } return []string{model.PaymentStatusUpdatedType} } return out } func normalizeHeaders(headers map[string]string) map[string]string { if len(headers) == 0 { return nil } out := make(map[string]string, len(headers)) for key, value := range headers { k := strings.TrimSpace(key) if k == "" { continue } out[k] = strings.TrimSpace(value) } if len(out) == 0 { return nil } return out } func mergeCallbackMutable(dst, src *model.Callback) { dst.Describable = src.Describable dst.ClientID = src.ClientID dst.Status = src.Status dst.URL = src.URL dst.EventTypes = append([]string(nil), src.EventTypes...) dst.RetryPolicy = model.CallbackRetryPolicy{ MinDelayMS: src.RetryPolicy.MinDelayMS, MaxDelayMS: src.RetryPolicy.MaxDelayMS, SigningMode: src.RetryPolicy.SigningMode, SecretRef: src.RetryPolicy.SecretRef, Headers: normalizeHeaders(src.RetryPolicy.Headers), MaxAttempts: src.RetryPolicy.MaxAttempts, RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS, } } func defaultInt(value, fallback int) int { if value > 0 { return value } return fallback } func trimDescription(in *string) *string { if in == nil { return nil } value := strings.TrimSpace(*in) if value == "" { return nil } return &value } func validateCallbackURL(raw string) error { parsed, err := url.ParseRequestURI(raw) if err != nil { return merrors.InvalidArgument("url is invalid", "url") } switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { case "https", "http": default: return merrors.InvalidArgument("url scheme must be http or https", "url") } if strings.TrimSpace(parsed.Host) == "" { return merrors.InvalidArgument("url host is required", "url") } return nil }