File: /home/xedaptot/be.naniguide.com/config/ads.php
<?php
return [
/*
|--------------------------------------------------------------------------
| Mock Mode
|--------------------------------------------------------------------------
| When true, all platform API calls route to MockAdPlatformAdapter.
| Use for development and E2E testing without real OAuth credentials.
|
| Checked EXCLUSIVELY in AdPlatformManager::resolveRawAdapter(). Service
| code must NEVER read this directly — the decorator chain makes the
| choice transparent.
*/
'mock_mode' => env('ADS_MOCK_MODE', false),
/*
|--------------------------------------------------------------------------
| Platform OAuth Credentials + Runtime Tunables
|--------------------------------------------------------------------------
| Credentials can be set via .env OR via admin settings (Admin > Settings > Ads).
| Admin settings take precedence via setting() helper where supported.
|
| Each platform block also carries `rate_limits`, `circuit_breaker`, `retry`
| and `http` sub-arrays consumed by the decorator chain. Keep these tuned
| per-platform because each API enforces different quotas.
*/
'platforms' => [
'meta' => [
'app_id' => env('META_ADS_APP_ID'),
'app_secret' => env('META_ADS_APP_SECRET'),
'api_version' => env('META_ADS_API_VERSION', 'v18.0'),
'base_url' => env('META_ADS_BASE_URL', 'https://graph.facebook.com'),
'scopes' => ['ads_management', 'ads_read', 'business_management', 'leads_retrieval'],
'rate_limits' => [
// Meta enforces ~200 calls/hour/user — stay under with a margin
'per_customer' => ['hits' => 180, 'per_seconds' => 3600],
'global' => ['hits' => 6000, 'per_seconds' => 3600],
],
'circuit_breaker' => [
'failure_threshold' => 5,
'cooldown_seconds' => 30,
],
'retry' => [
'max_attempts' => 3,
'base_delay_ms' => 2000,
],
'http' => [
'timeout_seconds' => 30,
'connect_timeout_seconds' => 5,
],
// Meta test ad account — no real spend, safe for contract tests (R12)
'test_ad_account_id' => env('META_TEST_AD_ACCOUNT_ID'),
],
'google' => [
'client_id' => env('GOOGLE_ADS_CLIENT_ID'),
'client_secret' => env('GOOGLE_ADS_CLIENT_SECRET'),
'developer_token' => env('GOOGLE_ADS_DEVELOPER_TOKEN'),
'api_version' => env('GOOGLE_ADS_API_VERSION', 'v16'),
'scopes' => ['https://www.googleapis.com/auth/adwords'],
'rate_limits' => [
'per_customer' => ['hits' => 100, 'per_seconds' => 60], // Google: 100/min/account
'global' => ['hits' => 2000, 'per_seconds' => 60],
],
'circuit_breaker' => ['failure_threshold' => 5, 'cooldown_seconds' => 30],
'retry' => ['max_attempts' => 3, 'base_delay_ms' => 2000],
'http' => ['timeout_seconds' => 30, 'connect_timeout_seconds' => 5],
'test_account_id' => env('GOOGLE_ADS_TEST_ACCOUNT_ID'),
// R15.2 — Google Merchant Center identity. `merchant_id` is
// distinct from the Google Ads `customer_id` and lives on a
// separate API surface (`shoppingcontent.googleapis.com`). When
// null, GoogleAdPlatform::createCatalog throws a clear
// ValidationException instead of silently mock-fallbacking.
'merchant_id' => env('GOOGLE_MERCHANT_ID'),
'content_country' => env('GOOGLE_MERCHANT_CONTENT_COUNTRY', 'US'),
'content_language' => env('GOOGLE_MERCHANT_CONTENT_LANGUAGE', 'en'),
],
'tiktok' => [
'app_id' => env('TIKTOK_ADS_APP_ID'),
'app_secret' => env('TIKTOK_ADS_APP_SECRET'),
'api_version' => env('TIKTOK_ADS_API_VERSION', 'v1.3'),
'base_url' => env('TIKTOK_ADS_BASE_URL', 'https://business-api.tiktok.com'),
'scopes' => [],
'rate_limits' => [
'per_customer' => ['hits' => 60, 'per_seconds' => 60], // TikTok: 60/min/token
'global' => ['hits' => 3000, 'per_seconds' => 60],
],
'circuit_breaker' => ['failure_threshold' => 5, 'cooldown_seconds' => 30],
'retry' => ['max_attempts' => 3, 'base_delay_ms' => 2000],
'http' => ['timeout_seconds' => 30, 'connect_timeout_seconds' => 5],
],
'linkedin' => [
'client_id' => env('LINKEDIN_ADS_CLIENT_ID'),
'client_secret' => env('LINKEDIN_ADS_CLIENT_SECRET'),
'api_version' => env('LINKEDIN_ADS_API_VERSION', '202404'),
'base_url' => env('LINKEDIN_ADS_BASE_URL', 'https://api.linkedin.com'),
'scopes' => ['r_ads', 'rw_ads', 'r_ads_reporting'],
'rate_limits' => [
'per_customer' => ['hits' => 100, 'per_seconds' => 3600],
'global' => ['hits' => 5000, 'per_seconds' => 3600],
],
'circuit_breaker' => ['failure_threshold' => 5, 'cooldown_seconds' => 30],
'retry' => ['max_attempts' => 3, 'base_delay_ms' => 2000],
'http' => ['timeout_seconds' => 30, 'connect_timeout_seconds' => 5],
],
],
/*
|--------------------------------------------------------------------------
| Queues
|--------------------------------------------------------------------------
| IMPORTANT — SAFE DEFAULT: ads jobs run on the `default` queue like every
| other Laravel job. This matches Acelle's out-of-the-box supervisor setup
| (`php artisan queue:work` with no --queue arg) so existing installs keep
| working after upgrade with zero ops action.
|
| Customers who want queue isolation (ads outage must not block email
| sends) opt in via .env:
|
| ADS_QUEUE_NAME=ads
| ADS_WEBHOOKS_QUEUE_NAME=ads-webhooks
|
| Then update supervisor to watch those queues:
| php artisan queue:work --queue=ads-webhooks,ads,default
|
| See docs/ads/review/PROGRESS.md "Feature-flag pattern" for the broader
| rule — every behavior change in Ads hardening phases must be opt-in.
*/
'queues' => [
'default' => env('ADS_QUEUE_NAME', 'default'),
'webhooks' => env('ADS_WEBHOOKS_QUEUE_NAME', 'default'),
'reconciliation' => env('ADS_RECONCILE_QUEUE_NAME', 'default'),
],
/*
|--------------------------------------------------------------------------
| Sync Intervals (cron)
|--------------------------------------------------------------------------
*/
'metrics_sync_interval' => env('ADS_METRICS_SYNC_INTERVAL', 15), // minutes
'token_refresh_interval' => env('ADS_TOKEN_REFRESH_INTERVAL', 30), // minutes
'health_check_interval' => env('ADS_HEALTH_CHECK_INTERVAL', 60), // minutes
'reconcile_interval' => env('ADS_RECONCILE_INTERVAL', 5), // minutes (recovery of orphan publish logs)
/*
|--------------------------------------------------------------------------
| Data Retention (GDPR / compliance — R11)
|--------------------------------------------------------------------------
| Artisan command `ads:prune-retention` (cron daily at 03:00) enforces
| these TTLs. Tune per regulatory requirements; zero disables.
*/
'retention' => [
'ad_leads_days' => (int) env('ADS_LEADS_RETENTION_DAYS', 180),
'ad_metrics_days' => (int) env('ADS_METRICS_RETENTION_DAYS', 730),
'ad_activity_logs_days' => (int) env('ADS_ACTIVITY_LOG_RETENTION_DAYS', 365),
'ad_publish_logs_days' => (int) env('ADS_PUBLISH_LOG_RETENTION_DAYS', 180),
],
/*
|--------------------------------------------------------------------------
| Emergency Kill Switches
|--------------------------------------------------------------------------
| Admin (or ops via .env) can disable any single platform instantly.
| AdPlatformManager::guardKillSwitch() throws PlatformKillSwitchException
| BEFORE any API call — zero chance of leaking through.
|
| Customer UI shows a suspension banner; campaigns remain intact, only
| outbound calls are blocked.
*/
'kill_switches' => [
'meta' => env('ADS_KILL_META', false),
'google' => env('ADS_KILL_GOOGLE', false),
'tiktok' => env('ADS_KILL_TIKTOK', false),
'linkedin' => env('ADS_KILL_LINKEDIN', false),
// Optional reason strings surfaced to admin + customer banner
'meta_reason' => env('ADS_KILL_META_REASON'),
'google_reason' => env('ADS_KILL_GOOGLE_REASON'),
'tiktok_reason' => env('ADS_KILL_TIKTOK_REASON'),
'linkedin_reason' => env('ADS_KILL_LINKEDIN_REASON'),
],
/*
|--------------------------------------------------------------------------
| Webhooks (R10)
|--------------------------------------------------------------------------
*/
'webhooks' => [
'meta_verify_token_env' => 'META_WEBHOOK_VERIFY_TOKEN',
'meta_verify_token' => env('META_WEBHOOK_VERIFY_TOKEN', ''),
'signature_tolerance_seconds' => 300,
'replay_protection_ttl_seconds' => 300,
// R10 — enforce HMAC signature on inbound webhook POSTs.
// OFF (default) = observation mode: signature is verified + logged
// but invalid-signature requests still pass through so staging
// can smoke-test against Meta's sample events (which don't sign
// with the production secret). Replay protection always runs
// regardless of this flag — dedup is always safe.
// ON = reject 401 on signature mismatch.
'enforce_signature' => env('ADS_WEBHOOKS_ENFORCE_SIGNATURE', false),
],
/*
|--------------------------------------------------------------------------
| Observability (R9)
|--------------------------------------------------------------------------
| Dedicated log channel. Sample successful adapter calls at 10% to avoid
| log spam; always log errors at 100%.
*/
'observability' => [
'log_channel' => env('ADS_LOG_CHANNEL', 'stack'),
'sample_rate_success' => (float) env('ADS_LOG_SAMPLE_SUCCESS', 0.1),
'sample_rate_error' => (float) env('ADS_LOG_SAMPLE_ERROR', 1.0),
],
/*
|--------------------------------------------------------------------------
| Compliance (R11)
|--------------------------------------------------------------------------
*/
'compliance' => [
// Remove subscriber from all ad audiences when they unsubscribe from email
'gdpr_remove_on_unsubscribe' => env('ADS_GDPR_REMOVE_ON_UNSUB', true),
// Audit admin when they view lead PII data
'admin_audit_lead_views' => env('ADS_AUDIT_LEAD_VIEWS', true),
],
/*
|--------------------------------------------------------------------------
| Automation Rules (R13)
|--------------------------------------------------------------------------
| Customer-defined rules that fire actions (add/remove from audience,
| start/pause campaign) on subscriber events (unsubscribe, open, click,
| purchase).
|
| enabled — master kill switch. When false, the listener still
| receives events but exits early without dispatching jobs.
| throttle_per_minute — max actions a single rule can fire in any
| 60-second window. Hard-stop guard against runaway rules; a
| misconfigured rule that fires on every email open can flood the
| queue otherwise.
| default_dry_run — initial value of the rule.dry_run column when
| a customer creates a new rule via the wizard. Default true so
| new rules don't silently mutate audiences before the customer
| reviews the simulation log.
*/
'automation' => [
'enabled' => env('ADS_AUTOMATION_ENABLED', true),
'throttle_per_minute' => (int) env('ADS_AUTOMATION_THROTTLE_PER_MINUTE', 10),
'default_dry_run' => env('ADS_AUTOMATION_DEFAULT_DRY_RUN', true),
// Lock TTL for the per-rule concurrency guard (ads_aut_running:{id}).
// Longer than any single action's expected runtime; releases on job
// completion / failure / timeout. Crash-safe: TTL expires
// automatically if the worker dies mid-execution.
'concurrency_lock_ttl_seconds' => (int) env('ADS_AUTOMATION_LOCK_TTL', 300),
],
/*
|--------------------------------------------------------------------------
| Product Catalog Sync (R15)
|--------------------------------------------------------------------------
| validate_images — when true, ProductImageValidator HEAD-checks every
| product image URL before sending to the adapter. Catches broken
| URLs upfront so the per-product error message says "image
| unreachable" instead of the platform's cryptic batch error. Default
| ON in production; staging fixtures may want to disable to avoid
| real HTTP calls during unit tests.
| image_timeout_ms — HEAD request timeout per image URL. Bumping this
| slows full-catalog validation linearly; default 3s tolerates slow
| CDNs without dragging out a 1000-product sync.
*/
'catalog' => [
'validate_images' => env('ADS_CATALOG_VALIDATE_IMAGES', true),
'image_timeout_ms' => (int) env('ADS_CATALOG_IMAGE_TIMEOUT_MS', 3000),
// R15.1 — minimum hours since last_synced_at before the daily
// sweeper re-dispatches a SyncAdProductCatalog job. Lower for
// staging where sources change quickly; higher to ease platform
// quota pressure. Manual catalogs are always skipped — they
// only sync on customer button-press.
'sweep_min_age_hours' => (int) env('ADS_CATALOG_SWEEP_MIN_AGE_HOURS', 24),
],
/*
|--------------------------------------------------------------------------
| Sandbox contract tests (R12)
|--------------------------------------------------------------------------
| Contract tests hit real platform sandboxes to detect API schema drift.
| Disabled by default — customers flip this for pre-release validation.
*/
'sandbox' => [
'enabled' => env('ADS_SANDBOX_TESTS', false),
],
/*
|--------------------------------------------------------------------------
| Hardening Feature Flags (R3+)
|--------------------------------------------------------------------------
| Risky behavioral changes ship behind flags, default OFF. Customers opt
| in via .env after staging verification.
|
| use_session_pattern (R3) — when true, services call
| $manager->session($platform)->chain->method()
| through the full 6-decorator chain. When false, legacy direct
| adapter calls + inline mock-mode branches execute unchanged.
|
| See docs/ads/review/PROGRESS.md "Feature-flag pattern" for rollout rules.
*/
'hardening' => [
'use_session_pattern' => env('ADS_USE_SESSION_PATTERN', false),
// R4 flag — when true, MetaAdPlatform calls the real Facebook Graph
// API for the 10 R1-extended methods (publishCampaign, pullMetrics,
// syncAudience, etc.). When false (default), those methods delegate
// to MockAdPlatform so customers without mock mode keep a working UX
// while we validate the real implementation in staging. OAuth +
// health check stay real regardless — they're the foundation that
// existing customers already rely on.
'real_meta_enabled' => env('ADS_REAL_META_ENABLED', false),
// R12.1 flag — when true, GoogleAdPlatform calls the real Google Ads
// REST API v17 for the 10 R1-extended methods. Same flag-OFF mock
// delegation as Meta's flag. Requires ADS_GOOGLE_DEVELOPER_TOKEN +
// approved Google Ads Developer Token tier (basic/standard).
'real_google_enabled' => env('ADS_REAL_GOOGLE_ENABLED', false),
// R12.2 flag — when true, TikTokAdPlatform calls the real TikTok
// Business API v1.3 for the 10 R1-extended methods. Same flag-OFF
// mock delegation as Meta + Google flags. Requires the customer's
// TikTok for Business app to be approved for production (sandbox
// apps return placeholder data even with the flag ON). Catalog
// methods throw `LogicException` with R15 hint when ON.
'real_tiktok_enabled' => env('ADS_REAL_TIKTOK_ENABLED', false),
// R12.3 flag — when true, LinkedInAdPlatform calls the real LinkedIn
// Marketing API (versioned `/rest/` surface) for the 10 R1-extended
// methods. Same flag-OFF mock delegation pattern. Requires
// Marketing Developer Platform (MDP) approval — the customer's
// LinkedIn app must have the rw_ads / r_ads_reporting scopes granted
// (Lead Sync API access is a separate approval; fetchLeads will
// surface the access error if the customer skipped it). Catalog
// methods throw `LogicException` with R15 hint when ON.
'real_linkedin_enabled' => env('ADS_REAL_LINKEDIN_ENABLED', false),
// R15.0 flag — when true, AdProductCatalogService::sync routes to
// the adapter chain (`$manager->session($platform)->chain->createCatalog
// / syncCatalog`) for any catalog with an `ad_platform_id` binding.
// When false (default), the legacy mock-fixture path runs unchanged
// — existing customers see no behavior change until they explicitly
// opt in via `.env`. Per-platform real-API flags (Meta R4, Google
// R15.2, TikTok R15.3, LinkedIn R15.4) gate the actual HTTP calls
// inside each adapter; this flag only gates the service-level
// routing decision.
'real_catalog_sync_enabled' => env('ADS_REAL_CATALOG_SYNC_ENABLED', false),
// R15.2 flag — when true, GoogleAdPlatform routes catalog calls to
// the real `shoppingcontent.googleapis.com` Merchant Center API.
// Independent from `real_google_enabled` (Ads side); a customer
// can run real Ads + mock catalog or real catalog + mock Ads.
// Requires `GOOGLE_MERCHANT_ID` set and the customer reconnected
// with `auth/content` scope. Flag-OFF delegates to MockAdPlatform.
'real_google_catalog_enabled' => env('ADS_REAL_GOOGLE_CATALOG_ENABLED', false),
// R15.3 flag — when true, TikTokAdPlatform routes catalog calls to
// the real Business API Catalog Manager endpoints (/catalog/create/
// and /catalog/product/upload/). Independent from `real_tiktok_
// enabled` (Ads side). No extra credentials beyond the existing
// TikTok app + advertiser binding. Flag-OFF delegates to MockAdPlatform.
'real_tiktok_catalog_enabled' => env('ADS_REAL_TIKTOK_CATALOG_ENABLED', false),
// R15.4 flag — when true, LinkedInAdPlatform routes catalog calls to
// the real Marketing API dynamic-ads endpoints (/rest/dataItemCatalogs
// and /rest/dataItems?action=batchCreate). IMPORTANT: LinkedIn
// Dynamic Ads access requires Marketing Developer Platform (MDP)
// approval beyond the standard `rw_ads` scope. Customers without
// MDP Dynamic Ads tier will see 403 PERMISSION_DENIED on every
// catalog call — adapter surfaces this as a ValidationException
// with explicit reconnect/onboarding hint. Flag-OFF delegates to
// MockAdPlatform. Independent from `real_linkedin_enabled`.
'real_linkedin_catalog_enabled' => env('ADS_REAL_LINKEDIN_CATALOG_ENABLED', false),
],
/*
|--------------------------------------------------------------------------
| Rate-limit + circuit breaker enforcement flags (R8)
|--------------------------------------------------------------------------
| Default behavior is OBSERVATION mode — decorators record bucket hits
| and failure counters in Cache but never throw. Customers verify
| Horizon / log volume in staging, then flip to ENFORCE mode in .env:
|
| ADS_RATE_LIMITS_ENFORCE=true # RateLimitDecorator throws
| RateLimitException on bucket
| exhaustion.
| ADS_CIRCUIT_BREAKER_ENFORCE=true # CircuitBreakerDecorator throws
| CircuitOpenException when a
| platform trips the failure
| threshold.
|
| Observation-mode telemetry is cheap; enforcement-mode changes customer-
| facing behavior. Keep defaults OFF.
*/
'rate_limits_enforce' => env('ADS_RATE_LIMITS_ENFORCE', false),
'circuit_breaker_enforce' => env('ADS_CIRCUIT_BREAKER_ENFORCE', false),
/*
|--------------------------------------------------------------------------
| Mock (dev/test only)
|--------------------------------------------------------------------------
| Opt-in N-th-call error simulation used by contract tests (R8/R12).
| See MockAdPlatform::maybeSimulateTransientError. Unset by default.
*/
'mock' => [
// 'error_simulation' => ['every' => 10, 'exception' => 'rate_limit'],
],
];