Compare commits

...

22 Commits

Author SHA1 Message Date
7951664d08 fix(growth): log bounded provider usage 2026-08-21 01:50:54 +03:30
d3189ac8f4 fix(growth): bound SSE authorization reads 2026-08-21 01:50:11 +03:30
a15c04cac1 fix(growth): harden AI jobs and cost controls 2026-08-21 01:48:47 +03:30
86e9d7c5f9 fix: keep growth report refresh optional 2026-08-21 01:46:11 +03:30
066adcfd1a docs: close completed growth analyst tasks 2026-08-21 01:19:47 +03:30
70244f7f79 fix: handle optional growth provider metadata 2026-08-21 01:16:08 +03:30
3e2d1f32c8 fix: finalize growth cost controls 2026-08-21 00:56:59 +03:30
bb4a85cd65 feat: wire growth report API and worker 2026-08-21 00:43:43 +03:30
d4265b662f feat: expose authenticated growth report API 2026-08-21 00:43:42 +03:30
d00700ffb8 feat: harden growth report orchestration 2026-08-21 00:43:33 +03:30
dd0ae15a95 fix: preserve typed growth report revisions 2026-08-21 00:43:31 +03:30
49084ea577 docs: finalize growth report lifecycle contract 2026-08-21 00:38:36 +03:30
6c80f71847 fix(growth): harden deterministic report evidence 2026-08-21 00:32:21 +03:30
1d8d4edf92 docs: define AI growth report API contract 2026-08-21 00:29:25 +03:30
027dffb59e feat: orchestrate deterministic growth report generation 2026-08-21 00:26:01 +03:30
6df21d4fd0 feat: add cost-safe AI growth configuration 2026-08-21 00:25:59 +03:30
1a00e897ca docs: align analytics with growth capability 2026-08-21 00:25:51 +03:30
8a0b30e936 fix(growth): validate reviewed event catalog 2026-08-21 00:18:10 +03:30
df140b4c25 feat(growth): add deterministic forecasting engine 2026-08-21 00:15:47 +03:30
37416a6f20 feat: add safe growth report persistence and AvalAI client 2026-08-21 00:14:26 +03:30
f3f51a97ef feat: add durable growth report schema 2026-08-21 00:14:14 +03:30
ae8747c996 docs: specify AI growth analyst 2026-08-21 00:07:17 +03:30
43 changed files with 4088 additions and 50 deletions

View File

@@ -11,3 +11,12 @@ SESSION_SECRET=replace-with-at-least-32-random-characters
CURSOR_SECRET=replace-with-a-different-32-character-secret
FRONTEND_ORIGIN=http://127.0.0.1:5173
COOKIE_SECURE=false
AI_GROWTH_ENABLED=false
AVALAI_API_KEY=
AVALAI_BASE_URL=https://api.avalai.ir
AVALAI_MODEL=gpt-5-mini-2025-08-07
AI_DAILY_REQUEST_BUDGET=0
AI_DAILY_TOKEN_BUDGET=0
AI_MAX_CONCURRENT=4
AI_CREATE_PER_MINUTE=10
AI_UNCACHED_SCOPES_PER_DAY=5

View File

@@ -1,6 +1,6 @@
# ZarinPal Merchant Analytics — Backend
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and later AvalAI integration.
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and the bounded AvalAI-assisted growth analyst.
Current state: the local MVP implements database migration, streaming dataset import,
secure demo identity, deterministic dashboard analytics, merchant-scoped details/CSV,

View File

