Coverage for app / backend / src / couchers / servicers / donations.py: 90%
91 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1import json
2import logging
4import grpc
5import sentry_sdk
6import stripe
7from google.protobuf import empty_pb2
8from sqlalchemy import select
9from sqlalchemy.orm import Session
11from couchers import urls
12from couchers.config import config
13from couchers.context import CouchersContext
14from couchers.event_log import log_event
15from couchers.helpers.badges import user_add_badge
16from couchers.models import DonationInitiation, DonationType, Invoice, InvoiceType, User
17from couchers.models.notifications import NotificationTopicAction
18from couchers.notifications.notify import notify
19from couchers.proto import donations_pb2, donations_pb2_grpc, notification_data_pb2, stripe_pb2_grpc
20from couchers.proto.google.api import httpbody_pb2
21from couchers.slack import send_slack_message
22from couchers.utils import not_none
24logger = logging.getLogger(__name__)
27def _create_stripe_customer(session: Session, user: User) -> None:
28 # create a new stripe id for this user
29 customer = stripe.Customer.create(
30 email=user.email,
31 # metadata allows us to store arbitrary metadata for ourselves
32 metadata={"user_id": user.id}, # type: ignore[dict-item]
33 api_key=config["STRIPE_API_KEY"],
34 )
35 user.stripe_customer_id = customer.id
36 # 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
37 session.commit()
40class Donations(donations_pb2_grpc.DonationsServicer):
41 def InitiateDonation(
42 self, request: donations_pb2.InitiateDonationReq, context: CouchersContext, session: Session
43 ) -> donations_pb2.InitiateDonationRes:
44 if not config["ENABLE_DONATIONS"]: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
47 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
49 if request.amount < 2: 49 ↛ 51line 49 didn't jump to line 51 because the condition on line 49 was never true
50 # we don't want to waste *all* of the donation on processing fees
51 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "donation_too_small")
53 if not user.stripe_customer_id: 53 ↛ 56line 53 didn't jump to line 56 because the condition on line 53 was always true
54 _create_stripe_customer(session, user)
56 if request.recurring:
57 item = {
58 "price": config["STRIPE_RECURRING_PRODUCT_ID"],
59 "quantity": request.amount,
60 }
61 else:
62 item = {
63 "price_data": {
64 "currency": "usd",
65 "unit_amount": request.amount * 100, # input is in cents
66 "product_data": {
67 "name": "Couchers financial supporter (one-time)",
68 "images": ["https://couchers.org/img/share.jpg"],
69 },
70 },
71 "quantity": 1,
72 }
74 checkout_session = stripe.checkout.Session.create(
75 client_reference_id=str(user.id),
76 # Stripe actually allows None, but the signature says it's either a string or not passed.
77 submit_type="donate" if not request.recurring else None, # type: ignore[arg-type]
78 customer=not_none(user.stripe_customer_id),
79 success_url=urls.donation_success_url(),
80 cancel_url=urls.donation_cancelled_url(),
81 payment_method_types=["card"],
82 mode="subscription" if request.recurring else "payment",
83 line_items=[item], # type: ignore[list-item]
84 api_key=config["STRIPE_API_KEY"],
85 )
87 session.add(
88 DonationInitiation(
89 user_id=user.id,
90 amount=request.amount,
91 stripe_checkout_session_id=checkout_session.id,
92 donation_type=DonationType.recurring if request.recurring else DonationType.one_time,
93 source=request.source if request.source else None,
94 )
95 )
97 log_event(
98 context,
99 session,
100 "donation.initiated",
101 {"amount": request.amount, "recurring": request.recurring, "source": request.source or None},
102 )
104 return donations_pb2.InitiateDonationRes(
105 stripe_checkout_session_id=checkout_session.id, stripe_checkout_url=checkout_session.url
106 )
108 def GetDonationPortalLink(
109 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
110 ) -> donations_pb2.GetDonationPortalLinkRes:
111 if not config["ENABLE_DONATIONS"]: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled")
114 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
116 if not user.stripe_customer_id:
117 _create_stripe_customer(session, user)
119 stripe_session = stripe.billing_portal.Session.create(
120 customer=not_none(user.stripe_customer_id),
121 return_url=urls.donation_url(),
122 api_key=config["STRIPE_API_KEY"],
123 )
125 return donations_pb2.GetDonationPortalLinkRes(stripe_portal_url=stripe_session.url)
128class Stripe(stripe_pb2_grpc.StripeServicer):
129 def Webhook(
130 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
131 ) -> httpbody_pb2.HttpBody:
132 # We're set up to receive the following webhook events (with explanations from stripe docs):
133 # For both recurring and one-off donations, we get a `charge.succeeded` event and we then send the user an
134 # invoice. There are other events too, but we don't handle them right now.
135 event = stripe.Webhook.construct_event( # type: ignore[no-untyped-call]
136 payload=request.data,
137 sig_header=context.headers.get("stripe-signature"),
138 secret=config["STRIPE_WEBHOOK_SECRET"],
139 api_key=config["STRIPE_API_KEY"],
140 )
141 data = event["data"]
142 event_type = event["type"]
143 event_id = event["id"]
144 data_object = data["object"]
145 metadata = data_object.get("metadata", {})
147 # Get the type of webhook event sent - used to check the status of PaymentIntents.
148 logger.info(f"Got signed Stripe webhook, {event_type=}, {event_id=}")
150 if event_type == "charge.succeeded":
151 if metadata.get("site_url") == config["MERCH_SHOP_URL"]:
152 # merch shop. look up this email and give them the swagster badge
153 customer_email = metadata["customer_email"]
154 amount = int(data_object["amount"]) // 100
155 user = session.execute(select(User).where(User.email == customer_email)).scalar_one_or_none()
156 if user:
157 user_add_badge(session, user.id, "swagster")
158 user_link = urls.user_link(username=user.username)
159 customer_info = f"<{user_link}|{user.name}>"
160 else:
161 customer_info = customer_email
162 try:
163 send_slack_message(
164 config["SLACK_MERCH_CHANNEL"],
165 f"Merch purchase: ${amount} from {customer_info}",
166 )
167 except Exception as e:
168 sentry_sdk.capture_exception(e)
169 else:
170 customer_id = data_object["customer"]
171 user = session.execute(select(User).where(User.stripe_customer_id == customer_id)).scalar_one()
172 # amount comes in cents
173 amount = int(data_object["amount"]) // 100
174 receipt_url = data_object["receipt_url"]
175 payment_intent_id = data_object["payment_intent"]
177 invoice = Invoice(
178 user_id=user.id,
179 amount=amount,
180 stripe_payment_intent_id=payment_intent_id,
181 stripe_receipt_url=receipt_url,
182 invoice_type=InvoiceType.on_platform,
183 )
184 session.add(invoice)
185 session.flush()
186 user.last_donated = invoice.created
188 notify(
189 session,
190 user_id=user.id,
191 topic_action=NotificationTopicAction.donation__received,
192 key="",
193 data=notification_data_pb2.DonationReceived(
194 amount=amount,
195 receipt_url=receipt_url,
196 ),
197 )
199 # Recurring donations go through Stripe invoices, one-time don't
200 is_recurring = data_object.get("invoice") is not None
201 donation_type = "recurring" if is_recurring else "one-time"
202 user_link = urls.user_link(username=user.username)
203 try:
204 send_slack_message(
205 config["SLACK_DONATIONS_CHANNEL"],
206 f"Donation received: ${amount} ({donation_type}) from <{user_link}|{user.name}>",
207 )
208 except Exception as e:
209 sentry_sdk.capture_exception(e)
210 else:
211 logger.info(f"Unhandled event from Stripe: {event_type}")
213 return httpbody_pb2.HttpBody(
214 content_type="application/json",
215 # json.dumps escapes non-ascii characters
216 data=json.dumps({"success": True}).encode("ascii"),
217 )