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

1import json 

2import logging 

3 

4import grpc 

5import sentry_sdk 

6import stripe 

7from google.protobuf import empty_pb2 

8from sqlalchemy import select 

9from sqlalchemy.orm import Session 

10 

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 

23 

24logger = logging.getLogger(__name__) 

25 

26 

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

38 

39 

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

46 

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

48 

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

52 

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) 

55 

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 } 

73 

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 ) 

86 

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 ) 

96 

97 log_event( 

98 context, 

99 session, 

100 "donation.initiated", 

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

102 ) 

103 

104 return donations_pb2.InitiateDonationRes( 

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

106 ) 

107 

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

113 

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

115 

116 if not user.stripe_customer_id: 

117 _create_stripe_customer(session, user) 

118 

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 ) 

124 

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

126 

127 

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

146 

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

149 

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

176 

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 

187 

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 ) 

198 

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

212 

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 )