Coverage for app/backend/src/couchers/servicers/donations.py: 94%
124 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1import json
2import logging
3from typing import Any
5import certifi
6import grpc
7import stripe
8from google.protobuf import empty_pb2
9from sqlalchemy import select
10from sqlalchemy.orm import Session
12from couchers import urls
13from couchers.config import config
14from couchers.context import CouchersContext
15from couchers.event_log import log_event
16from couchers.helpers.badges import user_add_badge
17from couchers.metrics import observe_revenue
18from couchers.models import DonationInitiation, DonationType, Invoice, InvoiceType, User
19from couchers.models.notifications import NotificationTopicAction
20from couchers.notifications.notify import notify
21from couchers.proto import donations_pb2, donations_pb2_grpc, notification_data_pb2, stripe_pb2_grpc
22from couchers.proto.google.api import httpbody_pb2
23from couchers.sentry import report_error
24from couchers.slack import send_slack_message
25from couchers.utils import not_none
27logger = logging.getLogger(__name__)
29# stripe-python ships its own CA bundle, which it rarely updates, so use certifi's like the rest of the backend
30stripe.ca_bundle_path = certifi.where()
33donationfrequency2sql = {
34 donations_pb2.DONATION_FREQUENCY_UNSPECIFIED: None,
35 donations_pb2.DONATION_FREQUENCY_ONE_TIME: DonationType.one_time,
36 donations_pb2.DONATION_FREQUENCY_MONTHLY: DonationType.monthly,
37 donations_pb2.DONATION_FREQUENCY_YEARLY: DonationType.yearly,
38}
41def _create_stripe_customer(session: Session, user: User) -> None:
42 # create a new stripe id for this user
43 customer = stripe.Customer.create(
44 email=user.email,
45 # metadata allows us to store arbitrary metadata for ourselves
46 metadata={"user_id": user.id}, # type: ignore[dict-item]
47 api_key=config.STRIPE_API_KEY,
48 )
49 user.stripe_customer_id = customer.id
50 # commit since we only ever want one stripe customer id per user, so if the rest of this api call fails, this will still be saved in the db
51 session.commit()
54class Donations(donations_pb2_grpc.DonationsServicer):
55 def InitiateDonation(
56 self, request: donations_pb2.InitiateDonationReq, context: CouchersContext, session: Session
57 ) -> donations_pb2.InitiateDonationRes:
58 if not context.get_boolean_value("donations_enabled", default=False):
59 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
61 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
63 if request.amount < 2: 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was never true
64 # we don't want to waste *all* of the donation on processing fees
65 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "donation_too_small")
67 donation_type = donationfrequency2sql.get(request.frequency)
68 if not donation_type:
69 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_donation_frequency")
71 if not user.stripe_customer_id: 71 ↛ 75line 71 didn't jump to line 75 because the condition on line 71 was always true
72 _create_stripe_customer(session, user)
74 # reserve the id up front so Stripe can be given it, the row is only inserted once the checkout session exists
75 donation_initiation_id = session.execute(select(DonationInitiation.next_id())).scalar_one()
76 # Stripe copies PaymentIntent metadata onto its charges, and subscription metadata onto its invoices
77 stripe_metadata = {"metadata": {"donation_initiation_id": str(donation_initiation_id)}}
79 if donation_type == DonationType.one_time:
80 mode = "payment"
81 submit_type: str | None = "donate"
82 payment_intent_data: dict[str, Any] | None = stripe_metadata
83 subscription_data: dict[str, Any] | None = None
84 item = {
85 "price_data": {
86 "currency": "usd",
87 "unit_amount": request.amount * 100, # input is in cents
88 "product_data": {
89 "name": "Couchers financial supporter (one-time)",
90 "images": ["https://couchers.org/img/share.jpg"],
91 },
92 },
93 "quantity": 1,
94 }
95 else:
96 mode = "subscription"
97 submit_type = None
98 payment_intent_data = None
99 subscription_data = stripe_metadata
100 recurring_price_ids = {
101 DonationType.monthly: config.STRIPE_RECURRING_PRODUCT_ID,
102 DonationType.yearly: config.STRIPE_YEARLY_RECURRING_PRODUCT_ID,
103 }
104 item = {
105 "price": recurring_price_ids[donation_type],
106 "quantity": request.amount,
107 }
109 checkout_session = stripe.checkout.Session.create(
110 client_reference_id=str(user.id),
111 # Stripe actually allows None, but the signature says it's either a string or not passed.
112 submit_type=submit_type, # type: ignore[arg-type]
113 customer=not_none(user.stripe_customer_id),
114 success_url=urls.donation_success_url(),
115 cancel_url=urls.donation_cancelled_url(),
116 payment_method_types=["card"],
117 mode=mode,
118 line_items=[item], # type: ignore[list-item]
119 api_key=config.STRIPE_API_KEY,
120 payment_intent_data=payment_intent_data, # type: ignore[arg-type]
121 subscription_data=subscription_data, # type: ignore[arg-type]
122 )
124 session.add(
125 DonationInitiation(
126 id=donation_initiation_id,
127 user_id=user.id,
128 amount=request.amount,
129 stripe_checkout_session_id=checkout_session.id,
130 donation_type=donation_type,
131 source=request.source if request.source else None,
132 )
133 )
135 log_event(
136 context,
137 session,
138 "donation.initiated",
139 {
140 "amount": request.amount,
141 "frequency": donation_type.name,
142 "source": request.source or None,
143 },
144 )
146 return donations_pb2.InitiateDonationRes(
147 stripe_checkout_session_id=checkout_session.id, stripe_checkout_url=checkout_session.url
148 )
150 def GetDonationPortalLink(
151 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
152 ) -> donations_pb2.GetDonationPortalLinkRes:
153 if not context.get_boolean_value("donations_enabled", default=False): 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
156 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
158 if not user.stripe_customer_id:
159 _create_stripe_customer(session, user)
161 stripe_session = stripe.billing_portal.Session.create(
162 customer=not_none(user.stripe_customer_id),
163 return_url=urls.donation_url(),
164 api_key=config.STRIPE_API_KEY,
165 )
167 return donations_pb2.GetDonationPortalLinkRes(stripe_portal_url=stripe_session.url)
170class Stripe(stripe_pb2_grpc.StripeServicer):
171 def Webhook(
172 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
173 ) -> httpbody_pb2.HttpBody:
174 # We're set up to receive the following webhook events (with explanations from stripe docs):
175 # For both recurring and one-off donations, we get a `charge.succeeded` event and we then send the user an
176 # invoice. There are other events too, but we don't handle them right now.
177 event = stripe.Webhook.construct_event(
178 payload=request.data,
179 sig_header=context.get_header("stripe-signature"),
180 secret=config.STRIPE_WEBHOOK_SECRET,
181 api_key=config.STRIPE_API_KEY,
182 )
183 data = event["data"]
184 event_type = event["type"]
185 event_id = event["id"]
186 data_object = data["object"]
187 metadata = data_object["metadata"].to_dict() if "metadata" in data_object else {}
189 # Get the type of webhook event sent - used to check the status of PaymentIntents.
190 logger.info(f"Got signed Stripe webhook, {event_type=}, {event_id=}")
192 if event_type == "charge.succeeded":
193 if metadata.get("site_url") == config.MERCH_SHOP_URL:
194 # merch shop. look up this email and give them the swagster badge
195 customer_email = metadata.get("customer_email") or data_object["billing_details"]["email"]
196 observe_revenue("merch", int(data_object["amount"]))
197 amount = int(data_object["amount"]) // 100
198 user = (
199 session.execute(
200 select(User).where(User.email == customer_email).where(User.deleted_at.is_(None))
201 ).scalar_one_or_none()
202 if customer_email
203 else None
204 )
205 if user:
206 user_add_badge(session, user.id, "swagster")
207 user_link = urls.user_link(username=user.username)
208 customer_info = f"<{user_link}|{user.name}>"
209 else:
210 customer_info = customer_email or "unknown email"
211 try:
212 send_slack_message(
213 config.SLACK_MERCH_CHANNEL,
214 f"Merch purchase: ${amount} from {customer_info}",
215 )
216 except Exception as e:
217 report_error(e)
218 elif session.execute(
219 select(Invoice.id).where(Invoice.stripe_payment_intent_id == data_object["payment_intent"])
220 ).scalar_one_or_none():
221 # Stripe delivers webhooks at least once, so we may see the same charge again
222 logger.info(f"Already recorded payment {data_object['payment_intent']}, ignoring duplicate {event_id=}")
223 else:
224 customer_id = data_object["customer"]
225 user = session.execute(select(User).where(User.stripe_customer_id == customer_id)).scalar_one()
226 # amount comes in cents
227 observe_revenue("donation", int(data_object["amount"]))
228 amount = int(data_object["amount"]) // 100
229 receipt_url = data_object["receipt_url"]
230 payment_intent_id = data_object["payment_intent"]
232 # Recurring donations go through Stripe invoices, one-time don't
233 is_recurring = data_object["invoice"] is not None
234 if is_recurring:
235 # subscription charges don't carry the subscription's metadata, but their invoice has a snapshot
236 stripe_invoice = stripe.Invoice.retrieve(data_object["invoice"], api_key=config.STRIPE_API_KEY)
237 subscription_details = stripe_invoice.parent.subscription_details if stripe_invoice.parent else None
238 subscription_metadata = subscription_details.metadata if subscription_details else None
239 initiation_metadata = subscription_metadata.to_dict() if subscription_metadata else {}
240 else:
241 initiation_metadata = metadata
242 donation_initiation = (
243 session.execute(
244 select(DonationInitiation).where(
245 DonationInitiation.id == int(initiation_metadata["donation_initiation_id"])
246 )
247 ).scalar_one()
248 if "donation_initiation_id" in initiation_metadata
249 else None
250 )
251 if donation_initiation:
252 invoice_donation_type: DonationType | None = donation_initiation.donation_type
253 else:
254 # payments from before we tagged them with their initiation, e.g. older subscriptions renewing
255 invoice_donation_type = None if is_recurring else DonationType.one_time
257 invoice = Invoice(
258 user_id=user.id,
259 amount=amount,
260 stripe_payment_intent_id=payment_intent_id,
261 stripe_receipt_url=receipt_url,
262 invoice_type=InvoiceType.on_platform,
263 donation_initiation_id=donation_initiation.id if donation_initiation else None,
264 donation_type=invoice_donation_type,
265 )
266 session.add(invoice)
267 session.flush()
268 user.last_donated = invoice.created
270 notify(
271 session,
272 user_id=user.id,
273 topic_action=NotificationTopicAction.donation__received,
274 key="",
275 data=notification_data_pb2.DonationReceived(
276 amount=amount,
277 receipt_url=receipt_url,
278 ),
279 )
281 donation_type = "recurring" if is_recurring else "one-time"
282 user_link = urls.user_link(username=user.username)
283 try:
284 send_slack_message(
285 config.SLACK_DONATIONS_CHANNEL,
286 f"Donation received: ${amount} ({donation_type}) from <{user_link}|{user.name}>",
287 )
288 except Exception as e:
289 report_error(e)
290 else:
291 logger.info(f"Unhandled event from Stripe: {event_type}")
293 return httpbody_pb2.HttpBody(
294 content_type="application/json",
295 # json.dumps escapes non-ascii characters
296 data=json.dumps({"success": True}).encode("ascii"),
297 )