@@ -13,7 +13,9 @@ import (
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/api"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
"github.com/redis/go-redis/v9"
)
func main() {
@@ -35,7 +37,12 @@ func main() {
os.Exit(1)
}
defer pool.Close()
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
redisClient := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
defer redisClient.Close()
repository := growthreport.NewRepository(pool)
coordinator := growthreport.NewCoordinator(redisClient, growthreport.CoordinatorConfig{CreatePerMinute: cfg.AICreatePerMinute, UncachedPerDay: cfg.AIUncachedScopesPerDay, DailyRequests: cfg.AIDailyRequestBudget, DailyTokens: cfg.AIDailyTokenBudget, MaxConcurrent: cfg.AIMaxConcurrent})
growthService := growthreport.NewService(repository, coordinator, growthreport.ServiceConfig{Enabled: cfg.AIGrowthEnabled, Model: cfg.AvalAIModel, PromptVersion: "1.0.0", SchemaVersion: "1.0.0"})
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure, growthService).Handler()
server := &http.Server{Addr: cfg.HTTPAddr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()

View File

@@ -25,14 +25,16 @@ func main() {
os.Exit(1)
}
defer pool.Close()
migration, err := os.ReadFile("migrations/000001_mvp.sql")
if err != nil {
logger.Error("reading migration failed", "error", err)
os.Exit(1)
for _, path := range []string{"migrations/000001_mvp.sql", "migrations/000002_growth_analyst.sql"} {
migration, err := os.ReadFile(path)
if err != nil {
logger.Error("reading migration failed", "path", path, "error", err)
os.Exit(1)
}
if err := database.Migrate(ctx, pool, string(migration)); err != nil {
logger.Error("migration failed", "path", path, "error", err)
os.Exit(1)
}
}
if err := database.Migrate(ctx, pool, string(migration)); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
logger.Info("migration complete", "version", 1)
logger.Info("migration complete", "version", 2)
}

View File

@@ -2,12 +2,16 @@ package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
"github.com/redis/go-redis/v9"
)
@@ -18,16 +22,49 @@ func main() {
logger.Error("configuration failed", "error", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
client := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
defer client.Close()
pingCtx, pingCancel := context.WithTimeout(ctx, 5*time.Second)
defer pingCancel()
if err := client.Ping(pingCtx).Err(); err != nil {
logger.Error("redis connection failed", "error", err)
if err := cfg.ValidateAPI(); err != nil {
logger.Error("worker configuration failed", "error", err)
os.Exit(1)
}
logger.Info("analytics worker ready", "redis_address", cfg.RedisAddr)
<-ctx.Done()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
pool, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("database connection failed", "error", err)
os.Exit(1)
}
defer pool.Close()
redisClient := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
defer redisClient.Close()
repository := growthreport.NewRepository(pool)
coordinator := growthreport.NewCoordinator(redisClient, growthreport.CoordinatorConfig{CreatePerMinute: cfg.AICreatePerMinute, UncachedPerDay: cfg.AIUncachedScopesPerDay, DailyRequests: cfg.AIDailyRequestBudget, DailyTokens: cfg.AIDailyTokenBudget, MaxConcurrent: cfg.AIMaxConcurrent})
avalai := growthreport.NewAvalAIClient(growthreport.AvalAIConfig{BaseURL: cfg.AvalAIBaseURL, APIKey: cfg.AvalAIAPIKey, Model: cfg.AvalAIModel, Timeout: 30 * time.Second}, &http.Client{})
processor := growthreport.NewProcessor(repository, growthreport.NewPostgreSQLBuilder(pool), avalai, coordinator, cfg.AIGrowthEnabled && cfg.AvalAIAPIKey != "")
logger.Info("growth worker ready", "ai_enabled", cfg.AIGrowthEnabled && cfg.AvalAIAPIKey != "")
poll := time.NewTicker(time.Second)
defer poll.Stop()
cleanup := time.NewTicker(24 * time.Hour)
defer cleanup.Stop()
for {
select {
case <-ctx.Done():
return
case <-poll.C:
workCtx, workCancel := context.WithTimeout(ctx, 2*time.Minute)
err := processor.ProcessNext(workCtx)
workCancel()
if err != nil && !errors.Is(err, growthreport.ErrNotFound) {
logger.Error("growth report processing failed", "error", err)
}
case <-cleanup.C:
cleanupCtx, cleanupCancel := context.WithTimeout(ctx, 30*time.Second)
deleted, err := repository.CleanupSuperseded(cleanupCtx, time.Now().UTC().AddDate(0, 0, -90))
cleanupCancel()
if err != nil {
logger.Error("growth cleanup failed", "error", err)
} else if deleted > 0 {
logger.Info("growth cleanup complete", "deleted_reports", deleted)
}
}
}
}

View File

@@ -15,7 +15,7 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti
- Merchant API: requests versioned analytics sections and exposes typed results/events.
- Dashboard: renders sections independently as they become ready.
- Analytics worker: calculates idempotent section results from PostgreSQL and caches approved aggregates in Redis.
- Future AI adapter: post-MVP consumer of minimized, approved aggregate facts only.
- AI growth analyst: separately approved consumer of minimized, approved aggregate facts only; see ADR 0015 and `GROWTH-ANALYST-SPEC.md`.
## In scope
@@ -36,9 +36,11 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti
## Out of scope for MVP
- Holiday or named-event reporting
- AI-generated recommendations or free-form LLM analysis
- Forecasting, causal claims, or automated campaign execution
The exclusions in this section describe the original four-section analytics MVP. The later, separately approved AI growth analyst adds bounded event analytics and forecasting without changing the canonical MVP period or metric contracts.
- Holiday or named-event reporting inside the original four analytics sections
- Unbounded or free-form LLM analysis
- Causal claims or automated campaign execution
- Arbitrary merchant-created formulas or cross-merchant filters
- Real-time streaming payment ingestion owned by this service
- Persisting calculated analytics as PostgreSQL business facts
@@ -64,7 +66,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
- Timing percentiles exclude null values and expose sample size and non-null coverage; missing time never becomes zero.
- `NoAttempt` markers create zero-attempt sessions and never count as attempts or retries. `InBank` is an observed attempt stage, not proof of rejection.
- Every result states the exact Tehran-local half-open period, Jalali label, unit, sample/denominator, dataset snapshot, metric-definition version, and calculation timestamp.
- Approved period types are one Tehran-local day, one Saturday–Friday week, one Jalali month, or one Persian season. Holiday/event dimensions do not exist in the MVP contract.
- Approved interactive reporting-period types remain one Tehran-local day, one Saturday–Friday week, one Jalali month, or one Persian season. The separately versioned growth-event catalog is evidence for growth reports, not a new interactive reporting-period type.
## Peer comparison contract

View File

@@ -216,7 +216,7 @@ Resolved: use normalized PostgreSQL source-of-truth entities plus a reporting-da
Resolved: the MVP reads one immutable completed-import snapshot and performs no payment-fact mutations. An idempotent `POST` starts four sections through Asynq `v0.26.0` (explicitly pre-1.0) in a separate Go worker, limited to 4 concurrent jobs/merchant and 16 globally. Redis caches results for 30 days; authenticated SSE uses 15-second heartbeats and one-hour replay. Sections have a 60-second attempt timeout and two transient exponential-backoff retries. Start, SSE, and manual-retry operations are rate-limited. No PostgreSQL cache-invalidation trigger exists in the MVP. See ADR 0011.
Resolved: holiday and named-event reporting are removed from scope. MVP reporting periods are limited to daily, weekly, Jalali-month, and Persian-season views. See ADR 0010.
Resolved: holiday and named-event reporting were removed from the original analytics MVP, whose interactive reporting periods remain daily, weekly, Jalali-month, and Persian-season views. The later growth analyst adds a separate reviewed event-evidence catalog under ADR 0015 without changing those reporting-period contracts. See ADRs 0010 and 0015.
Resolved: reporting weeks follow the Iranian week: Saturday through Friday in `Asia/Tehran`, represented as half-open API/database ranges. See ADR 0012.

View File

@@ -0,0 +1,60 @@
# Growth Analyst Capability Spec
- Status: Approved — implementation authorized 2026-08-21
- Capability: Read-only AI-assisted growth analytics
- Public contract: `docs/openapi.yaml`
- UI contract: frontend `docs/GROWTH-ANALYST-UX-SPEC.md`
## Objective
Give an authenticated merchant a trustworthy 90-day Persian growth outlook: historical event wins, upcoming similar occasions, likely low-performance windows, privacy-safe peer context, and compact campaign briefs. PostgreSQL and deterministic Go calculations own every number; AvalAI may only prioritize approved evidence and produce qualitative copy.
## Deterministic analytics contract
- Aggregate session count and paid Rial amount by Tehran-local day for the authenticated merchant and optional owned terminal. `Paid` and `Verified` are successful; money remains integer Rial at session grain.
- Forecast begins the day after the latest complete imported dataset date and covers 90 days. Count and amount are modeled independently from weekday-aware rolling baselines, bounded trend adjustment, catalogued event uplift, and empirical residual P10/P50/P90 values.
- Rolling-origin backtests classify usable output as `HIGH` or `MEDIUM`; other results return `INSUFFICIENT_DATA` and do not call AvalAI.
- Historical event effect compares matching weekdays from the preceding eight non-event weeks. At least four observations and 100 baseline sessions are required. A material win/decline is at least 20%.
- Upcoming similarity prefers an exact event family, then catalog tag similarity of at least 0.60. Return at most five opportunities and three risks. A risk requires a forecast at least 20% below a baseline with at least 100 sessions.
- Merchant scope may include peer context only under the existing threshold of 10 other same-category merchants and 1,000 combined peer sessions, with one equally weighted value per merchant. Terminal scope always suppresses peers.
- The versioned catalog stores Persian title, Gregorian date range, family, tags, source, supported merchant categories, and official/commercial classification. The initial reviewed catalog covers the imported history and the following 90 days.
## AI boundary
- AvalAI is backend-only and receives minimized aggregate evidence, never raw transactions, payer keys, credentials, arbitrary merchant ids, SQL, or peer rows.
- The consumer-owned `GrowthAnalyticsTools` boundary exposes only `get_growth_context`, `get_event_evidence`, `get_peer_context`, and `get_campaign_playbook`. Scope is injected from the authenticated job, not accepted from model arguments.
- Use `POST /v1/responses`, `store:false`, strict function schemas, strict structured output, `parallel_tool_calls:false`, no more than four tool calls and two model turns, 1,600 output tokens, a 30-second deadline, and one transient retry.
- Treat model output as untrusted. Accept only known evidence ids and qualitative Persian fields. Reject numeric claims, unsafe language, unknown references, refusal/incomplete output, or schema drift and use deterministic Persian fallback copy.
- Skip AvalAI when no material opportunity/risk exists or confidence is insufficient. AI failure produces `PARTIAL`, never an empty report.
## Persistence, caching, jobs, and limits
- PostgreSQL is authoritative for reports, revisions, deterministic evidence, validated AI copy, usage metadata, and idempotency. Redis is disposable hot cache, lock, rate-counter, and budget state.
- A fingerprint includes merchant/terminal scope, import watermark, forecast date/horizon, catalog, analytics, prompt/schema, and model versions. A matching successful report is reused without an AI call.
- Keep the current matching report indefinitely and superseded/manual-refresh revisions for 90 days. Concurrent identical requests collapse to one job.
- States are `QUEUED`, `ANALYZING`, `GENERATING`, `READY`, `PARTIAL`, and `FAILED`. Preserve the previous successful report during refresh/failure.
- Defaults: create 10/user/minute; five uncached scopes/merchant/Tehran day; one merchant-wide manual refresh/Tehran day; four concurrent generations globally; explicit production daily request/token budgets; one active generation per fingerprint.
- Budget exhaustion returns deterministic `PARTIAL` output. Only an unavailable manual refresh returns `429` with `nextRefreshAt`/`Retry-After`.
- Structured logs include safe report/version/status/cache/usage/latency fields without prompts, transaction values, usernames, or sensitive identifiers.
## Public API
- `POST /api/v1/analytics/growth-reports` accepts optional `terminalId` and `refresh`, requires CSRF plus `Idempotency-Key`, and returns an existing or queued report resource.
- `GET /api/v1/analytics/growth-reports/latest?terminalId=...` returns the latest authorized scope report.
- `GET /api/v1/analytics/growth-reports/{reportId}` returns an owned report; absent and cross-tenant ids are indistinguishable.
- `GET /api/v1/analytics/growth-reports/{reportId}/events` is authenticated SSE with stable ids, heartbeat, replay/snapshot recovery, and terminal status.
- Responses include deterministic series/ranges/confidence/evidence, opportunities, risks, peer result or suppression reason, validated briefs, cache metadata, and next refresh time.
## Boundaries and acceptance
- Always derive merchant identity from the server session, validate terminal ownership, use parameterized SQL/context deadlines, keep numbers deterministic, and preserve useful prior output on failure.
- Never add chat, memory, notifications, automated campaigns/actions, runtime web search, raw-data tools, or model-authored numerical predictions.
- Tests cover event/baseline/forecast/backtest/risk/peer logic, fingerprints, Tehran refresh boundaries, authorization, persistence, locks/deduplication, budgets, AI schemas/rejections/timeouts, fallback, SSE, and sensitive-field absence.
- Feature is disabled by default behind `AI_GROWTH_ENABLED`. Production enablement requires explicit AvalAI key/model/request budget/token budget configuration.
## Sources
- AvalAI Responses: https://docs.avalai.ir/en/api-reference/responses
- AvalAI function calling: https://docs.avalai.ir/en/guides/function-calling?api-mode=responses
- AvalAI structured outputs: https://docs.avalai.ir/en/guides/structured-outputs?api-mode=responses
- University of Tehran Iranian calendar: https://calendar.ut.ac.ir/

View File

@@ -13,7 +13,7 @@ Every merchant-facing number identifies its grain, formula, source fields, filte
- Money uses `payment_sessions.amount_rial` and `payment_sessions.fee_amount_rial` exactly once per session.
- Terminal filters join the session's `terminal_id`; peer cohorts join the merchant's `category_id` and exclude the active merchant.
- The sole completed `import_runs` row identifies the immutable MVP dataset snapshot included in cache/result metadata.
- `calendar_dates` defines Saturday–Friday weeks, Jalali months, and Persian seasons; no holiday dimension exists.
- `calendar_dates` defines Saturday–Friday weeks, Jalali months, and Persian seasons; it intentionally has no holiday dimension. Growth reports use their own reviewed, versioned event-evidence catalog under ADR 0015.
## Bank-paid sessions

View File

@@ -98,7 +98,7 @@ Unique `(import_run_id, severity, rule_code)` keeps one aggregate record per rul
| `reporting_week_end_exclusive` | `date` required | following Saturday; equals start + 7 days |
| `persian_season` | `smallint` required | `1..4` |
Unique `(jalali_year, jalali_month, jalali_day)` prevents duplicate calendar mappings. Indexes on `(jalali_year, jalali_month, gregorian_date)`, `(jalali_year, persian_season, gregorian_date)`, and `(reporting_week_start, gregorian_date)` support approved periods. Holiday/named-event columns do not exist.
Unique `(jalali_year, jalali_month, jalali_day)` prevents duplicate calendar mappings. Indexes on `(jalali_year, jalali_month, gregorian_date)`, `(jalali_year, persian_season, gregorian_date)`, and `(reporting_week_start, gregorian_date)` support approved periods. Holiday/named-event columns do not exist here; the later growth capability owns a separate reviewed event-evidence catalog.
### Merchant and demo identity

View File

@@ -0,0 +1,19 @@
# ADR 0015: Deterministic analytics with bounded AI interpretation
- Status: Accepted
- Date: 2026-08-21
## Context
Growth forecasting, event uplift, low-period detection, and peer comparisons must be reproducible, testable, privacy-safe, and available when an AI provider is unavailable. AvalAI adds value for semantic prioritization and natural Persian campaign guidance, but must not become the source of financial analytics.
## Decision
Use PostgreSQL plus deterministic Go analytics as the sole authority for numbers and evidence. Run a bounded backend-only AvalAI Responses workflow over sanitized aggregates, validate its structured qualitative output, and persist report snapshots by a versioned data fingerprint. Use Redis only for hot caching, coordination, budgets, and rate limits. Defer MCP; keep a narrow internal tool interface that an MCP adapter may wrap only when multiple independent agent clients exist.
## Consequences
- The growth page remains useful with AI disabled, unavailable, rate-limited, or rejected.
- Repeated page visits do not create paid calls while the fingerprint is unchanged.
- Algorithm, catalog, prompt, schema, model, or import changes intentionally invalidate the report.
- The backend owns more deterministic forecasting and validation code, but the result is auditable and tenant-safe.

View File

@@ -1,10 +1,10 @@
openapi: 3.1.0
info:
title: ZarinPal Merchant Analytics API
version: 1.0.0
summary: Contract-first API for authenticated merchant analytics.
version: 1.1.0
summary: Contract-first API for authenticated merchant analytics and growth reports.
description: |
Approved MVP interface. This document is the API source of truth; generated
Approved analytics and AI growth-report interface. This document is the API source of truth; generated
frontend types must come from this contract.
Merchant scope is always derived from the authenticated server-side session.
@@ -578,6 +578,66 @@ paths:
'504':
$ref: '#/components/responses/ExportTimeout'
/analytics/growth-reports:
post:
tags: [AI growth analyst]
operationId: createGrowthReport
summary: Create or reuse a growth report
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
- $ref: '#/components/parameters/CsrfTokenHeader'
- $ref: '#/components/parameters/OriginHeader'
- $ref: '#/components/parameters/RequestIdHeader'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateGrowthReportRequest'}
responses:
'200': {description: Matching durable report reused., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
'202': {description: Report accepted for generation., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
'422': {$ref: '#/components/responses/ValidationFailed'}
'429': {$ref: '#/components/responses/RateLimited'}
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
/analytics/growth-reports/latest:
get:
tags: [AI growth analyst]
operationId: getLatestGrowthReport
parameters:
- {name: terminalId, in: query, required: false, schema: {type: string}}
- $ref: '#/components/parameters/RequestIdHeader'
responses:
'200': {description: Latest successful report for the authorized scope., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'404': {$ref: '#/components/responses/ResourceNotFound'}
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
/analytics/growth-reports/{reportId}:
get:
tags: [AI growth analyst]
operationId: getGrowthReport
parameters:
- {name: reportId, in: path, required: true, schema: {type: string, pattern: '^growth_[a-f0-9]{24}$'}}
- $ref: '#/components/parameters/RequestIdHeader'
responses:
'200': {description: Authorized report snapshot., content: {application/json: {schema: {$ref: '#/components/schemas/GrowthReport'}}}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'404': {$ref: '#/components/responses/ResourceNotFound'}
'503': {$ref: '#/components/responses/AnalyticsUnavailable'}
/analytics/growth-reports/{reportId}/events:
get:
tags: [AI growth analyst]
operationId: streamGrowthReportEvents
parameters:
- {name: reportId, in: path, required: true, schema: {type: string}}
- {name: Last-Event-ID, in: header, required: false, schema: {type: string}}
responses:
'200': {description: Stable-id report snapshots and heartbeats., content: {text/event-stream: {schema: {type: string}}}}
'401': {$ref: '#/components/responses/Unauthenticated'}
'404': {$ref: '#/components/responses/ResourceNotFound'}
components:
securitySchemes:
merchantSession:
@@ -943,6 +1003,174 @@ components:
$ref: '#/components/schemas/Problem'
schemas:
CreateGrowthReportRequest:
type: object
additionalProperties: false
properties:
terminalId: {type: string, minLength: 1, maxLength: 128}
refresh:
type: boolean
description: When omitted, the request reuses the current matching report.
GrowthRange:
type: object
additionalProperties: false
required: [p10, p50, p90]
properties:
p10: {type: integer, format: int64, minimum: 0}
p50: {type: integer, format: int64, minimum: 0}
p90: {type: integer, format: int64, minimum: 0}
GrowthForecastPoint:
type: object
additionalProperties: false
required: [date, baselineSessions, baselineAmountRial, paidSessions, paidAmountRial]
properties:
date: {type: string, format: date-time}
baselineSessions: {type: integer, format: int64, minimum: 0}
baselineAmountRial: {type: integer, format: int64, minimum: 0}
paidSessions: {$ref: '#/components/schemas/GrowthRange'}
paidAmountRial: {$ref: '#/components/schemas/GrowthRange'}
eventIds: {type: array, items: {type: string}}
GrowthForecast:
type: object
additionalProperties: false
required: [startDate, horizonDays, confidence, points, backtest]
properties:
startDate: {type: string, format: date-time}
horizonDays: {type: integer, enum: [90]}
confidence: {type: string, enum: [HIGH, MEDIUM, INSUFFICIENT_DATA]}
suppressionReason: {type: string}
points: {type: array, maxItems: 90, items: {$ref: '#/components/schemas/GrowthForecastPoint'}}
backtest:
type: object
additionalProperties: false
required: [origins, sessionMapeBps, amountMapeBps, intervalCoverageBps]
properties:
origins: {type: integer, minimum: 0}
sessionMapeBps: {type: integer, format: int64, minimum: 0}
amountMapeBps: {type: integer, format: int64, minimum: 0}
intervalCoverageBps: {type: integer, format: int64, minimum: 0, maximum: 10000}
GrowthHistoricalWin:
type: object
additionalProperties: false
required: [evidenceId, eventId, eventTitleFa, startDate, endDate, materialWin]
properties:
evidenceId: {type: string}
eventId: {type: string}
eventTitleFa: {type: string}
startDate: {type: string, format: date-time}
endDate: {type: string, format: date-time}
eligible: {type: boolean}
suppressionReason: {type: string}
observationCount: {type: integer}
baselineSessionCount: {type: integer, format: int64}
baselinePaidSessions: {type: integer, format: int64}
baselineAmountRial: {type: integer, format: int64}
actualSessionCount: {type: integer, format: int64}
actualPaidSessions: {type: integer, format: int64}
actualAmountRial: {type: integer, format: int64}
sessionUpliftBps: {type: integer, format: int64}
paidSessionUpliftBps: {type: integer, format: int64}
amountUpliftBps: {type: integer, format: int64}
materialWin: {type: boolean}
materialDecline: {type: boolean}
GrowthOpportunity:
type: object
additionalProperties: false
required: [evidenceId, eventId, eventTitleFa, date, similarityBps, expectedSessions, expectedAmountRial, historicalEvidenceId]
properties:
evidenceId: {type: string}
eventId: {type: string}
eventTitleFa: {type: string}
date: {type: string, format: date-time}
similarityBps: {type: integer, format: int64, minimum: 6000, maximum: 10000}
expectedSessions: {$ref: '#/components/schemas/GrowthRange'}
expectedAmountRial: {$ref: '#/components/schemas/GrowthRange'}
historicalEvidenceId: {type: string}
GrowthRisk:
type: object
additionalProperties: false
required: [evidenceId, date, sessionDeclineBps, amountDeclineBps, expectedSessions, expectedAmountRial]
properties:
evidenceId: {type: string}
date: {type: string, format: date-time}
endDate: {type: string, format: date-time}
sessionDeclineBps: {type: integer, format: int64, minimum: 0, description: At least one decline dimension is 2000 BPS or greater.}
amountDeclineBps: {type: integer, format: int64, minimum: 0, description: At least one decline dimension is 2000 BPS or greater.}
expectedSessions: {$ref: '#/components/schemas/GrowthRange'}
expectedAmountRial: {$ref: '#/components/schemas/GrowthRange'}
GrowthPeer:
type: object
additionalProperties: false
required: [state, eligiblePeerCount, combinedPeerSessionCount, medianPaidSessions, percentileBps]
properties:
state: {type: string, enum: [READY, SUPPRESSED]}
reason: {type: string}
eligiblePeerCount: {type: integer, minimum: 0}
combinedPeerSessionCount: {type: integer, format: int64, minimum: 0}
medianPaidSessions: {type: integer, format: int64, minimum: 0}
percentileBps: {type: integer, format: int64, minimum: 0, maximum: 10000}
GrowthCampaignBrief:
type: object
additionalProperties: false
required: [headline, interpretation, offerSuggestion, messageSuggestion, preparationAction, limitation, evidenceIds]
properties:
headline: {type: string}
interpretation: {type: string}
offerSuggestion: {type: string}
messageSuggestion: {type: string}
preparationAction: {type: string}
limitation: {type: string}
evidenceIds: {type: array, minItems: 1, items: {type: string}}
GrowthFallbackBrief:
type: object
additionalProperties: false
required: [headlineFa, guidanceFa, offerFa, messageFa, preparationFa, limitationFa, evidenceIds]
properties:
headlineFa: {type: string}
guidanceFa: {type: string}
offerFa: {type: string}
messageFa: {type: string}
preparationFa: {type: string}
limitationFa: {type: string}
evidenceIds: {type: array, minItems: 1, items: {type: string}}
GrowthEvidence:
type: object
additionalProperties: false
required: [forecast, historicalWins, opportunities, risks, fallbackBriefs]
properties:
forecast: {$ref: '#/components/schemas/GrowthForecast'}
historicalWins: {type: array, items: {$ref: '#/components/schemas/GrowthHistoricalWin'}}
opportunities: {type: array, maxItems: 5, items: {$ref: '#/components/schemas/GrowthOpportunity'}}
risks: {type: array, maxItems: 3, items: {$ref: '#/components/schemas/GrowthRisk'}}
peer: {$ref: '#/components/schemas/GrowthPeer'}
fallbackBriefs: {type: array, items: {$ref: '#/components/schemas/GrowthFallbackBrief'}}
GrowthReport:
type: object
additionalProperties: false
required: [reportId, revision, status, scope, horizonDays, cached, stale, nextRefreshAt, createdAt, updatedAt, eventsUrl]
properties:
reportId: {type: string}
revision: {type: integer, minimum: 1}
status: {type: string, enum: [QUEUED, ANALYZING, GENERATING, READY, PARTIAL, FAILED]}
scope:
type: object
additionalProperties: false
properties: {terminalId: {type: [string, 'null']}}
horizonDays: {type: integer, enum: [90]}
forecastStartDate: {type: string, format: date-time}
deterministicEvidence: {$ref: '#/components/schemas/GrowthEvidence'}
aiCopy:
type: object
additionalProperties: false
required: [briefs]
properties: {briefs: {type: array, maxItems: 5, items: {$ref: '#/components/schemas/GrowthCampaignBrief'}}}
failureCode: {type: string}
cached: {type: boolean}
stale: {type: boolean}
nextRefreshAt: {type: string, format: date-time}
createdAt: {type: string, format: date-time}
updatedAt: {type: string, format: date-time}
eventsUrl: {type: string, format: uri-reference}
LoginRequest:
type: object
additionalProperties: false
@@ -1907,3 +2135,7 @@ components:
type: string
format: uri-reference
description: Optional safe recovery link for run-related problems.
nextRefreshAt:
type: string
format: date-time
description: Present for REFRESH_LIMITED responses.

36
docs/openapi_test.go Normal file
View File

@@ -0,0 +1,36 @@
package docs
import (
"os"
"strings"
"testing"
)
func TestCreateGrowthReportRequestFieldsAreOptional(t *testing.T) {
t.Parallel()
document, err := os.ReadFile("openapi.yaml")
if err != nil {
t.Fatalf("read OpenAPI document: %v", err)
}
const schemaStart = " CreateGrowthReportRequest:\n"
const nextSchema = " GrowthRange:\n"
start := strings.Index(string(document), schemaStart)
if start < 0 {
t.Fatal("CreateGrowthReportRequest schema not found")
}
schema := string(document)[start+len(schemaStart):]
var found bool
schema, _, found = strings.Cut(schema, nextSchema)
if !found {
t.Fatal("schema following CreateGrowthReportRequest not found")
}
if strings.Contains(schema, "required:") {
t.Fatalf("optional request fields must not be listed as required:\n%s", schema)
}
if strings.Contains(schema, "default:") {
t.Fatalf("optional request fields must not use defaults that generators treat as required:\n%s", schema)
}
}

225
internal/api/growth.go Normal file
View File

@@ -0,0 +1,225 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
)
var growthReportIDPattern = regexp.MustCompile(`^growth_[a-f0-9]{24}$`)
type growthReportService interface {
Create(context.Context, int64, int64, growthreport.CreateRequest, string) (growthreport.Record, bool, error)
Get(context.Context, int64, string) (growthreport.Record, error)
Latest(context.Context, int64, string) (growthreport.Record, error)
}
func (s *Server) createGrowthReport(w http.ResponseWriter, r *http.Request) {
if s.growth == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
key := r.Header.Get("Idempotency-Key")
if !validIdempotencyKey(key) {
s.problem(w, r, 422, "VALIDATION_FAILED", "A valid Idempotency-Key is required")
return
}
var input growthreport.CreateRequest
if err := decodeStrictJSON(w, r, &input); err != nil || len(input.TerminalID) > 128 {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid growth report request")
return
}
session := sessionFrom(r.Context())
record, cached, err := s.growth.Create(r.Context(), session.UserID, session.MerchantID, input, key)
if err != nil {
s.handleGrowthCreateError(w, r, err)
return
}
status := http.StatusAccepted
if cached {
status = http.StatusOK
}
w.Header().Set("Location", "/api/v1/analytics/growth-reports/"+record.ReportID)
s.writeJSON(w, status, record.Report)
}
func (s *Server) handleGrowthCreateError(w http.ResponseWriter, r *http.Request, err error) {
var limit *growthreport.RefreshLimitError
switch {
case errors.Is(err, growthreport.ErrDisabled):
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
case errors.Is(err, growthreport.ErrUnknownTerminal):
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
case errors.Is(err, growthreport.ErrIdempotencyConflict):
s.problem(w, r, 409, "IDEMPOTENCY_CONFLICT", "Idempotency key was already used for another request")
case errors.As(err, &limit):
seconds := max(int64(1), int64(time.Until(limit.NextRefreshAt).Seconds()))
w.Header().Set("Retry-After", strconv.FormatInt(seconds, 10))
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(429)
_ = json.NewEncoder(w).Encode(map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/refresh-limited", "title": "Too Many Requests", "status": 429, "code": "REFRESH_LIMITED", "detail": "Daily refresh is not available yet", "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID"), "nextRefreshAt": limit.NextRefreshAt})
case errors.Is(err, growthreport.ErrRateLimited):
s.problem(w, r, 429, "RATE_LIMITED", "Too many growth report requests")
default:
s.logger.Error("growth report creation failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
}
}
func (s *Server) latestGrowthReport(w http.ResponseWriter, r *http.Request) {
if s.growth == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
for key := range r.URL.Query() {
if key != "terminalId" {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown query parameter")
return
}
}
terminalID := r.URL.Query().Get("terminalId")
if len(terminalID) > 128 {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid terminal")
return
}
record, err := s.growth.Latest(r.Context(), sessionFrom(r.Context()).MerchantID, terminalID)
s.writeGrowthResult(w, r, record, err)
}
func (s *Server) getGrowthReport(w http.ResponseWriter, r *http.Request) {
if s.growth == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
id := r.PathValue("reportId")
if !growthReportIDPattern.MatchString(id) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
record, err := s.growth.Get(r.Context(), sessionFrom(r.Context()).MerchantID, id)
s.writeGrowthResult(w, r, record, err)
}
func (s *Server) writeGrowthResult(w http.ResponseWriter, r *http.Request, record growthreport.Record, err error) {
if errors.Is(err, growthreport.ErrNotFound) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
if err != nil {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
return
}
s.writeJSON(w, 200, record.Report)
}
func (s *Server) growthReportEvents(w http.ResponseWriter, r *http.Request) {
if s.growth == nil {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
id := r.PathValue("reportId")
if !growthReportIDPattern.MatchString(id) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
merchantID := sessionFrom(r.Context()).MerchantID
slots := s.growthSSESlots
if slots == nil {
slots = defaultGrowthSSESlots
}
select {
case slots <- struct{}{}:
defer func() { <-slots }()
default:
s.problem(w, r, http.StatusTooManyRequests, "RATE_LIMITED", "Too many growth report streams")
return
}
maxDuration := s.growthSSEMaxDuration
if maxDuration <= 0 {
maxDuration = defaultGrowthSSEMaxDuration
}
pollInterval := s.growthSSEPollInterval
if pollInterval <= 0 {
pollInterval = defaultGrowthSSEPollInterval
}
streamContext, cancel := context.WithTimeout(r.Context(), maxDuration)
defer cancel()
record, err := s.growth.Get(streamContext, merchantID, id)
if errors.Is(err, growthreport.ErrNotFound) {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
if err != nil {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Growth analytics unavailable")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
s.problem(w, r, 500, "INTERNAL_ERROR", "Streaming unavailable")
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
last := ""
for {
eventID := record.ReportID + ":" + strconv.FormatInt(record.UpdatedAt.UnixNano(), 10)
if eventID != last {
payload, _ := json.Marshal(map[string]any{"eventId": eventID, "report": record.Report})
_, _ = fmt.Fprintf(w, "id: %s\nevent: report.snapshot\ndata: %s\n\n", eventID, payload)
flusher.Flush()
last = eventID
}
if record.Status.Terminal() {
return
}
select {
case <-streamContext.Done():
return
case <-ticker.C:
record, err = s.growth.Get(streamContext, merchantID, id)
if err != nil {
return
}
case <-heartbeat.C:
_, _ = fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func validIdempotencyKey(key string) bool {
if len(key) < 8 || len(key) > 128 {
return false
}
for _, value := range []byte(key) {
if value < 0x21 || value > 0x7e {
return false
}
}
return true
}
func decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) error {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("trailing JSON")
}
return nil
}

194
internal/api/growth_test.go Normal file
View File

@@ -0,0 +1,194 @@
package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growthreport"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
)
func TestValidIdempotencyKeyUsesPrintableAllowlist(t *testing.T) {
t.Parallel()
tests := []struct {
name, key string
want bool
}{{"valid", "growth-123456", true}, {"short", "short", false}, {"space", "growth key", false}, {"unicode", "رشد-گزارش", false}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validIdempotencyKey(tt.key); got != tt.want {
t.Fatalf("validIdempotencyKey(%q)=%v", tt.key, got)
}
})
}
}
func TestDecodeStrictJSONRejectsTrailingAndUnknownFields(t *testing.T) {
t.Parallel()
for _, body := range []string{`{"refresh":true}{}`, `{"unexpected":true}`} {
request := httptest.NewRequest("POST", "/", strings.NewReader(body))
recorder := httptest.NewRecorder()
var value struct {
Refresh bool `json:"refresh"`
}
if err := decodeStrictJSON(recorder, request, &value); err == nil {
t.Fatalf("decodeStrictJSON(%q) error=nil", body)
}
}
}
func TestGrowthReportIDPatternRejectsTenantEnumerationInputs(t *testing.T) {
t.Parallel()
if !growthReportIDPattern.MatchString("growth_0123456789abcdef01234567") {
t.Fatal("valid report id rejected")
}
for _, id := range []string{"growth_1", "../growth_0123456789abcdef01234567", "run_0123456789abcdef01234567"} {
if growthReportIDPattern.MatchString(id) {
t.Fatalf("unsafe id accepted: %q", id)
}
}
}
type fakeGrowthService struct {
get func(int64, string) (growthreport.Record, error)
getContext func(context.Context, int64, string) (growthreport.Record, error)
create func() (growthreport.Record, bool, error)
}
func (f fakeGrowthService) Create(_ context.Context, _, _ int64, _ growthreport.CreateRequest, _ string) (growthreport.Record, bool, error) {
return f.create()
}
func (f fakeGrowthService) Get(ctx context.Context, merchantID int64, id string) (growthreport.Record, error) {
if f.getContext != nil {
return f.getContext(ctx, merchantID, id)
}
return f.get(merchantID, id)
}
func (f fakeGrowthService) Latest(_ context.Context, merchantID int64, terminal string) (growthreport.Record, error) {
return f.get(merchantID, terminal)
}
func TestGrowthEndpointsRequireAuthentication(t *testing.T) {
t.Parallel()
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: fakeGrowthService{}}
request := httptest.NewRequest("GET", "/api/v1/analytics/growth-reports/latest", nil)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status=%d", response.Code)
}
}
func TestGetGrowthReportHidesOtherMerchantResource(t *testing.T) {
t.Parallel()
service := fakeGrowthService{get: func(merchantID int64, _ string) (growthreport.Record, error) {
if merchantID != 7 {
t.Fatalf("merchant=%d", merchantID)
}
return growthreport.Record{}, growthreport.ErrNotFound
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
request := httptest.NewRequest("GET", "/", nil)
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
response := httptest.NewRecorder()
server.getGrowthReport(response, request)
if response.Code != 404 {
t.Fatalf("status=%d", response.Code)
}
}
func TestRefreshLimitProblemIncludesNextRefreshAt(t *testing.T) {
t.Parallel()
next := time.Now().UTC().Add(time.Hour)
service := fakeGrowthService{create: func() (growthreport.Record, bool, error) {
return growthreport.Record{}, false, &growthreport.RefreshLimitError{NextRefreshAt: next}
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
request := httptest.NewRequest("POST", "/api/v1/analytics/growth-reports", strings.NewReader(`{"refresh":true}`))
request.Header.Set("Idempotency-Key", "growth-refresh-1")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7, UserID: 3}))
response := httptest.NewRecorder()
server.createGrowthReport(response, request)
if response.Code != 429 || !strings.Contains(response.Body.String(), "nextRefreshAt") {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func TestGrowthSSEAuthorizesBeforeSettingStreamHeaders(t *testing.T) {
t.Parallel()
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
return growthreport.Record{}, growthreport.ErrNotFound
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service}
request := httptest.NewRequest("GET", "/", nil)
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
response := httptest.NewRecorder()
server.growthReportEvents(response, request)
if response.Code != 404 || !strings.HasPrefix(response.Header().Get("Content-Type"), "application/problem+json") {
t.Fatalf("status=%d content-type=%q", response.Code, response.Header().Get("Content-Type"))
}
}
func TestGrowthSSERejectsWhenGlobalCapacityIsFull(t *testing.T) {
t.Parallel()
slots := make(chan struct{}, 1)
slots <- struct{}{}
reads := 0
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
reads++
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued}}, nil
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: slots}
request := httptest.NewRequest("GET", "/", nil)
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
response := httptest.NewRecorder()
server.growthReportEvents(response, request)
if response.Code != http.StatusTooManyRequests || reads != 0 {
t.Fatalf("status=%d database reads=%d", response.Code, reads)
}
}
func TestGrowthSSEEndsAtConfiguredDeadline(t *testing.T) {
t.Parallel()
reads := 0
service := fakeGrowthService{get: func(int64, string) (growthreport.Record, error) {
reads++
return growthreport.Record{Report: growthreport.Report{ReportID: "growth_0123456789abcdef01234567", Status: growthreport.StatusQueued, UpdatedAt: time.Unix(int64(reads), 0)}}, nil
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond, growthSSEPollInterval: time.Millisecond}
request := httptest.NewRequest("GET", "/", nil)
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
response := httptest.NewRecorder()
started := time.Now()
server.growthReportEvents(response, request)
if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
t.Fatalf("stream remained open for %s", elapsed)
}
if reads < 1 || reads > 10 {
t.Fatalf("database reads=%d", reads)
}
}
func TestGrowthSSEBoundsInitialDatabaseRead(t *testing.T) {
t.Parallel()
service := fakeGrowthService{getContext: func(ctx context.Context, _ int64, _ string) (growthreport.Record, error) {
<-ctx.Done()
return growthreport.Record{}, ctx.Err()
}}
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), growth: service, growthSSESlots: make(chan struct{}, 1), growthSSEMaxDuration: 5 * time.Millisecond}
request := httptest.NewRequest("GET", "/", nil)
request.SetPathValue("reportId", "growth_0123456789abcdef01234567")
request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, identity.Session{MerchantID: 7}))
response := httptest.NewRecorder()
started := time.Now()
server.growthReportEvents(response, request)
if elapsed := time.Since(started); elapsed > 250*time.Millisecond || response.Code != http.StatusServiceUnavailable {
t.Fatalf("elapsed=%s status=%d", elapsed, response.Code)
}
}

