Refactor commerce models and enhance payment logic

Refactored commerce models to remove language prefixes from status choices, improved order and payment validation, and enforced business rules for payment and shipping combinations. Updated order item and cart calculations to use VAT-inclusive prices, added unique constraints and indexes to reviews, and improved stock management logic. Added new Stripe client methods for session and refund management, and updated Zasilkovna and Deutsche Post models for consistency. Minor fixes and improvements across related tasks, URLs, and configuration models.
This commit is contained in:
2026-01-20 23:45:21 +01:00
parent b38d126b6c
commit c0bd24ee5e
10 changed files with 353 additions and 96 deletions

View File

@@ -2,6 +2,7 @@ from account.tasks import send_email_with_context
from configuration.models import SiteConfiguration from configuration.models import SiteConfiguration
from celery import shared_task from celery import shared_task
from celery.schedules import crontab
@shared_task @shared_task
def send_contact_me_email_task(client_email, message_content): def send_contact_me_email_task(client_email, message_content):
@@ -15,3 +16,15 @@ def send_contact_me_email_task(client_email, message_content):
template_path="email/contact_me.html", template_path="email/contact_me.html",
context=context, context=context,
) )
def send_newly_added_items_to_store_email_task_last_week(item_id):
send_email_with_context(
recipients=SiteConfiguration.get_solo().contact_email,
subject="Nový produkt přidán do obchodu",
template_path="email/new_item_added.html",
context={
"item": item,
}
)

View File

