diff --git a/backend/app/services/membership_reconcile.py b/backend/app/services/membership_reconcile.py index ce5b057..70188d2 100644 --- a/backend/app/services/membership_reconcile.py +++ b/backend/app/services/membership_reconcile.py @@ -8,9 +8,10 @@ trustworthy enough to act on. 1. `subscribed_not_tracked` — you pay for this and FC does not follow it. The adoption win, and the only bucket carrying an action. 2. `tracked_not_subscribed` — FC follows this and the roster does not show you - paying for it. REPORT ONLY, by the operator's decision (2026-09-11): it says - what it sees and links to the existing Subscriptions row, and offers no - one-click disable. + paying for it. No longer shown on the card: the operator reversed the + 2026-09-11 "report only" call on 2026-09-13. The lapsed half of it now ACTS, + in `apply_membership_lapses` below (#3995). The absent half still only + reports, because absence proves nothing. 3. `matched` — the healthy set. Counted, not listed loudly. 4. `unidentified` — sources this join cannot speak to at all. Reported as exactly that, because the alternative is filing them under a verdict. @@ -39,7 +40,7 @@ rendered as lapsed. That is the whole reason it returns a tri-state. from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -233,3 +234,126 @@ async def reconcile_all(session: AsyncSession, now: datetime | None = None) -> d await reconcile(session, platform=p, now=now) for p in sorted(platforms) ] } + + +# --------------------------------------------------------------------------- +# Stop pulling what the account no longer pays for (#3995) +# --------------------------------------------------------------------------- +# +# Operator decision, 2026-09-13, reversing the 2026-09-11 "report only" call +# for this direction: "if I kill a subscription on patreon I would like the +# pulling to stop on curator as well", with automatic resume on resubscribing. +# +# This is a SOURCE-level action taken by the daily sweep, visible on the source +# row and reversible there. It is not a fetch-path decision. The line C5 draws, +# that the roster never decides a POST is inaccessible, still holds: nothing +# here reads per-post access, and no download path reads the roster +# (`test_no_fetch_path_can_read_the_roster`). The scheduler keeps selecting on +# `enabled` alone. +# +# Acts ONLY on positive evidence. A source whose matched membership says access +# has ended is stopped. A source with NO matched membership is left alone, +# because absence has innocent causes: a creator rename, a source never walked +# so no id is cached, a membership the platform stopped listing. Stopping on +# absence would switch off things the operator still pays for. +# +# Two app-managed config_overrides keys carry the state. The `_` prefix is +# already the "FC writes this, an operator edit preserves it" family. +# _membership_stopped set when the sweep stops a source; the sweep resumes +# ONLY sources carrying it, so a source the operator +# switched off by hand is never switched back on +# _membership_kept set by SourceService.update when the operator turns a +# stopped source back ON: a deliberate choice to keep +# pulling a lapsed creator, which the next sweep must +# not undo. Cleared when the membership is paid again. +STOPPED_KEY = "_membership_stopped" +KEPT_KEY = "_membership_kept" + + +def _access_expires_at(m: PlatformMembership) -> datetime | None: + """When paid access actually ends, if the platform says. + + Patreon keeps a cancelled membership's access until the end of the billing + period and reports that date (`member.access_expires_at`, note #3992). + SubscribeStar's page gives no such date, so a cancelled SubscribeStar + membership stops at once. Returns None when there is no usable date. + """ + details = m.details or {} + raw = details.get("access_expires_at") or (details.get("member") or {}).get("access_expires_at") + if not isinstance(raw, str) or not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +async def apply_membership_lapses( + session: AsyncSession, *, platform: str, now: datetime | None = None, +) -> dict: + """Stop sources whose paid access has ended; resume the ones this stopped. + + Refuses to act on a roster that isn't fresh, for the same reason C4 refuses + to draw conclusions from one. + """ + now = now or datetime.now(UTC) + state = await get_sync_state(session, platform) + if not roster_is_fresh(state, now=now): + return {"platform": platform, "skipped": "roster not fresh", "stopped": 0, "resumed": 0} + + memberships = (await session.execute( + select(PlatformMembership).where(PlatformMembership.platform == platform) + )).scalars().all() + sources = (await session.execute( + select(Source).where(Source.platform == platform) + )).scalars().all() + pairs = pair_sources_with_memberships(list(sources), list(memberships)) + + stopped: list[int] = [] + resumed: list[int] = [] + for source in sources: + pair = pairs.get(source.id) + if pair is None: + continue # absence is never acted on, see above + m, _kind = pair + paid = has_paid_access( + m.platform, m.status, + is_free_member=bool((m.details or {}).get("is_free_member")), + ) + co = dict(source.config_overrides or {}) + + if paid is True: + changed = co.pop(KEPT_KEY, None) is not None + if STOPPED_KEY in co: + co.pop(STOPPED_KEY) + source.enabled = True + resumed.append(source.id) + changed = True + if changed: + source.config_overrides = co + continue + + # Unknown status: never a reason to stop something (has_paid_access's + # tri-state exists for exactly this). + if paid is None: + continue + if not source.enabled or co.get(KEPT_KEY): + continue + expires = _access_expires_at(m) + if expires is not None and expires > now: + continue # still inside the paid-through period + + co[STOPPED_KEY] = {"at": now.isoformat(), "status": m.status} + source.config_overrides = co + source.enabled = False + # The same clean slate a manual disable gives (SourceService.update, + # #1285), so a stopped source doesn't linger as failing or gated. + source.last_error = None + source.error_type = None + source.consecutive_failures = 0 + stopped.append(source.id) + + await session.commit() + return {"platform": platform, "stopped": len(stopped), "resumed": len(resumed)} + diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index 034eeb5..439d566 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -19,6 +19,7 @@ from ..models import ( ) from .db_helpers import failing_sources_clause from .gallery_dl import ErrorType +from .membership_reconcile import KEPT_KEY, STOPPED_KEY from .membership_roster import gated_reasons_for_sources from .platforms import known_platform_keys from .scheduler_service import compute_next_check_at @@ -171,6 +172,25 @@ def arm_backfill(source: Source) -> None: source.backfill_runs_remaining = BACKFILL_MAX_CHUNKS +def _record_manual_enable_choice(source: Source, *, enabled: bool) -> None: + """Keep the membership sweep (#3995) from overriding the operator. + + Turning a source the sweep STOPPED back on is a deliberate choice to keep + pulling a lapsed creator, so it is marked kept and the next sweep leaves it + alone. Turning a source off by hand drops any sweep marker, so the sweep + never switches back on something the operator switched off themselves. + """ + co = dict(source.config_overrides or {}) + if enabled and STOPPED_KEY in co: + co.pop(STOPPED_KEY) + co[KEPT_KEY] = True + elif not enabled: + co.pop(STOPPED_KEY, None) + else: + return + source.config_overrides = co + + class SourceService: def __init__(self, session: AsyncSession): self.session = session @@ -428,6 +448,9 @@ class SourceService: for key, value in fields.items(): setattr(source, key, value) + if "enabled" in fields: + _record_manual_enable_choice(source, enabled=bool(fields["enabled"])) + if url_changed: # Repointing a source at a different creator makes a cached campaign # id WRONG, not merely stale, and `patreon_resolver` consults that diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 6e2b47e..9b8cd98 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -1247,6 +1247,7 @@ def sync_memberships() -> str: from ..services.artist_membership_service import rescan as membership_rescan from ..services.credential_crypto import CredentialCrypto from ..services.credential_service import CredentialService + from ..services.membership_reconcile import apply_membership_lapses from ..services.membership_roster import roster_user_id, sync_platform from ..services.patreon_client import PatreonClient from ..services.subscribestar_client import SubscribeStarClient @@ -1298,9 +1299,18 @@ def sync_memberships() -> str: ) async with async_factory() as session: - results.append( - await sync_platform(session, platform=platform, fetch=fetch) - ) + result = await sync_platform(session, platform=platform, fetch=fetch) + results.append(result) + + # #3995: stop pulling sources whose paid access has ended, and + # resume the ones this stopped once they are paid again. Only + # right after a successful sync, so it always acts on the roster + # just written, never on a stale one. + if result.get("ok"): + async with async_factory() as session: + result["lapses"] = await apply_membership_lapses( + session, platform=platform, + ) # #388 E4: offer the freshly-synced roster to the artists FC already # tracks. Chained here rather than given its own beat entry because @@ -1321,7 +1331,12 @@ def sync_memberships() -> str: if "skipped" in r: parts.append(f"{r['platform']}=skipped({r['skipped']})") elif r.get("ok"): - parts.append(f"{r['platform']}={r['count']}") + lapses = r.get("lapses") or {} + detail = ( + f"(stopped={lapses['stopped']},resumed={lapses['resumed']})" + if lapses.get("stopped") or lapses.get("resumed") else "" + ) + parts.append(f"{r['platform']}={r['count']}{detail}") else: parts.append(f"{r['platform']}=FAILED({r['error']})") if res.get("suggested") is not None: diff --git a/frontend/src/components/subscriptions/SourceRow.vue b/frontend/src/components/subscriptions/SourceRow.vue index 0c287a0..74cfb0d 100644 --- a/frontend/src/components/subscriptions/SourceRow.vue +++ b/frontend/src/components/subscriptions/SourceRow.vue @@ -39,8 +39,25 @@ {{ formatRelative(source.next_check_at, { future: true }) }} + Membership ended + + + Curator stopped checking this source because your membership + {{ membershipStopped.status ? `reads "${membershipStopped.status}"` : 'ended' }}. + It starts again on its own if you resubscribe. Turn it back on to + keep checking anyway. + + + + {{ source.consecutive_failures }} @@ -109,6 +126,12 @@ const props = defineProps({ // operator's subscription, not anything FC can retry. The count is only joined // in by the list endpoint, so phrase it without one when it's absent rather // than rendering a fabricated zero. +// #3995: set by the membership sweep when it stops a source whose paid access +// ended; cleared when it resumes, or when the operator toggles the source. +const membershipStopped = computed( + () => props.source.config_overrides?._membership_stopped || null, +) + const noAccessTip = computed(() => { const n = props.source.tier_gated_count const what = n diff --git a/tests/test_membership_lapses.py b/tests/test_membership_lapses.py new file mode 100644 index 0000000..b3586e1 --- /dev/null +++ b/tests/test_membership_lapses.py @@ -0,0 +1,190 @@ +"""#3995: stop pulling a source once the account stops paying, resume on resubscribe. + +Operator, 2026-09-13: "if I kill a subscription on patreon I would like the +pulling to stop on curator as well", with automatic resume when they resubscribe. + +Stopping a source is easy. Not stopping the wrong one is the work, so most of +these pin refusals: an absent membership is never a lapse, an unknown status is +never a lapse, a stale roster decides nothing, a paid-through period is honoured, +and the operator's own on/off choice always outranks the sweep in both directions. +""" +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select + +from backend.app.models import Artist, Source +from backend.app.services.membership_reconcile import ( + KEPT_KEY, + STOPPED_KEY, + apply_membership_lapses, +) +from backend.app.services.membership_roster import ROSTER_STALE_AFTER +from backend.app.services.source_service import SourceService +from tests.roster_builders import membership as _membership +from tests.roster_builders import synced as _synced + +pytestmark = pytest.mark.integration + + +async def _source( + db, *, overrides=None, enabled=True, url="https://www.patreon.com/an-old-handle", +): + artist = Artist(name="Maewix", slug="maewix") + db.add(artist) + await db.flush() + s = Source( + artist_id=artist.id, platform="patreon", url=url, enabled=enabled, + config_overrides={"patreon_campaign_id": "c1", **(overrides or {})}, + last_error="boom", error_type="tier_limited", consecutive_failures=3, + ) + db.add(s) + await db.flush() + return s.id + + +async def _state(db, source_id): + row = (await db.execute( + select(Source.enabled, Source.config_overrides, Source.error_type, + Source.consecutive_failures).where(Source.id == source_id) + )).one() + return row + + +@pytest.mark.asyncio +async def test_a_lapsed_membership_stops_its_source_with_a_clean_slate(db): + sid = await _source(db) + await _membership(db, campaign="c1", status="former_patron") + await _synced(db) + await db.commit() + + out = await apply_membership_lapses(db, platform="patreon") + assert out["stopped"] == 1 + + enabled, co, error_type, failures = await _state(db, sid) + assert enabled is False + assert co[STOPPED_KEY]["status"] == "former_patron" + assert co["patreon_campaign_id"] == "c1" # the identity cache survives + assert (error_type, failures) == (None, 0) + + +@pytest.mark.asyncio +async def test_the_paid_through_period_is_honoured(db): + """Patreon keeps access until the billing period ends, and says when.""" + sid = await _source(db) + later = (datetime.now(UTC) + timedelta(days=10)).isoformat() + await _membership( + db, campaign="c1", status="former_patron", + details={"member": {"access_expires_at": later}}, + ) + await _synced(db) + await db.commit() + + assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0 + assert (await _state(db, sid)).enabled is True + + +@pytest.mark.asyncio +async def test_an_absent_membership_is_never_a_lapse(db): + """No match has innocent causes (a rename, a never-walked source), so + absence alone must never switch a source off.""" + sid = await _source(db, overrides={"patreon_campaign_id": "nobody-has-this"}) + await _membership(db, campaign="c1", status="former_patron", details={}) + await _synced(db) + await db.commit() + + assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0 + assert (await _state(db, sid)).enabled is True + + +@pytest.mark.asyncio +async def test_an_unrecognised_status_is_never_a_lapse(db): + sid = await _source(db) + await _membership(db, campaign="c1", status="some_word_nobody_characterised") + await _synced(db) + await db.commit() + + assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0 + assert (await _state(db, sid)).enabled is True + + +@pytest.mark.asyncio +async def test_a_stale_roster_decides_nothing(db): + sid = await _source(db) + await _membership(db, campaign="c1", status="former_patron") + await _synced(db, ago=ROSTER_STALE_AFTER + timedelta(hours=1)) + await db.commit() + + out = await apply_membership_lapses(db, platform="patreon") + assert out["skipped"] == "roster not fresh" + assert (await _state(db, sid)).enabled is True + + +@pytest.mark.asyncio +async def test_resubscribing_resumes_only_what_the_sweep_stopped(db): + stopped = await _source( + db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}}, + ) + await _membership(db, campaign="c1", status="active_patron") + await _synced(db) + await db.commit() + + assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 1 + enabled, co, _e, _f = await _state(db, stopped) + assert enabled is True + assert STOPPED_KEY not in co + + +@pytest.mark.asyncio +async def test_a_source_the_operator_switched_off_is_never_switched_back_on(db): + sid = await _source(db, enabled=False) + await _membership(db, campaign="c1", status="active_patron") + await _synced(db) + await db.commit() + + assert (await apply_membership_lapses(db, platform="patreon"))["resumed"] == 0 + assert (await _state(db, sid)).enabled is False + + +@pytest.mark.asyncio +async def test_turning_a_stopped_source_back_on_keeps_it_on(db): + """Re-enabling a stopped source by hand is a choice to keep pulling a lapsed + creator. The next sweep must not undo it.""" + sid = await _source( + db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}}, + ) + await _membership(db, campaign="c1", status="former_patron") + await _synced(db) + await db.commit() + + await SourceService(db).update(sid, enabled=True) + enabled, co, _e, _f = await _state(db, sid) + assert enabled is True + assert co.get(KEPT_KEY) is True and STOPPED_KEY not in co + + assert (await apply_membership_lapses(db, platform="patreon"))["stopped"] == 0 + assert (await _state(db, sid)).enabled is True + + +@pytest.mark.asyncio +async def test_switching_a_stopped_source_off_by_hand_drops_the_resume_marker(db): + sid = await _source( + db, enabled=False, overrides={STOPPED_KEY: {"status": "former_patron"}}, + ) + await db.commit() + + await SourceService(db).update(sid, enabled=False) + _enabled, co, _e, _f = await _state(db, sid) + assert STOPPED_KEY not in co + + +@pytest.mark.asyncio +async def test_a_kept_source_is_released_once_paid_again(db): + sid = await _source(db, overrides={KEPT_KEY: True}) + await _membership(db, campaign="c1", status="active_patron") + await _synced(db) + await db.commit() + + await apply_membership_lapses(db, platform="patreon") + _enabled, co, _e, _f = await _state(db, sid) + assert KEPT_KEY not in co