View File

@@ -23,21 +23,37 @@ import (
const maxExportBytes = 50 << 20
const (
defaultGrowthSSECapacity = 64
defaultGrowthSSEMaxDuration = 5 * time.Minute
defaultGrowthSSEPollInterval = 2 * time.Second
)
var defaultGrowthSSESlots = make(chan struct{}, defaultGrowthSSECapacity)
var errExportBytesExceeded = errors.New("export exceeds byte limit")
type Server struct {
identity *identity.Service
analytics *analytics.Service
logger *slog.Logger
frontendOrigin string
cookieSecure bool
identity *identity.Service
analytics *analytics.Service
growth growthReportService
logger *slog.Logger
frontendOrigin string
cookieSecure bool
growthSSESlots chan struct{}
growthSSEMaxDuration time.Duration
growthSSEPollInterval time.Duration
}
type sessionKey struct{}
type tokenKey struct{}
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server {
return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool, growthService ...growthReportService) *Server {
server := &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure, growthSSESlots: defaultGrowthSSESlots, growthSSEMaxDuration: defaultGrowthSSEMaxDuration, growthSSEPollInterval: defaultGrowthSSEPollInterval}
if len(growthService) > 0 {
server.growth = growthService[0]
}
return server
}
func (s *Server) Handler() http.Handler {
@@ -54,6 +70,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
mux.Handle("POST /api/v1/analytics/growth-reports", s.authenticate(s.csrf(http.HandlerFunc(s.createGrowthReport))))
mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport)))
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport)))
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
return s.boundary(mux)
}

View File

@@ -8,21 +8,32 @@ import (
)
type Config struct {
HTTPAddr string
DatabaseURL string
RedisAddr string
RedisPassword string
ImportPath string
DemoUsername string
DemoPassword string
SessionSecret string
CursorSecret string
FrontendOrigin string
CookieSecure bool
AppEnv string
HTTPAddr string
DatabaseURL string
RedisAddr string
RedisPassword string
ImportPath string
DemoUsername string
DemoPassword string
SessionSecret string
CursorSecret string
FrontendOrigin string
CookieSecure bool
AIGrowthEnabled bool
AvalAIAPIKey string
AvalAIBaseURL string
AvalAIModel string
AIDailyRequestBudget int64
AIDailyTokenBudget int64
AIMaxConcurrent int
AICreatePerMinute int64
AIUncachedScopesPerDay int64
}
func Load() (Config, error) {
cfg := Config{
AppEnv: value("APP_ENV", "development"),
HTTPAddr: value("HTTP_ADDR", "127.0.0.1:8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
RedisAddr: value("REDIS_ADDR", "localhost:6379"),
@@ -33,6 +44,9 @@ func Load() (Config, error) {
SessionSecret: os.Getenv("SESSION_SECRET"),
CursorSecret: os.Getenv("CURSOR_SECRET"),
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
AvalAIAPIKey: os.Getenv("AVALAI_API_KEY"),
AvalAIBaseURL: value("AVALAI_BASE_URL", "https://api.avalai.ir"),
AvalAIModel: value("AVALAI_MODEL", "gpt-5-mini-2025-08-07"),
}
var err error
@@ -40,6 +54,31 @@ func Load() (Config, error) {
if err != nil {
return Config{}, fmt.Errorf("parsing cookie secure: %w", err)
}
cfg.AIGrowthEnabled, err = strconv.ParseBool(value("AI_GROWTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("parsing AI growth enabled: %w", err)
}
cfg.AIDailyRequestBudget, err = parseInt64("AI_DAILY_REQUEST_BUDGET", 0)
if err != nil {
return Config{}, err
}
cfg.AIDailyTokenBudget, err = parseInt64("AI_DAILY_TOKEN_BUDGET", 0)
if err != nil {
return Config{}, err
}
maxConcurrent, err := parseInt64("AI_MAX_CONCURRENT", 4)
if err != nil {
return Config{}, err
}
cfg.AIMaxConcurrent = int(maxConcurrent)
cfg.AICreatePerMinute, err = parseInt64("AI_CREATE_PER_MINUTE", 10)
if err != nil {
return Config{}, err
}
cfg.AIUncachedScopesPerDay, err = parseInt64("AI_UNCACHED_SCOPES_PER_DAY", 5)
if err != nil {
return Config{}, err
}
if cfg.DatabaseURL == "" {
return Config{}, errors.New("config: DATABASE_URL is required")
}
@@ -56,6 +95,17 @@ func (c Config) ValidateAPI() error {
if c.CursorSecret == c.SessionSecret {
return errors.New("config: CURSOR_SECRET must differ from SESSION_SECRET")
}
if c.AIMaxConcurrent < 1 || c.AICreatePerMinute < 1 || c.AIUncachedScopesPerDay < 1 {
return errors.New("config: AI limits must be positive")
}
if c.AppEnv == "production" && c.AIGrowthEnabled {
if c.AvalAIAPIKey == "" || c.AvalAIModel == "" {
return errors.New("config: enabled production AI requires AVALAI_API_KEY and AVALAI_MODEL")
}
if c.AIDailyRequestBudget < 1 || c.AIDailyTokenBudget < 1 {
return errors.New("config: enabled production AI requires positive daily budgets")
}
}
return nil
}
@@ -72,3 +122,12 @@ func value(name, fallback string) string {
}
return fallback
}
func parseInt64(name string, fallback int64) (int64, error) {
raw := value(name, strconv.FormatInt(fallback, 10))
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("config: %s must be a non-negative integer", name)
}
return parsed, nil
}

View File

@@ -33,3 +33,26 @@ func TestValidateAPIRequiresIndependentCursorSecret(t *testing.T) {
t.Fatal("ValidateAPI() error = nil, want independent secrets error")
}
}
func TestValidateAPIRequiresGrowthBudgetsInProduction(t *testing.T) {
t.Parallel()
cfg := Config{SessionSecret: "session-secret-that-is-at-least-32-bytes", CursorSecret: "cursor-secret-that-is-at-least-32-bytes", AppEnv: "production", AIGrowthEnabled: true, AvalAIAPIKey: "key", AvalAIModel: "model", AIMaxConcurrent: 4, AICreatePerMinute: 10, AIUncachedScopesPerDay: 5}
if err := cfg.ValidateAPI(); err == nil {
t.Fatal("ValidateAPI() error = nil, want AI budget error")
}
cfg.AIDailyRequestBudget, cfg.AIDailyTokenBudget = 10, 10_000
if err := cfg.ValidateAPI(); err != nil {
t.Fatalf("ValidateAPI() error = %v", err)
}
}
func TestLoadGrowthDefaultsAreCostSafe(t *testing.T) {
t.Setenv("DATABASE_URL", "postgresql://example")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.AIGrowthEnabled || cfg.AIDailyRequestBudget != 0 || cfg.AIDailyTokenBudget != 0 || cfg.AIMaxConcurrent != 4 {
t.Fatalf("unsafe AI defaults: %+v", cfg)
}
}

View File

@@ -0,0 +1,43 @@
package growth
import (
"slices"
"time"
)
type Session struct {
CreatedAt time.Time `json:"createdAt"`
Status string `json:"status"`
AmountRial int64 `json:"amountRial"`
}
// AggregateDaily groups already-authorized, scope-filtered sessions by Tehran
// local day. Paid and Verified are the only successful bank-payment statuses.
func AggregateDaily(sessions []Session, catalog []Event) []DailyPoint {
byDate := make(map[string]DailyPoint)
for _, session := range sessions {
local := session.CreatedAt.In(tehranLocation())
date := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, tehranLocation())
key := dayKey(date)
point := byDate[key]
point.Date = date
point.SessionCount++
if session.Status == "Paid" || session.Status == "Verified" {
point.PaidSessionCount++
point.PaidAmountRial += session.AmountRial
}
byDate[key] = point
}
points := make([]DailyPoint, 0, len(byDate))
for _, point := range byDate {
for _, event := range catalog {
if !dateBefore(point.Date, event.StartDate) && !dateAfter(point.Date, event.EndDate) {
point.EventIDs = append(point.EventIDs, event.ID)
}
}
slices.Sort(point.EventIDs)
points = append(points, point)
}
slices.SortFunc(points, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
return points
}

View File

@@ -0,0 +1,29 @@
package growth
import (
"testing"
"time"
)
func TestAggregateDailyUsesTehranDayAndSessionGrain(t *testing.T) {
sessions := []Session{
{CreatedAt: time.Date(2026, 1, 1, 20, 29, 0, 0, time.UTC), Status: "Paid", AmountRial: 100},
{CreatedAt: time.Date(2026, 1, 1, 20, 31, 0, 0, time.UTC), Status: "Verified", AmountRial: 200},
{CreatedAt: time.Date(2026, 1, 1, 21, 0, 0, 0, time.UTC), Status: "Failed", AmountRial: 900},
}
points := AggregateDaily(sessions, nil)
if len(points) != 2 || dayKey(points[0].Date) != "2026-01-01" || points[0].PaidAmountRial != 100 {
t.Fatalf("unexpected first day: %+v", points)
}
if dayKey(points[1].Date) != "2026-01-02" || points[1].SessionCount != 2 || points[1].PaidSessionCount != 1 || points[1].PaidAmountRial != 200 {
t.Fatalf("unexpected second day: %+v", points[1])
}
}
func TestAggregateDailyAnnotatesCatalogEvents(t *testing.T) {
events := []Event{{ID: "b", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}, {ID: "a", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}}
points := AggregateDaily([]Session{{CreatedAt: time.Date(2026, 5, 2, 8, 0, 0, 0, tehranLocation()), Status: "Paid", AmountRial: 10}}, events)
if len(points) != 1 || len(points[0].EventIDs) != 2 || points[0].EventIDs[0] != "a" || points[0].EventIDs[1] != "b" {
t.Fatalf("unexpected event annotation: %+v", points)
}
}

View File

@@ -0,0 +1,198 @@
package growth
import (
"testing"
"time"
)
func TestAnalyzeEventEffectUsesMatchingWeekdaysAndThresholds(t *testing.T) {
event := Event{ID: "teachers-day", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
history := make([]DailyPoint, 0, 9)
for weeks := 8; weeks >= 1; weeks-- {
history = append(history, DailyPoint{Date: event.StartDate.AddDate(0, 0, -7*weeks), SessionCount: 25, PaidSessionCount: 20, PaidAmountRial: 2_000_000})
}
history = append(history, DailyPoint{Date: event.StartDate, SessionCount: 40, PaidSessionCount: 30, PaidAmountRial: 3_000_000})
effect := AnalyzeEventEffect(history, event, nil)
if !effect.Eligible || !effect.MaterialWin {
t.Fatalf("expected eligible material win, got %+v", effect)
}
if effect.BaselineSessionCount != 25 || effect.SessionUpliftBPS != 6_000 || effect.AmountUpliftBPS != 5_000 {
t.Fatalf("unexpected effect: %+v", effect)
}
}
func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
event := Event{ID: "small", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
history := []DailyPoint{{Date: event.StartDate.AddDate(0, 0, -7), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -14), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -21), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -28), SessionCount: 20}, {Date: event.StartDate, SessionCount: 50}}
effect := AnalyzeEventEffect(history, event, nil)
if effect.Eligible || effect.SuppressionReason != SuppressionInsufficientBaselineSessions {
t.Fatalf("expected baseline suppression, got %+v", effect)
}
}
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
upcoming := []Event{
{ID: "exact", Family: "appreciation", Tags: []string{"unrelated"}},
{ID: "similar", Family: "other", Tags: []string{"gift", "education", "campaign"}},
{ID: "weak", Family: "other", Tags: []string{"internet"}},
}
matches := MatchUpcomingEvents(historical, upcoming)
if len(matches) != 2 || matches[0].Event.ID != "exact" || matches[0].ScoreBPS != 10_000 || matches[1].Event.ID != "similar" {
t.Fatalf("unexpected matches: %+v", matches)
}
}
func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 112)
for i := 112; i > 0; i-- {
day := start.AddDate(0, 0, -i)
base := int64(100 + int(day.Weekday())*5)
history = append(history, DailyPoint{Date: day, SessionCount: base + 20, PaidSessionCount: base, PaidAmountRial: base * 100_000})
}
forecast := BuildForecast(history, nil, start, 90)
if len(forecast.Points) != 90 || forecast.Confidence == ConfidenceInsufficientData {
t.Fatalf("unexpected forecast: confidence=%s points=%d", forecast.Confidence, len(forecast.Points))
}
for _, point := range forecast.Points {
if point.PaidSessions.P10 > point.PaidSessions.P50 || point.PaidSessions.P50 > point.PaidSessions.P90 || point.PaidAmountRial.P10 > point.PaidAmountRial.P50 || point.PaidAmountRial.P50 > point.PaidAmountRial.P90 {
t.Fatalf("unordered range: %+v", point)
}
}
}
func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) {
forecast := Forecast{Confidence: ConfidenceMedium, Points: []ForecastPoint{
{Date: date("2026-07-01"), BaselineSessions: 200, PaidSessions: Range{P50: 150}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 7_000_000}},
{Date: date("2026-07-02"), BaselineSessions: 99, PaidSessions: Range{P50: 10}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 1}},
}}
risks := DetectRisks(forecast, 3)
if len(risks) != 1 || risks[0].Date != date("2026-07-01") {
t.Fatalf("unexpected risks: %+v", risks)
}
}
func TestDetectRisksCoalescesAdjacentDaysAndRanksDeepestFirst(t *testing.T) {
forecast := Forecast{Confidence: ConfidenceHigh, Points: []ForecastPoint{
{Date: date("2026-07-01"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 150}, PaidAmountRial: Range{P50: 750}},
{Date: date("2026-07-02"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 100}, PaidAmountRial: Range{P50: 500}},
{Date: date("2026-07-04"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 120}, PaidAmountRial: Range{P50: 600}},
}}
risks := DetectRisks(forecast, 3)
if len(risks) != 2 || risks[0].Date != date("2026-07-01") || risks[0].EndDate != date("2026-07-02") || risks[0].SessionDeclineBPS != 5_000 {
t.Fatalf("unexpected ranked/coalesced risks: %+v", risks)
}
}
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
peers := make([]PeerMerchantValue, 10)
for i := range peers {
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
}
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 7_500 {
t.Fatalf("unexpected peer result: %+v", result)
}
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
if suppressed.State != PeerSuppressed || suppressed.Reason != SuppressionTerminalScope {
t.Fatalf("unexpected terminal result: %+v", suppressed)
}
}
func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini", ModelConfigVersion: "cfg-1"}
a := Fingerprint(input)
b := Fingerprint(input)
input.Model = "other"
if a == "" || a != b || a == Fingerprint(input) {
t.Fatalf("fingerprint stability/versioning failed")
}
}
func TestPeerPercentileUsesMidRankForTies(t *testing.T) {
peers := make([]PeerMerchantValue, 10)
for index := range peers {
peers[index] = PeerMerchantValue{PaidSessions: 50, SessionCount: 100}
}
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 50, Peers: peers})
if result.PercentileBPS != 5_000 {
t.Fatalf("all-tie percentile = %d, want 5000", result.PercentileBPS)
}
}
func TestFingerprintIncludesModelConfigurationPolicy(t *testing.T) {
input := FingerprintInput{MerchantID: 7, ForecastStart: date("2026-07-01"), HorizonDays: 90, Model: "mini", ModelConfigVersion: "cfg-1"}
first := Fingerprint(input)
input.ModelConfigVersion = "cfg-2"
if first == Fingerprint(input) {
t.Fatal("model configuration policy must invalidate fingerprint")
}
}
func TestTehranRefreshBoundary(t *testing.T) {
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
next := NextRefreshAt(last)
want := time.Date(2026, 8, 21, 20, 30, 0, 0, time.UTC)
if !next.Equal(want) || CanManualRefresh(last, want.Add(-time.Second)) || !CanManualRefresh(last, want) {
t.Fatalf("unexpected boundary next=%s", next)
}
}
func TestBuildFallbackUsesOnlyEvidenceAndPersianCopy(t *testing.T) {
bundle := EvidenceBundle{Opportunities: []Opportunity{{EvidenceID: "opp-1", EventTitleFA: "روز معلم"}}, Risks: []Risk{{EvidenceID: "risk-1"}}}
briefs := BuildFallback(bundle)
if len(briefs) != 2 || briefs[0].EvidenceIDs[0] != "opp-1" || briefs[0].HeadlineFA == "" {
t.Fatalf("unexpected briefs: %+v", briefs)
}
}
func TestBuildDeterministicReportLimitsAndOrdersOpportunities(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 120)
for i := 120; i > 0; i-- {
day := start.AddDate(0, 0, -i)
sessions := int64(120)
amount := int64(12_000_000)
if day.Equal(date("2026-05-02")) {
sessions = 180
amount = 18_000_000
}
history = append(history, DailyPoint{Date: day, SessionCount: sessions, PaidSessionCount: sessions, PaidAmountRial: amount})
}
catalog := []Event{
{ID: "past", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02"), Family: "appreciation", Tags: []string{"gift"}},
{ID: "future", TitleFA: "روز پدر", StartDate: date("2026-07-10"), EndDate: date("2026-07-10"), Family: "appreciation", Tags: []string{"gift"}},
}
report := BuildDeterministicReport(ReportInput{History: history, Catalog: catalog, ForecastStart: start, HorizonDays: 90, Peer: &PeerInput{TerminalScoped: true}})
if len(report.HistoricalWins) != 1 || len(report.Opportunities) != 1 || report.Opportunities[0].EventID != "future" {
t.Fatalf("unexpected report evidence: %+v", report)
}
if report.Peer == nil || report.Peer.Reason != SuppressionTerminalScope {
t.Fatalf("expected terminal peer suppression: %+v", report.Peer)
}
}
func TestFingerprintTreatsEquivalentForecastDatesEqually(t *testing.T) {
tehran, err := time.LoadLocation("Asia/Tehran")
if err != nil {
t.Fatal(err)
}
local := time.Date(2026, 7, 1, 0, 0, 0, 0, tehran)
utcInstant := local.UTC()
base := FingerprintInput{MerchantID: 1, ForecastStart: local, HorizonDays: 90}
a := Fingerprint(base)
base.ForecastStart = utcInstant
if a != Fingerprint(base) {
t.Fatal("equivalent forecast instants must fingerprint equally")
}
}
func date(value string) time.Time {
parsed, err := time.Parse(time.DateOnly, value)
if err != nil {
panic(err)
}
return parsed
}