@@ -1,3 +1,5 @@
from ast import Or
import dis
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -5,7 +7,7 @@ from django.core.exceptions import ValidationError
from decimal import Decimal from decimal import Decimal
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator, validate_email
try: try:
from weasyprint import HTML from weasyprint import HTML
@@ -146,12 +148,12 @@ class ProductImage(models.Model):
class Order(models.Model): class Order(models.Model):
class OrderStatus(models.TextChoices): class OrderStatus(models.TextChoices):
CREATED = "created", "cz#Vytvořeno" CREATED = "created", "Vytvořeno"
CANCELLED = "cancelled", "cz#Zrušeno" CANCELLED = "cancelled", "Zrušeno"
COMPLETED = "completed", "cz#Dokončeno" COMPLETED = "completed", "Dokončeno"
REFUNDING = "refunding", "cz#Vrácení v procesu" REFUNDING = "refunding", "Vrácení v procesu"
REFUNDED = "refunded", "cz#Vráceno" REFUNDED = "refunded", "Vráceno"
status = models.CharField( status = models.CharField(
max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED
@@ -216,10 +218,10 @@ class Order(models.Model):
else: else:
total = Decimal('0.0') total = Decimal('0.0')
# getting all prices from order items (without discount) # getting all prices from order items (without discount) - using VAT-inclusive prices
for item in self.items.all(): for item in self.items.all():
total = total + (item.product.price * item.quantity) total = total + (item.product.get_price_with_vat() * item.quantity)
return total + carrier_price return total + carrier_price
@@ -234,6 +236,24 @@ class Order(models.Model):
self.postal_code = self.user.postal_code self.postal_code = self.user.postal_code
self.country = self.user.country self.country = self.user.country
def clean(self):
"""Validate order data"""
# Validate required fields
required_fields = ['first_name', 'last_name', 'email', 'address', 'city', 'postal_code']
for field in required_fields:
if not getattr(self, field):
raise ValidationError(f"{field.replace('_', ' ').title()} is required.")
# Validate email format
try:
validate_email(self.email)
except ValidationError:
raise ValidationError("Invalid email format.")
# Validate order has items
if self.pk and not self.items.exists():
raise ValidationError("Order must have at least one item.")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Keep total_price always in sync with items and discount # Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price() self.total_price = self.calculate_total_price()
@@ -255,16 +275,16 @@ class Order(models.Model):
class Carrier(models.Model): class Carrier(models.Model):
class SHIPPING(models.TextChoices): class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "cz#Zásilkovna" ZASILKOVNA = "packeta", "Zásilkovna"
DEUTSCHEPOST = "deutschepost", "cz#Deutsche Post" DEUTSCHEPOST = "deutschepost", "Deutsche Post"
STORE = "store", "cz#Osobní odběr" STORE = "store", "Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE) shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices): class STATE(models.TextChoices):
PREPARING = "ordered", "cz#Objednávka se připravuje" PREPARING = "ordered", "Objednávka se připravuje"
SHIPPED = "shipped", "cz#Odesláno" SHIPPED = "shipped", "Odesláno"
DELIVERED = "delivered", "cz#Doručeno" DELIVERED = "delivered", "Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "cz#Připraveno k vyzvednutí" READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí"
#RETURNING = "returning", "Vracení objednávky" #RETURNING = "returning", "Vracení objednávky"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
@@ -285,27 +305,44 @@ class Carrier(models.Model):
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.pk is None: # Set shipping price for new carriers
if self.pk is None and self.shipping_price is None:
if self.shipping_price is None: # For new carriers, we might not have an order yet
self.shipping_price = self.get_price() self.shipping_price = self.get_price(order=None)
# Check if state changed to ready for pickup
old_state = None
if self.pk: if self.pk:
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE: old_carrier = Carrier.objects.filter(pk=self.pk).first()
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user) old_state = old_carrier.state if old_carrier else None
pass
else:
pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_price(self): # Send notification if state changed to ready for pickup
if (old_state != self.STATE.READY_TO_PICKUP and
self.state == self.STATE.READY_TO_PICKUP and
self.shipping_method == self.SHIPPING.STORE):
if hasattr(self, 'order') and self.order:
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
def get_price(self, order=None):
if self.shipping_method == self.SHIPPING.ZASILKOVNA: if self.shipping_method == self.SHIPPING.ZASILKOVNA:
return SiteConfiguration.get_solo().zasilkovna_shipping_price return SiteConfiguration.get_solo().zasilkovna_shipping_price
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST: elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
return SiteConfiguration.get_solo().deutschepost_shipping_price return SiteConfiguration.get_solo().deutschepost_shipping_price
else: elif self.shipping_method == self.SHIPPING.STORE:
# Store pickup is always free
return Decimal('0.0') return Decimal('0.0')
else:
# Check for free shipping based on order total
if order is None:
order = Order.objects.filter(carrier=self).first()
if order and order.total_price >= SiteConfiguration.get_solo().free_shipping_over:
return Decimal('0.0')
else:
return SiteConfiguration.get_solo().default_shipping_price or Decimal('50.0') # fallback price
#tohle bude vyvoláno pomocí admina přes api!!! #tohle bude vyvoláno pomocí admina přes api!!!
@@ -363,9 +400,9 @@ class Carrier(models.Model):
class Payment(models.Model): class Payment(models.Model):
class PAYMENT(models.TextChoices): class PAYMENT(models.TextChoices):
SHOP = "shop", "cz#Platba v obchodě", "de#Bezahlung im Geschäft" SHOP = "shop", "Platba v obchodě"
STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway" STRIPE = "stripe", "Platební Brána"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme" CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP) payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#FIXME: potvrdit že logika platby funguje správně #FIXME: potvrdit že logika platby funguje správně
@@ -373,16 +410,56 @@ class Payment(models.Model):
stripe = models.OneToOneField( stripe = models.OneToOneField(
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
) )
payed_at_shop = models.BooleanField(default=False, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def clean(self): def clean(self):
"""Validate payment and shipping method combinations""" """Validate payment and shipping method combinations"""
# TODO: Add validation logic for invalid payment/shipping combinations # Validate payment method consistency
# TODO: Skip GoPay integration for now if self.payment_method == self.PAYMENT.STRIPE and not self.stripe:
raise ValidationError("Stripe payment method requires a linked StripeModel instance.")
elif self.payment_method == self.PAYMENT.SHOP and self.stripe:
raise ValidationError("Shop payment method should not have a linked StripeModel instance.")
# Validate payment and shipping compatibility
if self.payment_method == self.PAYMENT.SHOP:
# SHOP payment only works with STORE pickup - customer pays at physical store
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method != Carrier.SHIPPING.STORE:
raise ValidationError(
"Shop payment is only compatible with store pickup. "
"For shipping orders, use Stripe or Cash on Delivery payment methods."
)
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY:
# Cash on delivery only works with shipping methods (not store pickup)
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method == Carrier.SHIPPING.STORE:
raise ValidationError(
"Cash on delivery is not compatible with store pickup. "
"For store pickup, use shop payment method."
)
# STRIPE payment works with all shipping methods - no additional validation needed
super().clean() super().clean()
def payed_manually(self):
"""Mark payment as completed"""
if self.payment_method == self.PAYMENT.SHOP:
self.payed_at_shop = True
self.save()
else:
raise ValidationError("Manuální platba je povolena pouze pro platbu v obchodě.")
# ------------------ SLEVOVÉ KÓDY ------------------ # ------------------ SLEVOVÉ KÓDY ------------------
@@ -441,7 +518,7 @@ class OrderItem(models.Model):
product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT) product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1)
def get_total_price(self, discounts: list[DiscountCode] = None): def get_total_price(self, discounts: list[DiscountCode] = list()):
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů. """Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
Logika dle SiteConfiguration: Logika dle SiteConfiguration:
@@ -453,12 +530,12 @@ class OrderItem(models.Model):
- Kombinace: nejprve procentuální část, poté odečtení fixní částky. - Kombinace: nejprve procentuální část, poté odečtení fixní částky.
- Sleva se nikdy nesmí dostat pod 0. - Sleva se nikdy nesmí dostat pod 0.
""" """
base_price = self.product.price * self.quantity # Use VAT-inclusive price for customer-facing calculations
base_price = self.product.get_price_with_vat() * self.quantity
if not discounts: if not discounts or discounts == []:
return base_price return base_price
config = SiteConfiguration.get_solo() config = SiteConfiguration.get_solo()
#seznám slev #seznám slev
@@ -527,16 +604,19 @@ class OrderItem(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.pk is None: if self.pk is None:
if self.order.payment.payment_method: # Check if order already has a processed payment
raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.") if (self.order.payment and
self.order.payment.payment_method and
self.order.payment.payment_method != Payment.PAYMENT.SHOP):
raise ValueError("Cannot modify items from order with processed payment method.")
else: # Validate stock availability
#nová položka objednávky, snížit skladové zásoby if self.product.stock < self.quantity:
if self.product.stock < self.quantity: raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}")
raise ValueError("Nedostatečný skladový zásob pro produkt.")
self.product.stock -= self.quantity # Reduce stock
self.product.save(update_fields=["stock"]) self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -545,10 +625,10 @@ class Refund(models.Model):
order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE) order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
class Reason(models.TextChoices): class Reason(models.TextChoices):
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "cz#Vrácení před uplynutím 14-ti denní lhůty" RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt" DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt"
WRONG_ITEM = "wrong_item", "cz#Špatná položka" WRONG_ITEM = "wrong_item", "Špatná položka"
OTHER = "other", "cz#Jiný důvod" OTHER = "other", "Jiný důvod"
reason_choice = models.CharField(max_length=40, choices=Reason.choices) reason_choice = models.CharField(max_length=40, choices=Reason.choices)
reason_text = models.TextField(blank=True) reason_text = models.TextField(blank=True)
@@ -635,17 +715,15 @@ class Refund(models.Model):
"return_reason": return_reason, "return_reason": return_reason,
} }
carrier = models.OneToOneField(
"Carrier",
on_delete=models.CASCADE,
related_name="refund",
null=True,
blank=True
)
html_string = render_to_string("refund/customer_in_package_returning_form.html", context) html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
# Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows.
if HTML is None:
raise RuntimeError(
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
)
pdf_bytes = HTML(string=html_string).write_pdf() pdf_bytes = HTML(string=html_string).write_pdf()
return pdf_bytes return pdf_bytes
@@ -665,6 +743,7 @@ class Invoice(models.Model):
order = Order.objects.get(invoice=self) order = Order.objects.get(invoice=self)
# Render HTML # Render HTML
html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order}) html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order})
# Import WeasyPrint lazily to avoid startup failures when system # Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows. # libraries (Pango/GObject) are not present on Windows.
if HTML is None: if HTML is None:
@@ -693,6 +772,19 @@ class Review(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'user') # Prevent multiple reviews per user per product
indexes = [
models.Index(fields=['product', 'rating']),
models.Index(fields=['created_at']),
]
def clean(self):
"""Validate that user hasn't already reviewed this product"""
if self.pk is None: # Only for new reviews
if Review.objects.filter(product=self.product, user=self.user).exists():
raise ValidationError("User has already reviewed this product.")
def __str__(self): def __str__(self):
return f"Review for {self.product.name} by {self.user.username}" return f"Review for {self.product.name} by {self.user.username}"
@@ -727,7 +819,7 @@ class Cart(models.Model):
return f"Anonymous cart ({self.session_key})" return f"Anonymous cart ({self.session_key})"
def get_total(self): def get_total(self):
"""Calculate total price of all items in cart""" """Calculate total price of all items in cart including VAT"""
total = Decimal('0.0') total = Decimal('0.0')
for item in self.items.all(): for item in self.items.all():
total += item.get_subtotal() total += item.get_subtotal()
@@ -754,8 +846,8 @@ class CartItem(models.Model):
return f"{self.quantity}x {self.product.name} in cart" return f"{self.quantity}x {self.product.name} in cart"
def get_subtotal(self): def get_subtotal(self):
"""Calculate subtotal for this cart item""" """Calculate subtotal for this cart item including VAT"""
return self.product.price * self.quantity return self.product.get_price_with_vat() * self.quantity
def clean(self): def clean(self):
"""Validate that product has enough stock""" """Validate that product has enough stock"""

