Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add idempotency to Stripe Create Calls #1909

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
afe87b0
Added SourceTransaction model.
arnav13081994 Jan 7, 2022
18f2bfe
Added SourceTransaction.__str__() method
arnav13081994 Jan 7, 2022
2215029
Added SourceTransaction.get_stripe_dashboard_url() method
arnav13081994 Jan 7, 2022
0a05fea
Exposed SourceTransaction Model on the Admin
arnav13081994 May 9, 2022
eae5870
Added SourceTransaction.api_list() method
arnav13081994 May 9, 2022
8e8e6da
Added SourceTransaction.api_retrieve() method
arnav13081994 May 8, 2022
2f98077
SourceTransaction instances will now get synced by the djstripe_sync_…
arnav13081994 Jan 7, 2022
8dd521a
Refactored Source object event handler into its own independent handler
arnav13081994 Jan 7, 2022
869d3c4
Added a webhook handler to handle all source.transaction.Y events
arnav13081994 Jan 7, 2022
ffc3f5c
Updated _handle_crud_like_event for SourceTransaction Objects
arnav13081994 Jan 7, 2022
9372d6f
Fix Linting Errors
arnav13081994 Feb 14, 2023
c89f6fc
Updated Changelog
arnav13081994 Feb 14, 2023
bab69e7
Update migration file name
arnav13081994 Mar 16, 2023
493dd0a
Fixed incorrect migration dependency name
arnav13081994 Apr 20, 2023
066d834
Removed sqlite test file
arnav13081994 Apr 20, 2023
8ba678e
Update 0021_sourcetransaction migration name to 0022_sourcetransaction
arnav13081994 Apr 20, 2023
71e35ad
Remove incorrect migration
arnav13081994 Apr 20, 2023
5c56dca
Set fallback value of metadata to {} instead of None
arnav13081994 Mar 27, 2023
10012c7
Updated StripeModel._api_create() to accept idempotency_key param
arnav13081994 Mar 27, 2023
9800773
Updated Charge.refund() to accept idempotency_key param
arnav13081994 Mar 27, 2023
539ed88
Updated Customer.add_invoice_item() to accept idempotency_key param
arnav13081994 Mar 27, 2023
b1e176b
Updated Customer.send_invoice() to accept idempotency_key param
arnav13081994 Mar 27, 2023
e4a8ba8
Updated the get_idempotency_key function
arnav13081994 Mar 27, 2023
94ee2dd
Updated Customer.purge() method to not delete the associated idempote…
arnav13081994 Mar 27, 2023
658df74
Renamed get_idempotency_key to create_idempotency_key
arnav13081994 Mar 27, 2023
ba8020c
Added Idempotencykey.update_action_field staticmethod
arnav13081994 Mar 27, 2023
67dee8d
Added StripeModel.get_or_create_idempotency_key
arnav13081994 Mar 27, 2023
56e4565
Updated StripeModel._api_create to use Idempotency Key object
arnav13081994 Mar 27, 2023
01d95c1
Updated TaxId._api_create() to use Idempotency Keys
arnav13081994 Mar 27, 2023
832fd36
Updated TransferReversal._api_create() to use Idempotency Keys
arnav13081994 Mar 27, 2023
d2a8672
Updated UsageRecord._api_create() to use Idempotency Keys
arnav13081994 Mar 27, 2023
e8e278c
Updated LegacySourceMixin._api_create() to use Idempotency Keys
arnav13081994 Mar 27, 2023
0308c33
Updated docs with support for idempotency_keys
arnav13081994 Mar 27, 2023
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
12 changes: 12 additions & 0 deletions djstripe/admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,18 @@ def get_queryset(self, request):
)


@admin.register(models.SourceTransaction)
class SourceTransactionAdmin(StripeModelAdmin):
list_display = ("status", "amount", "currency")
list_filter = (
"status",
"source__id",
"source__customer",
"source__customer__subscriber",
)
list_select_related = ("source", "source__customer", "source__customer__subscriber")


@admin.register(models.PaymentMethod)
class PaymentMethodAdmin(StripeModelAdmin):
list_display = ("customer", "type", "billing_details")
Expand Down
2 changes: 1 addition & 1 deletion djstripe/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def _post_clean(self):
url=url,
description=self.cleaned_data.get("description"),
enabled_events=self.cleaned_data.get("enabled_events"),
metadata=self.cleaned_data.get("metadata"),
metadata=self.cleaned_data.get("metadata", {}),
disabled=(not self.cleaned_data.get("enabled")),
)
except InvalidRequestError as e:
Expand Down
37 changes: 31 additions & 6 deletions djstripe/event_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,32 @@ def dispute_webhook_handler(event):
_handle_crud_like_event(target_cls=models.Dispute, event=event)


@webhooks.handler("source")
def source_webhook_handler(event):
"""Handle updates to Source objects
- charge: https://stripe.com/docs/api/charges
"""
# will recieve all events of the type source.X.Y so
# need to ensure the data object is related to Source Object
target_object_type = event.data.get("object", {}).get("object", {})