103
internal/growth/catalog.go Normal file
View File

@@ -0,0 +1,103 @@
package growth
import (
"fmt"
"slices"
"strings"
"time"
)
const officialCalendarSource = "https://calendar.ut.ac.ir/"
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21"
var importedMerchantCategories = []string{
"ارائه دهنده خدمات اینترنت",
"مراکز آموزشی مجازی",
"کیف و کفش فروشی",
"خدمات شبکه‌های کامپیوتری و اینترنت",
"فروشگاه لوازم آرایشی و بهداشتی",
}
// Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are
// Gregorian Tehran-local calendar dates and cover the imported 1405 history plus
// the first 90 forecast days after 2026-06-30.
func Catalog1405() []Event {
events := []Event{
officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}),
officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("oil-nationalization-1404", "ملی شدن صنعت نفت", "2026-03-19", "2026-03-19", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", true, []string{"هدیه", "خرید", "سال-نو", "تعطیل"}),
officialEvent("islamic-republic-day-1405", "روز جمهوری اسلامی", "2026-04-01", "2026-04-01", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", true, []string{"تفریح", "خرید", "تعطیل"}),
officialEvent("imam-sadegh-martyrdom-1405", "شهادت امام جعفر صادق", "2026-04-14", "2026-04-14", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", false, []string{"هدیه", "آموزش", "قدردانی"}),
officialEvent("eid-al-adha-1405", "عید قربان", "2026-05-27", "2026-05-27", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("khomeini-ghadir-1405", "رحلت امام خمینی و عید غدیر", "2026-06-04", "2026-06-04", "official-holiday", true, []string{"تعطیل", "مناسبت-ملی", "مناسبت-مذهبی"}),
officialEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("tasua-1405", "تاسوعای حسینی", "2026-06-24", "2026-06-24", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("ashura-1405", "عاشورای حسینی", "2026-06-25", "2026-06-25", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
commercialEvent("summer-midseason-1405", "میانه فصل تابستان", "2026-07-15", "2026-07-17", "seasonal-shopping", []string{"خرید", "تابستان", "پیشنهاد-فصلی"}, importedMerchantCategories),
officialEvent("arbaeen-1405", "اربعین حسینی", "2026-08-04", "2026-08-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("prophet-demise-1405", "رحلت پیامبر اکرم و شهادت امام حسن", "2026-08-12", "2026-08-12", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", false, []string{"هدیه", "قدردانی", "حرفه‌ای"}),
officialEvent("prophet-birthday-1405", "میلاد پیامبر اکرم و امام جعفر صادق", "2026-08-30", "2026-08-30", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
commercialEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", []string{"آموزش", "خرید", "مدرسه"}, []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}),
commercialEvent("autumn-opening-1405", "آغاز فصل پاییز", "2026-09-23", "2026-09-28", "seasonal-shopping", []string{"خرید", "پاییز", "پیشنهاد-فصلی"}, importedMerchantCategories),
}
result := make([]Event, len(events))
for index, event := range events {
result[index] = event
result[index].Tags = slices.Clone(event.Tags)
result[index].Categories = slices.Clone(event.Categories)
}
return result
}
func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event {
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: officialCalendarSource, SourceVersion: officialCalendarVersion, Kind: EventOfficial, OfficialHoliday: holiday, Universal: true}
}
func commercialEvent(id, title, start, end, family string, tags, categories []string) Event {
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: curatedCommercialSource, SourceVersion: CatalogVersion, Categories: slices.Clone(categories), Kind: EventCommercial, Universal: false}
}
func ValidateCatalog(events []Event) error {
seen := make(map[string]struct{}, len(events))
for index, event := range events {
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" || strings.TrimSpace(event.SourceVersion) == "" {
return fmt.Errorf("growth catalog event %d is incomplete", index)
}
if _, exists := seen[event.ID]; exists {
return fmt.Errorf("growth catalog contains duplicate id %q", event.ID)
}
seen[event.ID] = struct{}{}
if event.StartDate.IsZero() || event.EndDate.Before(event.StartDate) {
return fmt.Errorf("growth catalog event %q has invalid date range", event.ID)
}
if event.Kind != EventOfficial && event.Kind != EventCommercial {
return fmt.Errorf("growth catalog event %q has invalid kind", event.ID)
}
if len(normalizedSet(event.Tags)) == 0 {
return fmt.Errorf("growth catalog event %q lacks tags", event.ID)
}
if event.Universal && len(event.Categories) != 0 {
return fmt.Errorf("growth catalog universal event %q must not contain categories", event.ID)
}
if !event.Universal && len(event.Categories) == 0 {
return fmt.Errorf("growth catalog scoped event %q lacks categories", event.ID)
}
}
return nil
}
func mustCatalogDate(value string) time.Time {
parsed, err := time.Parse(time.DateOnly, value)
if err != nil {
panic("invalid reviewed event catalog date: " + value)
}
return parsed
}

View File

@@ -0,0 +1,111 @@
package growth
import "testing"
func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) {
first := Catalog1405()
if err := ValidateCatalog(first); err != nil {
t.Fatalf("catalog should be valid: %v", err)
}
first[0].Tags[0] = "mutated"
second := Catalog1405()
if second[0].Tags[0] == "mutated" {
t.Fatal("catalog tags escaped through a shared backing array")
}
}
func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
event := officialEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", false, []string{"tag"})
if err := ValidateCatalog([]Event{event, event}); err == nil {
t.Fatal("expected duplicate id error")
}
event.Source = ""
if err := ValidateCatalog([]Event{event}); err == nil {
t.Fatal("expected missing source error")
}
}
func TestCatalogCoversImportedHistoryAndForecastHorizon(t *testing.T) {
catalog := Catalog1405()
if catalog[0].StartDate.After(date("2026-01-03")) || catalog[len(catalog)-1].EndDate.Before(date("2026-09-28")) {
t.Fatalf("catalog coverage is incomplete: %s..%s", catalog[0].StartDate, catalog[len(catalog)-1].EndDate)
}
var scoped bool
for _, event := range catalog {
if event.ID == "back-to-school-1405" {
scoped = !event.Universal && len(event.Categories) == 2
}
}
if !scoped {
t.Fatal("back-to-school event must preserve explicit category scope")
}
}
func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)}
history := make([]DailyPoint, 0, 9)
for week := 1; week <= 8; week++ {
count := int64(30)
if week == 1 {
count = 1_000
}
history = append(history, DailyPoint{Date: target.StartDate.AddDate(0, 0, -7*week), SessionCount: count, PaidSessionCount: count, PaidAmountRial: count * 100})
}
history = append(history, DailyPoint{Date: target.StartDate, SessionCount: 50, PaidSessionCount: 50, PaidAmountRial: 5_000})
effect := AnalyzeEventEffect(history, target, []Event{excluded})
if effect.ObservationCount != 7 || effect.BaselineSessionCount != 30 || !effect.MaterialWin {
t.Fatalf("catalog event polluted baseline: %+v", effect)
}
}
func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) {
target := Event{ID: "two-day", StartDate: date("2026-05-02"), EndDate: date("2026-05-03")}
history := []DailyPoint{
{Date: date("2026-04-25"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-26"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-18"), SessionCount: 1_000, PaidSessionCount: 1_000, PaidAmountRial: 100_000}, // fragmented week, next day absent
{Date: date("2026-04-11"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-12"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-04"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-05"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-03-28"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-03-29"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: target.StartDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
{Date: target.EndDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
}
effect := AnalyzeEventEffect(history, target, nil)
if effect.ObservationCount != 4 || effect.BaselineSessionCount != 120 || effect.ActualSessionCount != 160 || effect.PaidSessionUpliftBPS != 5_000 {
t.Fatalf("unexpected complete-window effect: %+v", effect)
}
}
func TestForecastSuppressesPoorBacktest(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 140)
for i := 140; i > 0; i-- {
value := int64(100)
if i%2 == 0 {
value = 1_000
}
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 {
t.Fatalf("poor model should be suppressed: %+v", forecast)
}
}
func TestBacktestUsesProductionTrendPredictorAcrossFourteenDayHorizons(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 140)
for index := 0; index < 140; index++ {
block := index / 28
value := int64(100 + block*20)
history = append(history, DailyPoint{Date: start.AddDate(0, 0, index-140), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Backtest.Origins < 4 || forecast.Confidence == ConfidenceInsufficientData {
t.Fatalf("trend-aware rolling backtest should be usable: %+v", forecast.Backtest)
}
}

171
internal/growth/events.go Normal file
View File

@@ -0,0 +1,171 @@
package growth
import (
"slices"
"strings"
"time"
)
func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect {
effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID}
eventDates := catalogDates(append(slices.Clone(catalog), event))
byDate := pointsByDate(history)
eventDays := daysInclusive(event.StartDate, event.EndDate)
var baselineSessions, baselinePaid, baselineAmount int64
actualComplete := true
for _, eventDay := range eventDays {
point, ok := byDate[dayKey(eventDay)]
if !ok {
actualComplete = false
break
}
effect.ActualSessionCount += point.SessionCount
effect.ActualPaidSessions += point.PaidSessionCount
effect.ActualAmountRial += point.PaidAmountRial
}
if !actualComplete {
effect.SuppressionReason = SuppressionIncompleteEventWindow
return effect
}
for week := 1; week <= 8; week++ {
var windowSessions, windowPaid, windowAmount int64
complete := true
for _, eventDay := range eventDays {
candidate := eventDay.AddDate(0, 0, -7*week)
if eventDates[dayKey(candidate)] {
complete = false
break
}
point, ok := byDate[dayKey(candidate)]
if !ok {
complete = false
break
}
windowSessions += point.SessionCount
windowPaid += point.PaidSessionCount
windowAmount += point.PaidAmountRial
}
if !complete {
continue
}
effect.ObservationCount++
baselineSessions += windowSessions
baselinePaid += windowPaid
baselineAmount += windowAmount
}
if effect.ObservationCount < MinBaselineObservations {
effect.SuppressionReason = SuppressionInsufficientObservations
return effect
}
effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount)
effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount)
effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount)
if baselineSessions < MinBaselineSessions {
effect.SuppressionReason = SuppressionInsufficientBaselineSessions
return effect
}
effect.Eligible = true
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount, effect.BaselineSessionCount)
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions)
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial)
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
return effect
}
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
return 10_000, "FAMILY"
}
a := normalizedSet(historical.Tags)
b := normalizedSet(upcoming.Tags)
if len(a) == 0 || len(b) == 0 {
return 0, "TAGS"
}
intersection := 0
union := make(map[string]struct{}, len(a)+len(b))
for tag := range a {
union[tag] = struct{}{}
if _, ok := b[tag]; ok {
intersection++
}
}
for tag := range b {
union[tag] = struct{}{}
}
return int64(intersection * 10_000 / len(union)), "TAGS"
}
func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch {
matches := make([]EventMatch, 0, len(upcoming))
for _, candidate := range upcoming {
score, basis := MatchScoreBPS(historical, candidate)
if score >= MinSimilarityBPS {
matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis})
}
}
slices.SortFunc(matches, func(a, b EventMatch) int {
if a.ScoreBPS != b.ScoreBPS {
return int(b.ScoreBPS - a.ScoreBPS)
}
return strings.Compare(a.Event.ID, b.Event.ID)
})
return matches
}
func pointsByDate(points []DailyPoint) map[string]DailyPoint {
result := make(map[string]DailyPoint, len(points))
for _, point := range points {
result[dayKey(point.Date)] = point
}
return result
}
func catalogDates(events []Event) map[string]bool {
result := make(map[string]bool)
for _, event := range events {
for _, day := range daysInclusive(event.StartDate, event.EndDate) {
result[dayKey(day)] = true
}
}
return result
}
func daysInclusive(start, end time.Time) []time.Time {
if end.Before(start) {
return nil
}
days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) {
days = append(days, day)
}
return days
}
func normalizedSet(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
result[value] = struct{}{}
}
}
return result
}
func normalizeDate(value time.Time) time.Time {
year, month, day := value.Date()
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
}
func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) }
func dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) }
func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) }
func changeBPS(actual, baseline int64) int64 {
if baseline <= 0 {
return 0
}
return (actual - baseline) * 10_000 / baseline
}

323
internal/growth/forecast.go Normal file
View File

@@ -0,0 +1,323 @@
package growth
import (
"slices"
"time"
)
const (
defaultHorizonDays = 90
minimumHistoryDays = 56
maxTrendBPS = 2_000
)
func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horizonDays int) Forecast {
if horizonDays <= 0 {
horizonDays = defaultHorizonDays
}
result := Forecast{StartDate: normalizeDate(start), HorizonDays: horizonDays, Confidence: ConfidenceInsufficientData, SuppressionReason: SuppressionInsufficientHistory, Points: []ForecastPoint{}}
ordered := normalizedHistory(history, start)
if len(ordered) < minimumHistoryDays {
return result
}
result.Backtest = backtest(ordered, catalog)
result.Confidence = classifyConfidence(result.Backtest)
if result.Confidence == ConfidenceInsufficientData {
return result
}
result.SuppressionReason = ""
result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays)
return result
}
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
sessionResiduals, amountResiduals := modelResiduals(history, catalog)
points := make([]ForecastPoint, 0, horizon)
for index := 0; index < horizon; index++ {
day := start.AddDate(0, 0, index)
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
points = append(points, ForecastPoint{
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
PaidAmountRial: rangeFromResiduals(predictedAmount, amountResiduals), EventIDs: eventIDs,
})
}
return points
}
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
ordered := make([]DailyPoint, 0, len(history))
for _, point := range history {
if dateBefore(point.Date, before) {
point.Date = normalizeDate(point.Date)
ordered = append(ordered, point)
}
}
slices.SortFunc(ordered, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
return ordered
}
func weekdayMedian(history []DailyPoint, weekday time.Weekday, value func(DailyPoint) int64) int64 {
values := make([]int64, 0, 8)
for index := len(history) - 1; index >= 0 && len(values) < 8; index-- {
if history[index].Date.Weekday() == weekday {
values = append(values, value(history[index]))
}
}
return median(values)
}
func median(values []int64) int64 {
if len(values) == 0 {
return 0
}
copyOfValues := slices.Clone(values)
slices.Sort(copyOfValues)
middle := len(copyOfValues) / 2
if len(copyOfValues)%2 == 1 {
return copyOfValues[middle]
}
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
}
func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) {
sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
for index := minimumHistoryDays; index < len(history); index++ {
prior := history[:index]
_, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date)
sessions = append(sessions, history[index].PaidSessionCount-predictedSessions)
amounts = append(amounts, history[index].PaidAmountRial-predictedAmount)
}
return sessions, amounts
}
func rangeFromResiduals(center int64, residualValues []int64) Range {
return Range{
P10: maxInt64(0, center+quantile(residualValues, 10)),
P50: maxInt64(0, center+quantile(residualValues, 50)),
P90: maxInt64(0, center+quantile(residualValues, 90)),
}
}
func quantile(values []int64, percentile int) int64 {
if len(values) == 0 {
return 0
}
ordered := slices.Clone(values)
slices.Sort(ordered)
index := (len(ordered) - 1) * percentile / 100
return ordered[index]
}
func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
if len(history) < 56 {
return 0
}
var recent, previous int64
for _, point := range history[len(history)-28:] {
recent += value(point)
}
for _, point := range history[len(history)-56 : len(history)-28] {
previous += value(point)
}
trend := changeBPS(recent, previous)
if trend > maxTrendBPS {
return maxTrendBPS
}
if trend < -maxTrendBPS {
return -maxTrendBPS
}
return trend
}
func backtest(history []DailyPoint, catalog []Event) BacktestResult {
result := BacktestResult{}
var sessionError, amountError, covered int64
var evaluated int64
first := len(history) - 56
for origin := first; origin < len(history); origin += 14 {
if origin < minimumHistoryDays {
continue
}
result.Origins++
training := history[:origin]
sessionResiduals, amountResiduals := modelResiduals(training, catalog)
for index := origin; index < minInt(origin+14, len(history)); index++ {
actual := history[index]
_, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date)
sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals)
amountRange := rangeFromResiduals(amountCenter, amountResiduals)
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
covered++
}
evaluated++
}
}
if evaluated > 0 {
result.SessionMAPEBPS = sessionError / evaluated
result.AmountMAPEBPS = amountError / evaluated
result.IntervalCoverageBPS = covered * 10_000 / evaluated
}
return result
}
func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64, int64, int64, int64, []string) {
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
predictedSessions := applyBPS(baselineSessions, trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount }))
predictedAmount := applyBPS(baselineAmount, trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial }))
eventIDs := make([]string, 0, 1)
familyEffects := eventFamilyEffects(history, catalog)
for _, event := range catalog {
if dateBefore(day, event.StartDate) || dateAfter(day, event.EndDate) {
continue
}
eventIDs = append(eventIDs, event.ID)
if uplift, ok := familyEffects[event.Family]; ok {
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
predictedAmount = applyBPS(predictedAmount, uplift.amount)
}
}
return baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs
}
func classifyConfidence(backtest BacktestResult) Confidence {
if backtest.Origins < 4 {
return ConfidenceInsufficientData
}
worstMAPE := maxInt64(backtest.SessionMAPEBPS, backtest.AmountMAPEBPS)
if worstMAPE <= 2_000 && backtest.IntervalCoverageBPS >= 7_000 {
return ConfidenceHigh
}
if worstMAPE <= 4_000 && backtest.IntervalCoverageBPS >= 5_000 {
return ConfidenceMedium
}
return ConfidenceInsufficientData
}
type familyUplift struct{ sessions, amount int64 }
func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]familyUplift {
result := make(map[string]familyUplift)
counts := make(map[string]int64)
for _, event := range catalog {
if event.Family == "" || dateAfter(event.StartDate, history[len(history)-1].Date) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
if !effect.Eligible {
continue
}
value := result[event.Family]
value.sessions += effect.PaidSessionUpliftBPS
value.amount += effect.AmountUpliftBPS
result[event.Family] = value
counts[event.Family]++
}
for family, value := range result {
value.sessions /= counts[family]
value.amount /= counts[family]
result[family] = value
}
return result
}
func applyBPS(value, adjustment int64) int64 { return maxInt64(0, value*(10_000+adjustment)/10_000) }
func absolutePercentageErrorBPS(actual, predicted int64) int64 {
if actual <= 0 {
if predicted == 0 {
return 0
}
return 10_000
}
difference := actual - predicted
if difference < 0 {
difference = -difference
}
return difference * 10_000 / actual
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func DetectRisks(forecast Forecast, limit int) []Risk {
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
return nil
}
candidates := make([]Risk, 0, len(forecast.Points))
for _, point := range forecast.Points {
if point.BaselineSessions < MinBaselineSessions {
continue
}
sessionDecline := -changeBPS(point.PaidSessions.P50, point.BaselineSessions)
amountDecline := -changeBPS(point.PaidAmountRial.P50, point.BaselineAmountRial)
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
continue
}
candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
}
coalesced := coalesceRisks(candidates)
slices.SortStableFunc(coalesced, func(a, b Risk) int {
aSeverity := maxInt64(a.SessionDeclineBPS, a.AmountDeclineBPS)
bSeverity := maxInt64(b.SessionDeclineBPS, b.AmountDeclineBPS)
if aSeverity > bSeverity {
return -1
}
if aSeverity < bSeverity {
return 1
}
return a.Date.Compare(b.Date)
})
if len(coalesced) > limit {
coalesced = coalesced[:limit]
}
return coalesced
}
func coalesceRisks(candidates []Risk) []Risk {
if len(candidates) == 0 {
return nil
}
result := make([]Risk, 0, len(candidates))
current := candidates[0]
for _, candidate := range candidates[1:] {
if dayKey(candidate.Date) != dayKey(current.EndDate.AddDate(0, 0, 1)) {
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
result = append(result, current)
current = candidate
continue
}
current.EndDate = candidate.EndDate
if candidate.SessionDeclineBPS > current.SessionDeclineBPS {
current.SessionDeclineBPS = candidate.SessionDeclineBPS
current.ExpectedSessions = candidate.ExpectedSessions
}
if candidate.AmountDeclineBPS > current.AmountDeclineBPS {
current.AmountDeclineBPS = candidate.AmountDeclineBPS
current.ExpectedAmountRial = candidate.ExpectedAmountRial
}
}
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
return append(result, current)
}