View File

@@ -83,6 +83,24 @@ def notify_order_successfuly_created(order = None, user = None, **kwargs):
pass pass
@shared_task
def notify_order_payed(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_paid:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order has been paid",
template_path="email/order_paid.html",
context={
"user": user,
"order": order,
})
pass
@shared_task @shared_task
def notify_about_missing_payment(order = None, user = None, **kwargs): def notify_about_missing_payment(order = None, user = None, **kwargs):
@@ -103,6 +121,9 @@ def notify_about_missing_payment(order = None, user = None, **kwargs):
pass pass
# -- NOTIFICATIONS REFUND --
@shared_task @shared_task
def notify_refund_items_arrived(order = None, user = None, **kwargs): def notify_refund_items_arrived(order = None, user = None, **kwargs):
if not order or not user: if not order or not user:
@@ -142,3 +163,6 @@ def notify_refund_accepted(order = None, user = None, **kwargs):
}) })
pass pass
#

View File

@@ -13,7 +13,7 @@ from .views import (
CartViewSet, CartViewSet,
WishlistViewSet, WishlistViewSet,
AdminWishlistViewSet, AdminWishlistViewSet,
AnalyticsViewSet, AnalyticsView,
) )
router = DefaultRouter() router = DefaultRouter()
@@ -27,10 +27,10 @@ router.register(r'reviews', ReviewPublicViewSet, basename='review')
router.register(r'cart', CartViewSet, basename='cart') router.register(r'cart', CartViewSet, basename='cart')
router.register(r'wishlist', WishlistViewSet, basename='wishlist') router.register(r'wishlist', WishlistViewSet, basename='wishlist')
router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist') router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist')
router.register(r'analytics', AnalyticsViewSet, basename='analytics')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'), path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'), path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
path('analytics/', AnalyticsView.as_view(), name='analytics'),
] ]

