diff --git a/src/adclaw/meta/client.py b/src/adclaw/meta/client.py index 4a3e262..3339cfd 100644 --- a/src/adclaw/meta/client.py +++ b/src/adclaw/meta/client.py @@ -381,12 +381,24 @@ class MetaClient: objective: str, status: str = CREATE_STATUS, special_ad_categories: list[str] | None = None, + adset_budget_sharing: bool = False, ) -> str: """Create a paused campaign and return its id. Raises `ValueError` (making no network call) if `status` is anything other than "PAUSED" — campaigns are always created paused; use `update_object_status` to launch. + + `is_adset_budget_sharing_enabled` is **required** by Graph whenever + the campaign carries no budget of its own, which is always the case + here: MaskanX puts the budget on the ad set. Omitting it is + rejected with code 100 / subcode 4834011. + + It defaults to False on purpose. True lets ad sets lend each other + up to 20% of their budget, which makes a single ad set's daily + spend exceed the number we set for it — and the guardrails treat + that number as a ceiling. Predictable spend beats Meta's + optimisation here. """ if status != CREATE_STATUS: raise ValueError( @@ -400,6 +412,11 @@ class MetaClient: "objective": objective, "status": CREATE_STATUS, "special_ad_categories": json.dumps(special_ad_categories or []), + # Sent as a lowercase literal: form-encoding a Python bool + # yields "True", which Graph does not accept. + "is_adset_budget_sharing_enabled": ( + "true" if adset_budget_sharing else "false" + ), }, ) return result["id"] diff --git a/tests/test_meta_writes.py b/tests/test_meta_writes.py index 2001d74..0aee567 100644 --- a/tests/test_meta_writes.py +++ b/tests/test_meta_writes.py @@ -559,3 +559,46 @@ def test_build_creative_payload_falls_back_to_campaign_name_for_headline(): payload = build_creative_payload(spec, page_id="page_1", image_hash="hash123") assert payload["headline"] == "Test Campaign" + + +# --------------------------------------------------------------------------- +# create_campaign: the field Graph requires when the campaign has no budget +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_campaign_declares_adset_budget_sharing(): + """Graph rejects a budget-less campaign that omits this field. + + MaskanX always puts the budget on the ad set, so the campaign never + carries one and the field is never optional. Omitting it is what + produced code 100 / subcode 4834011 against the live account, with the + useless message "Invalid parameter". + + It must be the lowercase literal: form-encoding a Python bool sends + "True", which Graph does not accept. + """ + transport = FakeTransport([{"id": "c1"}]) + client = MetaClient(access_token="tok", transport=transport) + + await client.create_campaign("act_1", name="Q3", objective="OUTCOME_LEADS") + + sent = transport.calls[0]["params"]["is_adset_budget_sharing_enabled"] + assert sent == "false" + + +@pytest.mark.asyncio +async def test_adset_budget_sharing_is_off_by_default_but_overridable(): + """True lets ad sets lend each other up to 20% of their budget, so an + ad set can outspend the number we set for it. The guardrails treat that + number as a ceiling, so the default has to be off.""" + transport = FakeTransport([{"id": "c1"}, {"id": "c2"}]) + client = MetaClient(access_token="tok", transport=transport) + + await client.create_campaign("act_1", name="A", objective="OUTCOME_LEADS") + await client.create_campaign( + "act_1", name="B", objective="OUTCOME_LEADS", adset_budget_sharing=True, + ) + + assert transport.calls[0]["params"]["is_adset_budget_sharing_enabled"] == "false" + assert transport.calls[1]["params"]["is_adset_budget_sharing_enabled"] == "true"