View File

@@ -0,0 +1,101 @@
package growth
import (
"slices"
"strings"
"time"
)
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
wins := make([]EventEffect, 0)
for _, event := range catalog {
if !dateBefore(event.EndDate, before) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
if effect.Eligible && effect.MaterialWin {
wins = append(wins, effect)
}
}
slices.SortFunc(wins, func(a, b EventEffect) int {
aScore := maxInt64(a.PaidSessionUpliftBPS, a.AmountUpliftBPS)
bScore := maxInt64(b.PaidSessionUpliftBPS, b.AmountUpliftBPS)
if aScore != bScore {
if aScore > bScore {
return -1
}
return 1
}
return strings.Compare(a.EventID, b.EventID)
})
return wins
}
func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, start time.Time, limit int) []Opportunity {
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
return nil
}
eventByID := make(map[string]Event, len(catalog))
for _, event := range catalog {
eventByID[event.ID] = event
}
forecastByDate := make(map[string]ForecastPoint, len(forecast.Points))
for _, point := range forecast.Points {
forecastByDate[dayKey(point.Date)] = point
}
best := make(map[string]Opportunity)
for _, win := range wins {
historical := eventByID[win.EventID]
upcoming := make([]Event, 0)
for _, event := range catalog {
if !dateBefore(event.EndDate, start) {
upcoming = append(upcoming, event)
}
}
for _, match := range MatchUpcomingEvents(historical, upcoming) {
point, ok := forecastByDate[dayKey(match.Event.StartDate)]
if !ok {
continue
}
candidate := Opportunity{
EvidenceID: EvidenceID("opportunity", match.Event.ID+":"+win.EventID), EventID: match.Event.ID,
EventTitleFA: match.Event.TitleFA, Date: normalizeDate(match.Event.StartDate), SimilarityBPS: match.ScoreBPS,
ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial,
HistoricalEvidenceID: win.EvidenceID,
}
if current, exists := best[match.Event.ID]; !exists || candidate.SimilarityBPS > current.SimilarityBPS {
best[match.Event.ID] = candidate
}
}
}
result := make([]Opportunity, 0, len(best))
for _, opportunity := range best {
result = append(result, opportunity)
}
slices.SortFunc(result, func(a, b Opportunity) int {
if a.SimilarityBPS != b.SimilarityBPS {
if a.SimilarityBPS > b.SimilarityBPS {
return -1
}
return 1
}
if comparison := a.Date.Compare(b.Date); comparison != 0 {
return comparison
}
return strings.Compare(a.EventID, b.EventID)
})
if len(result) > limit {
result = result[:limit]
}
return result
}
func filterCatalog(catalog []Event, category string) []Event {
filtered := make([]Event, 0, len(catalog))
for _, event := range catalog {
if category == "" || event.Universal || slices.Contains(event.Categories, category) {
filtered = append(filtered, event)
}
}
return filtered
}

60
internal/growth/peer.go Normal file
View File

@@ -0,0 +1,60 @@
package growth
import "slices"
type PeerState string
const (
PeerReady PeerState = "READY"
PeerSuppressed PeerState = "SUPPRESSED"
)
type PeerMerchantValue struct {
PaidSessions int64 `json:"paidSessions"`
SessionCount int64 `json:"sessionCount"`
}
type PeerInput struct {
TerminalScoped bool `json:"terminalScoped"`
MerchantPaidSessions int64 `json:"merchantPaidSessions"`
Peers []PeerMerchantValue `json:"peers"`
}
type PeerResult struct {
State PeerState `json:"state"`
Reason SuppressionReason `json:"reason,omitempty"`
EligiblePeerCount int `json:"eligiblePeerCount"`
CombinedPeerSessions int64 `json:"combinedPeerSessions"`
MedianPaidSessions int64 `json:"medianPaidSessions"`
PercentileBPS int64 `json:"percentileBps"`
}
func EvaluatePeer(input PeerInput) PeerResult {
result := PeerResult{State: PeerSuppressed, EligiblePeerCount: len(input.Peers)}
if input.TerminalScoped {
result.Reason = SuppressionTerminalScope
return result
}
values := make([]int64, 0, len(input.Peers))
for _, peer := range input.Peers {
result.CombinedPeerSessions += peer.SessionCount
values = append(values, peer.PaidSessions)
}
if len(values) < 10 || result.CombinedPeerSessions < 1_000 {
result.Reason = SuppressionInsufficientCohort
return result
}
slices.Sort(values)
result.State = PeerReady
result.MedianPaidSessions = median(values)
var below, equal int64
for _, value := range values {
if input.MerchantPaidSessions > value {
below++
} else if input.MerchantPaidSessions == value {
equal++
}
}
result.PercentileBPS = (2*below + equal) * 5_000 / int64(len(values))
return result
}

131
internal/growth/report.go Normal file
View File

@@ -0,0 +1,131 @@
package growth
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
type FingerprintInput struct {
MerchantID int64 `json:"merchantId"`
TerminalID string `json:"terminalId,omitempty"`
DatasetWatermark string `json:"datasetWatermark"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
CatalogVersion string `json:"catalogVersion"`
AnalyticsVersion string `json:"analyticsVersion"`
PromptVersion string `json:"promptVersion"`
SchemaVersion string `json:"schemaVersion"`
Model string `json:"model"`
ModelConfigVersion string `json:"modelConfigVersion"`
}
func Fingerprint(input FingerprintInput) string {
payload, err := json.Marshal(struct {
FingerprintInput
ForecastDate string `json:"forecastDate"`
}{FingerprintInput: FingerprintInput{
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
ModelConfigVersion: input.ModelConfigVersion,
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
if err != nil {
return ""
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:])
}
type ReportInput struct {
History []DailyPoint `json:"history"`
Catalog []Event `json:"catalog"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
Category string `json:"category,omitempty"`
Peer *PeerInput `json:"peer,omitempty"`
}
type DeterministicReport struct {
Forecast Forecast `json:"forecast"`
HistoricalWins []EventEffect `json:"historicalWins"`
Opportunities []Opportunity `json:"opportunities"`
Risks []Risk `json:"risks"`
Peer *PeerResult `json:"peer,omitempty"`
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
}
func BuildDeterministicReport(input ReportInput) DeterministicReport {
catalog := filterCatalog(input.Catalog, input.Category)
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
wins := historicalWins(input.History, catalog, input.ForecastStart)
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
risks := DetectRisks(forecast, 3)
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
if input.Peer != nil {
peer := EvaluatePeer(*input.Peer)
report.Peer = &peer
}
return report
}
func TehranDay(value time.Time) string {
return value.In(tehranLocation()).Format(time.DateOnly)
}
func NextRefreshAt(lastRefresh time.Time) time.Time {
local := lastRefresh.In(tehranLocation())
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
return nextLocal.UTC()
}
func CanManualRefresh(lastRefresh, now time.Time) bool {
if lastRefresh.IsZero() {
return true
}
return !now.Before(NextRefreshAt(lastRefresh))
}
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
for _, opportunity := range bundle.Opportunities {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
GuidanceFA: "داده‌های قبلی شما نشان می‌دهد این مناسبت می‌تواند برای برنامه‌ریزی فروش ارزشمند باشد.",
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کرده‌ایم.",
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{opportunity.EvidenceID},
})
}
for _, risk := range bundle.Risks {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "برای یک بازه آرام‌تر آماده باشید",
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان می‌دهد.",
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.",
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{risk.EvidenceID},
})
}
return briefs
}
func EvidenceID(kind, stableKey string) string {
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
}
func tehranLocation() *time.Location {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
return location
}

155
internal/growth/types.go Normal file
View File

@@ -0,0 +1,155 @@
package growth
import "time"
const (
CatalogVersion = "1405.2"
AnalyticsVersion = "1.1.0"
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
MinBaselineObservations = 4
MinBaselineSessions = 100
MaterialChangeBPS = 2_000
MinSimilarityBPS = 6_000
)
type Confidence string
const (
ConfidenceHigh Confidence = "HIGH"
ConfidenceMedium Confidence = "MEDIUM"
ConfidenceInsufficientData Confidence = "INSUFFICIENT_DATA"
)
type SuppressionReason string
const (
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
SuppressionIncompleteEventWindow SuppressionReason = "INCOMPLETE_EVENT_WINDOW"
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
)
type DailyPoint struct {
Date time.Time `json:"date"`
SessionCount int64 `json:"sessionCount"`
PaidSessionCount int64 `json:"paidSessionCount"`
PaidAmountRial int64 `json:"paidAmountRial"`
EventIDs []string `json:"eventIds,omitempty"`
}
type EventKind string
const (
EventOfficial EventKind = "OFFICIAL"
EventCommercial EventKind = "COMMERCIAL"
)
type Event struct {
ID string `json:"id"`
TitleFA string `json:"titleFa"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Family string `json:"family"`
Tags []string `json:"tags"`
Source string `json:"source"`
SourceVersion string `json:"sourceVersion"`
Categories []string `json:"categories"`
Kind EventKind `json:"kind"`
OfficialHoliday bool `json:"officialHoliday"`
Universal bool `json:"universal"`
}
type EventEffect struct {
EvidenceID string `json:"evidenceId"`
EventID string `json:"eventId"`
Eligible bool `json:"eligible"`
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
ObservationCount int `json:"observationCount"`
BaselineSessionCount int64 `json:"baselineSessionCount"`
BaselinePaidSessions int64 `json:"baselinePaidSessions"`
BaselineAmountRial int64 `json:"baselineAmountRial"`
ActualSessionCount int64 `json:"actualSessionCount"`
ActualPaidSessions int64 `json:"actualPaidSessions"`
ActualAmountRial int64 `json:"actualAmountRial"`
SessionUpliftBPS int64 `json:"sessionUpliftBps"`
PaidSessionUpliftBPS int64 `json:"paidSessionUpliftBps"`
AmountUpliftBPS int64 `json:"amountUpliftBps"`
MaterialWin bool `json:"materialWin"`
MaterialDecline bool `json:"materialDecline"`
}
type EventMatch struct {
Event Event `json:"event"`
ScoreBPS int64 `json:"scoreBps"`
MatchBasis string `json:"matchBasis"`
}
type Range struct {
P10 int64 `json:"p10"`
P50 int64 `json:"p50"`
P90 int64 `json:"p90"`
}
type ForecastPoint struct {
Date time.Time `json:"date"`
BaselineSessions int64 `json:"baselineSessions"`
BaselineAmountRial int64 `json:"baselineAmountRial"`
PaidSessions Range `json:"paidSessions"`
PaidAmountRial Range `json:"paidAmountRial"`
EventIDs []string `json:"eventIds,omitempty"`
}
type BacktestResult struct {
Origins int `json:"origins"`
SessionMAPEBPS int64 `json:"sessionMapeBps"`
AmountMAPEBPS int64 `json:"amountMapeBps"`
IntervalCoverageBPS int64 `json:"intervalCoverageBps"`
}
type Forecast struct {
StartDate time.Time `json:"startDate"`
HorizonDays int `json:"horizonDays"`
Confidence Confidence `json:"confidence"`
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
Points []ForecastPoint `json:"points"`
Backtest BacktestResult `json:"backtest"`
}
type Opportunity struct {
EvidenceID string `json:"evidenceId"`
EventID string `json:"eventId"`
EventTitleFA string `json:"eventTitleFa"`
Date time.Time `json:"date"`
SimilarityBPS int64 `json:"similarityBps"`
ExpectedSessions Range `json:"expectedSessions"`
ExpectedAmountRial Range `json:"expectedAmountRial"`
HistoricalEvidenceID string `json:"historicalEvidenceId"`
}
type Risk struct {
EvidenceID string `json:"evidenceId"`
Date time.Time `json:"date"`
EndDate time.Time `json:"endDate"`
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
AmountDeclineBPS int64 `json:"amountDeclineBps"`
ExpectedSessions Range `json:"expectedSessions"`
ExpectedAmountRial Range `json:"expectedAmountRial"`
}
type EvidenceBundle struct {
Opportunities []Opportunity `json:"opportunities"`
Risks []Risk `json:"risks"`
}
type CampaignBrief struct {
HeadlineFA string `json:"headlineFa"`
GuidanceFA string `json:"guidanceFa"`
OfferFA string `json:"offerFa"`
MessageFA string `json:"messageFa"`
PreparationFA string `json:"preparationFa"`
LimitationFA string `json:"limitationFa"`
EvidenceIDs []string `json:"evidenceIds"`
}

View File

@@ -0,0 +1,219 @@
package growthreport
import (
"context"
"encoding/json"
"fmt"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
"github.com/jackc/pgx/v5/pgxpool"
)
type HistoricalWin struct {
growth.EventEffect
EventTitleFA string `json:"eventTitleFa"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
}
type PeerEvidence struct {
State growth.PeerState `json:"state"`
Reason growth.SuppressionReason `json:"reason,omitempty"`
EligiblePeerCount int `json:"eligiblePeerCount"`
CombinedPeerSessionCount int64 `json:"combinedPeerSessionCount"`
MedianPaidSessions int64 `json:"medianPaidSessions"`
PercentileBPS int64 `json:"percentileBps"`
}
type Evidence struct {
Forecast growth.Forecast `json:"forecast"`
HistoricalWins []HistoricalWin `json:"historicalWins"`
Opportunities []growth.Opportunity `json:"opportunities"`
Risks []growth.Risk `json:"risks"`
Peer *PeerEvidence `json:"peer,omitempty"`
FallbackBriefs []growth.CampaignBrief `json:"fallbackBriefs"`
}
type Analysis struct {
Evidence Evidence
Encoded json.RawMessage
KnownEvidence map[string]struct{}
Fallback AICopy
ShouldCallAI bool
}
type Builder interface {
Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error)
}
type PostgreSQLBuilder struct{ pool *pgxpool.Pool }
func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
return &PostgreSQLBuilder{pool: pool}
}
func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) {
catalog := growth.Catalog1405()
var category string
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&category); err != nil {
return Analysis{}, fmt.Errorf("loading merchant category: %w", err)
}
rows, err := b.pool.Query(ctx, `WITH bounds AS (SELECT min(created_at AT TIME ZONE 'Asia/Tehran')::date first_day,max(created_at AT TIME ZONE 'Asia/Tehran')::date last_day FROM payment_sessions),
days AS (SELECT generate_series(first_day,last_day,interval '1 day')::date local_date FROM bounds), scoped AS (
SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date,count(*) sessions,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')) paid_sessions,coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0) paid_amount
FROM payment_sessions ps WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2)) GROUP BY 1)
SELECT d.local_date,coalesce(s.sessions,0),coalesce(s.paid_sessions,0),coalesce(s.paid_amount,0) FROM days d LEFT JOIN scoped s USING(local_date) ORDER BY d.local_date`, merchantID, terminalID)
if err != nil {
return Analysis{}, fmt.Errorf("loading growth history: %w", err)
}
defer rows.Close()
history := make([]growth.DailyPoint, 0, 366)
for rows.Next() {
var point growth.DailyPoint
if err := rows.Scan(&point.Date, &point.SessionCount, &point.PaidSessionCount, &point.PaidAmountRial); err != nil {
return Analysis{}, fmt.Errorf("scanning growth history: %w", err)
}
for _, event := range catalog {
if !point.Date.Before(event.StartDate) && !point.Date.After(event.EndDate) {
point.EventIDs = append(point.EventIDs, event.ID)
}
}
history = append(history, point)
}
if err := rows.Err(); err != nil {
return Analysis{}, fmt.Errorf("iterating growth history: %w", err)
}
var latest time.Time
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)
peer, err := b.loadPeer(ctx, merchantID, terminalID)
if err != nil {
return Analysis{}, err
}
deterministic := growth.BuildDeterministicReport(growth.ReportInput{History: history, Catalog: catalog, ForecastStart: forecastStart, HorizonDays: 90, Category: category, Peer: &peer})
events := make(map[string]growth.Event, len(catalog))
for _, event := range catalog {
events[event.ID] = event
}
evidence := Evidence{Forecast: deterministic.Forecast, Opportunities: deterministic.Opportunities, Risks: deterministic.Risks, FallbackBriefs: deterministic.FallbackBriefs}
for _, effect := range deterministic.HistoricalWins {
event := events[effect.EventID]
evidence.HistoricalWins = append(evidence.HistoricalWins, HistoricalWin{EventEffect: effect, EventTitleFA: event.TitleFA, StartDate: event.StartDate, EndDate: event.EndDate})
}
if deterministic.Peer != nil {
evidence.Peer = &PeerEvidence{State: deterministic.Peer.State, Reason: deterministic.Peer.Reason, EligiblePeerCount: deterministic.Peer.EligiblePeerCount, CombinedPeerSessionCount: deterministic.Peer.CombinedPeerSessions, MedianPaidSessions: deterministic.Peer.MedianPaidSessions, PercentileBPS: deterministic.Peer.PercentileBPS}
}
encoded, err := json.Marshal(evidence)
if err != nil {
return Analysis{}, fmt.Errorf("encoding deterministic growth evidence: %w", err)
}
known := map[string]struct{}{}
for _, win := range evidence.HistoricalWins {
known[win.EvidenceID] = struct{}{}
}
for _, opportunity := range evidence.Opportunities {
known[opportunity.EvidenceID] = struct{}{}
}
for _, risk := range evidence.Risks {
known[risk.EvidenceID] = struct{}{}
}
return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: convertFallback(deterministic.FallbackBriefs), ShouldCallAI: deterministic.Forecast.Confidence != growth.ConfidenceInsufficientData && (len(deterministic.Opportunities) > 0 || len(deterministic.Risks) > 0)}, nil
}
func (b *PostgreSQLBuilder) loadPeer(ctx context.Context, merchantID int64, terminalID string) (growth.PeerInput, error) {
if terminalID != "" {
return growth.PeerInput{TerminalScoped: true}, nil
}
var own int64
if err := b.pool.QueryRow(ctx, `SELECT count(*) FROM payment_sessions WHERE merchant_id=$1 AND status IN ('Paid','Verified')`, merchantID).Scan(&own); err != nil {
return growth.PeerInput{}, fmt.Errorf("loading merchant peer value: %w", err)
}
rows, err := b.pool.Query(ctx, `SELECT ps.merchant_id,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.merchant_id<>$1 GROUP BY ps.merchant_id`, merchantID)
if err != nil {
return growth.PeerInput{}, fmt.Errorf("loading peer values: %w", err)
}
defer rows.Close()
input := growth.PeerInput{MerchantPaidSessions: own}
for rows.Next() {
var peer growth.PeerMerchantValue
var id int64
if err := rows.Scan(&id, &peer.PaidSessions, &peer.SessionCount); err != nil {
return growth.PeerInput{}, fmt.Errorf("scanning peer value: %w", err)
}
input.Peers = append(input.Peers, peer)
}
if err := rows.Err(); err != nil {
return growth.PeerInput{}, fmt.Errorf("iterating peer values: %w", err)
}
return input, nil
}
func convertFallback(items []growth.CampaignBrief) AICopy {
result := AICopy{Briefs: make([]CampaignBrief, 0, len(items))}
for _, item := range items {
result.Briefs = append(result.Briefs, CampaignBrief{Headline: item.HeadlineFA, Interpretation: item.GuidanceFA, OfferSuggestion: item.OfferFA, MessageSuggestion: item.MessageFA, PreparationAction: item.PreparationFA, Limitation: item.LimitationFA, EvidenceIDs: item.EvidenceIDs})
}
return result
}
type EvidenceTools struct{ analysis Analysis }
func NewEvidenceTools(analysis Analysis) EvidenceTools { return EvidenceTools{analysis: analysis} }
func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.RawMessage) (json.RawMessage, error) {
var args struct {
EvidenceID string `json:"evidenceId"`
}
if err := json.Unmarshal(arguments, &args); err != nil {
return nil, fmt.Errorf("invalid tool arguments: %w", err)
}
if len(args.EvidenceID) > 128 {
return nil, fmt.Errorf("growth tool: invalid evidence id")
}
switch name {
case "get_growth_context":
return json.Marshal(map[string]any{"confidence": t.analysis.Evidence.Forecast.Confidence, "opportunityEvidenceIds": evidenceIDs(t.analysis.Evidence.Opportunities), "riskEvidenceIds": riskEvidenceIDs(t.analysis.Evidence.Risks)})
case "get_peer_context":
if t.analysis.Evidence.Peer == nil {
return []byte(`{"state":"SUPPRESSED"}`), nil
}
return json.Marshal(t.analysis.Evidence.Peer)
case "get_event_evidence":
for _, item := range t.analysis.Evidence.HistoricalWins {
if item.EvidenceID == args.EvidenceID {
return json.Marshal(item)
}
}
for _, item := range t.analysis.Evidence.Opportunities {
if item.EvidenceID == args.EvidenceID {
return json.Marshal(item)
}
}
case "get_campaign_playbook":
for _, item := range t.analysis.Fallback.Briefs {
for _, id := range item.EvidenceIDs {
if id == args.EvidenceID {
return json.Marshal(item)
}
}
}
}
return nil, errorsNewUnknownEvidence()
}
func evidenceIDs(items []growth.Opportunity) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.EvidenceID)
}
return result
}
func riskEvidenceIDs(items []growth.Risk) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.EvidenceID)
}
return result
}
func errorsNewUnknownEvidence() error { return fmt.Errorf("growth tool: unknown evidence") }