View File

@@ -44,8 +44,8 @@ class SiteConfiguration(models.Model):
addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón")
class CURRENCY(models.TextChoices): class CURRENCY(models.TextChoices):
CZK = "CZK", "cz#Czech Koruna" CZK = "CZK", "Czech Koruna"
EUR = "EUR", "cz#Euro" EUR = "EUR", "Euro"
currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices) currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices)
class Meta: class Meta:
@@ -87,6 +87,10 @@ class VATRate(models.Model):
default=False, default=False,
help_text="Default rate for new products" help_text="Default rate for new products"
) )
is_active = models.BooleanField(
default=True,
help_text="Whether this VAT rate is active and available for use"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:

View File

@@ -42,12 +42,12 @@ class DeutschePostOrder(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class STATE(models.TextChoices): class STATE(models.TextChoices):
CREATED = "CREATED", "cz#Vytvořeno" CREATED = "CREATED", "Vytvořeno"
FINALIZED = "FINALIZED", "cz#Dokončeno" FINALIZED = "FINALIZED", "Dokončeno"
SHIPPED = "SHIPPED", "cz#Odesláno" SHIPPED = "SHIPPED", "Odesláno"
DELIVERED = "DELIVERED", "cz#Doručeno" DELIVERED = "DELIVERED", "Doručeno"
CANCELLED = "CANCELLED", "cz#Zrušeno" CANCELLED = "CANCELLED", "Zrušeno"
ERROR = "ERROR", "cz#Chyba" ERROR = "ERROR", "Chyba"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.CREATED) state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.CREATED)
@@ -280,10 +280,10 @@ class DeutschePostBulkOrder(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class STATUS(models.TextChoices): class STATUS(models.TextChoices):
CREATED = "CREATED", "cz#Vytvořeno" CREATED = "CREATED", "Vytvořeno"
PROCESSING = "PROCESSING", "cz#Zpracovává se" PROCESSING = "PROCESSING", "Zpracovává se"
COMPLETED = "COMPLETED", "cz#Dokončeno" COMPLETED = "COMPLETED", "Dokončeno"
ERROR = "ERROR", "cz#Chyba" ERROR = "ERROR", "Chyba"
status = models.CharField(max_length=20, choices=STATUS.choices, default=STATUS.CREATED) status = models.CharField(max_length=20, choices=STATUS.choices, default=STATUS.CREATED)

View File

@@ -10,6 +10,7 @@ stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class StripeClient: class StripeClient:
@staticmethod
def create_checkout_session(order): def create_checkout_session(order):
""" """
Vytvoří Stripe Checkout Session pro danou objednávku. Vytvoří Stripe Checkout Session pro danou objednávku.
@@ -42,8 +43,64 @@ class StripeClient:
return session return session
@staticmethod
def cancel_checkout_session(session_id):
"""
Zruší Stripe Checkout Session.
Args:
session_id (str): ID Stripe Checkout Session k zrušení.
Returns:
stripe.checkout.Session: Zrušená Stripe Checkout Session.
"""
try:
session = stripe.checkout.Session.expire(session_id)
return session
except Exception as e:
return {"error": str(e)}
@staticmethod
def get_checkout_session(session_id):
"""
Získá informace o Stripe Checkout Session.
Args:
session_id (str): ID Stripe Checkout Session.
Returns:
stripe.checkout.Session: Stripe Checkout Session objekt.
"""
try:
session = stripe.checkout.Session.retrieve(session_id)
return session
except Exception as e:
return {"error": str(e)}
@staticmethod
def get_payment_intent(payment_intent_id):
"""
Získá informace o Stripe Payment Intent.
Args:
payment_intent_id (str): ID Stripe Payment Intent.
Returns:
stripe.PaymentIntent: Stripe Payment Intent objekt.
"""
try:
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
return payment_intent
except Exception as e:
return {"error": str(e)}
@staticmethod
def refund_order(stripe_payment_intent): def refund_order(stripe_payment_intent):
"""
Vrátí platbu pro danou objednávku.
Args:
stripe_payment_intent (str): ID Stripe Payment Intent k vrácení.
Returns:
stripe.Refund: Vytvořený refund objekt nebo chyba.
"""
try: try:
refund = stripe.Refund.create( refund = stripe.Refund.create(
payment_intent=stripe_payment_intent payment_intent=stripe_payment_intent
@@ -51,4 +108,71 @@ class StripeClient:
return refund return refund
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return {"error": str(e)}
@staticmethod
def partial_refund_order(stripe_payment_intent, amount):
"""
Částečně vrátí platbu pro danou objednávku.
Args:
stripe_payment_intent (str): ID Stripe Payment Intent k vrácení.
amount (int): Částka k vrácení v haléřích.
Returns:
stripe.Refund: Vytvořený refund objekt nebo chyba.
"""
try:
refund = stripe.Refund.create(
payment_intent=stripe_payment_intent,
amount=amount
)
return refund
except Exception as e:
return {"error": str(e)}
@staticmethod
def create_customer(email, name=None, phone=None):
"""
Vytvoří Stripe Customer.
Args:
email (str): Email zákazníka.
name (str, optional): Jméno zákazníka.
phone (str, optional): Telefon zákazníka.
Returns:
stripe.Customer: Vytvořený Stripe Customer.
"""
try:
customer_data = {"email": email}
if name:
customer_data["name"] = name
if phone:
customer_data["phone"] = phone
customer = stripe.Customer.create(**customer_data)
return customer
except Exception as e:
return {"error": str(e)}
@staticmethod
def webhook_verify_signature(payload, sig_header, endpoint_secret):
"""
Ověří webhook signature od Stripe.
Args:
payload: Raw webhook payload.
sig_header: Stripe signature header.
endpoint_secret: Webhook endpoint secret.
Returns:
stripe.Event: Ověřený event objekt.
"""
try:
event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
)
return event
except ValueError as e:
return {"error": "Invalid payload"}
except stripe.error.SignatureVerificationError as e:
return {"error": "Invalid signature"}

View File

@@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.apps import apps from django.apps import apps
from django.utils import timezone
# Create your models here. # Create your models here.
@@ -29,23 +30,25 @@ class StripeModel(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
#if new # If new (no primary key yet)
if self.pk: if self.pk is None:
super().save(*args, **kwargs) # Save first to get pk
Order = apps.get_model('commerce', 'Order') Order = apps.get_model('commerce', 'Order')
Payment = apps.get_model('commerce', 'Payment') Payment = apps.get_model('commerce', 'Payment')
order = Order.objects.get(payment=Payment.objects.get(stripe=self)) order = Order.objects.get(payment=Payment.objects.get(stripe=self))
session = StripeClient.create_checkout_session(order)
session = StripeClient.create_checkout_session(order)# <-- předáme self.StripePayment
self.stripe_session_id = session.id self.stripe_session_id = session.id
self.stripe_payment_intent = session.payment_intent self.stripe_payment_intent = session.payment_intent
self.stripe_session_url = session.url self.stripe_session_url = session.url
# Save again with Stripe data
super().save(update_fields=['stripe_session_id', 'stripe_payment_intent', 'stripe_session_url', 'updated_at'])
else: else:
self.updated_at = models.DateTimeField(auto_now=True) self.updated_at = timezone.now()
super().save(*args, **kwargs)
super().save(*args, **kwargs)
def paid(self): def paid(self):
self.status = self.STATUS_CHOICES.PAID self.status = self.STATUS_CHOICES.PAID

View File

@@ -3,7 +3,7 @@ from zeep.exceptions import Fault
from zeep.transports import Transport from zeep.transports import Transport
from zeep.cache import SqliteCache from zeep.cache import SqliteCache
from configuration.models import Configuration from configuration.models import SiteConfiguration
import tempfile import tempfile
import base64 import base64
@@ -17,9 +17,6 @@ logger = logging.getLogger(__name__)
WSDL_URL = os.getenv("PACKETA_WSDL_URL", "https://www.zasilkovna.cz/api/soap.wsdl") WSDL_URL = os.getenv("PACKETA_WSDL_URL", "https://www.zasilkovna.cz/api/soap.wsdl")
PACKETA_API_PASSWORD = os.getenv("PACKETA_API_PASSWORD") PACKETA_API_PASSWORD = os.getenv("PACKETA_API_PASSWORD")
Configuration.g
# --- 1. Singleton pro klienta (aby se WSDL stáhlo jen jednou pro celý proces) --- # --- 1. Singleton pro klienta (aby se WSDL stáhlo jen jednou pro celý proces) ---
@lru_cache(maxsize=1) @lru_cache(maxsize=1)

View File

@@ -35,14 +35,14 @@ class ZasilkovnaPacket(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class STATE(models.TextChoices): class STATE(models.TextChoices):
WAITING_FOR_ORDER = "WAITING_FOR_ORDERING_SHIPMENT", "cz#Čeká na objednání zásilkovny" WAITING_FOR_ORDER = "WAITING_FOR_ORDERING_SHIPMENT", "Čeká na objednání zásilkovny"
PENDING = "PENDING", "cz#Podáno" PENDING = "PENDING", "Podáno"
SENDED = "SENDED", "cz#Odesláno" SENDED = "SENDED", "Odesláno"
ARRIVED = "ARRIVED", "cz#Doručeno" ARRIVED = "ARRIVED", "Doručeno"
CANCELED = "CANCELED", "cz#Zrušeno" CANCELED = "CANCELED", "Zrušeno"
RETURNING = "RETURNING", "cz#Posláno zpátky" RETURNING = "RETURNING", "Posláno zpátky"
RETURNED = "RETURNED", "cz#Vráceno" RETURNED = "RETURNED", "Vráceno"
state = models.CharField(max_length=35, choices=STATE.choices, default=STATE.PENDING) state = models.CharField(max_length=35, choices=STATE.choices, default=STATE.PENDING)
# ------- API ------- # ------- API -------