From 230c675b8f7675e4e0f392994225ba46e60f8c99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:24:16 +0000 Subject: [PATCH] feat: modelo de negocio de sponsors por tier (Oro/Plata/Bronce) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolution 32: agrega unit_type 'publication' y 'platform' a sponsor_agreements, restringe qué contenido puede auspiciar cada tier (chk_agreement_tier_scope) y garantiza con índices únicos parciales que Oro sea exclusivo a nivel de toda la plataforma (no por unidad) y que Plata tenga como máximo un acuerdo activo por unidad. publications gana sponsor_id/sponsor_label/sponsor_show_public igual que seasons/ collections/events. El límite de 2 sponsors Bronce activos por unidad no se puede expresar como índice único, así que se aplica con un trigger fuera de las evolutions de Play (sql/trigger_sponsor_bronze_limit.sql), siguiendo el mismo patrón que trigger_close_previous_stage.sql. docs/SPONSOR_TIER_MODEL.md documenta la evaluación de la evolution 31, las reglas de negocio resultantes y los próximos pasos de capa de aplicación (modelos, repos, admin UI, partial de badge). Validado aplicando las 32 evolutions contra Postgres real y probando las 10 reglas de negocio (exclusividad de Oro, alcance por tier, exclusividad de Plata, límite de Bronce). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011GvPuvd3UBUf1hPFRHNEza --- conf/evolutions/default/32.sql | 178 +++++++++++++++++++++++++ docs/SPONSOR_TIER_MODEL.md | 189 +++++++++++++++++++++++++++ sql/trigger_sponsor_bronze_limit.sql | 49 +++++++ 3 files changed, 416 insertions(+) create mode 100644 conf/evolutions/default/32.sql create mode 100644 docs/SPONSOR_TIER_MODEL.md create mode 100644 sql/trigger_sponsor_bronze_limit.sql diff --git a/conf/evolutions/default/32.sql b/conf/evolutions/default/32.sql new file mode 100644 index 0000000..907734e --- /dev/null +++ b/conf/evolutions/default/32.sql @@ -0,0 +1,178 @@ +# --- Modelo de negocio de sponsors: tiers Oro/Plata/Bronce con alcance de contenido +# --- +# --- Ajusta la evolution 31 para reflejar las reglas de negocio definitivas: +# --- +# --- 1. Oro -> auspicia TODO el contenido (temporadas, colecciones, eventos, +# --- publicaciones, newsletter) mediante un único acuerdo "platform". +# --- EXCLUSIVO: solo puede haber un acuerdo Oro activo en toda la +# --- plataforma al mismo tiempo (privilegio de exclusividad). +# --- 2. Plata -> auspicia publicaciones, eventos y newsletter (NO temporadas +# --- ni colecciones). Un acuerdo Plata activo por unidad como máximo. +# --- 3. Bronce -> auspicia eventos y publicaciones únicamente. Hasta 2 acuerdos +# --- Bronce activos por unidad (enforcement vía trigger, ver +# --- sql/trigger_sponsor_bronze_limit.sql). +# --- +# --- Cambios: +# --- - sponsor_agreements: nuevo unit_type 'publication' (+ FK publication_id) +# --- y 'platform' (acuerdo Oro, sin FK a ninguna unidad concreta). +# --- - chk_agreement_tier_scope: fija qué unit_type puede usar cada tier. +# --- - Índices únicos parciales que garantizan la exclusividad de Oro y el +# --- límite de 1 acuerdo Plata activo por unidad (Bronce vía trigger externo). +# --- - publications gana sponsor_id/sponsor_label/sponsor_show_public, igual +# --- que editorial_seasons/collections/community_events en la evolution 31. +# --- - sponsor_deliverables gana tipos de entregable exclusivos de Oro +# --- (homepage_banner, site_header_badge) para la vidriera de "todo el sitio". + +# --- !Ups + +-- ============================================================ +-- 1. SPONSOR_AGREEMENTS — nuevo unit_type 'publication' y 'platform' +-- ============================================================ + +ALTER TABLE sponsor_agreements + ADD COLUMN IF NOT EXISTS publication_id BIGINT REFERENCES publications(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_agreements_publication + ON sponsor_agreements(publication_id) WHERE publication_id IS NOT NULL; + +ALTER TABLE sponsor_agreements + DROP CONSTRAINT IF EXISTS chk_agreement_unit_type; + +ALTER TABLE sponsor_agreements + ADD CONSTRAINT chk_agreement_unit_type + CHECK (unit_type IN ('season', 'collection', 'event', 'newsletter', 'publication', 'platform')); + +ALTER TABLE sponsor_agreements + DROP CONSTRAINT IF EXISTS chk_unit_coherence; + +-- Exactamente una FK de unidad definida según unit_type. +-- 'newsletter' y 'platform' no referencian ninguna unidad concreta: +-- newsletter es un único producto; platform es "todo el sitio". +ALTER TABLE sponsor_agreements + ADD CONSTRAINT chk_unit_coherence CHECK ( + (unit_type = 'season' AND season_id IS NOT NULL AND collection_id IS NULL AND event_id IS NULL AND publication_id IS NULL) OR + (unit_type = 'collection' AND collection_id IS NOT NULL AND season_id IS NULL AND event_id IS NULL AND publication_id IS NULL) OR + (unit_type = 'event' AND event_id IS NOT NULL AND season_id IS NULL AND collection_id IS NULL AND publication_id IS NULL) OR + (unit_type = 'publication' AND publication_id IS NOT NULL AND season_id IS NULL AND collection_id IS NULL AND event_id IS NULL) OR + (unit_type = 'newsletter' AND season_id IS NULL AND collection_id IS NULL AND event_id IS NULL AND publication_id IS NULL) OR + (unit_type = 'platform' AND season_id IS NULL AND collection_id IS NULL AND event_id IS NULL AND publication_id IS NULL) + ); + +-- Regla de negocio central: qué unit_type puede usar cada tier. +-- Oro solo vende el paquete "platform" (todo el contenido a la vez). +-- Plata: publicaciones + eventos + newsletter. Bronce: eventos + publicaciones. +ALTER TABLE sponsor_agreements + ADD CONSTRAINT chk_agreement_tier_scope CHECK ( + (tier = 'gold' AND unit_type = 'platform') OR + (tier = 'silver' AND unit_type IN ('event', 'newsletter', 'publication')) OR + (tier = 'bronze' AND unit_type IN ('event', 'publication')) + ); + +-- ============================================================ +-- 2. EXCLUSIVIDAD — índices únicos parciales +-- ============================================================ + +-- Privilegio de exclusividad de Oro: como máximo UN acuerdo Oro +-- activo en TODA la plataforma al mismo tiempo (no por unidad). +CREATE UNIQUE INDEX IF NOT EXISTS idx_sponsor_gold_exclusive + ON sponsor_agreements(tier) + WHERE tier = 'gold' AND status = 'active'; + +-- Plata: un acuerdo activo como máximo por evento / publicación / newsletter. +CREATE UNIQUE INDEX IF NOT EXISTS idx_sponsor_silver_event_exclusive + ON sponsor_agreements(event_id) + WHERE tier = 'silver' AND unit_type = 'event' AND status = 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sponsor_silver_publication_exclusive + ON sponsor_agreements(publication_id) + WHERE tier = 'silver' AND unit_type = 'publication' AND status = 'active'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sponsor_silver_newsletter_exclusive + ON sponsor_agreements(unit_type) + WHERE tier = 'silver' AND unit_type = 'newsletter' AND status = 'active'; + +-- Bronce (hasta 2 activos por unidad) NO se puede expresar con un índice +-- único parcial (no es un límite de 1). Se aplica con un trigger definido +-- fuera de las evoluciones de Play: ver sql/trigger_sponsor_bronze_limit.sql +-- (Play no soporta PL/pgSQL con dollar-quoting, igual que en la evolution 16). + +-- ============================================================ +-- 3. PUBLICATIONS — campos de sponsoreo (igual que seasons/collections/events) +-- ============================================================ + +ALTER TABLE publications + ADD COLUMN IF NOT EXISTS sponsor_id BIGINT REFERENCES sponsors(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS sponsor_label VARCHAR(200), + ADD COLUMN IF NOT EXISTS sponsor_show_public BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_publications_sponsor + ON publications(sponsor_id) WHERE sponsor_id IS NOT NULL; + +-- ============================================================ +-- 4. SPONSOR_DELIVERABLES — superficies exclusivas de Oro (todo el sitio) +-- ============================================================ + +ALTER TABLE sponsor_deliverables + DROP CONSTRAINT IF EXISTS chk_deliverable_type; + +ALTER TABLE sponsor_deliverables + ADD CONSTRAINT chk_deliverable_type CHECK ( + deliverable_type IN ( + 'newsletter_mention', 'season_cover', 'article_footer', + 'event_logo', 'event_slot', 'case_study', 'metrics_report', + 'collection_cover', 'opening_essay_mention', + 'homepage_banner', 'site_header_badge' + ) + ); + +# --- !Downs + +ALTER TABLE sponsor_deliverables + DROP CONSTRAINT IF EXISTS chk_deliverable_type; + +ALTER TABLE sponsor_deliverables + ADD CONSTRAINT chk_deliverable_type CHECK ( + deliverable_type IN ( + 'newsletter_mention', 'season_cover', 'article_footer', + 'event_logo', 'event_slot', 'case_study', 'metrics_report', + 'collection_cover', 'opening_essay_mention' + ) + ); + +DROP INDEX IF EXISTS idx_publications_sponsor; + +ALTER TABLE publications + DROP COLUMN IF EXISTS sponsor_show_public, + DROP COLUMN IF EXISTS sponsor_label, + DROP COLUMN IF EXISTS sponsor_id; + +DROP INDEX IF EXISTS idx_sponsor_silver_newsletter_exclusive; +DROP INDEX IF EXISTS idx_sponsor_silver_publication_exclusive; +DROP INDEX IF EXISTS idx_sponsor_silver_event_exclusive; +DROP INDEX IF EXISTS idx_sponsor_gold_exclusive; + +ALTER TABLE sponsor_agreements + DROP CONSTRAINT IF EXISTS chk_agreement_tier_scope; + +ALTER TABLE sponsor_agreements + DROP CONSTRAINT IF EXISTS chk_unit_coherence; + +ALTER TABLE sponsor_agreements + ADD CONSTRAINT chk_unit_coherence CHECK ( + (unit_type = 'season' AND season_id IS NOT NULL AND collection_id IS NULL AND event_id IS NULL) OR + (unit_type = 'collection' AND collection_id IS NOT NULL AND season_id IS NULL AND event_id IS NULL) OR + (unit_type = 'event' AND event_id IS NOT NULL AND season_id IS NULL AND collection_id IS NULL) OR + (unit_type = 'newsletter' AND season_id IS NULL AND collection_id IS NULL AND event_id IS NULL) + ); + +ALTER TABLE sponsor_agreements + DROP CONSTRAINT IF EXISTS chk_agreement_unit_type; + +ALTER TABLE sponsor_agreements + ADD CONSTRAINT chk_agreement_unit_type + CHECK (unit_type IN ('season', 'collection', 'event', 'newsletter')); + +DROP INDEX IF EXISTS idx_agreements_publication; + +ALTER TABLE sponsor_agreements + DROP COLUMN IF EXISTS publication_id; diff --git a/docs/SPONSOR_TIER_MODEL.md b/docs/SPONSOR_TIER_MODEL.md new file mode 100644 index 0000000..20e82ac --- /dev/null +++ b/docs/SPONSOR_TIER_MODEL.md @@ -0,0 +1,189 @@ +# Modelo de negocio: Sponsors por tier (Oro / Plata / Bronce) + +Este documento evalúa el esquema de sponsors introducido en la evolution 31 +(`conf/evolutions/default/31.sql`) y define el modelo de negocio definitivo, +implementado en la evolution 32 (`conf/evolutions/default/32.sql`). + +## 1. Evaluación de la evolution 31 + +La 31 sentó una base sólida (catálogo `sponsors`, contratos +`sponsor_agreements`, entregables `sponsor_deliverables`, workflow de +estados), pero no reflejaba el modelo de tiers real: + +| Problema en 31 | Efecto | +|---|---| +| `unit_type` solo tenía `season/collection/event/newsletter` | No existía forma de auspiciar una **publicación** puntual — el pilar de contenido más frecuente del sitio. | +| Oro se modelaba igual que Plata/Bronce (un acuerdo por unidad) | Contradice "Oro auspicia todo el contenido": un sponsor Oro necesitaría N acuerdos (uno por temporada, uno por colección, uno por evento...) para lograr lo que debería ser un único paquete. | +| La exclusividad de Oro/Plata era solo un comentario ("enforcement en app") | Sin restricción en la base de datos, un bug de aplicación podía activar dos sponsors Oro a la vez — justo el privilegio que no se puede violar. | +| No había regla que impidiera que un Bronce comprara "temporada" o un Silver comprara "colección" | El límite de alcance por tier no estaba garantizado en ningún nivel. | + +La evolution 32 corrige los cuatro puntos sin romper lo existente (la tabla +`sponsor_agreements` está vacía hasta ahora — no hay backfill que resolver). + +## 2. El modelo de tiers + +| Tier | Auspicia | Alcance | Concurrencia | +|---|---|---|---| +| **Oro** | Temporadas + Colecciones + Eventos + Publicaciones + Newsletter (**todo**) | Sitio completo, un único acuerdo `platform` | **Exclusivo: 1 activo en toda la plataforma** | +| **Plata** | Publicaciones + Eventos + Newsletter | Por unidad (una publicación, un evento, o el newsletter) | 1 activo por unidad | +| **Bronce** | Eventos + Publicaciones | Por unidad | Hasta 2 activos por unidad | + +La idea clave: **el tier no es "elegí qué patrocino", es "qué tan grande es +el paquete y qué tan exclusivo es"**. Oro no elige una temporada — compra la +presencia de marca en cada superficie del sitio a la vez, y paga un precio +premium justamente porque **nadie más puede tener ese lugar mientras dura su +contrato**. + +### 2.1 Por qué Oro debe ser un único acuerdo `platform` + +Si Oro se modelara "por unidad" (como Plata/Bronce), un solo sponsor Oro +necesitaría abrir y mantener un acuerdo por cada temporada, colección y +evento — y cada vez que se crea contenido nuevo quedaría sin cubrir hasta +que alguien recuerde firmar un nuevo acuerdo. Eso rompe la promesa de "todo +el contenido". + +En su lugar, `sponsor_agreements` gana `unit_type = 'platform'`: un acuerdo +sin FK a ninguna unidad concreta (igual que `newsletter`, que ya no +referenciaba nada). Ese acuerdo es la fuente de verdad de "quién es el +sponsor Oro vigente"; la capa de aplicación lo consulta una sola vez (con +caché de request) para pintar el badge en header, footer, temporadas, +colecciones, eventos y publicaciones — sin necesidad de sincronizar N filas. + +### 2.2 Cómo se garantiza la exclusividad de Oro + +```sql +CREATE UNIQUE INDEX idx_sponsor_gold_exclusive + ON sponsor_agreements(tier) + WHERE tier = 'gold' AND status = 'active'; +``` + +Es un índice único parcial: solo indexa filas con `tier = 'gold' AND status += 'active'`, y como esas filas comparten siempre el mismo valor (`'gold'`), +Postgres rechaza la segunda fila que intente cumplir esa condición. No es +una convención de aplicación — es una invariante que la base de datos hace +cumplir sí o sí, incluso si un bug o un admin intentan saltarla. + +Esto también habilita **rotación de Oro en el tiempo** sin conflicto: la +Empresa A puede ser Oro de enero a junio (`completed`), y la Empresa B desde +julio (`active`) — nunca dos `active` a la vez, pero sí historial completo +para reporting. + +### 2.3 Concurrencia de Plata y Bronce + +- **Plata**: 1 acuerdo activo por evento / por publicación / y uno para el + newsletter — garantizado con tres índices únicos parciales análogos al de + Oro (`idx_sponsor_silver_event_exclusive`, + `idx_sponsor_silver_publication_exclusive`, + `idx_sponsor_silver_newsletter_exclusive`). +- **Bronce**: hasta 2 activos por unidad. Un límite de "hasta N" (N > 1) no + se puede expresar como índice único, así que se aplica con un trigger + (`sql/trigger_sponsor_bronze_limit.sql`) que cuenta los acuerdos Bronce + activos de la misma unidad antes de aceptar uno nuevo. Se mantiene fuera + de las evolutions de Play por la misma razón que el trigger de + `publication_stage_history` (evolution 16): el parser de evolutions no + soporta PL/pgSQL con dollar-quoting. + +### 2.4 El alcance por tier también se garantiza en la base + +```sql +ALTER TABLE sponsor_agreements ADD CONSTRAINT chk_agreement_tier_scope CHECK ( + (tier = 'gold' AND unit_type = 'platform') OR + (tier = 'silver' AND unit_type IN ('event', 'newsletter', 'publication')) OR + (tier = 'bronze' AND unit_type IN ('event', 'publication')) +); +``` + +Un intento de vender "Bronce + temporada" o "Plata + colección" falla al +insertar el acuerdo — no depende de que el código de la app lo valide bien +cada vez. + +## 3. Visibilidad en cada interfaz + +Para que el usuario asocie cada pieza de contenido con su sponsor +("sensibilizar... a visualizar la empresa que lo auspicia en cada Topic"), +cada tabla de contenido tiene sus propios campos de sponsoreo (denormalizados +para no pagar un join en cada render): + +| Tabla | Campos (desde evolution 31/32) | +|---|---| +| `editorial_seasons` | `sponsor_id`, `sponsor_label`, `sponsor_show_public` | +| `collections` | `sponsor_id`, `sponsor_label`, `sponsor_show_public` | +| `community_events` | `sponsor_id`, `sponsor_label`, `sponsor_show_public` | +| `publications` | `sponsor_id`, `sponsor_label`, `sponsor_show_public` *(nuevo en 32)* | +| Newsletter / sitio completo | Sin columna propia: se resuelve con `SELECT ... FROM sponsor_agreements WHERE tier='gold' AND status='active'` (siempre a lo sumo 1 fila) | + +`sponsor_show_public` permite activar el acuerdo internamente (`status = +'active'`) sin mostrar el badge todavía (por ejemplo, mientras se preparan +los assets de marca) — separa "vigente comercialmente" de "visible al +público". + +## 4. Entregables (deliverables) por tier + +`sponsor_deliverables` ya modelaba el catálogo de beneficios; se agregan dos +tipos exclusivos de Oro para las superficies "todo el sitio": + +| Tier | Deliverables típicos | +|---|---| +| Oro | `homepage_banner`, `site_header_badge`, `newsletter_mention`, `season_cover`, `collection_cover`, `event_logo`, `event_slot`, `article_footer`, `case_study`, `metrics_report` | +| Plata | `newsletter_mention`, `event_logo`, `event_slot`, `article_footer` | +| Bronce | `event_logo`, `article_footer` | + +## 5. Ciclo de vida de un acuerdo (sin cambios respecto a 31) + +``` +draft → negotiating → signed → active → completed | cancelled +``` + +Al pasar a `active` es cuando el índice de exclusividad (Oro/Plata) o el +trigger (Bronce) validan el cupo disponible; si no hay lugar, la transacción +falla y el admin ve el error al intentar activar el acuerdo, no después. + +## 6. Escalabilidad + +- **Nueva superficie de contenido** (ej. podcast, video): agregar el valor + al `CHECK` de `unit_type`, la FK correspondiente, y decidir a qué tiers se + la asigna en `chk_agreement_tier_scope` — no requiere tocar sponsors ni + agreements existentes. +- **Cambiar el límite de Bronce** (2 → 3): es un solo número dentro del + trigger, no una migración de esquema. +- **Un cuarto tier futuro** (ej. "Platino" por encima de Oro, o "Comunidad" + gratuito con visibilidad mínima): se agrega al `CHECK` de `tier` en + `sponsors`/`sponsor_agreements` y se define su fila en + `chk_agreement_tier_scope`. +- El modelo separa con claridad **catálogo** (`sponsors`), **contrato** + (`sponsor_agreements`) y **cumplimiento** (`sponsor_deliverables`), lo que + permite reportar métricas (ingresos por tier, entregables pendientes, + ocupación de cupos Bronce) sin acoplar la lógica comercial a las tablas de + contenido. + +## 7. Pendiente (fuera del alcance de esta evolution) + +El esquema no tiene todavía capa de aplicación: no existen modelos Scala, +repositorios, rutas, controladores ni vistas para sponsors (`grep -r sponsor +app/` no devuelve nada). Próximos pasos sugeridos, en orden: + +1. `app/domains/sponsors/models` — `Sponsor`, `SponsorAgreement`, + `SponsorDeliverable` (siguiendo la convención de otros dominios, ej. + `app/domains/events/models`). +2. `app/domains/sponsors/repositories` — altas/consultas, con el helper + `findActiveGold(): Future[Option[SponsorAgreement]]` como punto de + entrada único para el badge de sitio completo. +3. Admin UI (`app/views/admin`) para gestionar el workflow de acuerdos y + marcar deliverables como entregados. +4. Partial de vista reutilizable (`app/views/partials`) para el badge de + sponsor, parametrizado por tier, usado en seasons/collections/events/ + publications/newsletter. +5. Job periódico (o trigger a la activación) que regenere los + `sponsor_deliverables` de Oro cuando se publica contenido nuevo + (temporada, colección, evento) mientras el acuerdo Oro sigue `active`. + +## 8. Aplicar los cambios + +```bash +# 1. Aplicar evolution 32 (Play la aplica automáticamente al levantar la app, +# o manualmente vía el runner de evolutions habitual del proyecto). + +# 2. Crear el trigger de límite de Bronce (fuera de las evolutions de Play): +docker exec -i reactive_manifesto_db psql -U reactive_user \ + -d reactive_manifesto -f /tmp/trigger_sponsor_bronze_limit.sql +``` diff --git a/sql/trigger_sponsor_bronze_limit.sql b/sql/trigger_sponsor_bronze_limit.sql new file mode 100644 index 0000000..f4e6a20 --- /dev/null +++ b/sql/trigger_sponsor_bronze_limit.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- Trigger: enforce_bronze_sponsor_limit +-- Bronce admite hasta 2 acuerdos ACTIVOS simultáneos por unidad +-- (evento o publicación). Oro y Plata ya se garantizan como +-- máximo 1 activo por unidad mediante índices únicos parciales +-- en la evolution 32 (idx_sponsor_gold_exclusive, +-- idx_sponsor_silver_*_exclusive); un límite de 2 no se puede +-- expresar como índice único, por eso vive en un trigger. +-- +-- Se mantiene fuera de las evoluciones de Play porque el parser +-- no soporta PL/pgSQL (dollar-quoting / string literals), igual +-- que trigger_close_previous_stage.sql para la evolution 16. +-- +-- Ejecutar DESPUÉS de aplicar la evolución 32: +-- docker exec -i reactive_manifesto_db psql -U reactive_user \ +-- -d reactive_manifesto -f /tmp/trigger_sponsor_bronze_limit.sql +-- ============================================================ + +CREATE OR REPLACE FUNCTION enforce_bronze_sponsor_limit() +RETURNS TRIGGER AS $$ +DECLARE + active_count INT; +BEGIN + IF NEW.tier = 'bronze' AND NEW.status = 'active' THEN + SELECT COUNT(*) INTO active_count + FROM sponsor_agreements + WHERE tier = 'bronze' + AND status = 'active' + AND id != NEW.id + AND unit_type = NEW.unit_type + AND ( + (NEW.unit_type = 'event' AND event_id = NEW.event_id) OR + (NEW.unit_type = 'publication' AND publication_id = NEW.publication_id) + ); + + IF active_count >= 2 THEN + RAISE EXCEPTION + 'Límite de sponsors Bronce (2 activos) superado para % %', + NEW.unit_type, COALESCE(NEW.event_id, NEW.publication_id); + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_enforce_bronze_sponsor_limit + BEFORE INSERT OR UPDATE ON sponsor_agreements + FOR EACH ROW + EXECUTE FUNCTION enforce_bronze_sponsor_limit();