diff --git a/internal/eventcatalog/catalog.go b/internal/eventcatalog/catalog.go new file mode 100644 index 0000000..dbecf49 --- /dev/null +++ b/internal/eventcatalog/catalog.go @@ -0,0 +1,137 @@ +package eventcatalog + +import ( + "errors" + "fmt" + "strings" + "time" +) + +type EventType string +type Tone string +type CommercialPosture string +type Recurrence string + +const ( + TypePublicHoliday EventType = "PUBLIC_HOLIDAY" + TypeReligious EventType = "RELIGIOUS" + TypeNational EventType = "NATIONAL" + TypeCultural EventType = "CULTURAL" + TypeCommercial EventType = "COMMERCIAL" + TypeSeasonal EventType = "SEASONAL" + TypeEducation EventType = "EDUCATION" + TypeEconomic EventType = "ECONOMIC" + TypeEmergency EventType = "EMERGENCY_CLOSURE" + + ToneCelebratory Tone = "CELEBRATORY" + ToneJoyful Tone = "JOYFUL" + ToneNeutral Tone = "NEUTRAL" + ToneSolemn Tone = "SOLEMN" + ToneMourning Tone = "MOURNING" + ToneMemorial Tone = "MEMORIAL" + + PosturePromote CommercialPosture = "PROMOTE" + PostureCaution CommercialPosture = "CAUTION" + PostureNoPromotion CommercialPosture = "NO_PROMOTION" + + RecurrenceBlackFriday Recurrence = "BLACK_FRIDAY" + RecurrenceCyberMonday Recurrence = "CYBER_MONDAY" +) + +type Event struct { + ID string `json:"id"` + TitleFA string `json:"titleFa"` + TitleEN string `json:"titleEn,omitempty"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + Recurrence Recurrence `json:"recurrence,omitempty"` + Type EventType `json:"eventType"` + Tone Tone `json:"tone"` + CommercialPosture CommercialPosture `json:"commercialPosture"` + OfficialHoliday bool `json:"officialHoliday"` + SourceURI string `json:"sourceUri"` + SourceVersion string `json:"sourceVersion"` + SourceChecksum string `json:"sourceChecksum,omitempty"` + InclusionRationale string `json:"inclusionRationale"` + GeographicScope string `json:"geographicScope"` + MerchantCategories []string `json:"merchantCategories,omitempty"` + BehavioralChannels []string `json:"behavioralChannels,omitempty"` + LeadDays int `json:"leadDays"` + LagDays int `json:"lagDays"` + RelevanceBPS int `json:"relevanceBps"` + ConfidenceBPS int `json:"confidenceBps"` +} + +func ResolveRecurrence(rule Recurrence, year int, location *time.Location) (time.Time, error) { + if year < 1900 || year > 2200 { + return time.Time{}, errors.New("event catalog: recurrence year out of range") + } + thanksgiving := nthWeekday(year, time.November, time.Thursday, 4, location) + switch rule { + case RecurrenceBlackFriday: + return thanksgiving.AddDate(0, 0, 1), nil + case RecurrenceCyberMonday: + return thanksgiving.AddDate(0, 0, 4), nil + default: + return time.Time{}, fmt.Errorf("event catalog: unsupported recurrence %q", rule) + } +} + +func ValidateEvents(events []Event) error { + seen := make(map[string]struct{}, len(events)) + for _, event := range events { + if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.SourceURI) == "" || strings.TrimSpace(event.SourceVersion) == "" || strings.TrimSpace(event.InclusionRationale) == "" || strings.TrimSpace(event.GeographicScope) == "" { + return fmt.Errorf("event catalog: event %q is incomplete", event.ID) + } + if _, exists := seen[event.ID]; exists { + return fmt.Errorf("event catalog: duplicate event %q", event.ID) + } + seen[event.ID] = struct{}{} + start, err := time.Parse(time.DateOnly, event.StartDate) + if err != nil { + return fmt.Errorf("event catalog: event %q start date: %w", event.ID, err) + } + end, err := time.Parse(time.DateOnly, event.EndDate) + if err != nil || end.Before(start) { + return fmt.Errorf("event catalog: event %q has invalid end date", event.ID) + } + if !validType(event.Type) || !validTone(event.Tone) || !validPosture(event.CommercialPosture) { + return fmt.Errorf("event catalog: event %q has invalid classification", event.ID) + } + if (event.Tone == ToneMourning || event.Tone == ToneMemorial) && event.CommercialPosture == PosturePromote { + return fmt.Errorf("event catalog: event %q cannot promote during mourning or memorial", event.ID) + } + if event.LeadDays < 0 || event.LagDays < 0 || event.RelevanceBPS < 0 || event.RelevanceBPS > 10_000 || event.ConfidenceBPS < 0 || event.ConfidenceBPS > 10_000 { + return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID) + } + } + return nil +} + +func nthWeekday(year int, month time.Month, weekday time.Weekday, occurrence int, location *time.Location) time.Time { + first := time.Date(year, month, 1, 0, 0, 0, 0, location) + offset := (int(weekday) - int(first.Weekday()) + 7) % 7 + return first.AddDate(0, 0, offset+7*(occurrence-1)) +} + +func validType(value EventType) bool { + switch value { + case TypePublicHoliday, TypeReligious, TypeNational, TypeCultural, TypeCommercial, TypeSeasonal, TypeEducation, TypeEconomic, TypeEmergency: + return true + default: + return false + } +} + +func validTone(value Tone) bool { + switch value { + case ToneCelebratory, ToneJoyful, ToneNeutral, ToneSolemn, ToneMourning, ToneMemorial: + return true + default: + return false + } +} + +func validPosture(value CommercialPosture) bool { + return value == PosturePromote || value == PostureCaution || value == PostureNoPromotion +} diff --git a/internal/eventcatalog/catalog_test.go b/internal/eventcatalog/catalog_test.go new file mode 100644 index 0000000..1209c79 --- /dev/null +++ b/internal/eventcatalog/catalog_test.go @@ -0,0 +1,39 @@ +package eventcatalog + +import ( + "testing" + "time" +) + +func TestResolveBlackFridayUsesRecurrenceRule(t *testing.T) { + t.Parallel() + + got, err := ResolveRecurrence(RecurrenceBlackFriday, 2026, time.UTC) + if err != nil { + t.Fatal(err) + } + if want := "2026-11-27"; got.Format(time.DateOnly) != want { + t.Fatalf("Black Friday = %s, want %s", got.Format(time.DateOnly), want) + } +} + +func TestValidateEventsDistinguishesCelebrationAndMourning(t *testing.T) { + t.Parallel() + + events := []Event{ + {ID: "nowruz-1405", TitleFA: "نوروز", StartDate: "2026-03-21", EndDate: "2026-03-24", Type: TypeCultural, Tone: ToneCelebratory, CommercialPosture: PosturePromote, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000}, + {ID: "ashura-1405", TitleFA: "عاشورای حسینی", StartDate: "2026-06-25", EndDate: "2026-06-25", Type: TypeReligious, Tone: ToneMourning, CommercialPosture: PostureNoPromotion, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000}, + } + if err := ValidateEvents(events); err != nil { + t.Fatal(err) + } +} + +func TestValidateEventsRejectsPromotionalMourningEvent(t *testing.T) { + t.Parallel() + + event := Event{ID: "mourning", TitleFA: "یادبود", StartDate: "2026-01-01", EndDate: "2026-01-01", Type: TypeCultural, Tone: ToneMourning, CommercialPosture: PosturePromote, SourceURI: "https://example.test", SourceVersion: "v1", InclusionRationale: "reviewed", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000} + if err := ValidateEvents([]Event{event}); err == nil { + t.Fatal("expected mourning promotion validation error") + } +} diff --git a/internal/growthreport/adapter.go b/internal/growthreport/adapter.go index 0ee5fcb..515c9b8 100644 --- a/internal/growthreport/adapter.go +++ b/internal/growthreport/adapter.go @@ -47,10 +47,13 @@ type Builder interface { Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) } -type PostgreSQLBuilder struct{ pool *pgxpool.Pool } +type PostgreSQLBuilder struct { + pool *pgxpool.Pool + now func() time.Time +} func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder { - return &PostgreSQLBuilder{pool: pool} + return &PostgreSQLBuilder{pool: pool, now: time.Now} } func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) { @@ -88,7 +91,7 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina if err := b.pool.QueryRow(ctx, `SELECT max(created_at AT TIME ZONE 'Asia/Tehran')::date FROM payment_sessions`).Scan(&latest); err != nil { return Analysis{}, fmt.Errorf("loading dataset watermark date: %w", err) } - forecastStart := latest.AddDate(0, 0, 1) + forecastStart := tehranToday(b.now()) peer, err := b.loadPeer(ctx, merchantID, terminalID) if err != nil { return Analysis{}, err diff --git a/internal/growthreport/orchestrator.go b/internal/growthreport/orchestrator.go index 866500b..e30049b 100644 --- a/internal/growthreport/orchestrator.go +++ b/internal/growthreport/orchestrator.go @@ -46,10 +46,11 @@ func (s *Service) Create(ctx context.Context, userID, merchantID int64, request return Record{}, false, err } } - watermark, start, err := s.repository.DatasetMetadata(ctx) + watermark, _, err := s.repository.DatasetMetadata(ctx) if err != nil { return Record{}, false, err } + start := tehranToday(now) fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, start) var refreshDay *time.Time if request.Refresh { @@ -91,10 +92,11 @@ func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID strin record, err := s.repository.Latest(ctx, merchantID, terminalID, growth.AnalyticsVersion) if err == nil { record.Cached = true - watermark, start, metadataErr := s.repository.DatasetMetadata(ctx) + watermark, _, metadataErr := s.repository.DatasetMetadata(ctx) if metadataErr != nil { record.Stale = true } else { + start := tehranToday(s.now()) record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, start) } record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, terminalID, growth.AnalyticsVersion, s.now()) diff --git a/internal/growthreport/repository.go b/internal/growthreport/repository.go index 26e7349..79eddeb 100644 --- a/internal/growthreport/repository.go +++ b/internal/growthreport/repository.go @@ -295,7 +295,7 @@ func (r *Repository) DatasetMetadata(ctx context.Context) (string, time.Time, er if err != nil { return "", time.Time{}, fmt.Errorf("loading growth dataset metadata: %w", err) } - return sha + ":" + latest.Format(time.DateOnly), latest.AddDate(0, 0, 1), nil + return sha + ":" + latest.Format(time.DateOnly), latest, nil } func (r *Repository) CleanupSuperseded(ctx context.Context, cutoff time.Time) (int64, error) { diff --git a/internal/growthreport/service_test.go b/internal/growthreport/service_test.go index d7f46f5..793b091 100644 --- a/internal/growthreport/service_test.go +++ b/internal/growthreport/service_test.go @@ -27,6 +27,16 @@ func TestNextTehranRefreshUsesCalendarBoundary(t *testing.T) { } } +func TestTehranTodayUsesClockInsteadOfDatasetWatermark(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.August, 21, 8, 30, 0, 0, time.UTC) + got := tehranToday(now) + if got.Format(time.DateOnly) != "2026-08-21" { + t.Fatalf("tehranToday() = %s", got.Format(time.DateOnly)) + } +} + func TestEstimatedTokenReservationCoversEveryRetryAndTurn(t *testing.T) { t.Parallel() small := EstimatedTokenReservation([]byte("{}")) diff --git a/internal/growthreport/time.go b/internal/growthreport/time.go index fc13daf..643ac3a 100644 --- a/internal/growthreport/time.go +++ b/internal/growthreport/time.go @@ -2,6 +2,15 @@ package growthreport import "time" +func tehranToday(now time.Time) time.Time { + location, err := time.LoadLocation("Asia/Tehran") + if err != nil { + location = time.FixedZone("Asia/Tehran", 12600) + } + local := now.In(location) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) +} + func NextTehranRefresh(now time.Time) time.Time { location, err := time.LoadLocation("Asia/Tehran") if err != nil { diff --git a/internal/insight/time.go b/internal/insight/time.go new file mode 100644 index 0000000..1fdcf40 --- /dev/null +++ b/internal/insight/time.go @@ -0,0 +1,49 @@ +package insight + +import "time" + +type Freshness string + +const ( + FreshnessFresh Freshness = "FRESH" + FreshnessLagging Freshness = "LAGGING" + FreshnessStale Freshness = "STALE" +) + +type TimeContext struct { + AsOfDate string `json:"asOfDate"` + DataThroughDate string `json:"dataThroughDate"` + ForecastStartDate string `json:"forecastStartDate"` + Freshness Freshness `json:"freshness"` + DataLagDays int `json:"dataLagDays"` +} + +func BuildTimeContext(now, dataThrough time.Time) TimeContext { + location, err := time.LoadLocation("Asia/Tehran") + if err != nil { + location = time.FixedZone("Asia/Tehran", 3*60*60+30*60) + } + today := dateOnly(now.In(location), location) + through := dateOnly(dataThrough.In(location), location) + lag := max(0, int(today.Sub(through).Hours()/24)) + return TimeContext{ + AsOfDate: today.Format(time.DateOnly), DataThroughDate: through.Format(time.DateOnly), + ForecastStartDate: today.Format(time.DateOnly), Freshness: ClassifyFreshness(lag), DataLagDays: lag, + } +} + +func ClassifyFreshness(lagDays int) Freshness { + switch { + case lagDays <= 1: + return FreshnessFresh + case lagDays <= 7: + return FreshnessLagging + default: + return FreshnessStale + } +} + +func dateOnly(value time.Time, location *time.Location) time.Time { + year, month, day := value.In(location).Date() + return time.Date(year, month, day, 0, 0, 0, 0, location) +} diff --git a/internal/insight/time_test.go b/internal/insight/time_test.go new file mode 100644 index 0000000..101ff94 --- /dev/null +++ b/internal/insight/time_test.go @@ -0,0 +1,51 @@ +package insight + +import ( + "testing" + "time" +) + +func TestTimeContextUsesRealTehranTodayAndReportsDatasetLag(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.August, 21, 8, 30, 0, 0, time.UTC) + dataThrough := time.Date(2026, time.June, 30, 0, 0, 0, 0, time.UTC) + + got := BuildTimeContext(now, dataThrough) + + if got.AsOfDate != "2026-08-21" { + t.Fatalf("AsOfDate = %q, want real Tehran date", got.AsOfDate) + } + if got.DataThroughDate != "2026-06-30" { + t.Fatalf("DataThroughDate = %q", got.DataThroughDate) + } + if got.ForecastStartDate != got.AsOfDate { + t.Fatalf("ForecastStartDate = %q, want %q", got.ForecastStartDate, got.AsOfDate) + } + if got.Freshness != FreshnessStale || got.DataLagDays != 52 { + t.Fatalf("freshness = %s, lag = %d", got.Freshness, got.DataLagDays) + } +} + +func TestClassifyFreshnessUsesProductionBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lag int + want Freshness + }{ + {name: "same day", lag: 0, want: FreshnessFresh}, + {name: "yesterday", lag: 1, want: FreshnessFresh}, + {name: "two days", lag: 2, want: FreshnessLagging}, + {name: "seven days", lag: 7, want: FreshnessLagging}, + {name: "eight days", lag: 8, want: FreshnessStale}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClassifyFreshness(tt.lag); got != tt.want { + t.Fatalf("ClassifyFreshness(%d) = %s, want %s", tt.lag, got, tt.want) + } + }) + } +}