View File

@@ -0,0 +1,372 @@
package growthreport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
"unicode"
)
const maxAvalAIResponseBytes = 1 << 20
type AvalAIConfig struct {
BaseURL string
APIKey string
Model string
Timeout time.Duration
}
type AvalAIClient struct {
config AvalAIConfig
client *http.Client
}
type GrowthAnalyticsTools interface {
Execute(ctx context.Context, name string, arguments json.RawMessage) (json.RawMessage, error)
}
func NewAvalAIClient(config AvalAIConfig, client *http.Client) *AvalAIClient {
if config.BaseURL == "" {
config.BaseURL = "https://api.avalai.ir"
}
if config.Timeout <= 0 || config.Timeout > 30*time.Second {
config.Timeout = 30 * time.Second
}
if client == nil {
client = &http.Client{}
}
return &AvalAIClient{config: config, client: client}
}
func (c *AvalAIClient) Generate(ctx context.Context, evidence json.RawMessage, known map[string]struct{}) (AICopy, Usage, error) {
return c.GenerateWithTools(ctx, evidence, known, nil)
}
func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, error) {
if c.config.APIKey == "" || c.config.Model == "" {
return AICopy{}, Usage{}, errors.New("avalai: disabled")
}
base := map[string]any{
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
"max_output_tokens": 1600, "max_tool_calls": 4,
"instructions": "شما تحلیل‌گر رشد فارسی هستید. فقط بر اساس شواهد ارائه‌شده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازه‌ای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
"tools": growthTools(),
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "growth_copy", "strict": true, "schema": aiCopySchema()}},
}
var last error
var total Usage
for attempt := 0; attempt < 2; attempt++ {
copy, usage, transient, err := c.workflow(ctx, base, evidence, known, executor)
total = mergeUsage(total, usage)
if err == nil {
return copy, total, nil
}
last = err
if !transient || ctx.Err() != nil {
break
}
}
return AICopy{}, total, last
}
type responseEnvelope struct {
Status string `json:"status"`
Output []json.RawMessage `json:"output"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
EstimatedCost json.RawMessage `json:"estimated_cost"`
ProviderRequestID string `json:"-"`
}
type outputItem struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Refusal string `json:"refusal"`
} `json:"content"`
}
func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, bool, error) {
firstBody := cloneRequest(base)
firstBody["input"] = string(evidence)
first, usage, transient, err := c.do(ctx, firstBody)
if err != nil {
return AICopy{}, usage, transient, err
}
calls := make([]outputItem, 0, 4)
for _, raw := range first.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
calls = append(calls, item)
}
}
final := first
if len(calls) > 0 {
if executor == nil {
return AICopy{}, usage, false, errors.New("avalai: unexpected tool call")
}
if len(calls) > 4 {
return AICopy{}, usage, false, errors.New("avalai: tool call limit exceeded")
}
input := make([]any, 0, len(first.Output)+len(calls)+1)
input = append(input, map[string]any{"role": "user", "content": string(evidence)})
for _, raw := range first.Output {
var value any
if json.Unmarshal(raw, &value) == nil {
input = append(input, value)
}
}
for _, call := range calls {
if !allowedTool(call.Name) {
return AICopy{}, usage, false, errors.New("avalai: unknown tool")
}
result, err := executor.Execute(ctx, call.Name, call.Arguments)
if err != nil {
return AICopy{}, usage, false, fmt.Errorf("executing growth tool: %w", err)
}
input = append(input, map[string]any{"type": "function_call_output", "call_id": call.CallID, "output": string(result)})
}
secondBody := cloneRequest(base)
secondBody["input"] = input
second, secondUsage, secondTransient, err := c.do(ctx, secondBody)
usage = mergeUsage(usage, secondUsage)
if err != nil {
return AICopy{}, usage, secondTransient, err
}
final = second
for _, raw := range second.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
return AICopy{}, usage, false, errors.New("avalai: model exceeded two turns")
}
}
}
copy, err := parseAICopy(final, known)
if err != nil {
return AICopy{}, usage, false, err
}
return copy, usage, false, nil
}
func (c *AvalAIClient) do(parent context.Context, bodyValue map[string]any) (responseEnvelope, Usage, bool, error) {
body, err := json.Marshal(bodyValue)
if err != nil {
return responseEnvelope{}, Usage{}, false, fmt.Errorf("encoding AvalAI request: %w", err)
}
ctx, cancel := context.WithTimeout(parent, c.config.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.config.BaseURL, "/")+"/v1/responses", bytes.NewReader(body))
if err != nil {
return responseEnvelope{}, Usage{}, false, fmt.Errorf("creating AvalAI request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
req.Header.Set("Content-Type", "application/json")
started := time.Now()
resp, err := c.client.Do(req)
if err != nil {
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds()}, true, fmt.Errorf("calling AvalAI: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAvalAIResponseBytes+1))
if err != nil {
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, true, fmt.Errorf("reading AvalAI response: %w", err)
}
if len(responseBody) > maxAvalAIResponseBytes {
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, false, errors.New("avalai: response too large")
}
var result responseEnvelope
decodeErr := json.Unmarshal(responseBody, &result)
result.ProviderRequestID = resp.Header.Get("X-Request-ID")
usage := Usage{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, LatencyMS: time.Since(started).Milliseconds(), ProviderRequests: 1, ProviderRequestID: result.ProviderRequestID, EstimatedCost: costString(result.EstimatedCost)}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return result, usage, resp.StatusCode == 429 || resp.StatusCode >= 500, fmt.Errorf("avalai: HTTP %d", resp.StatusCode)
}
if decodeErr != nil {
return responseEnvelope{}, usage, false, fmt.Errorf("decoding AvalAI response: %w", decodeErr)
}
if result.Status != "completed" {
return result, usage, false, fmt.Errorf("avalai: incomplete response status %q", result.Status)
}
return result, usage, false, nil
}
func mergeUsage(total, next Usage) Usage {
total.InputTokens += next.InputTokens
total.OutputTokens += next.OutputTokens
total.LatencyMS += next.LatencyMS
total.ProviderRequests += next.ProviderRequests
total.ReservedTokens += next.ReservedTokens
if next.ProviderRequestID != "" {
total.ProviderRequestID = next.ProviderRequestID
}
total.EstimatedCost = addDecimalStrings(total.EstimatedCost, next.EstimatedCost)
return total
}
func addDecimalStrings(left, right string) string {
if left == "" {
return right
}
if right == "" {
return left
}
a, okA := new(big.Rat).SetString(left)
b, okB := new(big.Rat).SetString(right)
if !okA || !okB {
return left
}
text := new(big.Rat).Add(a, b).FloatString(12)
text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
return text
}
func costString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var text string
if json.Unmarshal(raw, &text) == nil {
if len(text) <= 64 {
return text
}
return ""
}
text = strings.TrimSpace(string(raw))
if len(text) > 64 {
return ""
}
for _, r := range text {
if !(unicode.IsDigit(r) || r == '.' || r == '-' || r == 'e' || r == 'E') {
return ""
}
}
return text
}
func parseAICopy(result responseEnvelope, known map[string]struct{}) (AICopy, error) {
var text string
for _, raw := range result.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "message" {
for _, content := range item.Content {
if content.Refusal != "" {
return AICopy{}, errors.New("avalai: refused")
}
if content.Type == "output_text" {
text += content.Text
}
}
}
}
var copy AICopy
if text == "" || json.Unmarshal([]byte(text), &copy) != nil {
return AICopy{}, errors.New("avalai: invalid structured output")
}
if err := ValidateAICopy(copy, known); err != nil {
return AICopy{}, err
}
return copy, nil
}
func cloneRequest(base map[string]any) map[string]any {
result := make(map[string]any, len(base)+1)
for key, value := range base {
result[key] = value
}
return result
}
func allowedTool(name string) bool {
switch name {
case "get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook":
return true
}
return false
}
func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
if len(copy.Briefs) == 0 || len(copy.Briefs) > 5 {
return errors.New("avalai: invalid brief count")
}
for _, brief := range copy.Briefs {
fields := []string{brief.Headline, brief.Interpretation, brief.OfferSuggestion, brief.MessageSuggestion, brief.PreparationAction, brief.Limitation}
for _, field := range fields {
if len([]rune(field)) > 600 {
return errors.New("avalai: field too long")
}
for _, r := range field {
if unicode.IsDigit(r) {
return errors.New("avalai: numeric claim rejected")
}
}
if containsQuantityWord(field) {
return errors.New("avalai: written numeric claim rejected")
}
lower := strings.ToLower(field)
for _, forbidden := range []string{"payer", "card", "رمز", "شماره کارت", "sql", "merchant_id"} {
if strings.Contains(lower, forbidden) {
return errors.New("avalai: unsafe content rejected")
}
}
}
if brief.Headline == "" || len(brief.EvidenceIDs) == 0 {
return errors.New("avalai: ungrounded brief")
}
for _, id := range brief.EvidenceIDs {
if _, ok := known[id]; !ok {
return errors.New("avalai: unknown evidence reference")
}
}
}
return nil
}
func containsQuantityWord(value string) bool {
quantityWords := map[string]struct{}{
"zero": {}, "one": {}, "two": {}, "three": {}, "four": {}, "five": {}, "six": {}, "seven": {}, "eight": {}, "nine": {}, "ten": {}, "eleven": {}, "twelve": {}, "thirteen": {}, "fourteen": {}, "fifteen": {}, "sixteen": {}, "seventeen": {}, "eighteen": {}, "nineteen": {}, "twenty": {}, "thirty": {}, "forty": {}, "fifty": {}, "sixty": {}, "seventy": {}, "eighty": {}, "ninety": {}, "hundred": {}, "thousand": {}, "million": {}, "billion": {}, "percent": {}, "percentage": {}, "double": {}, "triple": {}, "half": {},
"صفر": {}, "یک": {}, "دو": {}, "سه": {}, "چهار": {}, "پنج": {}, "شش": {}, "هفت": {}, "هشت": {}, "نه": {}, "ده": {}, "یازده": {}, "دوازده": {}, "سیزده": {}, "چهارده": {}, "پانزده": {}, "شانزده": {}, "هفده": {}, "هجده": {}, "نوزده": {}, "بیست": {}, "سی": {}, "چهل": {}, "پنجاه": {}, "شصت": {}, "هفتاد": {}, "هشتاد": {}, "نود": {}, "صد": {}, "هزار": {}, "میلیون": {}, "میلیارد": {}, "درصد": {}, "برابر": {}, "نصف": {},
}
for _, word := range strings.Fields(strings.ToLower(value)) {
word = strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\"'")
if _, found := quantityWords[word]; found {
return true
}
}
return false
}
func growthTools() []map[string]any {
names := []string{"get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook"}
tools := make([]map[string]any, 0, len(names))
for _, name := range names {
properties := map[string]any{}
required := []string{}
if name == "get_event_evidence" || name == "get_campaign_playbook" {
properties["evidenceId"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 128}
required = []string{"evidenceId"}
}
tools = append(tools, map[string]any{"type": "function", "name": name, "description": "Return approved sanitized deterministic evidence for this authenticated report scope.", "strict": true, "parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}})
}
return tools
}
func aiCopySchema() map[string]any {
briefProperties := map[string]any{}
for _, name := range []string{"headline", "interpretation", "offerSuggestion", "messageSuggestion", "preparationAction", "limitation"} {
briefProperties[name] = map[string]any{"type": "string"}
}
briefProperties["evidenceIds"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1}
return map[string]any{"type": "object", "properties": map[string]any{"briefs": map[string]any{"type": "array", "maxItems": 5, "items": map[string]any{"type": "object", "properties": briefProperties, "required": []string{"headline", "interpretation", "offerSuggestion", "messageSuggestion", "preparationAction", "limitation", "evidenceIds"}, "additionalProperties": false}}}, "required": []string{"briefs"}, "additionalProperties": false}
}

View File

@@ -0,0 +1,131 @@
package growthreport
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestAvalAICountsFailedRetryRequestsAndUsage(t *testing.T) {
t.Parallel()
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.Header().Set("X-Request-ID", fmt.Sprintf("request-%d", requests))
if requests == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"usage":{"input_tokens":7,"output_tokens":3},"estimated_cost":"0.01"}`))
return
}
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسب\",\"interpretation\":\"هم‌راستا با شواهد است.\",\"offerSuggestion\":\"پیشنهاد آماده کنید.\",\"messageSuggestion\":\"پیام روشن ارسال کنید.\",\"preparationAction\":\"مسیر پرداخت را بررسی کنید.\",\"limitation\":\"نتیجه قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":11,"output_tokens":5},"estimated_cost":"0.02"}`))
}))
defer server.Close()
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test", Model: "test", Timeout: time.Second}, server.Client())
_, usage, err := client.Generate(context.Background(), json.RawMessage(`{"evidence":"event_teacher"}`), map[string]struct{}{"event_teacher": {}})
if err != nil {
t.Fatal(err)
}
if requests != 2 || usage.ProviderRequests != 2 || usage.InputTokens != 18 || usage.OutputTokens != 8 || usage.EstimatedCost != "0.03" {
t.Fatalf("requests=%d usage=%+v", requests, usage)
}
}
func TestValidateAICopyRejectsNumbersAndUnknownEvidence(t *testing.T) {
t.Parallel()
tests := []struct {
name string
copy AICopy
want bool
}{
{name: "grounded Persian copy", copy: AICopy{Briefs: []CampaignBrief{{Headline: "فرصت مناسبی پیش رو دارید", Interpretation: "این مناسبت با الگوی موفق گذشته هم‌راستا است.", EvidenceIDs: []string{"event_teacher"}}}}, want: true},
{name: "numeric claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "رشد ۲۰ درصدی", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
{name: "Persian number-word claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "رشد بیست درصدی", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
{name: "English number-word claim", copy: AICopy{Briefs: []CampaignBrief{{Headline: "growth by twenty percent", EvidenceIDs: []string{"event_teacher"}}}}, want: false},
{name: "unknown evidence", copy: AICopy{Briefs: []CampaignBrief{{Headline: "فرصت مناسب", EvidenceIDs: []string{"unknown"}}}}, want: false},
}
known := map[string]struct{}{"event_teacher": {}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAICopy(tt.copy, known)
if (err == nil) != tt.want {
t.Fatalf("ValidateAICopy() error = %v, valid want %v", err, tt.want)
}
})
}
}
func TestAvalAIClientUsesBoundedStatelessRequest(t *testing.T) {
t.Parallel()
var request map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("authorization header missing")
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسبی پیش رو دارید\",\"interpretation\":\"با الگوی موفق گذشته هم‌راستا است.\",\"offerSuggestion\":\"پیشنهاد مرتبط آماده کنید.\",\"messageSuggestion\":\"پیام کوتاه و روشن ارسال کنید.\",\"preparationAction\":\"ظرفیت پرداخت را بررسی کنید.\",\"limitation\":\"این پیشنهاد قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":10,"output_tokens":20}}`))
}))
defer server.Close()
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "test-key", Model: "test-model", Timeout: time.Second}, server.Client())
copy, usage, err := client.Generate(context.Background(), json.RawMessage(`{"evidenceIds":["event_teacher"]}`), map[string]struct{}{"event_teacher": {}})
if err != nil {
t.Fatal(err)
}
if len(copy.Briefs) != 1 || usage.InputTokens != 10 || usage.OutputTokens != 20 {
t.Fatalf("Generate() = %+v, %+v", copy, usage)
}
if request["store"] != false || request["parallel_tool_calls"] != false {
t.Fatalf("unsafe request controls: %#v", request)
}
if request["max_output_tokens"] != float64(1600) {
t.Fatalf("max_output_tokens = %#v", request["max_output_tokens"])
}
tools, ok := request["tools"].([]any)
if !ok || len(tools) != 4 {
t.Fatalf("tools = %#v", request["tools"])
}
encoded, _ := json.Marshal(request)
if strings.Contains(string(encoded), "merchantId") || strings.Contains(string(encoded), "payer") {
t.Fatalf("sensitive field in request: %s", encoded)
}
}
type testTools struct{ calls int }
func (t *testTools) Execute(_ context.Context, name string, _ json.RawMessage) (json.RawMessage, error) {
t.calls++
return json.RawMessage(`{"state":"READY"}`), nil
}
func TestAvalAIClientExecutesAllowlistedToolThenStopsAfterSecondTurn(t *testing.T) {
t.Parallel()
turn := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
turn++
w.Header().Set("Content-Type", "application/json")
if turn == 1 {
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"function_call","call_id":"call_1","name":"get_growth_context","arguments":"{\"evidenceId\":\"event_teacher\"}"}],"usage":{"input_tokens":2,"output_tokens":1}}`))
return
}
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"briefs\":[{\"headline\":\"فرصت مناسب\",\"interpretation\":\"هم‌راستا با شواهد است.\",\"offerSuggestion\":\"پیشنهاد مرتبط آماده کنید.\",\"messageSuggestion\":\"پیام روشن ارسال کنید.\",\"preparationAction\":\"مسیر پرداخت را بررسی کنید.\",\"limitation\":\"نتیجه قطعی نیست.\",\"evidenceIds\":[\"event_teacher\"]}]}"}]}],"usage":{"input_tokens":3,"output_tokens":4}}`))
}))
defer server.Close()
executor := &testTools{}
client := NewAvalAIClient(AvalAIConfig{BaseURL: server.URL, APIKey: "key", Model: "model", Timeout: time.Second}, server.Client())
_, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{}`), map[string]struct{}{"event_teacher": {}}, executor)
if err != nil {
t.Fatal(err)
}
if executor.calls != 1 || turn != 2 || usage.InputTokens != 5 || usage.OutputTokens != 5 {
t.Fatalf("calls=%d turns=%d usage=%+v", executor.calls, turn, usage)
}
}

View File

@@ -0,0 +1,128 @@
package growthreport
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
var ErrRateLimited = errors.New("growth report: rate limited")
var ErrBudgetExhausted = errors.New("growth report: budget exhausted")
type CoordinatorConfig struct {
CreatePerMinute, UncachedPerDay, DailyRequests, DailyTokens int64
MaxConcurrent int
}
type Coordinator struct {
redis *redis.Client
config CoordinatorConfig
mu sync.Mutex
counters map[string]int64
expiries map[string]time.Time
}
func NewCoordinator(client *redis.Client, config CoordinatorConfig) *Coordinator {
if config.MaxConcurrent < 1 {
config.MaxConcurrent = 4
}
return &Coordinator{redis: client, config: config, counters: map[string]int64{}, expiries: map[string]time.Time{}}
}
func (c *Coordinator) AllowCreate(ctx context.Context, userID int64, now time.Time) error {
return c.increment(ctx, fmt.Sprintf("growth:create:%d:%s", userID, now.UTC().Format("200601021504")), c.config.CreatePerMinute, time.Minute, now)
}
func (c *Coordinator) AllowUncached(ctx context.Context, merchantID int64, now time.Time) error {
return c.increment(ctx, fmt.Sprintf("growth:scope:%d:%s", merchantID, tehranDate(now)), c.config.UncachedPerDay, 36*time.Hour, now)
}
func (c *Coordinator) ReserveBudget(ctx context.Context, requests, tokens int64, now time.Time) error {
if c.config.DailyRequests < 1 || c.config.DailyTokens < 1 {
return ErrBudgetExhausted
}
if c.redis == nil || requests < 1 || tokens < 1 || requests > c.config.DailyRequests || tokens > c.config.DailyTokens {
return ErrBudgetExhausted
}
script := redis.NewScript(`local req=tonumber(redis.call('GET',KEYS[1]) or '0');local tok=tonumber(redis.call('GET',KEYS[2]) or '0');local addReq=tonumber(ARGV[1]);local addTok=tonumber(ARGV[2]);if req+addReq>tonumber(ARGV[4]) or tok+addTok>tonumber(ARGV[5]) then return 0 end;redis.call('INCRBY',KEYS[1],addReq);redis.call('INCRBY',KEYS[2],addTok);redis.call('PEXPIRE',KEYS[1],ARGV[3]);redis.call('PEXPIRE',KEYS[2],ARGV[3]);return 1`)
date := tehranDate(now)
allowed, err := script.Run(ctx, c.redis, []string{"growth:budget:req:" + date, "growth:budget:tok:" + date}, requests, tokens, (36 * time.Hour).Milliseconds(), c.config.DailyRequests, c.config.DailyTokens).Int()
if err != nil || allowed != 1 {
return ErrBudgetExhausted
}
return nil
}
func (c *Coordinator) incrementRedis(ctx context.Context, key string, amount, limit int64, ttl time.Duration) error {
script := redis.NewScript(`local v=redis.call('INCRBY',KEYS[1],ARGV[1]);if v==tonumber(ARGV[1]) then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[3]) then return 0 end;return 1`)
value, err := script.Run(ctx, c.redis, []string{key}, amount, ttl.Milliseconds(), limit).Int()
if err != nil {
return err
}
if value == 0 {
return ErrRateLimited
}
return nil
}
func (c *Coordinator) Enter(ctx context.Context) (func(), error) {
if c.redis == nil {
return nil, ErrBudgetExhausted
}
acquire := redis.NewScript(`local v=redis.call('INCR',KEYS[1]);if v==1 then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[1]) then redis.call('DECR',KEYS[1]);return 0 end;return 1`)
for {
ok, err := acquire.Run(ctx, c.redis, []string{"growth:ai:concurrency"}, c.config.MaxConcurrent, (3 * time.Minute).Milliseconds()).Int()
if err != nil {
return nil, ErrBudgetExhausted
}
if ok == 1 {
return func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
script := redis.NewScript(`local v=tonumber(redis.call('GET',KEYS[1]) or '0');if v>0 then return redis.call('DECR',KEYS[1]) end return 0`)
_ = script.Run(releaseCtx, c.redis, []string{"growth:ai:concurrency"}).Err()
}, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func (c *Coordinator) increment(ctx context.Context, key string, limit int64, ttl time.Duration, now time.Time) error {
return c.incrementBy(ctx, key, 1, limit, ttl, now)
}
func (c *Coordinator) incrementBy(ctx context.Context, key string, amount, limit int64, ttl time.Duration, now time.Time) error {
if limit < 1 {
return ErrRateLimited
}
if c.redis != nil {
script := redis.NewScript(`local v=redis.call('INCRBY',KEYS[1],ARGV[1]);if v==tonumber(ARGV[1]) then redis.call('PEXPIRE',KEYS[1],ARGV[2]) end;if v>tonumber(ARGV[3]) then return 0 end;return 1`)
value, err := script.Run(ctx, c.redis, []string{key}, amount, ttl.Milliseconds(), limit).Int()
if err == nil {
if value == 0 {
return ErrRateLimited
}
return nil
}
}
c.mu.Lock()
defer c.mu.Unlock()
if expiry := c.expiries[key]; !expiry.After(now) {
c.counters[key] = 0
c.expiries[key] = now.Add(ttl)
}
next := c.counters[key] + amount
if next > limit {
return ErrRateLimited
}
c.counters[key] = next
return nil
}
func tehranDate(now time.Time) string {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
location = time.FixedZone("Asia/Tehran", 12600)
}
return now.In(location).Format(time.DateOnly)
}

View File

@@ -0,0 +1,26 @@
package growthreport
import (
"context"
"errors"
"testing"
"time"
)
func TestCoordinatorFallsBackToBoundedProcessCounters(t *testing.T) {
t.Parallel()
coordinator := NewCoordinator(nil, CoordinatorConfig{CreatePerMinute: 2, UncachedPerDay: 1, DailyRequests: 1, DailyTokens: 1600, MaxConcurrent: 1})
now := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
if err := coordinator.AllowCreate(context.Background(), 7, now); err != nil {
t.Fatal(err)
}
if err := coordinator.AllowCreate(context.Background(), 7, now); err != nil {
t.Fatal(err)
}
if err := coordinator.AllowCreate(context.Background(), 7, now); !errors.Is(err, ErrRateLimited) {
t.Fatalf("third request error=%v", err)
}
if err := coordinator.ReserveBudget(context.Background(), MaxProviderRequests, 1600, now); !errors.Is(err, ErrBudgetExhausted) {
t.Fatalf("budget without Redis error=%v", err)
}
}

View File

@@ -0,0 +1,95 @@
package growthreport
import (
"encoding/json"
"fmt"
"time"
)
type Status string
const (
StatusQueued Status = "QUEUED"
StatusAnalyzing Status = "ANALYZING"
StatusGenerating Status = "GENERATING"
StatusReady Status = "READY"
StatusPartial Status = "PARTIAL"
StatusFailed Status = "FAILED"
)
func (s Status) Terminal() bool { return s == StatusReady || s == StatusPartial || s == StatusFailed }
type Scope struct {
TerminalID *string `json:"terminalId"`
}
type CampaignBrief struct {
Headline string `json:"headline"`
Interpretation string `json:"interpretation"`
OfferSuggestion string `json:"offerSuggestion"`
MessageSuggestion string `json:"messageSuggestion"`
PreparationAction string `json:"preparationAction"`
Limitation string `json:"limitation"`
EvidenceIDs []string `json:"evidenceIds"`
}
type AICopy struct {
Briefs []CampaignBrief `json:"briefs"`
}
type Usage struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
LatencyMS int64 `json:"latencyMs"`
ProviderRequests int64 `json:"providerRequests"`
ReservedTokens int64 `json:"reservedTokens"`
ProviderRequestID string `json:"providerRequestId,omitempty"`
EstimatedCost string `json:"estimatedCost,omitempty"`
}
type Report struct {
ReportID string `json:"reportId"`
Revision int `json:"revision"`
Status Status `json:"status"`
Scope Scope `json:"scope"`
HorizonDays int `json:"horizonDays"`
ForecastStartDate *time.Time `json:"forecastStartDate,omitempty"`
DeterministicEvidence json.RawMessage `json:"deterministicEvidence,omitempty"`
AICopy *AICopy `json:"aiCopy,omitempty"`
FailureCode *string `json:"failureCode,omitempty"`
Cached bool `json:"cached"`
Stale bool `json:"stale"`
NextRefreshAt time.Time `json:"nextRefreshAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
EventsURL string `json:"eventsUrl"`
}
type Record struct {
Report
MerchantID int64
TerminalDBID *int64
Fingerprint string
DatasetWatermark string
CatalogVersion string
AnalyticsVersion string
PromptVersion string
SchemaVersion string
Model string
Usage Usage
ManualRefresh bool
TehranRefreshDay *time.Time
ClaimToken string
}
type CreateRequest struct {
TerminalID string `json:"terminalId"`
Refresh bool `json:"refresh"`
}
type RefreshLimitError struct{ NextRefreshAt time.Time }
func (e *RefreshLimitError) Error() string {
return fmt.Sprintf("%s: next refresh at %s", ErrRefreshLimited, e.NextRefreshAt.Format(time.RFC3339))
}
func (e *RefreshLimitError) Unwrap() error { return ErrRefreshLimited }

