Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion codecov/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@
"'sha256-eKdXhLyOdPl2/gp1Ob116rCU2Ox54rseyz1MwCmzb6w='",
"'sha256-a1pELtDJXf8fPX1YL2JiBM91RQBeIAswunzgwMEsvwA='",
"'sha256-cNIcuS0BVLuBVP5rpfeFE42xHz7r5hMyf9YdfknWuCg='",
"'sha256-bmwAzHxhO1mBINfkKkKPopyKEv4ppCHx/z84wQJ9nOY='",
"'sha256-jQoC6QpIonlMBPFbUGlJFRJFFWbbijMl7Z8XqWrb46o='",
"https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js",
"https://cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png",
"https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css",
Expand Down Expand Up @@ -350,7 +352,11 @@
)
CORS_ALLOWED_ORIGINS: list[str] = []

GRAPHQL_PLAYGROUND = True
GRAPHQL_PLAYGROUND = get_settings_module() in [
SettingsModule.DEV.value,
SettingsModule.STAGING.value,
SettingsModule.TESTING.value,
]

UPLOAD_THROTTLING_ENABLED = get_config(
"setup", "upload_throttling_enabled", default=True
Expand Down
7 changes: 4 additions & 3 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
aiodataloader
ariadne
ariadne_django
ariadne==0.23
ariadne_django==0.3.0
celery>=5.3.6
cerberus
ddtrace
Django>=4.2.15
Django>=4.2.16
django-cors-headers
django-csp
django-dynamic-fixture
Expand Down Expand Up @@ -49,6 +49,7 @@ sentry-sdk>=2.13.0
sentry-sdk[celery]
setproctitle
simplejson
starlette==0.40.0
stripe>=9.6.0
urllib3>=1.26.19
vcrpy
Expand Down
17 changes: 8 additions & 9 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ anyio==3.6.1
# starlette
appdirs==1.4.4
# via virtualenv
ariadne==0.19.1
ariadne==0.23.0
# via
# -r requirements.in
# ariadne-django
ariadne-django==0.2.0
ariadne-django==0.3.0
# via -r requirements.in
asgiref==3.6.0
# via django
Expand Down Expand Up @@ -109,7 +109,7 @@ deprecated==1.2.12
# via opentelemetry-api
distlib==0.3.1
# via virtualenv
django==4.2.15
django==4.2.16
# via
# -r requirements.in
# ariadne-django
Expand Down Expand Up @@ -177,7 +177,6 @@ freezegun==1.1.0
# via -r requirements.in
google-api-core[grpc]==2.11.1
# via
# google-api-core
# google-cloud-core
# google-cloud-pubsub
# google-cloud-storage
Expand Down Expand Up @@ -409,9 +408,7 @@ requests==2.32.3
# shared
# stripe
rfc3986[idna2008]==1.4.0
# via
# httpx
# rfc3986
# via httpx
rsa==4.7.2
# via google-auth
s3transfer==0.5.0
Expand Down Expand Up @@ -450,8 +447,10 @@ sqlparse==0.5.0
# via
# ddtrace
# django
starlette==0.36.2
# via ariadne
starlette==0.40.0
# via
# -r requirements.in
# ariadne
stripe==9.6.0
# via -r requirements.in
text-unidecode==1.3
Expand Down
122 changes: 122 additions & 0 deletions services/billing.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timezone

import stripe
from dateutil.relativedelta import relativedelta
from django.conf import settings

from billing.constants import REMOVED_INVOICE_STATUSES
Expand Down Expand Up @@ -146,6 +148,87 @@ def list_filtered_invoices(self, owner: Owner, limit=10):
)
return list(invoices_filtered_by_status_and_total)

def cancel_and_refund(
self,
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
):
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
stripe.Subscription.cancel(owner.stripe_subscription_id)

start_of_last_period = current_subscription_datetime - relativedelta(months=1)
invoice_grace_period_start = current_subscription_datetime - relativedelta(
days=1
)

if subscription_plan_interval == "year":
start_of_last_period = current_subscription_datetime - relativedelta(
years=1
)
invoice_grace_period_start = current_subscription_datetime - relativedelta(
days=3
)

invoices_list = stripe.Invoice.list(
subscription=owner.stripe_subscription_id,
status="paid",
created={
"created.gte": int(start_of_last_period.timestamp()),
"created.lt": int(current_subscription_datetime.timestamp()),
},
)

# we only want to refund the invoices PAID recently for the latest, current period. "invoices_list" gives us any invoice
# created over the last month/year based on what period length they are on but the customer could have possibly
# switched from monthly to yearly recently.
recently_paid_invoices_list = [
invoice
for invoice in invoices_list["data"]
if invoice["status_transitions"]["paid_at"] is not None
and invoice["status_transitions"]["paid_at"]
>= int(invoice_grace_period_start.timestamp())
]

created_refund = False
# there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period
for invoice in recently_paid_invoices_list:
# refund if the invoice has a charge and it has been fully paid
if invoice["charge"] is not None and invoice["amount_remaining"] == 0:
stripe.Refund.create(invoice["charge"])
created_refund = True

if created_refund:
# update the customer's balance back to 0 in accordance to
# https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
stripe.Customer.modify(
owner.stripe_customer_id,
balance=0,
metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
)
log.info(
"Grace period cancelled a subscription and autorefunded associated invoices",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining - 1,
),
)
else:
log.info(
"Grace period cancelled a subscription but did not find any appropriate invoices to autorefund",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining,
),
)

@_log_stripe_error
def delete_subscription(self, owner: Owner):
subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id)
Expand All @@ -155,13 +238,52 @@ def delete_subscription(self, owner: Owner):
f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
extra=dict(ownerid=owner.ownerid),
)

if subscription_schedule_id:
log.info(
f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}",
extra=dict(ownerid=owner.ownerid),
)
stripe.SubscriptionSchedule.release(subscription_schedule_id)

# we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
current_subscription_datetime = datetime.fromtimestamp(
subscription["current_period_start"], tz=timezone.utc
)
difference_from_now = datetime.now(timezone.utc) - current_subscription_datetime

subscription_plan_interval = getattr(
getattr(subscription, "plan", None), "interval", None
)
within_refund_grace_period = (
subscription_plan_interval == "month" and difference_from_now.days < 1
) or (subscription_plan_interval == "year" and difference_from_now.days < 3)

if within_refund_grace_period:
customer = stripe.Customer.retrieve(owner.stripe_customer_id)
# we are currently allowing customers 2 autorefund instances
autorefunds_remaining = int(
customer["metadata"].get("autorefunds_remaining", "2")
)
log.info(
"Deleting subscription with attempted immediate cancellation with autorefund within grace period",
extra=dict(
owner_id=owner.ownerid,
user_id=self.requesting_user.ownerid,
subscription_id=owner.stripe_subscription_id,
customer_id=owner.stripe_customer_id,
autorefunds_remaining=autorefunds_remaining,
),
)
if autorefunds_remaining > 0:
return self.cancel_and_refund(
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
)

# schedule a cancellation at the end of the paid period with no refund
stripe.Subscription.modify(
owner.stripe_subscription_id,
cancel_at_period_end=True,
Expand Down
2 changes: 1 addition & 1 deletion services/tests/samples/stripe_invoice.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"total_tax_amounts": [],
"webhooks_delivered_at": 1489789437
},
{
{
"id": "in_19yTU92eZvKYlo2C7uDjvu69",
"object": "invoice",
"account_country": "US",
Expand Down
Loading
Loading