Coverage for app/backend/src/couchers/servicers/donations.py: 92%

94 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1import json 

2import logging 

3 

4import grpc 

5import stripe 

6from google.protobuf import empty_pb2 

7from sqlalchemy import select 

8from sqlalchemy.orm import Session 

9 

10from couchers import urls 

11from couchers.config import config 

12from couchers.context import CouchersContext 

13from couchers.event_log import log_event 

14from couchers.helpers.badges import user_add_badge 

15from couchers.metrics import observe_revenue 

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.sentry import report_error 

22from couchers.slack import send_slack_message 

23from couchers.utils import not_none 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28def _create_stripe_customer(session: Session, user: User) -> None: 

29 # create a new stripe id for this user 

30 customer = stripe.Customer.create( 

31 email=user.email, 

32 # metadata allows us to store arbitrary metadata for ourselves 

33 metadata={"user_id": user.id}, # type: ignore[dict-item] 

34 api_key=config.STRIPE_API_KEY, 

35 ) 

36 user.stripe_customer_id = customer.id 

37 # 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 

38 session.commit() 

39 

40 

41class Donations(donations_pb2_grpc.DonationsServicer): 

42 def InitiateDonation( 

43 self, request: donations_pb2.InitiateDonationReq, context: CouchersContext, session: Session 

44 ) -> donations_pb2.InitiateDonationRes: 

45 if not context.get_boolean_value("donations_enabled", default=False): 

46 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled") 

47 

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

49 

50 if request.amount < 2: 50 ↛ 52line 50 didn't jump to line 52 because the condition on line 50 was never true

51 # we don't want to waste *all* of the donation on processing fees 

52 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "donation_too_small") 

53 

54 if not user.stripe_customer_id: 54 ↛ 57line 54 didn't jump to line 57 because the condition on line 54 was always true

55 _create_stripe_customer(session, user) 

56 

57 if request.recurring: 

58 item = { 

59 "price": config.STRIPE_RECURRING_PRODUCT_ID, 

60 "quantity": request.amount, 

61 } 

62 else: 

63 item = { 

64 "price_data": { 

65 "currency": "usd", 

66 "unit_amount": request.amount * 100, # input is in cents 

67 "product_data": { 

68 "name": "Couchers financial supporter (one-time)", 

69 "images": ["https://couchers.org/img/share.jpg"], 

70 }, 

71 }, 

72 "quantity": 1, 

73 } 

74 

75 checkout_session = stripe.checkout.Session.create( 

76 client_reference_id=str(user.id), 

77 # Stripe actually allows None, but the signature says it's either a string or not passed. 

78 submit_type="donate" if not request.recurring else None, # type: ignore[arg-type] 

79 customer=not_none(user.stripe_customer_id), 

80 success_url=urls.donation_success_url(), 

81 cancel_url=urls.donation_cancelled_url(), 

82 payment_method_types=["card"], 

83 mode="subscription" if request.recurring else "payment", 

84 line_items=[item], # type: ignore[list-item] 

85 api_key=config.STRIPE_API_KEY, 

86 ) 

87 

88 session.add( 

89 DonationInitiation( 

90 user_id=user.id, 

91 amount=request.amount, 

92 stripe_checkout_session_id=checkout_session.id, 

93 donation_type=DonationType.recurring if request.recurring else DonationType.one_time, 

94 source=request.source if request.source else None, 

95 ) 

96 ) 

97 

98 log_event( 

99 context, 

100 session, 

101 "donation.initiated", 

102 {"amount": request.amount, "recurring": request.recurring, "source": request.source or None}, 

103 ) 

104 

105 return donations_pb2.InitiateDonationRes( 

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

107 ) 

108 

109 def GetDonationPortalLink( 

110 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

111 ) -> donations_pb2.GetDonationPortalLinkRes: 

112 if not context.get_boolean_value("donations_enabled", default=False): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "donations_disabled") 

114 

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

116 

117 if not user.stripe_customer_id: 

118 _create_stripe_customer(session, user) 

119 

120 stripe_session = stripe.billing_portal.Session.create( 

121 customer=not_none(user.stripe_customer_id), 

122 return_url=urls.donation_url(), 

123 api_key=config.STRIPE_API_KEY, 

124 ) 