if target_object_type == "source":
_handle_crud_like_event(target_cls=models.Source, event=event)


@webhooks.handler("source.transaction")
def source_transaction_webhook_handler(event):
"""Handle updates to Source objects
- charge: https://stripe.com/docs/api/charges
"""
# will recieve all events of the type source.transaction.Y so
# need to ensure the data object is related to SourceTransaction Object
target_object_type = event.data.get("object", {}).get("object", {})

if target_object_type == "source_transaction":
_handle_crud_like_event(target_cls=models.SourceTransaction, event=event)


@webhooks.handler(
"checkout",
"coupon",
Expand All @@ -269,14 +295,13 @@ def dispute_webhook_handler(event):
"product",
"setup_intent",
"subscription_schedule",
"source",
"tax_rate",
"transfer",
)
def other_object_webhook_handler(event):
"""
Handle updates to checkout, coupon, file, invoice, invoiceitem, payment_intent,
plan, product, setup_intent, subscription_schedule, source, tax_rate
plan, product, setup_intent, subscription_schedule, tax_rate
and transfer objects.

Docs for:
Expand All @@ -293,7 +318,6 @@ def other_object_webhook_handler(event):
- product: https://stripe.com/docs/api/products
- setup_intent: https://stripe.com/docs/api/setup_intents
- subscription_schedule: https://stripe.com/docs/api/subscription_schedules
- source: https://stripe.com/docs/api/sources
- tax_rate: https://stripe.com/docs/api/tax_rates/
- transfer: https://stripe.com/docs/api/transfers
"""
Expand All @@ -313,7 +337,6 @@ def other_object_webhook_handler(event):
"transfer": models.Transfer,
"setup_intent": models.SetupIntent,
"subscription_schedule": models.SubscriptionSchedule,
"source": models.Source,
"tax_rate": models.TaxRate,
}.get(event.category)

