package coach import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "time" ) type repositoryStore interface { Profile(context.Context, int64, string) (Profile, error) SaveProfile(context.Context, int64, int64, string, string, string, ProfileInput) (Profile, bool, error) terminal(context.Context, int64, string) (int64, error) Enqueue(context.Context, enqueueInput) (Job, bool, error) EnqueueManual(context.Context, enqueueInput) (Job, bool, error) Claim(context.Context) (claimedJob, error) Evidence(context.Context, claimedJob) (Profile, MetricEvidence, error) Complete(context.Context, claimedJob, Generation, MetricEvidence, ProviderUsage, string) error Fail(context.Context, claimedJob, string) error Dashboard(context.Context, int64, string) (Dashboard, error) Plans(context.Context, int64, string) ([]Plan, error) Actions(context.Context, int64, string) ([]Action, error) Goals(context.Context, int64, string) ([]MonthlyGoal, error) ProposeEventDeadlines(context.Context, int64, string) (int64, error) TransitionSubtask(context.Context, int64, int64, string, string, string, string, string) (GoalSubtask, error) DecideDeadlineProposal(context.Context, int64, int64, string, string, string, string) (DeadlineProposal, error) DecideRecommendation(context.Context, int64, int64, string, string, string, DecisionInput) (*Action, error) Transition(context.Context, int64, int64, string, string, string, string) (Action, error) GetJob(context.Context, int64, string) (Job, error) CurrentProfiles(context.Context) ([]scheduledProfile, error) EvaluateMetrics(context.Context, string) (int, error) CurrentWatermark(context.Context) (string, error) DecideDifficulty(context.Context, int64, int64, string, string, string, string) (DifficultyProposal, error) EvaluateDifficulty(context.Context) (int, error) EventsAfter(context.Context, int64, string, int64) ([]Event, error) } type Service struct { repository repositoryStore generator Generator now func() time.Time dailyRequests int64 dailyTokens int64 } func (s *Service) WithBudget(requests, tokens int64) *Service { s.dailyRequests = requests s.dailyTokens = tokens return s } func NewService(repository repositoryStore, generator Generator) *Service { return &Service{repository: repository, generator: generator, now: time.Now} } func (s *Service) Profile(ctx context.Context, merchantID int64, terminalID string) (Profile, error) { if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil { return Profile{}, err } return s.repository.Profile(ctx, merchantID, terminalID) } func (s *Service) SaveProfile(ctx context.Context, merchantID, userID int64, terminalID, key string, input ProfileInput) (Profile, []Job, bool, error) { input = NormalizeProfileInput(input) if err := input.Validate(); err != nil { return Profile{}, nil, false, err } hash := hashJSON(input) profile, cached, err := s.repository.SaveProfile(ctx, merchantID, userID, terminalID, key, hash, input) if err != nil { return Profile{}, nil, false, err } if cached { return profile, nil, true, nil } jobs := []Job{} for _, cadence := range []string{"MONTHLY", "WEEKLY", "DAILY"} { job, _, err := s.enqueue(ctx, merchantID, terminalID, profile, cadence, "PROFILE_CREATED", "") if err != nil { return Profile{}, nil, false, err } jobs = append(jobs, job) } if _, err := s.repository.ProposeEventDeadlines(ctx, merchantID, terminalID); err != nil { return Profile{}, nil, false, err } return profile, jobs, false, nil } func (s *Service) Dashboard(ctx context.Context, merchantID int64, terminalID string) (Dashboard, error) { return s.repository.Dashboard(ctx, merchantID, terminalID) } func (s *Service) Plans(ctx context.Context, merchantID int64, terminalID string) ([]Plan, error) { if terminalID != "" { if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil { return nil, err } } return s.repository.Plans(ctx, merchantID, terminalID) } func (s *Service) Actions(ctx context.Context, merchantID int64, terminalID string) ([]Action, error) { if terminalID != "" { if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil { return nil, err } } return s.repository.Actions(ctx, merchantID, terminalID) } func (s *Service) Goals(ctx context.Context, merchantID int64, terminalID string) ([]MonthlyGoal, error) { if terminalID != "" { if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil { return nil, err } } return s.repository.Goals(ctx, merchantID, terminalID) } func (s *Service) TransitionSubtask(ctx context.Context, merchantID, userID int64, goalID, subtaskID, key string, input SubtaskTransitionInput) (GoalSubtask, error) { if input.Transition != "COMPLETE" && input.Transition != "REOPEN" { return GoalSubtask{}, ErrInvalidTransition } return s.repository.TransitionSubtask(ctx, merchantID, userID, goalID, subtaskID, key, hashJSON(map[string]any{"goalId": goalID, "subtaskId": subtaskID, "transition": input.Transition}), input.Transition) } func (s *Service) DecideDeadlineProposal(ctx context.Context, merchantID, userID int64, proposalID, key string, input DeadlineDecisionInput) (DeadlineProposal, error) { if input.Decision != "ACCEPT" && input.Decision != "REJECT" { return DeadlineProposal{}, ErrInvalidTransition } return s.repository.DecideDeadlineProposal(ctx, merchantID, userID, proposalID, key, hashJSON(map[string]any{"proposalId": proposalID, "decision": input.Decision}), input.Decision) } func (s *Service) Refresh(ctx context.Context, merchantID int64, terminalID, key string, input RefreshInput) (Job, bool, error) { if err := input.Validate(); err != nil { return Job{}, false, err } profile, err := s.Profile(ctx, merchantID, terminalID) if err != nil { return Job{}, false, err } terminalDBID, err := s.repository.terminal(ctx, merchantID, terminalID) if err != nil { return Job{}, false, err } start, end := PeriodBounds(input.Cadence, s.now()) fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d|MANUAL|%s", merchantID, terminalID, input.Cadence, start.Format(time.DateOnly), profile.Version, key)) job, cached, err := s.repository.EnqueueManual(ctx, enqueueInput{MerchantID: merchantID, TerminalID: terminalDBID, TerminalKey: terminalID, ProfileID: profile.ProfileID, Cadence: input.Cadence, Reason: "MANUAL", Fingerprint: fingerprint, IdempotencyKey: key, RequestHash: hashString(terminalID + "|" + input.Cadence), Start: start, End: end}) if errors.Is(err, ErrRefreshLimited) { return Job{}, false, &RefreshLimitError{NextRefreshAt: end.AddDate(0, 0, 1)} } return job, cached, err } func (s *Service) enqueue(ctx context.Context, merchantID int64, terminalID string, profile Profile, cadence, reason, key string) (Job, bool, error) { terminalDBID, err := s.repository.terminal(ctx, merchantID, terminalID) if err != nil { return Job{}, false, err } start, end := PeriodBounds(cadence, s.now()) fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d", merchantID, terminalID, cadence, start.Format(time.DateOnly), profile.Version)) return s.repository.Enqueue(ctx, enqueueInput{MerchantID: merchantID, TerminalID: terminalDBID, TerminalKey: terminalID, ProfileID: profile.ProfileID, Cadence: cadence, Reason: reason, Fingerprint: fingerprint, IdempotencyKey: key, RequestHash: hashString(terminalID + cadence), Start: start, End: end}) } func (s *Service) ProcessNext(ctx context.Context) error { job, err := s.repository.Claim(ctx) if err != nil { return err } if s.generator == nil { _ = s.repository.Fail(ctx, job, "PROVIDER_UNAVAILABLE") return nil } if usageRepository, ok := s.repository.(interface { DailyProviderUsage(context.Context) (int64, int64, error) }); ok && (s.dailyRequests > 0 || s.dailyTokens > 0) { requests, tokens, usageErr := usageRepository.DailyProviderUsage(ctx) if usageErr != nil { _ = s.repository.Fail(ctx, job, "BUDGET_CHECK_FAILED") return usageErr } if (s.dailyRequests > 0 && requests >= s.dailyRequests) || (s.dailyTokens > 0 && tokens >= s.dailyTokens) { _ = s.repository.Fail(ctx, job, "BUDGET_EXHAUSTED") return nil } } profile, evidence, err := s.repository.Evidence(ctx, job) if err != nil { _ = s.repository.Fail(ctx, job, "EVIDENCE_FAILED") return err } generation, usage, err := s.generator.Generate(ctx, job.Cadence, profile, evidence) if err != nil { _ = s.repository.Fail(ctx, job, "PROVIDER_OR_VALIDATION_FAILED") return err } return s.repository.Complete(ctx, job, generation, evidence, usage, s.generator.Model()) } func (s *Service) ScheduleDue(ctx context.Context) (int, error) { profiles, err := s.repository.CurrentProfiles(ctx) if err != nil { return 0, err } created := 0 now := s.now() dailyStart, _ := PeriodBounds("DAILY", now) weekStart, _ := PeriodBounds("WEEKLY", now) monthStart, _ := PeriodBounds("MONTHLY", now) for _, item := range profiles { cadences := []string{"DAILY"} if dailyStart.Equal(weekStart) { cadences = append(cadences, "WEEKLY") } if dailyStart.Equal(monthStart) { cadences = append(cadences, "MONTHLY") } for _, cadence := range cadences { start, end := PeriodBounds(cadence, now) fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d", item.MerchantID, item.Profile.TerminalID, cadence, start.Format(time.DateOnly), item.Profile.Version)) _, cached, err := s.repository.Enqueue(ctx, enqueueInput{MerchantID: item.MerchantID, TerminalID: item.TerminalDBID, TerminalKey: item.Profile.TerminalID, ProfileID: item.Profile.ProfileID, Cadence: cadence, Reason: "SCHEDULED", Fingerprint: fingerprint, Start: start, End: end}) if err != nil { return created, err } if !cached { created++ } } if _, err := s.repository.ProposeEventDeadlines(ctx, item.MerchantID, item.Profile.TerminalID); err != nil { return created, err } } return created, nil } func (s *Service) DecideDifficulty(ctx context.Context, merchantID, userID int64, id, key string, input DecisionInput) (DifficultyProposal, error) { return s.repository.DecideDifficulty(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "decision": input.Decision}), input.Decision) } func (s *Service) DecideRecommendation(ctx context.Context, merchantID, userID int64, id, key string, input DecisionInput) (*Action, error) { if input.Decision != "ACCEPTED" && input.Decision != "REJECTED" { return nil, ErrInvalidTransition } if len([]rune(input.Note)) > 500 { return nil, errors.New("growth coach: decision note too long") } return s.repository.DecideRecommendation(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "input": input}), input) } func (s *Service) Transition(ctx context.Context, merchantID, userID int64, id, key string, input TransitionInput) (Action, error) { return s.repository.Transition(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "transition": input.Transition}), input.Transition) } func (s *Service) GetJob(ctx context.Context, merchantID int64, id string) (Job, error) { return s.repository.GetJob(ctx, merchantID, id) } func (s *Service) EventsAfter(ctx context.Context, merchantID int64, id string, after int64) ([]Event, error) { return s.repository.EventsAfter(ctx, merchantID, id, after) } func (s *Service) EvaluateMetrics(ctx context.Context) (int, error) { watermark, err := s.repository.CurrentWatermark(ctx) if err != nil { return 0, err } return s.repository.EvaluateMetrics(ctx, watermark) } func (s *Service) EvaluateDifficulty(ctx context.Context) (int, error) { return s.repository.EvaluateDifficulty(ctx) } // PaymentUpdateListener is the stable boundary used by the CSV importer today // and by a future webhook/API adapter without changing coach evaluation rules. type PaymentUpdateListener interface { OnPaymentUpdate(context.Context, string) error } func (s *Service) OnPaymentUpdate(ctx context.Context, watermark string) error { _, err := s.repository.EvaluateMetrics(ctx, watermark) return err } func hashJSON(value any) string { data, _ := json.Marshal(value); return hashString(string(data)) } func hashString(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) }