Coverage for src/couchers/servicers/donations.py: 96%

48 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-12-20 18:03 +0000

1import json 

2import logging 

3 

4import grpc 

5import stripe 

6 

7from couchers import errors, urls 

8from couchers.config import config 

9from couchers.models import DonationInitiation, DonationType, Invoice, User 

10from couchers.notifications.notify import notify 

11from couchers.sql import couchers_select as select 

12from proto import donations_pb2, donations_pb2_grpc, notification_data_pb2, stripe_pb2_grpc 

13from proto.google.api import httpbody_pb2 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class Donations(donations_pb2_grpc.DonationsServicer): 

19 def InitiateDonation(self, request, context, session): 

20 if not config["ENABLE_DONATIONS"]: 

21 context.abort(grpc.StatusCode.UNAVAILABLE, errors.DONATIONS_DISABLED) 

22 

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

24 

25 if request.amount < 2: 

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

27 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DONATION_TOO_SMALL) 

28 

29 if not user.stripe_customer_id: 

30 # create a new stripe id for this user 

31 customer = stripe.Customer.create( 

32 email=user.email, 

33 # metadata allows us to store arbitrary metadata for ourselves 

34 metadata={"user_id": user.id}, 

35 api_key=config["STRIPE_API_KEY"], 

36 ) 

37 user.stripe_customer_id = customer.id 

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

39 session.commit() 

40 

41 if request.recurring: 

42 item = { 

43 "price": config["STRIPE_RECURRING_PRODUCT_ID"], 

44 "quantity": request.amount, 

45 } 

46 else: 

47 item = { 

48 "price_data": { 

49 "currency": "usd", 

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

51 "product_data": { 

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

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

54 }, 

55 }, 

56 "quantity": 1, 

57 } 

58 

59 checkout_session = stripe.checkout.Session.create( 

60 client_reference_id=user.id, 

61 submit_type="donate" if not request.recurring else None, 

62 customer=user.stripe_customer_id, 

63 success_url=urls.donation_success_url(), 

64 cancel_url=urls.donation_cancelled_url(), 

65 payment_method_types=["card"], 

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

67 line_items=[item], 

68 api_key=config["STRIPE_API_KEY"], 

69 ) 

70 

71 session.add( 

72 DonationInitiation( 

73 user_id=user.id, 

74 amount=request.amount, 

75 stripe_checkout_session_id=checkout_session.id, 

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

77 ) 

78 ) 

79 

80 return donations_pb2.InitiateDonationRes( 

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

82 ) 

83 

84 

85class Stripe(stripe_pb2_grpc.StripeServicer): 

86 def Webhook(self, request, context, session): 

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

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

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

90 headers = dict(context.invocation_metadata()) 

91 

92 event = stripe.Webhook.construct_event( 

93 payload=request.data, 

94 sig_header=headers.get("stripe-signature"), 

95 secret=config["STRIPE_WEBHOOK_SECRET"], 

96 api_key=config["STRIPE_API_KEY"], 

97 ) 

98 data = event["data"] 

99 event_type = event["type"] 

100 event_id = event["id"] 

101 data_object = data["object"] 

102 

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

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

105 

106 if event_type == "charge.succeeded": 

107 customer_id = data_object["customer"] 

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

109 # amount comes in cents 

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

111 receipt_url = data_object["receipt_url"] 

112 

113 # may be check for amount to enable phone verify 

114 user.has_donated = True 

115 

116 session.add( 

117 Invoice( 

118 user_id=user.id, 

119 amount=amount, 

120 stripe_payment_intent_id=data_object["payment_intent"], 

121 stripe_receipt_url=receipt_url, 

122 ) 

123 ) 

124 

125 notify( 

126 session, 

127 user_id=user.id, 

128 topic_action="donation:received", 

129 data=notification_data_pb2.DonationReceived( 

130 amount=amount, 

131 receipt_url=receipt_url, 

132 ), 

133 ) 

134 else: 

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

136 

137 return httpbody_pb2.HttpBody( 

138 content_type="application/json", 

139 # json.dumps escapes non-ascii characters 

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

141 )