Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions backend/app/commands/sync_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
EXPIRED_SUBSCRIPTION_STATUS,
TERMINATED_SUBSCRIPTION_STATUS,
)
from app.events.subscriptions.utils import get_datasource_id
from app.events.subscriptions.utils import get_datasource_id, get_live_subscription
from app.exceptions import InvalidDataSourceIdError
from app.notifications import ColumnHeader, NotificationDetails, send_error, send_info
from app.telemetry import capture_telemetry_cli_command
Expand Down Expand Up @@ -101,6 +101,7 @@ def build_query(products: list[str], status: list[str], since: datetime, until:


async def sync_subscription(
client: MPTClient,
entitlement_handler: EntitlementHandler,
account: Account,
subscription: dict[str, Any],
Expand Down Expand Up @@ -139,12 +140,29 @@ async def sync_subscription(
message=f"Created new entitlement {created.id}.",
)
else:
if entitlements:
bound = [
entitlement
for entitlement in entitlements
if entitlement.affiliate_external_id == sub_id
]
if bound:
actions = []
for entitlement in entitlements:
if entitlement.affiliate_external_id != sub_id:
continue
if entitlement.status == EntitlementStatus.NEW:
# Another live subscription for the same datasource takes the entitlement over.
replacement = await get_live_subscription(client, account, bound[0].datasource_id)
for entitlement in bound:
if replacement is not None:
await entitlement_handler.update(
entitlement, data={"affiliate_external_id": replacement["id"]}
)
actions.append(
f"The entitlement {entitlement.id} was moved to the subscription "
f"{replacement['id']}."
)
logger.info(
f"Moved entitlement {entitlement.id} from subscription {sub_id} "
f"to the subscription {replacement['id']}."
)
elif entitlement.status == EntitlementStatus.NEW:
await entitlement_handler.delete(entitlement)
actions.append(f"The entitlement {entitlement.id} was deleted.")
logger.info(f"Deleted entitlement {entitlement.id} for subscription {sub_id}.")
Expand All @@ -154,12 +172,11 @@ async def sync_subscription(
logger.info(
f"Terminated entitlement {entitlement.id} for subscription {sub_id}."
)
if actions:
return SyncResult(
subscription_id=sub_id,
account_id=account.id,
message="\n".join(actions),
)
return SyncResult(
subscription_id=sub_id,
account_id=account.id,
message="\n".join(actions),
)

return None

Expand Down Expand Up @@ -240,6 +257,7 @@ async def sync_page(
],
)
result = await sync_subscription(
client,
entitlement_handler,
account,
subscription,
Expand Down
28 changes: 26 additions & 2 deletions backend/app/events/subscriptions/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
SubscriptionNotFoundError,
SubscriptionNotValidError,
)
from app.events.subscriptions.utils import get_datasource_id, is_product_supported
from app.events.subscriptions.utils import (
get_datasource_id,
get_live_subscription,
is_product_supported,
)
from app.exceptions import InvalidDataSourceIdError
from app.utils import validate_datasource_id_format

Expand All @@ -31,10 +35,12 @@ class SubscriptionProcessor(EventProcessor):

def __init__(
self,
installation_client: MPTClient,
entitlement_repo: EntitlementRepository,
account: Account,
subscription: dict[str, Any],
):
self.installation_client = installation_client
self.entitlement_repo = entitlement_repo
self.account = account
self.subscription = subscription
Expand Down Expand Up @@ -105,10 +111,27 @@ async def handle_active(self, entitlement: Entitlement | None) -> ProcessingResu
return self.completed(f"The entitlement {entitlement.id} was created.")

async def handle_inactive(self, entitlement: Entitlement | None) -> ProcessingResult:
"""Drop an entitlement that was never redeemed, terminate the one in use."""
"""
Drop an entitlement that was never redeemed, terminate the one in use.

When another live subscription covers the same datasource, the entitlement is kept
and moved to it instead.
"""
if entitlement is None:
return self.completed(f"No entitlement is bound to the subscription {self.object_id}.")

replacement = await get_live_subscription(
self.installation_client, self.account, entitlement.datasource_id
)
if replacement is not None:
await self.entitlement_repo.update(
entitlement, data={"affiliate_external_id": replacement["id"]}
)
return self.completed(
f"The entitlement {entitlement.id} was moved to the subscription "
f"{replacement['id']}."
)

if entitlement.status == EntitlementStatus.NEW:
await self.entitlement_repo.delete(entitlement)
return self.completed(f"The entitlement {entitlement.id} was deleted.")
Expand Down Expand Up @@ -150,6 +173,7 @@ async def _get_processor(self, object_id: str) -> SubscriptionProcessor:
if not subscription:
raise SubscriptionNotFoundError(f"No subscription found for {object_id}.")
return SubscriptionProcessor(
installation_client=self.installation_client,
entitlement_repo=self.entitlement_repo,
account=self.account,
subscription=subscription,
Expand Down
31 changes: 31 additions & 0 deletions backend/app/events/subscriptions/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import logging
from typing import Any

from app.api_clients.mpt import MPTClient
from app.db.models import Account
from app.events.subscriptions.constants import (
EXPIRED_SUBSCRIPTION_STATUS,
TERMINATED_SUBSCRIPTION_STATUS,
)

logger = logging.getLogger(__name__)

Expand All @@ -19,3 +24,29 @@ def is_product_supported(subscription: dict[str, Any], account: Account) -> bool
if product.strip()
}
return subscription["product"]["id"].lower() in products


async def get_live_subscription(
client: MPTClient,
account: Account,
datasource_id: str,
) -> dict[str, Any] | None:
"""
Return a marketplace subscription for the datasource that has not ended, if any.

A subscription can be replaced by a new one for the same datasource (e.g. split billing or
a change of client, buyer or licensee): the entitlement must follow the live one instead
of being terminated with the old one.
"""
query = (
"and("
f"eq(externalIds.vendor,{datasource_id}),"
f"out(status,({TERMINATED_SUBSCRIPTION_STATUS},{EXPIRED_SUBSCRIPTION_STATUS})),"
f"in(product.id,({account.products}))"
")"
)
return await client.get_first(
"commerce/subscriptions",
query=query,
select=["id", "name", "status", "externalIds"],
)
89 changes: 81 additions & 8 deletions backend/tests/commands/test_sync_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,16 @@
TERMINATED_SUBSCRIPTION_STATUS,
)
from app.notifications import ColumnHeader
from tests.types import ModelFactory, MPTSubscriptionFactory, SubscriptionsPageMocker
from tests.types import (
ModelFactory,
MPTSubscriptionFactory,
SubscriptionMocker,
SubscriptionsPageMocker,
)

SUBSCRIPTION_ID = "SUB-1234-5678"
DATASOURCE_ID = "ds0001234"
REPLACEMENT_ID = "SUB-8765-4321"
SINCE = datetime(2026, 9, 12, 9, 0, 0, tzinfo=UTC)
UNTIL = datetime(2026, 9, 13, 9, 0, 0, tzinfo=UTC)
WINDOW = "changed between 2026-09-12 09:00:00 UTC and 2026-09-13 09:00:00 UTC"
Expand Down Expand Up @@ -112,14 +118,17 @@ def test_build_query_filters_by_product_status_and_window(


async def test_sync_subscription_creates_missing_entitlement(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
db_session: AsyncSession,
) -> None:
subscription = mpt_subscription_factory()

result = await sync_subscription(entitlement_handler, subscription_account, subscription, [])
result = await sync_subscription(
mpt_client, entitlement_handler, subscription_account, subscription, []
)

entitlement = (await get_entitlements(db_session, subscription_account))[0]
assert entitlement.status == EntitlementStatus.NEW
Expand All @@ -135,6 +144,7 @@ async def test_sync_subscription_creates_missing_entitlement(


async def test_sync_subscription_skips_an_active_subscription_with_active_entitlement(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
Expand All @@ -148,13 +158,14 @@ async def test_sync_subscription_skips_an_active_subscription_with_active_entitl
subscription = mpt_subscription_factory()

result = await sync_subscription(
entitlement_handler, subscription_account, subscription, [entitlement]
mpt_client, entitlement_handler, subscription_account, subscription, [entitlement]
)

assert result is None


async def test_sync_subscription_reports_more_than_one_live_entitlement(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
Expand All @@ -170,7 +181,7 @@ async def test_sync_subscription_reports_more_than_one_live_entitlement(
subscription = mpt_subscription_factory()

result = await sync_subscription(
entitlement_handler, subscription_account, subscription, [first, second]
mpt_client, entitlement_handler, subscription_account, subscription, [first, second]
)

assert result is not None
Expand All @@ -187,14 +198,17 @@ async def test_sync_subscription_reports_more_than_one_live_entitlement(
[TERMINATED_SUBSCRIPTION_STATUS, EXPIRED_SUBSCRIPTION_STATUS],
)
async def test_sync_subscription_ignore_non_active_subscription_without_entitlements(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
subscription_status: str,
) -> None:
subscription = mpt_subscription_factory(status=subscription_status)

result = await sync_subscription(entitlement_handler, subscription_account, subscription, [])
result = await sync_subscription(
mpt_client, entitlement_handler, subscription_account, subscription, []
)

assert result is None

Expand All @@ -204,9 +218,11 @@ async def test_sync_subscription_ignore_non_active_subscription_without_entitlem
[TERMINATED_SUBSCRIPTION_STATUS, EXPIRED_SUBSCRIPTION_STATUS],
)
async def test_sync_subscription_actions_for_non_active_subscription(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
mock_live_subscription_lookup: SubscriptionMocker,
entitlement_factory: ModelFactory[Entitlement],
db_session: AsyncSession,
subscription_status: str,
Expand All @@ -225,8 +241,10 @@ async def test_sync_subscription_actions_for_non_active_subscription(
)
subscription = mpt_subscription_factory(status=subscription_status)

mock_live_subscription_lookup([])

result = await sync_subscription(
entitlement_handler, subscription_account, subscription, [new_ent, active_ent]
mpt_client, entitlement_handler, subscription_account, subscription, [new_ent, active_ent]
)

await db_session.refresh(new_ent)
Expand All @@ -247,6 +265,7 @@ async def test_sync_subscription_actions_for_non_active_subscription(
[TERMINATED_SUBSCRIPTION_STATUS, EXPIRED_SUBSCRIPTION_STATUS],
)
async def test_sync_subscription_ignores_entitlements_of_other_subscriptions(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
Expand All @@ -270,7 +289,7 @@ async def test_sync_subscription_ignores_entitlements_of_other_subscriptions(
subscription = mpt_subscription_factory(status=subscription_status)

result = await sync_subscription(
entitlement_handler, subscription_account, subscription, [new_ent, active_ent]
mpt_client, entitlement_handler, subscription_account, subscription, [new_ent, active_ent]
)

assert result is None
Expand All @@ -282,9 +301,11 @@ async def test_sync_subscription_ignores_entitlements_of_other_subscriptions(


async def test_sync_subscription_only_acts_on_entitlements_of_the_subscription(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
mock_live_subscription_lookup: SubscriptionMocker,
entitlement_factory: ModelFactory[Entitlement],
db_session: AsyncSession,
) -> None:
Expand All @@ -303,8 +324,10 @@ async def test_sync_subscription_only_acts_on_entitlements_of_the_subscription(
)
subscription = mpt_subscription_factory(status=TERMINATED_SUBSCRIPTION_STATUS)

mock_live_subscription_lookup([])

result = await sync_subscription(
entitlement_handler, subscription_account, subscription, [own_ent, other_ent]
mpt_client, entitlement_handler, subscription_account, subscription, [own_ent, other_ent]
)

await db_session.refresh(own_ent)
Expand All @@ -315,6 +338,56 @@ async def test_sync_subscription_only_acts_on_entitlements_of_the_subscription(
assert result.message == f"The entitlement {own_ent.id} was terminated."


@pytest.mark.parametrize(
"subscription_status",
[TERMINATED_SUBSCRIPTION_STATUS, EXPIRED_SUBSCRIPTION_STATUS],
)
async def test_sync_subscription_moves_entitlements_to_the_live_replacement(
mpt_client: MPTClient,
entitlement_handler: EntitlementHandler,
subscription_account: Account,
mpt_subscription_factory: MPTSubscriptionFactory,
mock_live_subscription_lookup: SubscriptionMocker,
entitlement_factory: ModelFactory[Entitlement],
db_session: AsyncSession,
subscription_status: str,
) -> None:
"""A live subscription for the same datasource keeps the entitlements and takes them over."""
new_ent = await entitlement_factory(
owner=subscription_account,
datasource_id=DATASOURCE_ID,
affiliate_external_id=SUBSCRIPTION_ID,
status=EntitlementStatus.NEW,
)
active_ent = await entitlement_factory(
owner=subscription_account,
datasource_id=DATASOURCE_ID,
affiliate_external_id=SUBSCRIPTION_ID,
status=EntitlementStatus.ACTIVE,
)
subscription = mpt_subscription_factory(status=subscription_status)
mock_live_subscription_lookup([mpt_subscription_factory(subscription_id=REPLACEMENT_ID)])

result = await sync_subscription(
mpt_client, entitlement_handler, subscription_account, subscription, [new_ent, active_ent]
)

await db_session.refresh(new_ent)
assert new_ent.status == EntitlementStatus.NEW
assert new_ent.affiliate_external_id == REPLACEMENT_ID
await db_session.refresh(active_ent)
assert active_ent.status == EntitlementStatus.ACTIVE
assert active_ent.affiliate_external_id == REPLACEMENT_ID
assert active_ent.terminated_at is None

assert result is not None
assert result.succeeded is True
assert result.message == (
f"The entitlement {new_ent.id} was moved to the subscription {REPLACEMENT_ID}.\n"
f"The entitlement {active_ent.id} was moved to the subscription {REPLACEMENT_ID}."
)


async def test_sync_page(
mpt_client: MPTClient,
mock_subscriptions_api: SubscriptionsPageMocker,
Expand Down
Loading
Loading