Coverage for src/couchers/servicers/donations.py: 94%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import json
2import logging
4import grpc
5import stripe
7from couchers import errors, urls
8from couchers.config import config
9from couchers.db import session_scope
10from couchers.models import Invoice, OneTimeDonation, RecurringDonation, User
11from couchers.sql import couchers_select as select
12from couchers.tasks import send_donation_email
13from couchers.utils import now
14from proto import donations_pb2, donations_pb2_grpc, stripe_pb2_grpc
15from proto.google.api import httpbody_pb2
17logger = logging.getLogger(__name__)
20class Donations(donations_pb2_grpc.DonationsServicer):
21 def InitiateDonation(self, request, context):
22 if not config["ENABLE_DONATIONS"]:
23 context.abort(grpc.StatusCode.UNAVAILABLE, errors.DONATIONS_DISABLED)
25 with session_scope() as session:
26 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
28 if request.amount < 2:
29 # we don't want to waste *all* the donations on processing fees
30 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.DONATION_TOO_SMALL)
32 if not user.stripe_customer_id:
33 # create a new stripe id for this user
34 customer = stripe.Customer.create(
35 email=user.email,
36 # metadata allows us to store arbitrary metadata for ourselves
37 metadata={"user_id": user.id},
38 api_key=config["STRIPE_API_KEY"],
39 )
40 user.stripe_customer_id = customer.id
41 # 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
42 session.commit()
44 if request.recurring:
45 item = {
46 "price": config["STRIPE_RECURRING_PRODUCT_ID"],
47 "quantity": request.amount,
48 }
49 else:
50 item = {
51 "price_data": {
52 "currency": "usd",
53 "unit_amount": request.amount * 100, # input is in cents
54 "product_data": {
55 "name": f"Couchers financial supporter (one-time)",
56 "images": ["https://couchers.org/img/share.jpg"],
57 },
58 },
59 "quantity": 1,
60 }
62 checkout_session = stripe.checkout.Session.create(
63 client_reference_id=user.id,
64 submit_type="donate" if not request.recurring else None,
65 customer=user.stripe_customer_id,
66 success_url=urls.donation_success_url(),
67 cancel_url=urls.donation_cancelled_url(),
68 payment_method_types=["card"],
69 mode="subscription" if request.recurring else "payment",
70 line_items=[item],
71 api_key=config["STRIPE_API_KEY"],
72 )
74 if request.recurring:
75 session.add(
76 RecurringDonation(
77 user_id=user.id,
78 amount=request.amount,
79 stripe_checkout_session_id=checkout_session.id,
80 )
81 )
82 else:
83 session.add(
84 OneTimeDonation(
85 user_id=user.id,
86 amount=request.amount,
87 stripe_checkout_session_id=checkout_session.id,
88 stripe_payment_intent_id=checkout_session.payment_intent,
89 paid=None,
90 )
91 )
93 return donations_pb2.InitiateDonationRes(stripe_checkout_session_id=checkout_session.id)
96class Stripe(stripe_pb2_grpc.StripeServicer):
97 def Webhook(self, request, context):
98 # We're set up to receive the following webhook events (with explanations from stripe docs):
99 # For both recurring and one-off donations, we get a `checkout.session.completed` event, and then a `payment_intent.succeeded` event
100 headers = dict(context.invocation_metadata())
102 event = stripe.Webhook.construct_event(
103 payload=request.data,
104 sig_header=headers.get("stripe-signature"),
105 secret=config["STRIPE_WEBHOOK_SECRET"],
106 api_key=config["STRIPE_API_KEY"],
107 )
108 data = event["data"]
109 event_type = event["type"]
110 event_id = event["id"]
111 data_object = data["object"]
113 # Get the type of webhook event sent - used to check the status of PaymentIntents.
114 logger.info(f"Got signed Stripe webhook, {event_type=}, {event_id=}")
116 if event_type == "checkout.session.completed":
117 checkout_session_id = data_object["id"]
118 if data_object["payment_intent"]:
119 with session_scope() as session:
120 donation = session.execute(
121 select(OneTimeDonation).where(OneTimeDonation.stripe_checkout_session_id == checkout_session_id)
122 ).scalar_one()
123 if data_object["payment_status"] == "paid":
124 donation.paid = now()
125 else:
126 raise Exception("Unknown payment status")
127 elif data_object["subscription"]:
128 with session_scope() as session:
129 donation = session.execute(
130 select(RecurringDonation).where(
131 RecurringDonation.stripe_checkout_session_id == checkout_session_id
132 )
133 ).scalar_one()
134 donation.stripe_subscription_id = data_object["subscription"]
135 else:
136 raise Exception("Unknown payment type")
137 elif event_type == "payment_intent.succeeded":
138 customer_id = data_object["customer"]
139 with session_scope() as session:
140 user = session.execute(select(User).where(User.stripe_customer_id == customer_id)).scalar_one()
141 invoice_data = data_object["charges"]["data"][0]
142 # amount comes in cents
143 amount = int(float(invoice_data["amount"]) / 100)
144 receipt_url = invoice_data["receipt_url"]
145 session.add(
146 Invoice(
147 user_id=user.id,
148 amount=amount,
149 stripe_payment_intent_id=invoice_data["payment_intent"],
150 stripe_receipt_url=receipt_url,
151 )
152 )
153 send_donation_email(user, amount, receipt_url)
154 else:
155 logger.info(f"Unhandled event from Stripe: {event_type}")
157 return httpbody_pb2.HttpBody(
158 content_type="application/json",
159 # json.dumps escapes non-ascii characters
160 data=json.dumps({"success": True}).encode("ascii"),
161 )