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

1import json 

2import logging 

3from typing import Any 

4 

5import certifi 

6import grpc 

7import stripe 

8from google.protobuf import empty_pb2 

9from sqlalchemy import select 

10from sqlalchemy.orm import Session 

11 

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 

26 

27logger = logging.getLogger(__name__) 

28 

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() 

31 

32 

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} 

39 

40 

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() 

52 

53 

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") 

60 

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

62 

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") 

66 

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") 

70 

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) 

73 

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)}} 

78 

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 } 

108 

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 ) 

123 

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 ) 

134 

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 ) 

145 

146 return donations_pb2.InitiateDonationRes( 

147 stripe_checkout_session_id=checkout_session.id, stripe_checkout_url=checkout_session.url 

148 ) 

149 

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") 

155 

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

157 

158 if not user.stripe_customer_id: 

159 _create_stripe_customer(session, user) 

160 

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 ) 

166 

167 return donations_pb2.GetDonationPortalLinkRes(stripe_portal_url=stripe_session.url) 

168 

169 

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 {} 

188 

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=}") 

191 

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"] 

231 

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 

256 

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 

269 

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 ) 

280 

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}") 

292 

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 )