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

94 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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["customer_email"] 

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

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

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

158 if user: 

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

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

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

162 else: 

163 customer_info = customer_email 

164 try: 

165 send_slack_message( 

166 config.SLACK_MERCH_CHANNEL, 

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

168 ) 

169 except Exception as e: 

170 report_error(e) 

171 else: 

172 customer_id = data_object["customer"] 

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

174 # amount comes in cents 

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

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

177 receipt_url = data_object["receipt_url"] 

178 payment_intent_id = data_object["payment_intent"] 

179 

180 invoice = Invoice( 

181 user_id=user.id, 

182 amount=amount, 

183 stripe_payment_intent_id=payment_intent_id, 

184 stripe_receipt_url=receipt_url, 

185 invoice_type=InvoiceType.on_platform, 

186 ) 

187 session.add(invoice) 

188 session.flush() 

189 user.last_donated = invoice.created 

190 

191 notify( 

192 session, 

193 user_id=user.id, 

194 topic_action=NotificationTopicAction.donation__received, 

195 key="", 

196 data=notification_data_pb2.DonationReceived( 

197 amount=amount, 

198 receipt_url=receipt_url, 

199 ), 

200 ) 

201 

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

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

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

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

206 try: 

207 send_slack_message( 

208 config.SLACK_DONATIONS_CHANNEL, 

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

210 ) 

211 except Exception as e: 

212 report_error(e) 

213 else: 

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

215 

216 return httpbody_pb2.HttpBody( 

217 content_type="application/json", 

218 # json.dumps escapes non-ascii characters 

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

220 )