125 

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

127 

128 

129class Stripe(stripe_pb2_grpc.StripeServicer): 

130 def Webhook( 

131 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session 

132 ) -> httpbody_pb2.HttpBody: 

133 # We're set up to receive the following webhook events (with explanations from stripe docs): 

134 # For both recurring and one-off donations, we get a `charge.succeeded` event and we then send the user an 

135 # invoice. There are other events too, but we don't handle them right now. 

136 event = stripe.Webhook.construct_event( # type: ignore[no-untyped-call] 

137 payload=request.data, 

138 sig_header=context.get_header("stripe-signature"), 

139 secret=config.STRIPE_WEBHOOK_SECRET, 

140 api_key=config.STRIPE_API_KEY, 

141 ) 

142 data = event["data"] 

143 event_type = event["type"] 

144 event_id = event["id"] 

145 data_object = data["object"] 

146 metadata = data_object.get("metadata", {}) 

147 

148 # Get the type of webhook event sent - used to check the status of PaymentIntents. 

149 logger.info(f"Got signed Stripe webhook, {event_type=}, {event_id=}") 

150 

151 if event_type == "charge.succeeded": 

152 if metadata.get("site_url") == config.MERCH_SHOP_URL: 

153 # merch shop. look up this email and give them the swagster badge 

154 customer_email = metadata.get("customer_email") or data_object["billing_details"].get("email") 

155 observe_revenue("merch", int(data_object["amount"])) 

156 amount = int(data_object["amount"]) // 100 

157 user = ( 

158 session.execute(select(User).where(User.email == customer_email)).scalar_one_or_none() 

159 if customer_email 

160 else None 

161 ) 

162 if user: 

163 user_add_badge(session, user.id, "swagster") 

164 user_link = urls.user_link(username=user.username) 

165 customer_info = f"<{user_link}|{user.name}>" 

166 else: 

167 customer_info = customer_email or "unknown email" 

168 try: 

169 send_slack_message( 

170 config.SLACK_MERCH_CHANNEL, 

171 f"Merch purchase: ${amount} from {customer_info}", 

172 ) 

173 except Exception as e: 

174 report_error(e) 

175 else: 

176 customer_id = data_object["customer"] 

177 user = session.execute(select(User).where(User.stripe_customer_id == customer_id)).scalar_one() 

178 # amount comes in cents 

179 observe_revenue("donation", int(data_object["amount"])) 

180 amount = int(data_object["amount"]) // 100 

181 receipt_url = data_object["receipt_url"] 

182 payment_intent_id = data_object["payment_intent"] 

183 

184 invoice = Invoice( 

185 user_id=user.id, 

186 amount=amount, 

187 stripe_payment_intent_id=payment_intent_id, 

188 stripe_receipt_url=receipt_url, 

189 invoice_type=InvoiceType.on_platform, 

190 ) 

191 session.add(invoice) 

192 session.flush() 

193 user.last_donated = invoice.created 

194 

195 notify( 

196 session, 

197 user_id=user.id, 

198 topic_action=NotificationTopicAction.donation__received, 

199 key="", 

200 data=notification_data_pb2.DonationReceived( 

201 amount=amount, 

202 receipt_url=receipt_url, 

203 ), 

204 ) 

205 

206 # Recurring donations go through Stripe invoices, one-time don't 

207 is_recurring = data_object.get("invoice") is not None 

208 donation_type = "recurring" if is_recurring else "one-time" 

209 user_link = urls.user_link(username=user.username) 

210 try: 

211 send_slack_message( 

212 config.SLACK_DONATIONS_CHANNEL, 

213 f"Donation received: ${amount} ({donation_type}) from <{user_link}|{user.name}>", 

214 ) 

215 except Exception as e: 

216 report_error(e) 

217 else: 

218 logger.info(f"Unhandled event from Stripe: {event_type}") 

219 

220 return httpbody_pb2.HttpBody( 

221 content_type="application/json", 

222 # json.dumps escapes non-ascii characters 

223 data=json.dumps({"success": True}).encode("ascii"), 

224 )