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
« prev ^ index » next coverage.py v7.5.0, created at 2024-12-20 18:03 +0000
1import json
2import logging
4import grpc
5import stripe
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
15logger = logging.getLogger(__name__)
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)
23 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
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)
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()
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 }
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 )
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 )
80 return donations_pb2.InitiateDonationRes(
81 stripe_checkout_session_id=checkout_session.id, stripe_checkout_url=checkout_session.url
82 )
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())
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"]
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=}")
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"]
113 # may be check for amount to enable phone verify
114 user.has_donated = True
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 )
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}")
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 )