View File

@@ -0,0 +1,180 @@
package growthreport
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
)
var ErrDisabled = errors.New("growth report: disabled")
const MaxProviderRequests int64 = 4
type ServiceConfig struct {
Enabled bool
Model, PromptVersion, SchemaVersion string
}
type Service struct {
repository *Repository
coordinator *Coordinator
config ServiceConfig
now func() time.Time
}
func NewService(repository *Repository, coordinator *Coordinator, config ServiceConfig) *Service {
return &Service{repository: repository, coordinator: coordinator, config: config, now: time.Now}
}
func (s *Service) Create(ctx context.Context, userID, merchantID int64, request CreateRequest, idempotencyKey string) (Record, bool, error) {
if !s.config.Enabled {
return Record{}, false, ErrDisabled
}
now := s.now().UTC()
if err := s.coordinator.AllowCreate(ctx, userID, now); err != nil {
return Record{}, false, err
}
watermark, start, err := s.repository.DatasetMetadata(ctx)
if err != nil {
return Record{}, false, err
}
fingerprint := s.fingerprint(merchantID, request.TerminalID, watermark, start)
var refreshDay *time.Time
if request.Refresh {
day := tehranMidnight(now)
refreshDay = &day
}
record, cached, err := s.repository.CreateOrReuse(ctx, CreateRecord{ID: newReportID(), MerchantID: merchantID, TerminalID: request.TerminalID, Fingerprint: fingerprint, DatasetWatermark: watermark, ForecastStartDate: &start, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ManualRefresh: request.Refresh, RefreshDay: refreshDay, IdempotencyKey: idempotencyKey, RequestHash: RequestHash(merchantID, request.TerminalID, request.Refresh)}, func() error {
return s.coordinator.AllowUncached(ctx, merchantID, now)
})
if err != nil {
if errors.Is(err, ErrRefreshLimited) {
next, quotaErr := s.repository.NextRefreshAt(ctx, merchantID, now)
if quotaErr != nil {
return Record{}, false, quotaErr
}
return Record{}, false, &RefreshLimitError{NextRefreshAt: next}
}
return Record{}, false, err
}
record.Cached = cached
record.NextRefreshAt, err = s.repository.NextRefreshAt(ctx, merchantID, now)
return record, cached, err
}
func (s *Service) Get(ctx context.Context, merchantID int64, id string) (Record, error) {
record, err := s.repository.Get(ctx, merchantID, id)
if err == nil {
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, s.now())
}
return record, err
}
func (s *Service) Latest(ctx context.Context, merchantID int64, terminalID string) (Record, error) {
record, err := s.repository.Latest(ctx, merchantID, terminalID)
if err == nil {
record.Cached = true
watermark, start, metadataErr := s.repository.DatasetMetadata(ctx)
if metadataErr != nil {
record.Stale = true
} else {
record.Stale = record.Fingerprint != s.fingerprint(merchantID, terminalID, watermark, start)
}
record.NextRefreshAt, _ = s.repository.NextRefreshAt(ctx, merchantID, s.now())
}
return record, err
}
func (s *Service) fingerprint(merchantID int64, terminalID, watermark string, start time.Time) string {
return growth.Fingerprint(growth.FingerprintInput{MerchantID: merchantID, TerminalID: terminalID, DatasetWatermark: watermark, ForecastStart: start, HorizonDays: 90, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ModelConfigVersion: growth.DefaultModelConfigVersion})
}
type Processor struct {
repository *Repository
builder Builder
avalai *AvalAIClient
coordinator *Coordinator
aiEnabled bool
now func() time.Time
}
func NewProcessor(repository *Repository, builder Builder, avalai *AvalAIClient, coordinator *Coordinator, aiEnabled bool) *Processor {
return &Processor{repository: repository, builder: builder, avalai: avalai, coordinator: coordinator, aiEnabled: aiEnabled, now: time.Now}
}
func (p *Processor) ProcessNext(ctx context.Context) error {
record, err := p.repository.Claim(ctx)
if err != nil {
return err
}
terminal := ""
if record.Scope.TerminalID != nil {
terminal = *record.Scope.TerminalID
}
analysis, err := p.builder.Build(ctx, record.MerchantID, terminal)
if err != nil {
_ = p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, StatusFailed, json.RawMessage(`{}`), nil, Usage{}, "ANALYTICS_FAILED")
return err
}
if err := p.repository.StoreEvidence(ctx, record.ReportID, record.ClaimToken, analysis.Encoded); err != nil {
return err
}
copy := analysis.Fallback
status := StatusPartial
failure := "AI_DISABLED_OR_UNAVAILABLE"
if analysis.ShouldCallAI && p.aiEnabled && p.avalai != nil {
reservedTokens := EstimatedTokenReservation(analysis.Encoded)
release, enterErr := p.coordinator.Enter(ctx)
if enterErr == nil {
defer release()
if err := p.coordinator.ReserveBudget(ctx, MaxProviderRequests, reservedTokens, p.now()); err == nil {
generated, usage, aiErr := p.avalai.GenerateWithTools(ctx, analysis.Encoded, analysis.KnownEvidence, NewEvidenceTools(analysis))
usage.ReservedTokens = reservedTokens
if aiErr == nil {
slog.Info("growth AI generation complete", "report_id", record.ReportID, "provider_requests", usage.ProviderRequests, "reserved_tokens", usage.ReservedTokens, "input_tokens", usage.InputTokens, "output_tokens", usage.OutputTokens, "latency_ms", usage.LatencyMS, "estimated_cost", usage.EstimatedCost, "provider_request_id", usage.ProviderRequestID)
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, StatusReady, analysis.Encoded, &generated, usage, "")
}
slog.Warn("growth AI output rejected", "report_id", record.ReportID, "reason", "AI_REJECTED", "provider_requests", usage.ProviderRequests, "reserved_tokens", usage.ReservedTokens, "input_tokens", usage.InputTokens, "output_tokens", usage.OutputTokens, "latency_ms", usage.LatencyMS, "estimated_cost", usage.EstimatedCost, "provider_request_id", usage.ProviderRequestID)
failure = "AI_REJECTED"
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, status, analysis.Encoded, &copy, usage, failure)
} else {
failure = "AI_BUDGET_EXHAUSTED"
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
}
} else {
failure = "AI_CONCURRENCY_UNAVAILABLE"
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
}
}
if !analysis.ShouldCallAI || !p.aiEnabled {
slog.Info("growth deterministic fallback used", "report_id", record.ReportID, "reason", failure)
}
return p.repository.CompleteClaimed(ctx, record.ReportID, record.ClaimToken, status, analysis.Encoded, &copy, Usage{}, failure)
}
// EstimatedTokenReservation reserves for two complete attempts, each with two
// provider turns, before any network call. Bytes are deliberately counted as
// tokens and the fixed allowance covers tool schemas, instructions, tool
// outputs, and every turn's 1,600-token output ceiling.
func EstimatedTokenReservation(evidence json.RawMessage) int64 {
return int64(12*len(evidence) + 32000)
}
func newReportID() string {
data := make([]byte, 12)
if _, err := rand.Read(data); err != nil {
return fmt.Sprintf("growth_%d", time.Now().UnixNano())
}
return "growth_" + hex.EncodeToString(data)
}
func tehranMidnight(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)
}

View File