Expand Down Expand Up @@ -408,8 +431,10 @@ def _handle_crud_like_event(
if event.parts[:2] == ["account", "external_account"] and stripe_account:
kwargs["account"] = models.Account._get_or_retrieve(id=stripe_account)

# Stripe doesn't allow retrieval of Discount Objects
if target_cls not in (models.Discount,):
# Stripe doesn't allow direct retrieval of Discount and SourceTransaction Objects
# indirect retrieval via Source will not work as SourceTransaction will not have a source attached in
# source.transaction.created event
if target_cls not in (models.Discount, models.SourceTransaction):
data = target_cls(**kwargs).api_retrieve(
stripe_account=stripe_account, api_key=event.default_api_key
)
Expand Down
25 changes: 25 additions & 0 deletions djstripe/management/commands/djstripe_sync_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def _should_sync_model(self, model):
models.ApplicationFeeRefund,
models.LineItem,
models.Source,
models.SourceTransaction,
models.TransferReversal,
models.TaxId,
models.UsageRecordSummary,
Expand Down Expand Up @@ -359,6 +360,29 @@ def get_list_kwargs_src(default_list_kwargs):

return all_list_kwargs

def get_list_kwargs_srctxn(default_list_kwargs):
"""Returns sequence of kwargs to sync SourceTransactions for
all Stripe Accounts"""

all_list_kwargs = []
for def_kwarg in default_list_kwargs:
stripe_account = def_kwarg.get("stripe_account")
api_key = def_kwarg.get("api_key")
for stripe_customer in models.Customer.api_list(
stripe_account=stripe_account, api_key=api_key
):
all_list_kwargs.append({"id": stripe_customer.id, **def_kwarg})

# fetch all Sources associated with the current customer instance
for source in models.Customer.stripe_class.list_sources(
id=stripe_customer.id,
stripe_account=stripe_account,
object="source",
api_key=api_key,
).auto_paging_iter():
all_list_kwargs.append({"id": source.id, **def_kwarg})
return all_list_kwargs

@staticmethod
def get_list_kwargs_si(default_list_kwargs):
"""Returns sequence of kwargs to sync Subscription Items for
Expand Down Expand Up @@ -473,6 +497,7 @@ def get_list_kwargs(self, model, api_key: str):
"LineItem": self.get_list_kwargs_il,
"PaymentMethod": self.get_list_kwargs_pm,
"Source": self.get_list_kwargs_src,
"SourceTransaction": self.get_list_kwargs_srctxn,
"SubscriptionItem": self.get_list_kwargs_si,
"CountrySpec": self.get_list_kwargs_country_spec,
"TransferReversal": self.get_list_kwargs_trr,
Expand Down
111 changes: 111 additions & 0 deletions djstripe/migrations/0022_sourcetransaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Generated by Django 3.2.10 on 2022-01-06 18:22

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

import djstripe.enums
import djstripe.fields


class Migration(migrations.Migration):
dependencies = [
("djstripe", "0021_add_payout_original_payout"),
]

operations = [
migrations.CreateModel(
name="SourceTransaction",
fields=[
("djstripe_created", models.DateTimeField(auto_now_add=True)),
("djstripe_updated", models.DateTimeField(auto_now=True)),
(
"djstripe_id",
models.BigAutoField(
primary_key=True, serialize=False, verbose_name="ID"
),
),
("id", djstripe.fields.StripeIdField(max_length=255, unique=True)),
(
"livemode",
models.BooleanField(
blank=True,
default=None,
help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.",
null=True,
),
),
(
"created",
djstripe.fields.StripeDateTimeField(
blank=True,
help_text="The datetime this object was created in stripe.",
null=True,
),
),
(
"ach_credit_transfer",
djstripe.fields.JSONField(
help_text="The data corresponding to the ach_credit_transfer type."
),
),
(
"amount",
djstripe.fields.StripeDecimalCurrencyAmountField(
blank=True,
decimal_places=2,
help_text="Amount (as decimal) associated with the ACH Credit Transfer. This is the amount your customer has sent for which the source will be chargeable once ready. ",
max_digits=11,
null=True,
),
),
(
"currency",
djstripe.fields.StripeCurrencyCodeField(
help_text="Three-letter ISO currency code", max_length=3
),
),
(
"customer_data",
djstripe.fields.JSONField(
blank=True,
help_text="Customer defined string used to initiate the ACH Credit Transfer.",
null=True,
),
),
(
"status",
djstripe.fields.StripeEnumField(
enum=djstripe.enums.SourceTransactionStatus,
help_text="The status of the ACH Credit Transfer. Only `chargeable` sources can be used to create a charge.",
max_length=10,
),
),
(
"djstripe_owner_account",
djstripe.fields.StripeForeignKey(
blank=True,
help_text="The Stripe Account this object belongs to.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="djstripe.account",
to_field=settings.DJSTRIPE_FOREIGN_KEY_TO_FIELD,
),
),
(
"source",
djstripe.fields.StripeForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="djstripe.source",
to_field=settings.DJSTRIPE_FOREIGN_KEY_TO_FIELD,
),
),
],
options={
"get_latest_by": "created",
"abstract": False,
},
),
]
2 changes: 2 additions & 0 deletions djstripe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
DjstripePaymentMethod,
PaymentMethod,
Source,
SourceTransaction,
)
from .sigma import ScheduledQueryRun
from .webhooks import WebhookEndpoint, WebhookEventTrigger
Expand Down Expand Up @@ -94,6 +95,7 @@
"SetupIntent",
"Session",
"Source",
"SourceTransaction",
"StripeModel",
"Subscription",
"SubscriptionItem",
Expand Down
52 changes: 46 additions & 6 deletions djstripe/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import timedelta
from typing import Dict, List, Optional, Type

from django.core.exceptions import FieldDoesNotExist
from django.db import IntegrityError, models, transaction
from django.utils import dateformat, timezone
from stripe.api_resources.abstract.api_resource import APIResource
Expand Down Expand Up @@ -204,20 +205,49 @@ def api_retrieve(self, api_key=None, stripe_account=None):
)

@classmethod
def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs):
def get_or_create_idempotency_key(cls, action, idempotency_key):
"""
Creates and returns an idempotency_key if not given.
"""
# Prefer passed in idempotency_key.
if not idempotency_key:
# Create idempotency_key
idempotency_key = djstripe_settings.create_idempotency_key(
object_type=cls.__name__.lower(),
action=action,
livemode=djstripe_settings.STRIPE_LIVE_MODE,
)

return idempotency_key

@classmethod
def _api_create(
cls, idempotency_key=None, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs
):
"""
Call the stripe API's create operation for this model.

:param api_key: The api key to use for this request. \
Defaults to djstripe_settings.STRIPE_SECRET_KEY.
:type api_key: string
"""
with transaction.atomic():
# Get or Create idempotency_key
idempotency_key = cls.get_or_create_idempotency_key(
action="create", idempotency_key=idempotency_key
)

return cls.stripe_class.create(
api_key=api_key,
stripe_version=djstripe_settings.STRIPE_API_VERSION,
**kwargs,
)
stripe_obj = cls.stripe_class.create(
api_key=api_key,
idempotency_key=idempotency_key,
stripe_version=djstripe_settings.STRIPE_API_VERSION,
**kwargs,
)

# Update the action of the idempotency_key by appending stripe_obj.id to it
IdempotencyKey.update_action_field(idempotency_key, stripe_obj)

return stripe_obj

def _api_delete(self, api_key=None, stripe_account=None, **kwargs):
"""
Expand Down Expand Up @@ -1087,3 +1117,13 @@ def __str__(self):
@property
def is_expired(self) -> bool:
return timezone.now() > self.created + timedelta(hours=24)

@staticmethod
def update_action_field(uuid, stripe_obj):
# Update the action of the idempotency_key by appending stripe_obj.id to it
idempotency_key_object_qs = IdempotencyKey.objects.filter(uuid=uuid)
if idempotency_key_object_qs.exists():
idempotency_key_object = idempotency_key_object_qs.get()
if idempotency_key_object.action.split(":")[-1] == "":
idempotency_key_object.action += stripe_obj["id"]
idempotency_key_object.save()
Loading