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

50 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-07-22 17:19 +0000

1import json 

2import logging 

3 

4import grpc 

5import stripe 

6 

7from couchers import errors, urls 

8from couchers.config import config 

9from couchers.db import session_scope 

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

11from couchers.notifications.notify import notify 

12from couchers.sql import couchers_select as select 

13from proto import donations_pb2, donations_pb2_grpc, notification_data_pb2, stripe_pb2_grpc 

14from proto.google.api import httpbody_pb2 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class Donations(donations_pb2_grpc.DonationsServicer): 

20 def InitiateDonation(self, request, context): 

21 if not config["ENABLE_DONATIONS"]: 

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

23 

24 with session_scope() as session: 

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

26 

27 if request.amount < 2: 

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

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

30 

31 if not user.stripe_customer_id: 

32 # create a new stripe id for this user 

33 customer = stripe.Customer.create( 

34 email=user.email, 

35 # metadata allows us to store arbitrary metadata for ourselves 

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

37 api_key=config["STRIPE_API_KEY"], 

38 ) 

39 user.stripe_customer_id = customer.id 

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

41 session.commit() 

42 

43 if request.recurring: 

44 item = { 

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

46 "quantity": request.amount, 

47 } 

48 else: 

49 item = { 

50 "price_data": { 

51 "currency": "usd", 

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

53 "product_data": { 

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

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

56 }, 

57 }, 

58 "quantity": 1, 

59 } 

60 

61 checkout_session = stripe.checkout.Session.create( 

62 client_reference_id=user.id, 

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

64 customer=user.stripe_customer_id, 

65 success_url=urls.donation_success_url(), 

66 cancel_url=urls.donation_cancelled_url(), 

67 payment_method_types=["card"], 

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

69 line_items=[item], 

70 api_key=config["STRIPE_API_KEY"], 

71 ) 

72 

73 session.add( 

74 DonationInitiation( 

75 user_id=user.id, 

76 amount=request.amount, 

77 stripe_checkout_session_id=checkout_session.id, 

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

79 ) 

80 ) 

81 

82 return donations_pb2.InitiateDonationRes( 

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

84 ) 

85 

86 

87class Stripe(stripe_pb2_grpc.StripeServicer): 

88 def Webhook(self, request, context): 

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

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

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

92 headers = dict(context.invocation_metadata()) 

93 

94 event = stripe.Webhook.construct_event( 

95 payload=request.data, 

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

97 secret=config["STRIPE_WEBHOOK_SECRET"], 

98 api_key=config["STRIPE_API_KEY"], 

99 ) 

100 data = event["data"] 

101 event_type = event["type"] 

102 event_id = event["id"] 

103 data_object = data["object"] 

104 

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

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

107 

108 if event_type == "charge.succeeded": 

109 customer_id = data_object["customer"] 

110 with session_scope() as session: 

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

112 # amount comes in cents 

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

114 receipt_url = data_object["receipt_url"] 

115 session.add( 

116 Invoice( 

117 user_id=user.id, 

118 amount=amount, 

119 stripe_payment_intent_id=data_object["payment_intent"], 

120 stripe_receipt_url=receipt_url, 

121 ) 

122 ) 

123 

124 notify( 

125 user_id=user.id, 

126 topic_action="donation:received", 

127 data=notification_data_pb2.DonationReceived( 

128 amount=amount, 

129 receipt_url=receipt_url, 

130 ), 

131 ) 

132 else: 

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

134 

135 return httpbody_pb2.HttpBody( 

136 content_type="application/json", 

137 # json.dumps escapes non-ascii characters 

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

139 )