fix(meta): send the budget-sharing flag Graph requires on every campaign
The connection test finally said what was wrong: "Must specify True or False in is_adset_budget_sharing_enabled ... if you are not using campaign budget." Code 100, subcode 4834011 — the same failure that has been blocking a live sync, previously reported only as "Invalid parameter". MaskanX always puts the budget on the ad set, so the campaign never carries one and Graph never treats this field as optional. It was never sent at all, so no campaign could be created in this account. Defaulting to false, not true. True lets ad sets lend each other up to 20% of their budget, which means an ad set can outspend the daily budget we set for it — and validate_guardrails treats that number as a ceiling. Predictable spend beats Meta's optimisation. Overridable per call. Sent as the lowercase literal "false": form-encoding a Python bool would put "True" on the wire, which Graph rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user