@@ -0,0 +1,294 @@
package growthreport
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound = errors.New("growth report: not found")
ErrUnknownTerminal = errors.New("growth report: unknown terminal")
ErrIdempotencyConflict = errors.New("growth report: idempotency conflict")
ErrRefreshLimited = errors.New("growth report: refresh limited")
)
type Repository struct{ pool *pgxpool.Pool }
const reportLeaseDuration = 3 * time.Minute
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
type CreateRecord struct {
ID string
MerchantID int64
TerminalID string
Fingerprint string
DatasetWatermark string
ForecastStartDate *time.Time
CatalogVersion string
AnalyticsVersion string
PromptVersion string
SchemaVersion string
Model string
ManualRefresh bool
RefreshDay *time.Time
IdempotencyKey string
RequestHash string
}
func (r *Repository) CreateOrReuse(ctx context.Context, in CreateRecord, beforeInsert func() error) (Record, bool, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return Record{}, false, fmt.Errorf("beginning report transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
lockKey := fmt.Sprintf("growth-revision:%d:%s", in.MerchantID, in.TerminalID)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); err != nil {
return Record{}, false, fmt.Errorf("locking growth report request: %w", err)
}
var terminalDBID *int64
if in.TerminalID != "" {
var id int64
if err := tx.QueryRow(ctx, `SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2`, in.MerchantID, in.TerminalID).Scan(&id); errors.Is(err, pgx.ErrNoRows) {
return Record{}, false, ErrUnknownTerminal
} else if err != nil {
return Record{}, false, fmt.Errorf("validating report terminal: %w", err)
}
terminalDBID = &id
}
if in.IdempotencyKey != "" {
var requestHash, reportID string
err := tx.QueryRow(ctx, `SELECT request_hash,report_id FROM growth_report_idempotency WHERE merchant_id=$1 AND idempotency_key=$2`, in.MerchantID, in.IdempotencyKey).Scan(&requestHash, &reportID)
if err == nil {
if requestHash != in.RequestHash {
return Record{}, false, ErrIdempotencyConflict
}
record, err := getWith(ctx, tx, in.MerchantID, reportID)
if err != nil {
return Record{}, false, err
}
return record, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return Record{}, false, fmt.Errorf("reading report idempotency: %w", err)
}
}
if !in.ManualRefresh {
record, err := currentWith(ctx, tx, in.MerchantID, in.Fingerprint)
if err == nil {
return record, true, nil
}
if !errors.Is(err, ErrNotFound) {
return Record{}, false, err
}
}
if in.ManualRefresh && in.RefreshDay != nil {
var exists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM growth_reports WHERE merchant_id=$1 AND manual_refresh=true AND tehran_refresh_day=$2)`, in.MerchantID, in.RefreshDay).Scan(&exists); err != nil {
return Record{}, false, fmt.Errorf("checking daily refresh: %w", err)
}
if exists {
return Record{}, false, ErrRefreshLimited
}
}
if beforeInsert != nil {
if err := beforeInsert(); err != nil {
return Record{}, false, err
}
}
var revision int
if err := tx.QueryRow(ctx, `SELECT coalesce(max(revision),0)+1 FROM growth_reports WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text`, in.MerchantID, nilIfEmpty(in.TerminalID)).Scan(&revision); err != nil {
return Record{}, false, fmt.Errorf("calculating growth report revision: %w", err)
}
_, err = tx.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,terminal_id,terminal_source_key,fingerprint,revision,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,manual_refresh,tehran_refresh_day)
VALUES($1,$2,$3,$4,$5,$6,'QUEUED',$7,$8,$9,$10,$11,$12,$13,$14,$15)`, in.ID, in.MerchantID, terminalDBID, nilIfEmpty(in.TerminalID), in.Fingerprint, revision, in.ForecastStartDate, in.DatasetWatermark, in.CatalogVersion, in.AnalyticsVersion, in.PromptVersion, in.SchemaVersion, in.Model, in.ManualRefresh, in.RefreshDay)
if err != nil {
if in.ManualRefresh && isUniqueViolation(err) {
return Record{}, false, ErrRefreshLimited
}
return Record{}, false, fmt.Errorf("creating growth report: %w", err)
}
if in.IdempotencyKey != "" {
if _, err := tx.Exec(ctx, `INSERT INTO growth_report_idempotency(merchant_id,idempotency_key,request_hash,report_id) VALUES($1,$2,$3,$4)`, in.MerchantID, in.IdempotencyKey, in.RequestHash, in.ID); err != nil {
return Record{}, false, fmt.Errorf("creating report idempotency: %w", err)
}
}
record, err := getWith(ctx, tx, in.MerchantID, in.ID)
if err != nil {
return Record{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return Record{}, false, fmt.Errorf("committing growth report: %w", err)
}
return record, false, nil
}
func (r *Repository) Get(ctx context.Context, merchantID int64, id string) (Record, error) {
return getWith(ctx, r.pool, merchantID, id)
}
func (r *Repository) Latest(ctx context.Context, merchantID int64, terminalID string) (Record, error) {
return scanRecord(r.pool.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND status IN ('READY','PARTIAL') ORDER BY created_at DESC LIMIT 1`, merchantID, nilIfEmpty(terminalID)))
}
func (r *Repository) Claim(ctx context.Context) (Record, error) {
// PostgreSQL is the durable queue and coordination authority. SKIP LOCKED plus
// the state transition prevents two worker processes from generating one row.
tx, err := r.pool.Begin(ctx)
if err != nil {
return Record{}, fmt.Errorf("beginning report claim: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `UPDATE growth_reports SET status='QUEUED',lease_token=NULL,lease_expires_at=NULL,updated_at=now(),failure_code='WORKER_LEASE_EXPIRED' WHERE status IN ('ANALYZING','GENERATING') AND (lease_expires_at IS NULL OR lease_expires_at<now())`); err != nil {
return Record{}, fmt.Errorf("recovering expired report leases: %w", err)
}
row := tx.QueryRow(ctx, reportSelect+` WHERE id=(SELECT id FROM growth_reports WHERE status='QUEUED' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)`)
record, err := scanRecord(row)
if errors.Is(err, ErrNotFound) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, err
}
claimToken := newReportID()
leaseExpiresAt := time.Now().UTC().Add(reportLeaseDuration)
if _, err := tx.Exec(ctx, `UPDATE growth_reports SET status='ANALYZING',lease_token=$2,lease_expires_at=$3,updated_at=now() WHERE id=$1`, record.ReportID, claimToken, leaseExpiresAt); err != nil {
return Record{}, fmt.Errorf("claiming growth report: %w", err)
}
record.Status = StatusAnalyzing
record.ClaimToken = claimToken
record.UpdatedAt = time.Now().UTC()
if err := tx.Commit(ctx); err != nil {
return Record{}, fmt.Errorf("committing report claim: %w", err)
}
return record, nil
}
func (r *Repository) StoreEvidence(ctx context.Context, reportID, claimToken string, evidence json.RawMessage) error {
command, err := r.pool.Exec(ctx, `UPDATE growth_reports SET deterministic_evidence=$3,status='GENERATING',lease_expires_at=now()+make_interval(secs => $4),updated_at=now() WHERE id=$1 AND lease_token=$2 AND status='ANALYZING'`, reportID, claimToken, evidence, int(reportLeaseDuration/time.Second))
if err != nil {
return fmt.Errorf("storing deterministic evidence: %w", err)
}
if command.RowsAffected() != 1 {
return ErrNotFound
}
return nil
}
func (r *Repository) CompleteClaimed(ctx context.Context, reportID, claimToken string, status Status, evidence json.RawMessage, copy *AICopy, usage Usage, failureCode string) error {
if status != StatusReady && status != StatusPartial && status != StatusFailed {
return errors.New("growth report: invalid terminal status")
}
var ai any
if copy != nil {
ai = copy
}
command, err := r.pool.Exec(ctx, `UPDATE growth_reports SET status=$3,deterministic_evidence=$4,ai_response=$5,failure_code=$6,input_tokens=$7,output_tokens=$8,latency_ms=$9,provider_requests=$10,reserved_tokens=$11,provider_request_id=$12,estimated_cost=$13,lease_token=NULL,lease_expires_at=NULL,updated_at=now(),completed_at=now() WHERE id=$1 AND lease_token=$2`, reportID, claimToken, status, evidence, ai, nilIfEmpty(failureCode), usage.InputTokens, usage.OutputTokens, usage.LatencyMS, usage.ProviderRequests, usage.ReservedTokens, nilIfEmpty(usage.ProviderRequestID), nilIfEmpty(usage.EstimatedCost))
if err != nil {
return fmt.Errorf("completing growth report: %w", err)
}
if command.RowsAffected() != 1 {
return ErrNotFound
}
return nil
}
const reportSelect = `SELECT id,revision,merchant_id,terminal_id,terminal_source_key,fingerprint,status,horizon_days,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,deterministic_evidence,ai_response,failure_code,input_tokens,output_tokens,latency_ms,provider_requests,reserved_tokens,provider_request_id,estimated_cost,manual_refresh,tehran_refresh_day,created_at,updated_at FROM growth_reports`
type rowScanner interface{ Scan(...any) error }
type queryer interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func getWith(ctx context.Context, q queryer, merchantID int64, id string) (Record, error) {
return scanRecord(q.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND id=$2`, merchantID, id))
}
func currentWith(ctx context.Context, q queryer, merchantID int64, fingerprint string) (Record, error) {
return scanRecord(q.QueryRow(ctx, reportSelect+` WHERE merchant_id=$1 AND fingerprint=$2 AND manual_refresh=false AND status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL') ORDER BY created_at DESC LIMIT 1`, merchantID, fingerprint))
}
func scanRecord(row rowScanner) (Record, error) {
var r Record
var status string
var evidence []byte
var ai []byte
var terminalSource *string
var providerRequestID, estimatedCost *string
err := row.Scan(&r.ReportID, &r.Revision, &r.MerchantID, &r.TerminalDBID, &terminalSource, &r.Fingerprint, &status, &r.HorizonDays, &r.ForecastStartDate, &r.DatasetWatermark, &r.CatalogVersion, &r.AnalyticsVersion, &r.PromptVersion, &r.SchemaVersion, &r.Model, &evidence, &ai, &r.FailureCode, &r.Usage.InputTokens, &r.Usage.OutputTokens, &r.Usage.LatencyMS, &r.Usage.ProviderRequests, &r.Usage.ReservedTokens, &providerRequestID, &estimatedCost, &r.ManualRefresh, &r.TehranRefreshDay, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("scanning growth report: %w", err)
}
r.Status = Status(status)
if providerRequestID != nil {
r.Usage.ProviderRequestID = *providerRequestID
}
if estimatedCost != nil {
r.Usage.EstimatedCost = *estimatedCost
}
if terminalSource != nil {
r.Scope.TerminalID = terminalSource
}
r.DeterministicEvidence = json.RawMessage(evidence)
r.EventsURL = "/api/v1/analytics/growth-reports/" + r.ReportID + "/events"
if len(ai) > 0 {
var copy AICopy
if json.Unmarshal(ai, &copy) == nil {
r.AICopy = &copy
}
}
return r, nil
}
func nilIfEmpty(value string) any {
if value == "" {
return nil
}
return value
}
func isUniqueViolation(err error) bool {
type sqlState interface{ SQLState() string }
var state sqlState
return errors.As(err, &state) && state.SQLState() == "23505"
}
func (r *Repository) NextRefreshAt(ctx context.Context, merchantID int64, now time.Time) (time.Time, error) {
var last *time.Time
if err := r.pool.QueryRow(ctx, `SELECT max(created_at) FROM growth_reports WHERE merchant_id=$1 AND manual_refresh=true`, merchantID).Scan(&last); err != nil {
return time.Time{}, fmt.Errorf("loading refresh quota: %w", err)
}
if last == nil {
return now.UTC(), nil
}
return NextTehranRefresh(*last), nil
}
func (r *Repository) DatasetMetadata(ctx context.Context) (string, time.Time, error) {
var sha string
var latest time.Time
err := r.pool.QueryRow(ctx, `SELECT ir.source_sha256,max(ps.created_at AT TIME ZONE 'Asia/Tehran')::date
FROM payment_sessions ps JOIN import_runs ir ON ir.id=ps.import_run_id WHERE ir.status='COMPLETED'
GROUP BY ir.id,ir.source_sha256 ORDER BY ir.completed_at DESC LIMIT 1`).Scan(&sha, &latest)
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
}
func (r *Repository) CleanupSuperseded(ctx context.Context, cutoff time.Time) (int64, error) {
command, err := r.pool.Exec(ctx, `DELETE FROM growth_reports old WHERE old.created_at<$1 AND EXISTS(SELECT 1 FROM growth_reports newer WHERE newer.merchant_id=old.merchant_id AND newer.terminal_source_key IS NOT DISTINCT FROM old.terminal_source_key AND newer.id<>old.id AND newer.revision>old.revision AND newer.status IN ('READY','PARTIAL'))`, cutoff)
if err != nil {
return 0, fmt.Errorf("cleaning superseded growth reports: %w", err)
}
return command.RowsAffected(), nil
}

View File

@@ -0,0 +1,81 @@
package growthreport
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestRepositoryRecoversExpiredActiveLeases(t *testing.T) {
pool, merchantID := growthRepositoryTestPool(t)
ctx := context.Background()
now := time.Now().UTC()
for index, status := range []Status{StatusAnalyzing, StatusGenerating} {
id := fmt.Sprintf("growth_%024x", index+1)
_, err := pool.Exec(ctx, `INSERT INTO growth_reports(id,merchant_id,fingerprint,status,forecast_start_date,dataset_watermark,catalog_version,analytics_version,prompt_version,schema_version,model,lease_token,lease_expires_at,created_at,updated_at) VALUES($1,$2,$3,$4,current_date,'test','test','test','test','test','test',$5,$6,$7,$7)`, id, merchantID, id, status, "expired-token", now.Add(-time.Minute), now.Add(time.Duration(index)*time.Second))
if err != nil {
t.Fatal(err)
}
}
repository := NewRepository(pool)
first, err := repository.Claim(ctx)
if err != nil {
t.Fatal(err)
}
if first.Status != StatusAnalyzing || first.ClaimToken == "" || first.ClaimToken == "expired-token" {
t.Fatalf("recovered claim=%+v", first)
}
if err := repository.CompleteClaimed(ctx, first.ReportID, "expired-token", StatusPartial, json.RawMessage(`{}`), nil, Usage{}, "stale"); !errors.Is(err, ErrNotFound) {
t.Fatalf("stale completion error=%v", err)
}
second, err := repository.Claim(ctx)
if err != nil || second.ReportID == first.ReportID || second.ClaimToken == "" {
t.Fatalf("second claim=%+v error=%v", second, err)
}
}
func TestRepositoryChecksUncachedAllowanceBeforeInsert(t *testing.T) {
pool, merchantID := growthRepositoryTestPool(t)
repository := NewRepository(pool)
start := time.Now().UTC()
input := CreateRecord{ID: "growth_ffffffffffffffffffffffff", MerchantID: merchantID, Fingerprint: "blocked", DatasetWatermark: "test", ForecastStartDate: &start, CatalogVersion: "test", AnalyticsVersion: "test", PromptVersion: "test", SchemaVersion: "test", Model: "test"}
_, _, err := repository.CreateOrReuse(context.Background(), input, func() error { return ErrRateLimited })
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("CreateOrReuse error=%v", err)
}
var count int
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM growth_reports WHERE id=$1`, input.ID).Scan(&count); err != nil || count != 0 {
t.Fatalf("queued reports=%d error=%v", count, err)
}
}
func growthRepositoryTestPool(t *testing.T) (*pgxpool.Pool, int64) {
t.Helper()
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not configured")
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
t.Fatal(err)
}
sourceKey := fmt.Sprintf("growth-test-%d", time.Now().UnixNano())
var merchantID int64
if err := pool.QueryRow(context.Background(), `INSERT INTO merchants(source_key,category_id,category_title,display_name) VALUES($1,1,'test','test') RETURNING id`, sourceKey).Scan(&merchantID); err != nil {
pool.Close()
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM growth_reports WHERE merchant_id=$1`, merchantID)
_, _ = pool.Exec(context.Background(), `DELETE FROM merchants WHERE id=$1`, merchantID)
pool.Close()
})
return pool, merchantID
}

View File

@@ -0,0 +1,12 @@
package growthreport
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func RequestHash(merchantID int64, terminalID string, refresh bool) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%t", merchantID, terminalID, refresh)))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,51 @@
package growthreport
import (
"testing"
"time"
)
func TestRequestHashIsStableAndScopeSensitive(t *testing.T) {
t.Parallel()
a := RequestHash(7, "T-1", false)
if a != RequestHash(7, "T-1", false) {
t.Fatal("RequestHash() is not stable")
}
if a == RequestHash(7, "T-2", false) || a == RequestHash(7, "T-1", true) {
t.Fatal("RequestHash() ignored request scope")
}
}
func TestNextTehranRefreshUsesCalendarBoundary(t *testing.T) {
t.Parallel()
now := time.Date(2026, 8, 21, 20, 0, 0, 0, time.UTC)
next := NextTehranRefresh(now)
location, _ := time.LoadLocation("Asia/Tehran")
local := next.In(location)
if local.Hour() != 0 || local.Minute() != 0 || !next.After(now) {
t.Fatalf("NextTehranRefresh() = %s", next)
}
}
func TestEstimatedTokenReservationCoversEveryRetryAndTurn(t *testing.T) {
t.Parallel()
small := EstimatedTokenReservation([]byte("{}"))
large := EstimatedTokenReservation(make([]byte, 1000))
if small < 4*1600 || large <= small || large != 44000 {
t.Fatalf("reservations small=%d large=%d", small, large)
}
}
func TestServiceFingerprintChangesWithDatasetAndConfiguration(t *testing.T) {
t.Parallel()
start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC)
service := &Service{config: ServiceConfig{Model: "model-a", PromptVersion: "prompt-a", SchemaVersion: "schema-a"}}
current := service.fingerprint(7, "T-1", "watermark-a", start)
if current == service.fingerprint(7, "T-1", "watermark-b", start) {
t.Fatal("dataset watermark did not invalidate fingerprint")
}
service.config.PromptVersion = "prompt-b"
if current == service.fingerprint(7, "T-1", "watermark-a", start) {
t.Fatal("prompt version did not invalidate fingerprint")
}
}

View File

@@ -0,0 +1,12 @@
package growthreport
import "time"
func NextTehranRefresh(now time.Time) time.Time {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
location = time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
local := now.In(location)
return time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, location).UTC()
}

View File

@@ -0,0 +1,77 @@
CREATE TABLE IF NOT EXISTS growth_reports (
id text PRIMARY KEY,
merchant_id bigint NOT NULL REFERENCES merchants(id),
terminal_id bigint REFERENCES terminals(id),
terminal_source_key text,
fingerprint text NOT NULL,
revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
status text NOT NULL CHECK (status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL','FAILED')),
horizon_days integer NOT NULL DEFAULT 90 CHECK (horizon_days = 90),
forecast_start_date date,
dataset_watermark text NOT NULL,
catalog_version text NOT NULL,
analytics_version text NOT NULL,
prompt_version text NOT NULL,
schema_version text NOT NULL,
model text NOT NULL,
deterministic_evidence jsonb,
ai_response jsonb,
failure_code text,
input_tokens bigint NOT NULL DEFAULT 0 CHECK (input_tokens >= 0),
output_tokens bigint NOT NULL DEFAULT 0 CHECK (output_tokens >= 0),
latency_ms bigint NOT NULL DEFAULT 0 CHECK (latency_ms >= 0),
provider_requests bigint NOT NULL DEFAULT 0 CHECK (provider_requests >= 0),
reserved_tokens bigint NOT NULL DEFAULT 0 CHECK (reserved_tokens >= 0),
provider_request_id text,
estimated_cost text,
lease_token text,
lease_expires_at timestamptz,
manual_refresh boolean NOT NULL DEFAULT false,
tehran_refresh_day date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS provider_request_id text;
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS estimated_cost text;
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS provider_requests bigint NOT NULL DEFAULT 0 CHECK (provider_requests >= 0);
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS reserved_tokens bigint NOT NULL DEFAULT 0 CHECK (reserved_tokens >= 0);
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS lease_token text;
-- statement-break
ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
-- statement-break
ALTER TABLE growth_reports ALTER COLUMN deterministic_evidence DROP NOT NULL;
-- statement-break
CREATE UNIQUE INDEX IF NOT EXISTS growth_reports_current_fingerprint_idx
ON growth_reports (merchant_id, fingerprint)
WHERE manual_refresh = false AND status IN ('QUEUED','ANALYZING','GENERATING','READY','PARTIAL');
-- statement-break
CREATE UNIQUE INDEX IF NOT EXISTS growth_reports_daily_merchant_refresh_idx
ON growth_reports (merchant_id, tehran_refresh_day)
WHERE manual_refresh = true;
-- statement-break
CREATE INDEX IF NOT EXISTS growth_reports_scope_latest_idx
ON growth_reports (merchant_id, terminal_id, revision DESC, created_at DESC);
-- statement-break
CREATE INDEX IF NOT EXISTS growth_reports_pending_idx
ON growth_reports (created_at)
WHERE status IN ('QUEUED','ANALYZING','GENERATING');
-- statement-break
CREATE TABLE IF NOT EXISTS growth_report_idempotency (
merchant_id bigint NOT NULL REFERENCES merchants(id),
idempotency_key text NOT NULL,
request_hash text NOT NULL,
report_id text NOT NULL REFERENCES growth_reports(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, idempotency_key)
);
-- statement-break
CREATE INDEX IF NOT EXISTS growth_report_idempotency_created_idx
ON growth_report_idempotency (created_at);
-- statement-break
INSERT INTO schema_migrations(version) VALUES (2) ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,10 @@
# AI Growth Analyst Implementation Tasks
- [x] G1: Add approved spec, ADR, OpenAPI resources, event catalog, report schema, and reversible migration.
- [x] G2: Add pure tested daily aggregation, event effect, similarity, 90-day forecast, residual ranges, backtest confidence, risk, and peer-suppression logic.
- [x] G3: Add PostgreSQL report repository, fingerprint/idempotency, Redis hot cache/locks/budgets, and Tehran-day limits.
- [x] G4: Add bounded worker state machine and sanitized `GrowthAnalyticsTools` boundary.
- [x] G5: Add AvalAI Responses client, strict tool/final schemas, output validation, retry/timeout, usage metadata, and deterministic Persian fallbacks.
- [x] G6: Add authenticated create/latest/get/SSE handlers with terminal ownership, CSRF, idempotency, replay, and `429` refresh semantics.
- [x] G7: Wire configuration/composition roots, feature flag, local environment templates, retention cleanup, safe logs, and operational documentation.
- [ ] G8: Run unit/integration/contract/security/race/vet/build verification and real local PostgreSQL/Redis/API smoke without sending an AvalAI request unless a user-provided key is configured.