Skip to content

Commit d16102d

Browse files
aapelivclaude
andcommitted
Backend: migrate ENABLE_DONATIONS env toggle to donations_enabled feature flag
Replace the static ENABLE_DONATIONS env config with a per-request `donations_enabled` feature flag so donations can be toggled remotely without a deploy. The Stripe credentials stay in env and are now required in production unconditionally (since the flag can be flipped on at any time), failing loudly at boot if missing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5ead923 commit d16102d

5 files changed

Lines changed: 18 additions & 14 deletions

File tree

app/backend.dev.env

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ DATABASE_CONNECTION_STRING=postgresql://postgres:203d805f4b62c0a1b2f1f6b82d4583d
1313

1414
ADD_DUMMY_DATA=1
1515

16-
ENABLE_DONATIONS=0
1716
STRIPE_API_KEY=sk_test_...
1817
STRIPE_WEBHOOK_SECRET=whsec_...
1918
STRIPE_RECURRING_PRODUCT_ID=price_...

app/backend/src/couchers/config.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@
4040
("GEOLITE2_ASN_MMDB_FILE_LOCATION", str, ""),
4141
# Whether to try adding dummy data
4242
("ADD_DUMMY_DATA", bool),
43-
# Donations
44-
("ENABLE_DONATIONS", bool),
43+
# Donations (gated at runtime by the `donations_enabled` feature flag)
4544
("STRIPE_API_KEY", str),
4645
("STRIPE_WEBHOOK_SECRET", str),
4746
("STRIPE_RECURRING_PRODUCT_ID", str),
@@ -151,10 +150,10 @@ def check_config(cfg: dict[str, Any]) -> None:
151150
raise Exception("Production site must have SMS enabled")
152151
if cfg["IN_TEST"]:
153152
raise Exception("IN_TEST while not DEV")
154-
155-
if cfg["ENABLE_DONATIONS"]:
153+
# Donations are gated at runtime by the `donations_enabled` feature flag, which can be flipped on
154+
# remotely at any time, so prod must always have Stripe credentials present so the feature can run.
156155
if not cfg["STRIPE_API_KEY"] or not cfg["STRIPE_WEBHOOK_SECRET"] or not cfg["STRIPE_RECURRING_PRODUCT_ID"]:
157-
raise Exception("No Stripe API key/recurring donation ID but donations enabled")
156+
raise Exception("Stripe credentials must be configured in production")
158157

159158
if cfg["ENABLE_STRONG_VERIFICATION"]:
160159
if not cfg["IRIS_ID_PUBKEY"] or not cfg["IRIS_ID_SECRET"] or not cfg["VERIFICATION_DATA_PUBLIC_KEY"]:

app/backend/src/couchers/servicers/donations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class Donations(donations_pb2_grpc.DonationsServicer):
4141
def InitiateDonation(
4242
self, request: donations_pb2.InitiateDonationReq, context: CouchersContext, session: Session
4343
) -> donations_pb2.InitiateDonationRes:
44-
if not config["ENABLE_DONATIONS"]:
44+
if not context.get_boolean_value("donations_enabled", default=False):
4545
context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
4646

4747
user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
@@ -108,7 +108,7 @@ def InitiateDonation(
108108
def GetDonationPortalLink(
109109
self, request: empty_pb2.Empty, context: CouchersContext, session: Session
110110
) -> donations_pb2.GetDonationPortalLinkRes:
111-
if not config["ENABLE_DONATIONS"]:
111+
if not context.get_boolean_value("donations_enabled", default=False):
112112
context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
113113

114114
user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()

app/backend/src/tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ def testconfig():
176176
config["MODS_EMAIL_RECIPIENT"] = "mods@couchers.org.invalid"
177177
config["ENABLE_EMAIL_ICS_ATTACHMENTS"] = True
178178

179-
config["ENABLE_DONATIONS"] = False
180179
config["STRIPE_API_KEY"] = ""
181180
config["STRIPE_WEBHOOK_SECRET"] = ""
182181
config["STRIPE_RECURRING_PRODUCT_ID"] = ""

app/backend/src/tests/test_donations.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from unittest.mock import patch
33

4+
import grpc
45
import pytest
56
from google.protobuf import empty_pb2
67
from sqlalchemy import select
@@ -21,13 +22,23 @@ def _(testconfig):
2122
pass
2223

2324

25+
def test_donations_disabled(db, feature_flags):
26+
feature_flags.set("donations_enabled", False)
27+
_, token = generate_user()
28+
29+
with donations_session(token) as donations:
30+
with pytest.raises(grpc.RpcError) as e:
31+
donations.InitiateDonation(donations_pb2.InitiateDonationReq(amount=100))
32+
assert e.value.code() == grpc.StatusCode.UNAVAILABLE
33+
assert e.value.details() == "Donations are currently disabled."
34+
35+
2436
def test_one_time_donation_flow(db, monkeypatch):
2537
user, token = generate_user()
2638
user_email = user.email
2739
user_id = user.id
2840

2941
new_config = config.copy()
30-
new_config["ENABLE_DONATIONS"] = True
3142
new_config["STRIPE_API_KEY"] = "dummy_api_key"
3243
new_config["STRIPE_WEBHOOK_SECRET"] = "dummy_webhook_secret"
3344
new_config["STRIPE_RECURRING_PRODUCT_ID"] = "price_1KIbmbIfR5z29g5kFWPEUnC6"
@@ -132,7 +143,6 @@ def test_recurring_donation_flow(db, monkeypatch):
132143
user_id = user.id
133144

134145
new_config = config.copy()
135-
new_config["ENABLE_DONATIONS"] = True
136146
new_config["STRIPE_API_KEY"] = "dummy_api_key"
137147
new_config["STRIPE_WEBHOOK_SECRET"] = "dummy_webhook_secret"
138148
new_config["STRIPE_RECURRING_PRODUCT_ID"] = "price_1IRoHdE5kUmYuPWz9tX8UpRv"
@@ -257,7 +267,6 @@ def test_customer_portal_url(db, monkeypatch):
257267
user_id = user.id
258268

259269
new_config = config.copy()
260-
new_config["ENABLE_DONATIONS"] = True
261270
new_config["STRIPE_API_KEY"] = "dummy_api_key"
262271

263272
monkeypatch.setattr(couchers.servicers.donations, "config", new_config)
@@ -376,7 +385,6 @@ def test_slack_notification_on_one_time_donation(db, monkeypatch):
376385
user, token = generate_user()
377386

378387
new_config = config.copy()
379-
new_config["ENABLE_DONATIONS"] = True
380388
new_config["STRIPE_API_KEY"] = "dummy_api_key"
381389
new_config["STRIPE_WEBHOOK_SECRET"] = "dummy_webhook_secret"
382390
new_config["STRIPE_RECURRING_PRODUCT_ID"] = "price_1KIbmbIfR5z29g5kFWPEUnC6"
@@ -407,7 +415,6 @@ def test_slack_notification_on_recurring_donation(db, monkeypatch):
407415
user, token = generate_user()
408416

409417
new_config = config.copy()
410-
new_config["ENABLE_DONATIONS"] = True
411418
new_config["STRIPE_API_KEY"] = "dummy_api_key"
412419
new_config["STRIPE_WEBHOOK_SECRET"] = "dummy_webhook_secret"
413420
new_config["STRIPE_RECURRING_PRODUCT_ID"] = "price_1IRoHdE5kUmYuPWz9tX8UpRv"

0 commit comments

Comments
 (0)