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:
AFFAANh
2026-08-07 13:29:16 +05:30
co-authored by Claude Opus 5
parent e1bba0cac1
commit f752c4fdf3
2 changed files with 60 additions and 0 deletions
+17
View File
@@ -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"]
+43
View File
@@ -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"