SOP-FIN-01: Ad Spend Sync v1.2¶
Automated daily ingestion of ad spend from Meta and Google Ads into Supabase for CAC reporting, with attribution-ready architecture for real CAC computation.
Document ID: SOP-FIN-01-v1.2 Version: 1.2.1 Status: 🟢 Deployed Last Updated: 2026-03-20 Owner: Protocol Raw Operations Replaces: SOP-04-v4.0 (deprecated — Make.com-based architecture) Review Date: 2026-06-19
Key Changes in v1.2.1¶
- ✅ Deployed to production (2026-03-20)
- ✅ Adapted all
ops_eventsusage to match actual table schema:entity_type(notevent_type),kind(notstatus),meta(notdetails), plus mandatoryentity_id(UUID, generated viagen_random_uuid()) - ✅ Adapted
monitoring_runsusage:result_json(notresult),result_summary(not omitted), status values limited torunning/success/error(nothealthy/alert_triggered) - ✅ Monitoring function return format adapted to match
run-monitorMonitorResultinterface: returnssuccess,run_id,check_name,duration_ms,alert_config(not top-levelalertboolean) - ✅ Meta account ID updated to
act_921829787442603(was placeholderact_1834567227424666)
Implementation Notes: Schema Adaptations¶
The raw_ops.ops_events and raw_ops.monitoring_runs tables pre-date this SOP. The following column mappings were applied during implementation:
ops_events column mapping:
| SOP spec (original) | Actual column | Notes |
|---|---|---|
event_type |
entity_type |
e.g. 'ad_spend_sync', 'fx_rate_sync' |
status |
kind |
e.g. 'SUCCESS', 'FAILED', 'WARNING', 'PARTIAL', 'AUTH_FAILED' |
details |
meta |
JSONB payload with provider, date, counts, etc. |
| (not in spec) | entity_id |
Required NOT NULL UUID — use gen_random_uuid() for finance events with no natural entity |
| (not in spec) | message |
Required NOT NULL text — human-readable summary of the event |
monitoring_runs column mapping:
| SOP spec (original) | Actual column | Notes |
|---|---|---|
result |
result_json |
JSONB with check details |
| (not in spec) | result_summary |
Text summary for quick scanning |
completed_at |
(not present) | Column does not exist; omit |
status = 'healthy' |
status = 'success' |
CHECK constraint only allows: running, success, error |
status = 'alert_triggered' |
status = 'success' |
Alert state is communicated via alert_config in the return value, not via status |
fn_check_ad_spend_freshness_v2() return format:
Must return run-monitor's MonitorResult interface — not a top-level alert boolean:
{
"success": true,
"run_id": "uuid",
"check_name": "ad_spend_freshness",
"duration_ms": 37,
"alert_config": {
"channel": "ops-alerts",
"severity": "warning",
"title": "Ad Spend Sync Health Check",
"message": "Issues detected for 2026-03-19",
"fields": [...]
}
}
When no alert is needed, alert_config is null (not omitted).
Key Changes in v1.2¶
- ✅ Fixed FX cross-rate logic: EUR→GBP derived from USD-base response (
GBP_per_EUR = GBP_per_USD / EUR_per_USD) - ✅ Added
raw_ops.ad_accountsconfig table as deterministic currency source for each provider account - ✅ Replaced hand-wavy retry language with explicit "no automatic retry" Phase A design + escalation path
- ✅ Added Phase 2: CAC Attribution section with
v_cac_attributedview spec and SOP-ORD-01 dependency - ✅ Simplified FX fallback wording to "import-date rate" and "previous available daily rate (max 1 day stale)"
Key Changes in v1.1¶
- ✅ Added
GOOGLE_ADS_DEVELOPER_TOKENandGOOGLE_ADS_LOGIN_CUSTOMER_IDto required secrets - ✅ Added "Cron-to-Edge Authentication" section documenting the
app.settings.service_role_keycontract - ✅ Hardened all
SECURITY DEFINERfunctions withSET search_path,REVOKE ALL, explicitGRANT EXECUTE - ✅ Added "Business Day Convention" section — all dates are Europe/London, documented throughout
- ✅ Added
fx_rate_is_fallbackcolumn toad_campaignsfor FX audit trail - ✅ Added Google variance monitoring (was missing in v1.0)
- ✅ FX freshness monitor now checks for expected currencies explicitly (USD, EUR), not just row count
- ✅ Split Metabase dashboards: "Ad Spend Sync Health" (ops) and "Ad Spend & CAC" (business)
- ✅ Renamed Edge Function from
fin-ad-spend-synctofin-ad-spend-provider-sync - ✅ Added
raw_payloadstorage note for future migration to run-level ingestion log - ✅ All Metabase date references standardised to London business date
Key Changes from SOP-04¶
- ✅ Rebuilt from scratch around Supabase-native control flow (
pg_cron → pg_net → Edge Function → PostgreSQL function → ops-alerter) - ✅ Make.com removed entirely — zero Make scenarios
- ✅ Credentials moved from Make.com Data Store to Supabase Edge Function secrets
- ✅ N+1 per-campaign API calls replaced with bulk account-level queries
- ✅ FX conversion moved into PostgreSQL (computed on UPSERT, recomputable)
- ✅ Monitoring built in from day one — SOP-MON-01 compliant
- ✅
ops_eventslogging is transactional, not a post-launch add-on - ✅ Explicit SLOs, failure states, and alerting thresholds defined
Purpose¶
Import previous-day ad spend from Meta Ads and Google Ads into raw_ops.ad_campaigns daily, with automatic FX conversion to GBP. This data feeds the CAC dashboard in Metabase, which is a core input to the Weekly Growth Review.
Zero manual intervention required at any scale.
Scope¶
This SOP covers: - Daily FX rate ingestion from ExchangeRate-API - Daily ad spend ingestion from Meta Marketing API (bulk, account-level) - Daily ad spend ingestion from Google Ads API (searchStream, account-level) - FX conversion to GBP within PostgreSQL - Monitoring and alerting for sync health
Does not cover: Dashboard construction (Metabase), ad creative management, budget allocation decisions.
Related SOPs:
- SOP-MON-01: Monitoring & Alerting Architecture (master pattern, including run-monitor dispatch and ops-alerter payload contract)
- SOP-META-01: Phase A Paid Acquisition Strategy (upstream — what campaigns exist)
Business Day Convention¶
All date logic in this SOP uses Europe/London as the business timezone.
- D-1 (yesterday): Always computed as
(now() AT TIME ZONE 'Europe/London')::date - 1in PostgreSQL, andnew Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' })minus 1 day in Edge Functions. - Cron schedules are defined in UTC. During BST (last Sunday in March to last Sunday in October),
08:00 UTC = 09:00 London. During GMT,08:00 UTC = 08:00 London. The chosen UTC times ensure syncs always run after midnight London time, so D-1 data is complete regardless of DST transition. - SLOs in the monitoring section refer to UTC wall-clock times.
- Metabase queries must use
(now() AT TIME ZONE 'Europe/London')::date - 1for "yesterday", not bareCURRENT_DATE - 1(which uses database timezone). ops_events.details.datealways stores the London business date being imported, not the UTC date of the import run.
Architecture¶
Data Flow¶
pg_cron: fin-fx-sync (07:55 UTC)
↓
pg_net (async HTTP)
↓
Edge Function: fin-fx-sync
↓
ExchangeRate-API → raw_ops.currency_rates (UPSERT)
↓
ops_events (logged)
pg_cron: fin-ad-spend-meta (08:00 UTC)
↓
pg_net (async HTTP)
↓
Edge Function: fin-ad-spend-provider-sync
↓
Meta Marketing API (bulk account-level insights)
↓
raw_ops.fn_import_ad_spend_v1(provider, payload)
↓
Validate → Normalize → FX Convert → UPSERT ad_campaigns → Log ops_events
↓ (on failure)
ops-alerter → Slack #ops-alerts
pg_cron: fin-ad-spend-google (08:05 UTC)
↓
Same pattern as Meta (same Edge Function, different provider param)
pg_cron: monitor-ad-spend-freshness (08:30 UTC)
↓
pg_net → run-monitor → fn_check_ad_spend_freshness_v2()
↓
monitoring_runs → ops-alerter (if thresholds breached)
↓
Slack #ops-alerts / #ops-urgent
Make.com Involvement¶
None. This is single-system data ingestion (external API → Supabase). There is no multi-system orchestration. The three existing Make.com scenarios (Currency Rate Sync, Meta Ads Daily Sync, Google Ads Daily Sync) should be deactivated after this SOP is deployed and validated.
Credential Management¶
All credentials stored as Supabase Edge Function secrets (Deno.env.get()). Never in Make.com Data Store, never in code, never in database.
Required Secrets¶
| Secret Name | Purpose | Rotation |
|---|---|---|
META_ACCESS_TOKEN |
Meta system user token (long-lived, business-owned) | No expiry for system user tokens. Verify annually. |
GOOGLE_CLIENT_ID |
Google OAuth client ID | Static |
GOOGLE_CLIENT_SECRET |
Google OAuth client secret | Rotate if compromised |
GOOGLE_REFRESH_TOKEN |
Google OAuth refresh token | No expiry unless revoked. Re-auth if invalid_grant error. |
GOOGLE_ADS_DEVELOPER_TOKEN |
Required header for all Google Ads API calls | Static. Applied for during Google Ads API onboarding. |
GOOGLE_ADS_LOGIN_CUSTOMER_ID |
Manager account ID (if using MCC structure). Omit if direct account access. | Static |
EXCHANGERATE_API_KEY |
ExchangeRate-API key | Static |
Setting Secrets¶
supabase secrets set META_ACCESS_TOKEN=<value>
supabase secrets set GOOGLE_CLIENT_ID=<value>
supabase secrets set GOOGLE_CLIENT_SECRET=<value>
supabase secrets set GOOGLE_REFRESH_TOKEN=<value>
supabase secrets set GOOGLE_ADS_DEVELOPER_TOKEN=<value>
supabase secrets set GOOGLE_ADS_LOGIN_CUSTOMER_ID=<value> # only if MCC structure
supabase secrets set EXCHANGERATE_API_KEY=<value>
Meta System User Setup¶
The Meta access token must be a system user token from Business Manager, not a personal user token. System user tokens do not expire and survive personnel changes.
- Go to business.facebook.com → Business Settings → System Users
- Create or verify
Protocol Raw APIsystem user exists - Assign Assets → Ad Account →
act_921829787442603 - Generate token with
ads_readpermission - Store as
META_ACCESS_TOKENsecret
Google Ads API Setup¶
- Google Ads Developer Token: apply via Google Ads API Center in the Google Ads UI. Required for all API calls (sent as
developer-tokenheader). - OAuth credentials: created in console.cloud.google.com → APIs & Services → Credentials.
- Login Customer ID: only required if the ad account is managed under a Manager (MCC) account. If so, set
GOOGLE_ADS_LOGIN_CUSTOMER_IDand include aslogin-customer-idheader in API calls. If direct account access, omit.
Google OAuth Token Refresh¶
The Edge Function handles access token refresh automatically using the stored refresh token. If Google returns invalid_grant:
- Go to console.cloud.google.com → APIs & Services → Credentials
- Use OAuth Playground to re-authorize with the Protocol Raw OAuth client
- Generate new refresh token
- Update secret:
supabase secrets set GOOGLE_REFRESH_TOKEN=<new_value>
Database Schema¶
Table: raw_ops.ad_campaigns¶
CREATE TABLE IF NOT EXISTS raw_ops.ad_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform text NOT NULL CHECK (platform IN ('meta', 'google')),
account_id text NOT NULL,
campaign_id text NOT NULL,
campaign_name text NOT NULL,
campaign_status text, -- 'ACTIVE', 'PAUSED', 'DELETED', etc.
date date NOT NULL,
spend_local numeric(12,4) NOT NULL DEFAULT 0,
currency_code text NOT NULL DEFAULT 'GBP',
fx_rate_to_gbp numeric(10,6) NOT NULL DEFAULT 1.0,
fx_rate_is_fallback boolean NOT NULL DEFAULT false,
spend_gbp numeric(12,4) GENERATED ALWAYS AS (spend_local * fx_rate_to_gbp) STORED,
impressions integer NOT NULL DEFAULT 0,
clicks integer NOT NULL DEFAULT 0,
conversions integer NOT NULL DEFAULT 0,
cost_per_click numeric(10,4) GENERATED ALWAYS AS (
CASE WHEN clicks > 0 THEN (spend_local * fx_rate_to_gbp) / clicks ELSE NULL END
) STORED,
cost_per_conversion numeric(10,4) GENERATED ALWAYS AS (
CASE WHEN conversions > 0 THEN (spend_local * fx_rate_to_gbp) / conversions ELSE NULL END
) STORED,
raw_payload jsonb, -- full API response for debugging (see note below)
imported_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_ad_campaigns_platform_account_campaign_date
UNIQUE (platform, account_id, campaign_id, date)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_date ON raw_ops.ad_campaigns (date DESC);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_platform_date ON raw_ops.ad_campaigns (platform, date DESC);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_imported_at ON raw_ops.ad_campaigns (imported_at DESC);
-- RLS
ALTER TABLE raw_ops.ad_campaigns ENABLE ROW LEVEL SECURITY;
-- Service role bypasses RLS. No anon/authenticated access needed.
COMMENT ON TABLE raw_ops.ad_campaigns IS 'Daily ad spend by campaign, imported from Meta/Google APIs. SOP-FIN-01.';
Design notes:
- spend_gbp is a GENERATED column computed from spend_local * fx_rate_to_gbp. If FX rates are corrected, update fx_rate_to_gbp and spend_gbp recomputes automatically.
- fx_rate_is_fallback marks rows where the same-day FX rate was unavailable and a D-2 rate was used instead. This enables downstream reporting to flag approximate values. If fresh rates arrive later, re-run the affected date to overwrite with exact rates.
- raw_payload stores the full API response per campaign-day for debugging. At Phase A/B volumes this is fine. When campaign count exceeds ~500/day, migrate full payloads to a separate raw_ops.ad_spend_ingestion_log table keyed by run, and keep only a minimal forensic subset per row.
- The UNIQUE constraint on (platform, account_id, campaign_id, date) enables idempotent UPSERT. Running the sync twice for the same day is safe.
Table: raw_ops.currency_rates¶
CREATE TABLE IF NOT EXISTS raw_ops.currency_rates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
base_currency text NOT NULL,
target_currency text NOT NULL DEFAULT 'GBP',
rate numeric(10,6) NOT NULL,
rate_date date NOT NULL,
source text NOT NULL DEFAULT 'exchangerate-api',
fetched_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_currency_rates_pair_date
UNIQUE (base_currency, target_currency, rate_date)
);
CREATE INDEX IF NOT EXISTS idx_currency_rates_date ON raw_ops.currency_rates (rate_date DESC);
ALTER TABLE raw_ops.currency_rates ENABLE ROW LEVEL SECURITY;
COMMENT ON TABLE raw_ops.currency_rates IS 'Daily FX rates for ad spend conversion. SOP-FIN-01.';
Table: raw_ops.ad_accounts¶
Static config table. Stores known ad accounts and their currencies. The Edge Function reads currency from here rather than querying the provider API at runtime or guessing.
CREATE TABLE IF NOT EXISTS raw_ops.ad_accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform text NOT NULL CHECK (platform IN ('meta', 'google')),
account_id text NOT NULL,
account_name text NOT NULL,
currency_code text NOT NULL, -- 'GBP', 'USD', 'EUR'
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_ad_accounts_platform_account
UNIQUE (platform, account_id)
);
ALTER TABLE raw_ops.ad_accounts ENABLE ROW LEVEL SECURITY;
-- Seed data for Phase A
INSERT INTO raw_ops.ad_accounts (platform, account_id, account_name, currency_code)
VALUES
('meta', 'act_921829787442603', 'Protocol Raw - Meta', 'GBP'),
('google', '9345978544', 'Protocol Raw - Google', 'GBP')
ON CONFLICT (platform, account_id) DO NOTHING;
COMMENT ON TABLE raw_ops.ad_accounts IS 'Ad account config: platform, ID, currency. Source of truth for currency in ad spend imports. SOP-FIN-01.';
Usage: The Edge Function queries SELECT currency_code FROM raw_ops.ad_accounts WHERE platform = $1 AND account_id = $2 AND is_active = true before normalizing the campaign payload. If the account is not found, the sync fails with a clear error rather than guessing.
### View: `analytics.v_cac_daily`
```sql
CREATE OR REPLACE VIEW analytics.v_cac_daily AS
SELECT
ac.date,
ac.platform,
SUM(ac.spend_gbp) AS spend_gbp,
SUM(ac.impressions) AS impressions,
SUM(ac.clicks) AS clicks,
SUM(ac.conversions) AS conversions,
CASE WHEN SUM(ac.clicks) > 0
THEN SUM(ac.spend_gbp) / SUM(ac.clicks)
ELSE NULL
END AS cpc_gbp,
CASE WHEN SUM(ac.conversions) > 0
THEN SUM(ac.spend_gbp) / SUM(ac.conversions)
ELSE NULL
END AS cpa_gbp,
bool_or(ac.fx_rate_is_fallback) AS includes_fallback_fx
FROM raw_ops.ad_campaigns ac
GROUP BY ac.date, ac.platform;
COMMENT ON VIEW analytics.v_cac_daily IS 'Daily ad spend aggregated by platform for CAC dashboard. [SOP-FIN-01](SOP-FIN-01_Ad_Spend_Sync_v1_2 (1).md).';
GRANT SELECT ON analytics.v_cac_daily TO authenticated;
This is a standard view, not materialized. It reads directly from ad_campaigns and is always current. At Phase A/B volumes (tens of campaigns), query time is sub-second. Materialization is unnecessary until hundreds of thousands of rows accumulate.
View: analytics.v_cac_weekly¶
CREATE OR REPLACE VIEW analytics.v_cac_weekly AS
SELECT
date_trunc('week', ac.date)::date AS week_starting,
ac.platform,
SUM(ac.spend_gbp) AS spend_gbp,
SUM(ac.impressions) AS impressions,
SUM(ac.clicks) AS clicks,
SUM(ac.conversions) AS conversions,
CASE WHEN SUM(ac.clicks) > 0
THEN SUM(ac.spend_gbp) / SUM(ac.clicks)
ELSE NULL
END AS cpc_gbp,
CASE WHEN SUM(ac.conversions) > 0
THEN SUM(ac.spend_gbp) / SUM(ac.conversions)
ELSE NULL
END AS cpa_gbp,
bool_or(ac.fx_rate_is_fallback) AS includes_fallback_fx
FROM raw_ops.ad_campaigns ac
GROUP BY date_trunc('week', ac.date)::date, ac.platform;
GRANT SELECT ON analytics.v_cac_weekly TO authenticated;
PostgreSQL Functions¶
Function Hardening Standard¶
All functions in this SOP follow this security pattern:
After creation, permissions are locked:
REVOKE ALL ON FUNCTION raw_ops.<function_name> FROM PUBLIC;
GRANT EXECUTE ON FUNCTION raw_ops.<function_name> TO service_role;
This ensures functions execute with the owner's privileges, cannot be hijacked via search_path manipulation, and are only callable through the service role (i.e., from Edge Functions, not from anon or authenticated clients).
raw_ops.fn_import_ad_spend_v1()¶
Core import function. Called by the Edge Function with a normalized payload. Handles validation, FX lookup, UPSERT, and audit logging in a single transaction.
CREATE OR REPLACE FUNCTION raw_ops.fn_import_ad_spend_v1(
p_provider text, -- 'meta' or 'google'
p_account_id text,
p_date date,
p_campaigns jsonb -- array of {campaign_id, campaign_name, campaign_status, spend, currency, impressions, clicks, conversions, raw}
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = raw_ops, public
AS $$
DECLARE
v_campaign jsonb;
v_fx_rate numeric(10,6);
v_fx_is_fallback boolean;
v_currency text;
v_rows_upserted integer := 0;
v_rows_skipped integer := 0;
v_total_spend_gbp numeric(12,4) := 0;
v_start_time timestamptz := clock_timestamp();
BEGIN
-- Validate provider
IF p_provider NOT IN ('meta', 'google') THEN
RAISE EXCEPTION 'Invalid provider: %. Must be meta or google.', p_provider;
END IF;
-- Process each campaign
FOR v_campaign IN SELECT * FROM jsonb_array_elements(p_campaigns)
LOOP
v_currency := UPPER(COALESCE(v_campaign->>'currency', 'GBP'));
v_fx_is_fallback := false;
-- Look up FX rate (GBP→GBP = 1.0)
IF v_currency = 'GBP' THEN
v_fx_rate := 1.0;
ELSE
SELECT rate INTO v_fx_rate
FROM currency_rates
WHERE base_currency = v_currency
AND target_currency = 'GBP'
AND rate_date = p_date;
IF v_fx_rate IS NULL THEN
-- Fallback: try previous day's rate
SELECT rate INTO v_fx_rate
FROM currency_rates
WHERE base_currency = v_currency
AND target_currency = 'GBP'
AND rate_date = p_date - 1;
IF v_fx_rate IS NOT NULL THEN
v_fx_is_fallback := true;
END IF;
END IF;
IF v_fx_rate IS NULL THEN
-- No rate available. Log warning, skip this campaign.
INSERT INTO ops_events (entity_type, entity_id, kind, message, meta)
VALUES (
'ad_spend_sync',
gen_random_uuid(),
'WARNING',
format('No FX rate found for %s on %s or %s. Campaign skipped.', v_currency, p_date, p_date - 1),
jsonb_build_object(
'provider', p_provider,
'campaign_id', v_campaign->>'campaign_id'
)
);
v_rows_skipped := v_rows_skipped + 1;
CONTINUE;
END IF;
END IF;
-- UPSERT campaign data
INSERT INTO ad_campaigns (
platform, account_id, campaign_id, campaign_name, campaign_status,
date, spend_local, currency_code, fx_rate_to_gbp, fx_rate_is_fallback,
impressions, clicks, conversions, raw_payload, imported_at, updated_at
)
VALUES (
p_provider,
p_account_id,
v_campaign->>'campaign_id',
v_campaign->>'campaign_name',
v_campaign->>'campaign_status',
p_date,
COALESCE((v_campaign->>'spend')::numeric, 0),
v_currency,
v_fx_rate,
v_fx_is_fallback,
COALESCE((v_campaign->>'impressions')::integer, 0),
COALESCE((v_campaign->>'clicks')::integer, 0),
COALESCE((v_campaign->>'conversions')::integer, 0),
v_campaign->'raw',
now(),
now()
)
ON CONFLICT (platform, account_id, campaign_id, date)
DO UPDATE SET
campaign_name = EXCLUDED.campaign_name,
campaign_status = EXCLUDED.campaign_status,
spend_local = EXCLUDED.spend_local,
currency_code = EXCLUDED.currency_code,
fx_rate_to_gbp = EXCLUDED.fx_rate_to_gbp,
fx_rate_is_fallback = EXCLUDED.fx_rate_is_fallback,
impressions = EXCLUDED.impressions,
clicks = EXCLUDED.clicks,
conversions = EXCLUDED.conversions,
raw_payload = EXCLUDED.raw_payload,
updated_at = now();
v_rows_upserted := v_rows_upserted + 1;
v_total_spend_gbp := v_total_spend_gbp + (COALESCE((v_campaign->>'spend')::numeric, 0) * v_fx_rate);
END LOOP;
-- Log result to ops_events
INSERT INTO ops_events (entity_type, entity_id, kind, message, meta)
VALUES (
'ad_spend_sync',
gen_random_uuid(),
CASE WHEN v_rows_skipped > 0 THEN 'PARTIAL' ELSE 'SUCCESS' END,
format('Ad spend import: %s upserted, %s skipped for %s on %s', v_rows_upserted, v_rows_skipped, p_provider, p_date),
jsonb_build_object(
'provider', p_provider,
'account_id', p_account_id,
'date', p_date,
'campaigns_upserted', v_rows_upserted,
'campaigns_skipped', v_rows_skipped,
'total_spend_gbp', round(v_total_spend_gbp, 2),
'duration_ms', EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000
)
);
RETURN jsonb_build_object(
'status', CASE WHEN v_rows_skipped > 0 THEN 'partial' ELSE 'success' END,
'provider', p_provider,
'date', p_date,
'campaigns_upserted', v_rows_upserted,
'campaigns_skipped', v_rows_skipped,
'total_spend_gbp', round(v_total_spend_gbp, 2)
);
END;
$$;
REVOKE ALL ON FUNCTION raw_ops.fn_import_ad_spend_v1(text, text, date, jsonb) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION raw_ops.fn_import_ad_spend_v1(text, text, date, jsonb) TO service_role;
COMMENT ON FUNCTION raw_ops.fn_import_ad_spend_v1 IS 'Bulk UPSERT ad spend with FX conversion. [SOP-FIN-01](SOP-FIN-01_Ad_Spend_Sync_v1_2 (1).md).';
FX Fallback Policy¶
The fallback logic above implements the following policy:
- Import-date rate preferred. If
currency_rateshas a rate for the date being imported, use it.fx_rate_is_fallback = false. - Previous available daily rate as fallback (max 1 day stale). If the import-date rate is missing (e.g., FX sync failed), use the previous day's rate.
fx_rate_is_fallback = true. - No rate available. Campaign row is skipped entirely and logged as WARNING. It will be picked up on the next successful sync re-run for that date.
- Correction policy. If fallback rows exist and fresh rates later become available, re-run the affected date via manual trigger (see Retry Policy). The UPSERT will overwrite with the correct rate and clear the fallback flag.
- Maximum acceptable stale rate: 1 day. If rates are missing for 2+ consecutive days, the monitoring function will alert and campaigns will be skipped until rates are restored.
- Metabase visibility. The
includes_fallback_fxflag onv_cac_dailyallows dashboards to show a warning indicator when any row in a day's aggregation used a fallback rate.
raw_ops.fn_import_fx_rates_v1()¶
CREATE OR REPLACE FUNCTION raw_ops.fn_import_fx_rates_v1(
p_rates jsonb, -- array of {base_currency, rate, rate_date}
p_source text DEFAULT 'exchangerate-api'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = raw_ops, public
AS $$
DECLARE
v_rate jsonb;
v_count integer := 0;
BEGIN
FOR v_rate IN SELECT * FROM jsonb_array_elements(p_rates)
LOOP
INSERT INTO currency_rates (base_currency, target_currency, rate, rate_date, source)
VALUES (
UPPER(v_rate->>'base_currency'),
'GBP',
(v_rate->>'rate')::numeric,
(v_rate->>'rate_date')::date,
p_source
)
ON CONFLICT (base_currency, target_currency, rate_date)
DO UPDATE SET
rate = EXCLUDED.rate,
fetched_at = now();
v_count := v_count + 1;
END LOOP;
INSERT INTO ops_events (entity_type, entity_id, kind, message, meta)
VALUES ('fx_rate_sync', gen_random_uuid(), 'SUCCESS', 'FX rates imported', jsonb_build_object('rates_upserted', v_count, 'source', p_source));
RETURN jsonb_build_object('status', 'success', 'rates_upserted', v_count);
END;
$$;
REVOKE ALL ON FUNCTION raw_ops.fn_import_fx_rates_v1(jsonb, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION raw_ops.fn_import_fx_rates_v1(jsonb, text) TO service_role;
COMMENT ON FUNCTION raw_ops.fn_import_fx_rates_v1 IS 'Bulk UPSERT FX rates. [SOP-FIN-01](SOP-FIN-01_Ad_Spend_Sync_v1_2 (1).md).';
Edge Functions¶
1. fin-fx-sync¶
Purpose: Fetch daily FX rates from ExchangeRate-API and store in database.
Endpoint: POST /functions/v1/fin-fx-sync
Authentication: Validates Authorization: Bearer <service_role_key> header. See "Cron-to-Edge Authentication" in Security section.
Logic:
1. Read EXCHANGERATE_API_KEY from environment
2. Compute target date as D-1 in Europe/London
3. Fetch GET https://v6.exchangerate-api.com/v6/{key}/latest/USD
4. The response contains USD-base rates. Extract:
- USD→GBP directly from conversion_rates.GBP
- USD→EUR directly from conversion_rates.EUR
- Derive EUR→GBP as: GBP_per_EUR = GBP_per_USD / EUR_per_USD
5. Build rates array with both {base_currency: 'USD', rate: GBP_per_USD} and {base_currency: 'EUR', rate: GBP_per_EUR}
6. Call raw_ops.fn_import_fx_rates_v1() with rates for the target date
7. Return result
Cross-rate example: If API returns GBP = 0.7900 and EUR = 0.9200 (both per 1 USD), then EUR→GBP = 0.7900 / 0.9200 = 0.8587. This means 1 EUR = 0.8587 GBP.
Error handling:
- HTTP 4xx/5xx from ExchangeRate-API: log error to ops_events with type fx_rate_sync and status FAILED, return error response
- Missing expected rates in response (USD or EUR absent): log PARTIAL, return partial
Retry: No automatic retry. See Retry Policy section above.
2. fin-ad-spend-provider-sync¶
Purpose: Fetch previous-day ad spend from a single provider and import into database. Called once per provider via separate pg_cron jobs with different provider parameters.
Endpoint: POST /functions/v1/fin-ad-spend-provider-sync
Authentication: Validates Authorization: Bearer <service_role_key> header. See "Cron-to-Edge Authentication" in Security section.
Request body:
date is optional; defaults to D-1 in Europe/London.
Normalized campaign payload contract (output of provider-specific parsing, input to fn_import_ad_spend_v1):
{
"campaign_id": "123456",
"campaign_name": "Protocol Raw - Reconsiderer - London",
"campaign_status": "ACTIVE",
"spend": "42.50",
"currency": "GBP",
"impressions": "12500",
"clicks": "340",
"conversions": "8",
"raw": { ... }
}
All fields are strings (provider APIs return strings). The PostgreSQL function handles type casting. raw contains the original provider response object for this campaign, stored in raw_payload for debugging.
Meta flow:
1. Read META_ACCESS_TOKEN from environment
2. Compute target date (D-1 London)
3. Fetch account-level insights:
GET https://graph.facebook.com/v21.0/act_921829787442603/insights
?level=campaign
&fields=campaign_id,campaign_name,spend,impressions,clicks,actions
&time_range={"since":"2026-03-18","until":"2026-03-18"}
&limit=500
paging.next URL if present, accumulate all pages
5. Normalize each campaign into the payload contract above
6. Extract conversions from actions array where action_type = 'offsite_conversion.fb_pixel_purchase'. If no matching action, conversions = 0.
7. Currency: looked up from raw_ops.ad_accounts for this account ID. Not parsed from API response.
8. Call raw_ops.fn_import_ad_spend_v1('meta', 'act_921829787442603', date, campaigns_array)
9. If function returns status: 'partial' or import throws, call ops-alerter with severity warning to #ops-alerts
Google flow:
1. Read GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, GOOGLE_ADS_DEVELOPER_TOKEN from environment
2. Optionally read GOOGLE_ADS_LOGIN_CUSTOMER_ID (only if MCC structure)
3. Compute target date (D-1 London)
4. Exchange refresh token for access token:
POST https://oauth2.googleapis.com/token
Body: grant_type=refresh_token&client_id=...&client_secret=...&refresh_token=...
invalid_grant error: log to ops_events with status AUTH_FAILED, alert via ops-alerter to #ops-urgent, return error. Do not retry auth failures.
6. Fetch campaign metrics:
POST https://googleads.googleapis.com/v18/customers/9345978544/googleAds:searchStream
Headers:
Authorization: Bearer {access_token}
developer-token: {GOOGLE_ADS_DEVELOPER_TOKEN}
login-customer-id: {GOOGLE_ADS_LOGIN_CUSTOMER_ID} (only if MCC)
Body: {
"query": "SELECT campaign.id, campaign.name, campaign.status,
metrics.cost_micros, metrics.impressions, metrics.clicks,
metrics.conversions, segments.date
FROM campaign
WHERE segments.date = '2026-03-18'"
}
cost_micros / 1_000_000 → spend. Currency looked up from raw_ops.ad_accounts for this account (not guessed from API response). If account not found in ad_accounts, fail with clear error.
8. Call raw_ops.fn_import_ad_spend_v1('google', '9345978544', date, campaigns_array)
9. Same alerting logic as Meta
Error handling (both providers):
| Error | Action |
|---|---|
| HTTP 401 (auth) | Log AUTH_FAILED to ops_events, alert #ops-urgent, do not retry |
| HTTP 429 (rate limit) | Log FAILED to ops_events with error detail, alert #ops-alerts. Manual rerun required (see Retry Policy). |
| HTTP 5xx (server) | Log FAILED to ops_events with error detail, alert #ops-alerts. Manual rerun required (see Retry Policy). |
| Empty response (200 OK, 0 campaigns) | Log SUCCESS with campaigns_upserted: 0. This is normal for days with no active campaigns. |
| Partial response (some campaigns parsed, some malformed) | Process valid campaigns, skip malformed, log PARTIAL, alert #ops-alerts |
Key distinction: An API returning 200 OK with an empty data array is different from an API returning an error. The Edge Function must distinguish these and log them differently. Zero spend days are expected and should not trigger alerts. A malformed individual campaign row does not abort the batch — it is skipped and logged.
Retry Policy¶
Phase A design: no automatic retry. Alert + manual rerun.
At Phase A volumes (2-5 campaigns, once daily), a failed sync is a low-frequency event. The monitoring function at 08:30 catches any missing data and alerts Slack. The ops team manually reruns the affected sync using the runbook instructions.
This is a deliberate design choice, not an omission. Building a retry table, replay Edge Function, and recovery cron job for a once-daily job that fails maybe once a month is over-engineering at this stage.
Manual rerun procedure:
-- Rerun FX sync
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/fin-fx-sync',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{}'::jsonb
);
-- Rerun ad spend for a specific provider and date
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/fin-ad-spend-provider-sync',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{"provider": "meta", "date": "2026-03-18"}'::jsonb
);
Escalation trigger: If sync failures exceed 2 per week sustained over a rolling 2-week window, add a dedicated fin-ad-spend-retry pg_cron job at 09:00 UTC that queries ops_events for FAILED syncs in the last 2 hours and replays them. The monitoring function already tracks rolling 7-day failure count and will escalate to #ops-urgent at 3+ failures, providing the signal to implement this.
pg_cron Jobs¶
FX Rate Sync¶
SELECT cron.schedule(
'fin-fx-sync',
'55 7 * * *', -- 07:55 UTC daily (before ad spend sync)
$$
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/fin-fx-sync',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{}'::jsonb
);
$$
);
Meta Ad Spend Sync¶
SELECT cron.schedule(
'fin-ad-spend-meta',
'0 8 * * *', -- 08:00 UTC daily
$$
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/fin-ad-spend-provider-sync',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{"provider": "meta"}'::jsonb
);
$$
);
Google Ad Spend Sync¶
SELECT cron.schedule(
'fin-ad-spend-google',
'5 8 * * *', -- 08:05 UTC daily
$$
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/fin-ad-spend-provider-sync',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{"provider": "google"}'::jsonb
);
$$
);
Ad Spend Freshness Monitor¶
SELECT cron.schedule(
'monitor-ad-spend-freshness',
'30 8 * * *', -- 08:30 UTC daily (after both syncs should have completed)
$$
SELECT net.http_post(
url := 'https://znfjpllsiuyezqlneqzr.supabase.co/functions/v1/run-monitor',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := '{"check": "ad_spend_freshness"}'::jsonb
);
$$
);
DST note: These UTC times are chosen so that even during BST (UTC+1), all syncs complete well after midnight London time. Provider D-1 data finalisation is typically complete by 04:00-06:00 local time, so 07:55-08:05 UTC provides ample margin in both GMT and BST.
Job Management¶
-- View all FIN jobs
SELECT jobid, jobname, schedule, active
FROM cron.job
WHERE jobname LIKE 'fin-%' OR jobname = 'monitor-ad-spend-freshness'
ORDER BY jobname;
-- Disable a job
UPDATE cron.job SET active = false WHERE jobname = 'fin-ad-spend-google';
-- Enable a job
UPDATE cron.job SET active = true WHERE jobname = 'fin-ad-spend-google';
Monitoring¶
SLO Definition¶
| Metric | Target | Severity |
|---|---|---|
| FX rates for D-1 ingested (USD and EUR) | By 08:00 UTC | warning |
| Meta spend for D-1 ingested | By 08:15 UTC (if Meta sync job active) | warning |
| Google spend for D-1 ingested | By 08:20 UTC (if Google sync job active) | warning |
| Zero failed provider syncs (rolling 7 days) | 0 failures | warning at 1, critical at 3 |
| Row count variance vs 7-day mean (per platform) | Within 2σ | warning |
Monitoring Function: raw_ops.fn_check_ad_spend_freshness_v2()¶
Follows SOP-MON-01 pattern exactly. Dispatched by run-monitor Edge Function when called with {"check": "ad_spend_freshness"}. Alert payload follows the ops-alerter contract defined in SOP-MON-01.
CREATE OR REPLACE FUNCTION raw_ops.fn_check_ad_spend_freshness_v2()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = raw_ops, public
AS $$
DECLARE
v_run_id uuid;
v_start_time timestamptz;
v_duration_ms integer;
v_alert_needed boolean := false;
v_alert_severity text := 'warning';
v_alert_fields jsonb := '[]'::jsonb;
v_yesterday date := (now() AT TIME ZONE 'Europe/London')::date - 1;
v_meta_count integer;
v_google_count integer;
v_fx_usd_exists boolean;
v_fx_eur_exists boolean;
v_failed_7d integer;
v_meta_7d_avg numeric;
v_meta_7d_stddev numeric;
v_google_7d_avg numeric;
v_google_7d_stddev numeric;
BEGIN
v_start_time := clock_timestamp();
-- Create monitoring run record
INSERT INTO monitoring_runs (check_name, status)
VALUES ('ad_spend_freshness', 'running')
RETURNING id INTO v_run_id;
-- ── FX Rate Checks ──
-- Check for specific expected currencies, not just row count
SELECT EXISTS (
SELECT 1 FROM currency_rates
WHERE base_currency = 'USD' AND target_currency = 'GBP' AND rate_date = v_yesterday
) INTO v_fx_usd_exists;
SELECT EXISTS (
SELECT 1 FROM currency_rates
WHERE base_currency = 'EUR' AND target_currency = 'GBP' AND rate_date = v_yesterday
) INTO v_fx_eur_exists;
IF NOT v_fx_usd_exists OR NOT v_fx_eur_exists THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object(
'label', 'FX Rates',
'value', format('USD: %s, EUR: %s for %s',
CASE WHEN v_fx_usd_exists THEN 'OK' ELSE 'MISSING' END,
CASE WHEN v_fx_eur_exists THEN 'OK' ELSE 'MISSING' END,
v_yesterday),
'inline', true
)
);
END IF;
-- ── Meta Sync Check ──
SELECT COUNT(*) INTO v_meta_count
FROM ad_campaigns
WHERE platform = 'meta' AND date = v_yesterday;
IF NOT EXISTS (
SELECT 1 FROM ops_events
WHERE entity_type = 'ad_spend_sync'
AND meta->>'provider' = 'meta'
AND (meta->>'date')::date = v_yesterday
AND kind IN ('SUCCESS', 'PARTIAL')
AND created_at > now() - interval '24 hours'
) THEN
-- Only alert if Meta sync job is active
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'fin-ad-spend-meta' AND active = true) THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Meta Sync', 'value', 'NO SUCCESS event for ' || v_yesterday, 'inline', true)
);
END IF;
END IF;
-- ── Google Sync Check ──
SELECT COUNT(*) INTO v_google_count
FROM ad_campaigns
WHERE platform = 'google' AND date = v_yesterday;
IF NOT EXISTS (
SELECT 1 FROM ops_events
WHERE entity_type = 'ad_spend_sync'
AND meta->>'provider' = 'google'
AND (meta->>'date')::date = v_yesterday
AND kind IN ('SUCCESS', 'PARTIAL')
AND created_at > now() - interval '24 hours'
) THEN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'fin-ad-spend-google' AND active = true) THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Google Sync', 'value', 'NO SUCCESS event for ' || v_yesterday, 'inline', true)
);
END IF;
END IF;
-- ── Rolling 7-Day Failure Count ──
SELECT COUNT(*) INTO v_failed_7d
FROM ops_events
WHERE entity_type IN ('ad_spend_sync', 'fx_rate_sync')
AND kind = 'FAILED'
AND created_at > now() - interval '7 days';
IF v_failed_7d >= 3 THEN
v_alert_needed := true;
v_alert_severity := 'critical';
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Failed Syncs (7d)', 'value', v_failed_7d::text, 'inline', true)
);
ELSIF v_failed_7d >= 1 THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Failed Syncs (7d)', 'value', v_failed_7d::text, 'inline', true)
);
END IF;
-- ── Meta Row Count Variance (7-day rolling) ──
SELECT AVG(cnt), STDDEV(cnt) INTO v_meta_7d_avg, v_meta_7d_stddev
FROM (
SELECT date, COUNT(*) AS cnt
FROM ad_campaigns
WHERE platform = 'meta' AND date >= v_yesterday - 7 AND date < v_yesterday
GROUP BY date
) sub;
IF v_meta_7d_avg IS NOT NULL AND v_meta_7d_stddev IS NOT NULL AND v_meta_7d_stddev > 0 THEN
IF ABS(v_meta_count - v_meta_7d_avg) > 2 * v_meta_7d_stddev THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Meta Variance',
'value', format('Today: %s, 7d avg: %s (+/-%s)', v_meta_count, round(v_meta_7d_avg,1), round(v_meta_7d_stddev,1)),
'inline', false)
);
END IF;
END IF;
-- ── Google Row Count Variance (7-day rolling) ──
SELECT AVG(cnt), STDDEV(cnt) INTO v_google_7d_avg, v_google_7d_stddev
FROM (
SELECT date, COUNT(*) AS cnt
FROM ad_campaigns
WHERE platform = 'google' AND date >= v_yesterday - 7 AND date < v_yesterday
GROUP BY date
) sub;
IF v_google_7d_avg IS NOT NULL AND v_google_7d_stddev IS NOT NULL AND v_google_7d_stddev > 0 THEN
IF ABS(v_google_count - v_google_7d_avg) > 2 * v_google_7d_stddev THEN
v_alert_needed := true;
v_alert_fields := v_alert_fields || jsonb_build_array(
jsonb_build_object('label', 'Google Variance',
'value', format('Today: %s, 7d avg: %s (+/-%s)', v_google_count, round(v_google_7d_avg,1), round(v_google_7d_stddev,1)),
'inline', false)
);
END IF;
END IF;
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
-- Update monitoring run
UPDATE monitoring_runs
SET
status = 'success',
result_json = jsonb_build_object(
'yesterday', v_yesterday,
'fx_usd', v_fx_usd_exists,
'fx_eur', v_fx_eur_exists,
'meta_campaigns', v_meta_count,
'google_campaigns', v_google_count,
'failed_7d', v_failed_7d,
'alert_needed', v_alert_needed
),
result_summary = format('FX: USD=%s EUR=%s | Meta: %s | Google: %s | Failed 7d: %s',
CASE WHEN v_fx_usd_exists THEN 'OK' ELSE 'MISS' END,
CASE WHEN v_fx_eur_exists THEN 'OK' ELSE 'MISS' END,
v_meta_count, v_google_count, v_failed_7d),
duration_ms = v_duration_ms
WHERE id = v_run_id;
-- Return in run-monitor MonitorResult format
RETURN jsonb_build_object(
'success', true,
'run_id', v_run_id,
'check_name', 'ad_spend_freshness',
'duration_ms', v_duration_ms,
'alert_config', CASE
WHEN NOT v_alert_needed THEN NULL
ELSE jsonb_build_object(
'channel', CASE WHEN v_alert_severity = 'critical' THEN 'ops-urgent' ELSE 'ops-alerts' END,
'severity', v_alert_severity,
'title', 'Ad Spend Sync Health Check',
'message', format('Issues detected for %s', v_yesterday),
'fields', v_alert_fields
)
END
);
EXCEPTION WHEN OTHERS THEN
v_duration_ms := EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time)) * 1000;
UPDATE monitoring_runs
SET status = 'error', duration_ms = v_duration_ms, error_message = SQLERRM
WHERE id = v_run_id;
RETURN jsonb_build_object(
'success', false,
'run_id', v_run_id,
'check_name', 'ad_spend_freshness',
'error', SQLERRM,
'duration_ms', v_duration_ms
);
END;
$$;
REVOKE ALL ON FUNCTION raw_ops.fn_check_ad_spend_freshness_v2() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION raw_ops.fn_check_ad_spend_freshness_v2() TO service_role;
COMMENT ON FUNCTION raw_ops.fn_check_ad_spend_freshness_v2 IS '[SOP-MON-01](../monitoring/SOP_MON_01_Monitoring_Alerting_Architecture_v1_0.md) compliant ad spend freshness monitor. [SOP-FIN-01](SOP-FIN-01_Ad_Spend_Sync_v1_2 (1).md).';
Metabase Dashboards¶
Dashboard 1: "Ad Spend Sync Health" (Operations)¶
Monitors the health of the sync pipeline itself. Reviewed when alerts fire or during ops standup.
| Tile | Query Source |
|---|---|
| Last Successful Sync by Provider | SELECT meta->>'provider' AS provider, MAX(created_at) AS last_sync FROM raw_ops.ops_events WHERE entity_type = 'ad_spend_sync' AND kind IN ('SUCCESS','PARTIAL') GROUP BY meta->>'provider' |
| Degraded Syncs (PARTIAL) last 7d | SELECT created_at, meta FROM raw_ops.ops_events WHERE entity_type = 'ad_spend_sync' AND kind = 'PARTIAL' AND created_at > now() - interval '7 days' ORDER BY created_at DESC |
| Failed Syncs last 7d | SELECT created_at, meta FROM raw_ops.ops_events WHERE entity_type IN ('ad_spend_sync','fx_rate_sync') AND kind = 'FAILED' AND created_at > now() - interval '7 days' ORDER BY created_at DESC |
| FX Rate Freshness | SELECT base_currency, rate, rate_date FROM raw_ops.currency_rates WHERE rate_date = (now() AT TIME ZONE 'Europe/London')::date - 1 ORDER BY base_currency |
| Fallback FX Rows (last 7d) | SELECT date, platform, COUNT(*) FROM raw_ops.ad_campaigns WHERE fx_rate_is_fallback = true AND date > (now() AT TIME ZONE 'Europe/London')::date - 8 GROUP BY date, platform ORDER BY date DESC |
| Campaign Count by Platform (yesterday) | SELECT platform, COUNT(DISTINCT campaign_id) FROM raw_ops.ad_campaigns WHERE date = (now() AT TIME ZONE 'Europe/London')::date - 1 GROUP BY platform |
Dashboard 2: "Ad Spend & CAC" (Business)¶
Business metrics for the Weekly Growth Review. Read by the founder and eventually the marketing lead.
| Tile | Query Source |
|---|---|
| Yesterday's Total Spend (GBP) | SELECT SUM(spend_gbp) FROM raw_ops.ad_campaigns WHERE date = (now() AT TIME ZONE 'Europe/London')::date - 1 |
| Spend by Platform (last 30d) | analytics.v_cac_daily filtered last 30 days, grouped by platform. Line chart. |
| Daily CPA Trend (last 30d) | analytics.v_cac_daily line chart, cpa_gbp by date, split by platform |
| Weekly Spend & CPA | analytics.v_cac_weekly bar (spend) + line (CPA) chart |
| Blended CAC vs Target | analytics.v_cac_daily aggregated across platforms, compared to Phase A target (£80-100) |
| Data Quality Indicator | SELECT bool_or(includes_fallback_fx) FROM analytics.v_cac_daily WHERE date = (now() AT TIME ZONE 'Europe/London')::date - 1 — show warning badge if true |
Operational Runbook¶
Daily Schedule (Automated)¶
| Time (UTC) | London (GMT) | London (BST) | Job | What Happens |
|---|---|---|---|---|
| 07:55 | 07:55 | 08:55 | fin-fx-sync |
Fetch USD→GBP, EUR→GBP rates for D-1 |
| 08:00 | 08:00 | 09:00 | fin-ad-spend-meta |
Fetch Meta D-1 spend (bulk) |
| 08:05 | 08:05 | 09:05 | fin-ad-spend-google |
Fetch Google D-1 spend (bulk) |
| 08:30 | 08:30 | 09:30 | monitor-ad-spend-freshness |
Check all data arrived, alert if not |
Troubleshooting¶
Scenario: Meta API returns 401
- Cause: System user token revoked or permissions changed
- Check: SELECT * FROM raw_ops.ops_events WHERE entity_type = 'ad_spend_sync' AND meta->>'provider' = 'meta' ORDER BY created_at DESC LIMIT 5;
- Fix: Regenerate system user token in Business Manager, update secret: supabase secrets set META_ACCESS_TOKEN=<new_value>
Scenario: Google API returns invalid_grant
- Cause: Refresh token revoked (user changed password, app access revoked)
- Fix: Re-authorize via OAuth Playground, update secret: supabase secrets set GOOGLE_REFRESH_TOKEN=<new_value>
Scenario: Google API returns 403 with "developer token not approved"
- Cause: Developer token not yet approved by Google, or wrong token used
- Check: Verify GOOGLE_ADS_DEVELOPER_TOKEN secret matches the approved token in the Google Ads API Center
- Fix: If pending approval, wait for Google. If wrong token, update secret.
Scenario: Data present but spend_gbp is 0 when spend_local is not
- Cause: FX rate missing for that currency/date, or fx_rate_to_gbp is 0
- Check: SELECT * FROM raw_ops.currency_rates WHERE rate_date = '2026-03-18';
- Fix: Manually trigger FX sync, then re-run the ad spend sync for that date to overwrite with correct rates
Scenario: Rows show fx_rate_is_fallback = true
- Cause: Import-date FX rate was unavailable; previous day's rate used instead
- Action: Check if FX sync failed that day. Once fresh rates are available, re-run the affected date (see Retry Policy). The UPSERT will overwrite with correct rate and clear the fallback flag.
Scenario: Monitoring alert but data is actually present
- Cause: Sync completed after the 08:30 monitor ran
- Check: SELECT * FROM raw_ops.ops_events WHERE entity_type = 'ad_spend_sync' AND created_at > now() - interval '2 hours' ORDER BY created_at DESC;
- Action: If data arrived late, no action needed. If this happens repeatedly, investigate API latency or adjust monitor schedule.
Scenario: Campaign count drops to 0 unexpectedly
- Causes: All campaigns paused (legitimate), API auth issue returning empty (check ops_events status — SUCCESS with 0 campaigns vs FAILED)
- Check: Verify in Meta/Google dashboards that campaigns are actually paused
Launch Activation Checklist¶
Phase 1: FX Sync and Config (deploy first)¶
- [ ] Create
raw_ops.currency_ratestable - [ ] Create
raw_ops.ad_accountstable and seed Phase A accounts - [ ] Create
raw_ops.fn_import_fx_rates_v1()function (with REVOKE/GRANT) - [ ] Deploy
fin-fx-syncEdge Function - [ ] Set
EXCHANGERATE_API_KEYsecret - [ ] Create
fin-fx-syncpg_cron job - [ ] Manually trigger and verify rates appear in database (check both USD→GBP and EUR→GBP derived rates)
- [ ] Verify ops_events log entry with type
fx_rate_sync
Phase 2: Meta Sync¶
- [ ] Create
raw_ops.ad_campaignstable (DROP existing from SOP-04 if schema differs) - [ ] Create
raw_ops.fn_import_ad_spend_v1()function (with REVOKE/GRANT) - [ ] Deploy
fin-ad-spend-provider-syncEdge Function - [ ] Set
META_ACCESS_TOKENsecret - [ ] Create
fin-ad-spend-metapg_cron job (initially disabled) - [ ] Manually trigger with
{"provider": "meta", "date": "YYYY-MM-DD"}for a known spend day - [ ] Verify data in
ad_campaigns:spend_gbpcomputed correctly,fx_rate_is_fallback= false,ops_eventslogged - [ ] Enable pg_cron job
Phase 3: Google Sync (when first Google campaign launches)¶
- [ ] Verify Google Ads account has billing configured
- [ ] Verify Developer Token is approved in Google Ads API Center
- [ ] Set secrets:
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REFRESH_TOKEN,GOOGLE_ADS_DEVELOPER_TOKEN - [ ] Set
GOOGLE_ADS_LOGIN_CUSTOMER_IDif MCC structure applies - [ ] Create
fin-ad-spend-googlepg_cron job (initially disabled) - [ ] Manually trigger with
{"provider": "google", "date": "YYYY-MM-DD"} - [ ] Verify data in
ad_campaigns - [ ] Enable pg_cron job
Phase 4: Monitoring & Dashboards¶
- [ ] Create
raw_ops.fn_check_ad_spend_freshness_v2()function (with REVOKE/GRANT) - [ ] Add
ad_spend_freshnesscase torun-monitorEdge Function - [ ] Create
monitor-ad-spend-freshnesspg_cron job - [ ] Create
analytics.v_cac_dailyandanalytics.v_cac_weeklyviews - [ ] Build Metabase "Ad Spend Sync Health" dashboard (ops)
- [ ] Build Metabase "Ad Spend & CAC" dashboard (business)
- [ ] Test alert by temporarily disabling the Meta sync job and waiting for monitor
Phase 5: Cleanup¶
- [ ] Deactivate Make.com scenario: "SOP 04: Currency Rate Sync"
- [ ] Deactivate Make.com scenario: "SOP 04: Meta Ads Daily Sync"
- [ ] Deactivate Make.com scenario: "SOP 04: Google Ads Daily Sync"
- [ ] Deactivate Make.com scenario: "Ad Spend CSV Import"
- [ ] Update Technical Operations Handbook to reflect SOP-FIN-01 replacing SOP-04
Phase 2: CAC Attribution (Post-Launch)¶
Status: Blocked on SOP-ORD-01 v5.1¶
This SOP currently delivers spend visibility — how much was spent on each platform per day. It does not yet deliver actual CAC because that requires joining spend data against attributed customer acquisitions.
What's Missing¶
The raw_ops.orders table does not currently extract UTM parameters into queryable columns. Shopify captures UTMs via landing_site and referring_site fields in the order webhook payload, but these are buried in webhook_inbox.raw_payload and not parsed into first-class columns.
Dependency: SOP-ORD-01 v5.1¶
The order ingestion pipeline (SOP-ORD-01) needs a v5.1 update to:
- Parse UTM params from Shopify webhook
landing_siteURL during order processing - Store as columns on
raw_ops.orders:
ALTER TABLE raw_ops.orders ADD COLUMN IF NOT EXISTS
utm_source text, -- 'meta', 'google', 'organic', 'referral', etc.
utm_medium text, -- 'paid', 'cpc', 'email', etc.
utm_campaign text, -- campaign name
utm_content text; -- creative/variant ID
CREATE INDEX IF NOT EXISTS idx_orders_utm_source ON raw_ops.orders (utm_source) WHERE utm_source IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_orders_created_date_utm ON raw_ops.orders (((created_at AT TIME ZONE 'Europe/London')::date), utm_source);
This belongs in SOP-ORD-01 because UTM data on orders benefits more than just CAC: referral attribution, lifecycle segmentation, cohort analysis by acquisition channel, and Box-2 retention by source.
Target View: analytics.v_cac_attributed¶
Once UTM columns exist on orders, create this view:
CREATE OR REPLACE VIEW analytics.v_cac_attributed AS
WITH daily_spend AS (
SELECT
date,
platform,
SUM(spend_gbp) AS spend_gbp
FROM raw_ops.ad_campaigns
GROUP BY date, platform
),
daily_acquisitions AS (
SELECT
(o.created_at AT TIME ZONE 'Europe/London')::date AS date,
CASE
WHEN o.utm_source = 'meta' THEN 'meta'
WHEN o.utm_source = 'google' THEN 'google'
WHEN o.utm_source = 'referral' THEN 'referral'
ELSE 'organic'
END AS channel,
COUNT(DISTINCT o.customer_id) FILTER (
WHERE o.id = (
SELECT MIN(o2.id) FROM raw_ops.orders o2
WHERE o2.customer_id = o.customer_id
)
) AS new_customers
FROM raw_ops.orders o
WHERE o.status = 'PAID'
GROUP BY 1, 2
)
SELECT
COALESCE(s.date, a.date) AS date,
COALESCE(s.platform, a.channel) AS channel,
COALESCE(s.spend_gbp, 0) AS spend_gbp,
COALESCE(a.new_customers, 0) AS new_customers,
CASE WHEN COALESCE(a.new_customers, 0) > 0
THEN COALESCE(s.spend_gbp, 0) / a.new_customers
ELSE NULL
END AS cac_gbp
FROM daily_spend s
FULL OUTER JOIN daily_acquisitions a
ON s.date = a.date AND s.platform = a.channel;
COMMENT ON VIEW analytics.v_cac_attributed IS 'Daily CAC by channel: ad spend joined against attributed new customer orders. [SOP-FIN-01](SOP-FIN-01_Ad_Spend_Sync_v1_2 (1).md) Phase 2.';
GRANT SELECT ON analytics.v_cac_attributed TO authenticated;
Note: The new_customers subquery identifies first-time buyers by checking if the order is the customer's earliest order. This avoids counting repeat orders as acquisitions. The logic may need refinement based on how subscription renewals are recorded (they should not count as new customer acquisitions).
Blended CAC View: analytics.v_cac_blended¶
CREATE OR REPLACE VIEW analytics.v_cac_blended AS
SELECT
date,
SUM(spend_gbp) AS total_spend_gbp,
SUM(new_customers) AS total_new_customers,
CASE WHEN SUM(new_customers) > 0
THEN SUM(spend_gbp) / SUM(new_customers)
ELSE NULL
END AS blended_cac_gbp
FROM analytics.v_cac_attributed
WHERE channel IN ('meta', 'google') -- only paid channels in blended CAC
GROUP BY date;
GRANT SELECT ON analytics.v_cac_blended TO authenticated;
Metabase Additions (Phase 2)¶
Once attribution is live, add to the "Ad Spend & CAC" business dashboard:
| Tile | Query Source |
|---|---|
| Blended CAC (rolling 7d) | analytics.v_cac_blended — 7-day rolling average |
| CAC by Channel (last 30d) | analytics.v_cac_attributed — line chart, cac_gbp by date per channel |
| New Customers by Channel (last 30d) | analytics.v_cac_attributed — stacked bar, new_customers by channel |
| CAC vs Target | analytics.v_cac_blended — blended CAC line vs £80-100 Phase A target band |
Implementation Timeline¶
- SOP-ORD-01 v5.1: Add UTM columns to orders. Can be implemented immediately as a standalone change.
- SOP-FIN-01 Phase 2 views: Create once UTM data is flowing. Views are backward-compatible (they'll return empty results until orders with UTMs exist).
- Target: Week 1-2 of Phase A. CAC data is critical for the Weekly Growth Review from launch.
Security¶
Cron-to-Edge Authentication¶
All pg_cron jobs in this SOP call Edge Functions via pg_net with:
This is the established project-wide pattern used across SOP-MON-01, SOP-HDI-01, and SOP-ORD-01. The contract:
- What credential: The Supabase
service_roleJWT, which provides full database access bypassing RLS. - Where stored:
app.settings.service_role_keyis a Supabase-managed project setting, set automatically by the platform. It is not a user-created database config value. - Who can read it: Only database sessions running as
postgres(superuser) or viapg_cronexecution context. Not readable byanon,authenticated, orservice_roleSQL sessions through the PostgREST API. Not exposed via any Supabase client. - How Edge Functions validate: Each Edge Function reads the
Authorizationheader and verifies it is a valid Supabase JWT. The Supabase Edge Function runtime handles this automatically when the function is invoked with--no-verify-jwtdisabled (the default). - Why this does not broaden blast radius: The service role key already exists as a Supabase secret accessible to Edge Functions via
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'). Using it in pg_cron→pg_net calls does not create a new secret surface — it re-uses the existing platform credential within the platform boundary.
General Security Posture¶
- RLS enabled on
ad_campaigns,currency_rates, andad_accounts. Service role bypasses. No anon/authenticated write access. - All functions are
SECURITY DEFINERwithSET search_path = raw_ops, public— execute with owner privileges, immune to search_path injection. - All functions have
REVOKE ALL FROM PUBLICand explicitGRANT EXECUTE TO service_role— not callable from anon or authenticated contexts. - No dynamic SQL in any function. All queries use parameterized inputs via PL/pgSQL variables.
- No credentials in code or database. All secrets in Supabase Edge Function environment variables.
Version History¶
| Version | Date | Changes | Author |
|---|---|---|---|
| 1.2 | 2026-03-19 | Fixed FX cross-rate derivation. Added ad_accounts config table. Explicit retry policy (no auto-retry Phase A). Phase 2 CAC Attribution section with v_cac_attributed view spec. | Protocol Raw Operations |
| 1.1 | 2026-03-19 | Added Google Ads developer token. Hardened SECURITY DEFINER. London timezone standardised. FX fallback marking. Google variance monitoring. Split dashboards. Edge Function renamed. Cron auth documented. | Protocol Raw Operations |
| 1.0 | 2026-03-19 | Complete rewrite from SOP-04. Supabase-native architecture. Make.com removed. Bulk API calls. FX in PostgreSQL. SOP-MON-01 monitoring. | Protocol Raw Operations |
SOP-FIN-01: Ad Spend Sync v1.2 Replaces: SOP-04 v4.0 Status: 🔵 Ready to Deploy