django-stripe-hooks 0.0.1.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-stripe-hooks
3
+ Version: 0.0.1.dev1
4
+ Summary: Webhooks for syncing Stripe payment data with your Django database.
5
+ License: MIT
6
+ Author: Geoffrey Eisenbarth
7
+ Author-email: geoffrey.eisenbarth@gmail.com
8
+ Requires-Python: >=3.12,<4
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: django (>=6.0)
15
+ Requires-Dist: stripe (>=14.4.1,<15.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Django Stripe Hooks
19
+
20
+ TODO
21
+
22
+ ## Installing
23
+
24
+ 1) Add to `INSTALLED_APPS`:
25
+
26
+ 2) Add to `urls.py`:
27
+
28
+ `path('stripe/', include('django_stripe_hooks.urls')),`
29
+
30
+
31
+ ## Contributing
32
+
33
+ ### Running Tests
34
+
35
+ 1) The test environment requires Stripe CLI package to be installed.
36
+
37
+ ```
38
+ curl -L https://github.com/stripe/stripe-cli/releases/download/v1.39.0/stripe_1.39.0_linux_x86_64.tar.gz -o stripe.tar.gz
39
+ sudo tar -xvf stripe.tar.gz -C /usr/local/bin
40
+ rm stripe.tar.gz
41
+
42
+ stripe --version
43
+
44
+ ```
45
+
46
+ 2) Run test: `poetry run pytest -s`
47
+
48
+ To generate coverage report, use `poetry run pytest --cov --cov-branch --cov-report=xml`
49
+
50
+ Whenever tests fail, you can check `stripe_cli.log` for more details.
51
+
@@ -0,0 +1,33 @@
1
+ # Django Stripe Hooks
2
+
3
+ TODO
4
+
5
+ ## Installing
6
+
7
+ 1) Add to `INSTALLED_APPS`:
8
+
9
+ 2) Add to `urls.py`:
10
+
11
+ `path('stripe/', include('django_stripe_hooks.urls')),`
12
+
13
+
14
+ ## Contributing
15
+
16
+ ### Running Tests
17
+
18
+ 1) The test environment requires Stripe CLI package to be installed.
19
+
20
+ ```
21
+ curl -L https://github.com/stripe/stripe-cli/releases/download/v1.39.0/stripe_1.39.0_linux_x86_64.tar.gz -o stripe.tar.gz
22
+ sudo tar -xvf stripe.tar.gz -C /usr/local/bin
23
+ rm stripe.tar.gz
24
+
25
+ stripe --version
26
+
27
+ ```
28
+
29
+ 2) Run test: `poetry run pytest -s`
30
+
31
+ To generate coverage report, use `poetry run pytest --cov --cov-branch --cov-report=xml`
32
+
33
+ Whenever tests fail, you can check `stripe_cli.log` for more details.
@@ -0,0 +1,484 @@
1
+ from typing import TypeVar
2
+
3
+ from django.contrib import admin
4
+ from django.db import models
5
+ from django.http import HttpRequest
6
+ from django.utils.html import format_html
7
+ from django.utils.safestring import mark_safe
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from django_stripe_hooks.models import (
11
+ Product, Price, PriceTier,
12
+ Coupon, PromotionCode,
13
+ Customer, PaymentMethod, Subscription,
14
+ PaymentIntent, BalanceTransaction, Invoice, Charge, Refund
15
+ )
16
+
17
+
18
+ ParentModelT = TypeVar("ParentModelT", bound=models.Model)
19
+ ChildModelT = TypeVar("ChildModelT", bound=models.Model)
20
+
21
+
22
+ class StripeModelAdmin(admin.ModelAdmin[ParentModelT]):
23
+ """ModelAdmin for Stripe models.
24
+
25
+ Notes
26
+ -----
27
+ Authors are required to use Stripe SDK to create objects,
28
+ so we remove add/change/delete permissions here.
29
+
30
+ """
31
+
32
+ def has_add_permission(
33
+ self,
34
+ request: HttpRequest,
35
+ ) -> bool:
36
+ return False
37
+
38
+ def has_change_permission(
39
+ self,
40
+ request: HttpRequest,
41
+ obj: ParentModelT | None = None,
42
+ ) -> bool:
43
+ return False
44
+
45
+ def has_delete_permission(
46
+ self,
47
+ request: HttpRequest,
48
+ obj: ParentModelT | None = None,
49
+ ) -> bool:
50
+ return False
51
+
52
+
53
+ class StripeModelInline(admin.TabularInline[ChildModelT, ParentModelT]):
54
+ """Inline ModelAdmin for Stripe models.
55
+
56
+ Notes
57
+ -----
58
+ Authors are required to use Stripe SDK to create objects,
59
+ so we remove add/change/delete permissions here.
60
+
61
+ """
62
+
63
+ def has_add_permission(
64
+ self,
65
+ request: HttpRequest,
66
+ obj: models.Model | None = None,
67
+ ) -> bool:
68
+ return False
69
+
70
+ def has_change_permission(
71
+ self,
72
+ request: HttpRequest,
73
+ obj: models.Model | None = None,
74
+ ) -> bool:
75
+ return False
76
+
77
+ def has_delete_permission(
78
+ self,
79
+ request: HttpRequest,
80
+ obj: models.Model | None = None,
81
+ ) -> bool:
82
+ return False
83
+
84
+
85
+ @admin.register(Product)
86
+ class ProductAdmin(StripeModelAdmin[Product]):
87
+ list_display = (
88
+ 'name',
89
+ )
90
+
91
+ fieldsets = (
92
+ (None, {
93
+ 'fields': (
94
+ 'name',
95
+ 'description',
96
+ ),
97
+ }),
98
+ )
99
+
100
+
101
+ class PriceTierInline(StripeModelInline[PriceTier, Price]):
102
+ model = PriceTier
103
+ extra = 2
104
+ fields = ('flat_amount', 'unit_amount', 'up_to')
105
+
106
+
107
+ # TODO: This uses custom admin html and css. Is it needed?
108
+ @admin.register(Price)
109
+ class PriceAdmin(StripeModelAdmin[Price]):
110
+ list_display = (
111
+ 'nickname',
112
+ 'product',
113
+ 'type',
114
+ 'interval',
115
+ 'unit_amount',
116
+ 'active',
117
+ )
118
+ list_select_related = ('product', )
119
+
120
+ fieldsets = (
121
+ (None, {
122
+ 'fields': (
123
+ 'product',
124
+ 'nickname',
125
+ 'active',
126
+ ),
127
+ }),
128
+ (_("Billing Details"), {
129
+ 'fields': (
130
+ 'unit_amount',
131
+ 'type',
132
+ 'interval',
133
+ 'usage_type',
134
+ 'billing_scheme',
135
+ 'tiers_mode',
136
+ ),
137
+ }),
138
+ )
139
+
140
+
141
+ @admin.register(Coupon)
142
+ class CouponAdmin(StripeModelAdmin[Coupon]):
143
+ list_display = (
144
+ 'name',
145
+ 'terms',
146
+ )
147
+
148
+ fieldsets = (
149
+ (None, {
150
+ 'fields': (
151
+ 'name',
152
+ 'duration',
153
+ 'percent_off',
154
+ 'amount_off',
155
+ 'products',
156
+ ),
157
+ }),
158
+ )
159
+ filter_horizontal = ('products', )
160
+
161
+
162
+ @admin.register(PromotionCode)
163
+ class PromotionCodeAdmin(StripeModelAdmin[PromotionCode]):
164
+ list_display = (
165
+ 'code',
166
+ 'coupon',
167
+ 'coupon__terms',
168
+ 'expires_at',
169
+ 'redemptions',
170
+ 'active',
171
+ )
172
+ list_select_related = ('coupon', )
173
+ list_display_links = None
174
+
175
+ fieldsets = (
176
+ (None, {
177
+ 'fields': (
178
+ 'code',
179
+ 'coupon',
180
+ 'expires_at',
181
+ 'max_redemptions',
182
+ 'active',
183
+ ),
184
+ }),
185
+ )
186
+
187
+ @admin.display(description=_("Terms"))
188
+ def coupon__terms(self, obj: PromotionCode) -> str:
189
+ return obj.coupon.terms
190
+
191
+
192
+ class SubscriptionInline(StripeModelInline[Subscription, Customer]):
193
+ model = Subscription
194
+ extra = 0
195
+ fields = (
196
+ 'status',
197
+ 'current_period_start',
198
+ 'current_period_end',
199
+ 'promotion_code',
200
+ 'cancel_at_period_end',
201
+ )
202
+
203
+
204
+ class PaymentMethodInline(StripeModelInline[PaymentMethod, Customer]):
205
+ model = PaymentMethod
206
+ extra = 0
207
+ fields = (
208
+ 'card_info',
209
+ 'card_exp_month',
210
+ 'card_exp_year',
211
+ 'zip_code',
212
+ 'is_default',
213
+ )
214
+
215
+
216
+ class InvoiceInline(StripeModelInline[Invoice, Customer]):
217
+ model = Invoice
218
+ extra = 0
219
+ fields = (
220
+ 'total',
221
+ 'status',
222
+ 'period_start',
223
+ 'period_end',
224
+ 'pdf',
225
+ 'link',
226
+ )
227
+
228
+ @admin.display(description=_("PDF"))
229
+ def pdf_link(self, obj: Invoice) -> str:
230
+ html = format_html(
231
+ '<a href="{url}">PDF</a>',
232
+ url=obj.invoice_pdf,
233
+ )
234
+ return html
235
+
236
+ @admin.display(description=_("Link"))
237
+ def link(self, obj: Invoice) -> str:
238
+ html = format_html(
239
+ '<a href="{url}">Link</a>',
240
+ url=obj.hosted_invoice_url,
241
+ )
242
+ return html
243
+
244
+
245
+ @admin.register(Customer)
246
+ class CustomerAdmin(StripeModelAdmin[Customer]):
247
+ search_fields = (
248
+ 'email',
249
+ 'name',
250
+ 'phone',
251
+ )
252
+
253
+ list_display = (
254
+ 'email',
255
+ 'name',
256
+ 'phone',
257
+ )
258
+
259
+ fieldsets = (
260
+ (None, {'fields': ()}),
261
+ )
262
+
263
+ inline_type = 'stacked'
264
+ inlines = (
265
+ SubscriptionInline,
266
+ PaymentMethodInline,
267
+ InvoiceInline,
268
+ )
269
+
270
+
271
+ @admin.register(Subscription)
272
+ class SubscriptionAdmin(StripeModelAdmin[Subscription]):
273
+ search_fields = (
274
+ 'customer__email',
275
+ 'customer__name',
276
+ 'customer__phone',
277
+ )
278
+ list_display = (
279
+ 'customer',
280
+ 'status_verbose',
281
+ 'promotion_code',
282
+ 'current_period_end',
283
+ )
284
+ list_select_related = (
285
+ 'customer',
286
+ )
287
+
288
+ fieldsets = (
289
+ (None, {
290
+ 'fields': (
291
+ 'customer',
292
+ 'current_period_start',
293
+ 'current_period_end',
294
+ 'promotion_code',
295
+ 'cancel_at_period_end',
296
+ ),
297
+ }),
298
+ )
299
+ inlines = (InvoiceInline, )
300
+
301
+ @admin.display(description=_("Status"), ordering='status')
302
+ def status_verbose(self, obj: Subscription) -> str:
303
+ if obj.cancel_at_period_end:
304
+ status = 'cancels'
305
+ else:
306
+ status = obj.status
307
+
308
+ text = {
309
+ k: str(v) for k, v in Subscription.STATUSES
310
+ } | {
311
+ 'active': f'Renews {obj.current_period_end:%b %d, %Y}',
312
+ 'cancels': f'Cancels {obj.current_period_end:%b %d, %Y}',
313
+ }
314
+ return text[status]
315
+
316
+
317
+ @admin.register(PaymentIntent)
318
+ class PaymentIntentAdmin(StripeModelAdmin[PaymentIntent]):
319
+ search_fields = (
320
+ 'amount',
321
+ 'description',
322
+ 'customer__email',
323
+ 'customer__name',
324
+ 'customer__phone',
325
+ )
326
+ list_display = (
327
+ 'payment_method',
328
+ 'amount',
329
+ 'customer',
330
+ 'description',
331
+ 'status',
332
+ )
333
+
334
+
335
+ @admin.register(BalanceTransaction)
336
+ class BalanceTransactionAdmin(StripeModelAdmin[BalanceTransaction]):
337
+ search_fields = (
338
+ 'type',
339
+ 'status',
340
+ )
341
+
342
+ list_display = (
343
+ 'type',
344
+ 'status',
345
+ 'amount_display',
346
+ 'fee_display',
347
+ 'net_display',
348
+ 'available_on',
349
+ )
350
+ list_display_links = None
351
+
352
+ def _currency_display(self, obj: BalanceTransaction, field: str) -> str:
353
+ """Displays currency-related fields."""
354
+ amount = getattr(obj, field)
355
+ if field == 'fee' or amount < 0:
356
+ sign = '-'
357
+ elif amount > 0:
358
+ sign = '+'
359
+ elif amount == 0:
360
+ sign = ''
361
+
362
+ if obj.currency == 'usd':
363
+ s = f"{sign}${abs(amount)} USD"
364
+ else:
365
+ s = f"{sign} {abs(amount) * 100} {obj.currency.upper()}"
366
+ return s
367
+
368
+ @admin.display(description=_("Amount"), ordering='amount')
369
+ def amount_display(self, obj: BalanceTransaction) -> str:
370
+ return self._currency_display(obj, 'amount')
371
+
372
+ @admin.display(description=_("Fee"), ordering='fee')
373
+ def fee_display(self, obj: BalanceTransaction) -> str:
374
+ return self._currency_display(obj, 'fee')
375
+
376
+ @admin.display(description=_("Net"), ordering='net')
377
+ def net_display(self, obj: BalanceTransaction) -> str:
378
+ return self._currency_display(obj, 'net')
379
+
380
+
381
+ @admin.register(Invoice)
382
+ class InvoiceAdmin(StripeModelAdmin[Invoice]):
383
+ search_fields = (
384
+ 'customer__email',
385
+ 'customer__name',
386
+ 'customer__phone',
387
+ )
388
+
389
+ list_display = (
390
+ 'customer',
391
+ 'total',
392
+ 'period_start',
393
+ 'period_end',
394
+ 'status_chip',
395
+ 'pdf',
396
+ 'link',
397
+ )
398
+ list_select_related = (
399
+ 'customer',
400
+ 'subscription',
401
+ )
402
+ list_filter = (
403
+ 'period_start',
404
+ )
405
+ list_display_links = None
406
+
407
+ @admin.display(description=_("Status"), ordering='status')
408
+ def status_chip(self, obj: Invoice) -> str:
409
+ """Format to add badge HTML."""
410
+ STATUSES = {
411
+ 'draft': ('Draft', 'info'),
412
+ 'open': ('Open', 'warn'),
413
+ 'paid': ('Paid', 'ok'),
414
+ 'uncollectible': ('Uncollectible', 'bad'),
415
+ 'void': ('Void', 'bad'),
416
+ }
417
+ status, color = STATUSES[obj.status]
418
+ html = f'<chip class="{color}">{status}</chip>'
419
+ return mark_safe(html)
420
+
421
+ @admin.display(description=_("PDF"))
422
+ def pdf(self, obj: Invoice) -> str:
423
+ html = format_html(
424
+ '<a href="{url}">PDF</a>',
425
+ url=obj.invoice_pdf,
426
+ )
427
+ return html
428
+
429
+ @admin.display(description=_("Link"))
430
+ def link(self, obj: Invoice) -> str:
431
+ html = format_html(
432
+ '<a href="{url}">Link</a>',
433
+ url=obj.hosted_invoice_url,
434
+ )
435
+ return html
436
+
437
+
438
+ @admin.register(Charge)
439
+ class ChargeAdmin(StripeModelAdmin[Charge]):
440
+ search_fields = (
441
+ 'customer__email',
442
+ 'customer__name',
443
+ 'customer__phone',
444
+ )
445
+
446
+ list_display = (
447
+ 'customer',
448
+ 'invoice',
449
+ 'amount',
450
+ 'status',
451
+ 'created',
452
+ 'description',
453
+ 'disputed',
454
+ 'refunded',
455
+ )
456
+ list_select_related = (
457
+ 'customer',
458
+ 'invoice',
459
+ )
460
+
461
+
462
+ @admin.register(Refund)
463
+ class RefundAdmin(StripeModelAdmin[Refund]):
464
+ list_display = (
465
+ 'charge__customer',
466
+ 'amount',
467
+ 'reason',
468
+ 'status',
469
+ )
470
+
471
+ fieldsets = (
472
+ (None, {
473
+ 'fields': (
474
+ 'charge',
475
+ 'reason',
476
+ ('amount', 'currency'),
477
+ ),
478
+ }),
479
+ )
480
+
481
+ @admin.display(description=_("Customer"))
482
+ def charge__customer(self, obj: Refund) -> str:
483
+ assert isinstance(obj.charge, Charge)
484
+ return str(obj.charge.customer)
@@ -0,0 +1,24 @@
1
+ from django.apps import AppConfig
2
+ from django.conf import settings
3
+ from django.core.exceptions import ImproperlyConfigured
4
+ from django.utils.translation import gettext_lazy as _
5
+
6
+
7
+ class StripeConfig(AppConfig):
8
+ name = 'django_stripe_hooks'
9
+ verbose_name = _('Stripe Payments')
10
+ required_settings = [
11
+ 'STRIPE_PUBLIC_KEY',
12
+ 'STRIPE_SECRET_KEY',
13
+ 'STRIPE_WEBHOOK_SECRET_KEY',
14
+ 'STRIPE_API_VERSION',
15
+ ]
16
+
17
+ def ready(self) -> None:
18
+ """Validate that necessary settings have been defined."""
19
+ for value in self.required_settings:
20
+ if not hasattr(settings, value):
21
+ message = _(
22
+ f"[django-stripe-hooks] {value} must be defined in settings.py."
23
+ )
24
+ raise ImproperlyConfigured(message)