Major refactor of commerce and Stripe integration

Refactored commerce models to support refunds, invoices, and improved carrier/payment logic. Added new serializers and viewsets for products, categories, images, discount codes, and refunds. Introduced Stripe client integration and removed legacy Stripe admin/model code. Updated Dockerfile for PDF generation dependencies. Removed obsolete migration files and updated configuration app initialization. Added invoice template and tasks for order cleanup.
This commit is contained in:
2025-11-18 01:00:03 +01:00
parent 7a715efeda
commit b8a1a594b2
35 changed files with 1215 additions and 332 deletions

View File

@@ -1,12 +1,18 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from decimal import Decimal
from django.template.loader import render_to_string
from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator
from weasyprint import HTML
import os
from configuration.models import ShopConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripePayment
from thirdparty.stripe.models import StripeModel
#FIXME: přidat soft delete pro všchny modely !!!!
@@ -40,6 +46,17 @@ class Product(models.Model):
code = models.CharField(max_length=100, unique=True, blank=True, null=True)
variants = models.ManyToManyField(
"self",
symmetrical=True,
blank=True,
related_name="variant_of",
help_text=(
"Symetrické varianty produktu: pokud přidáte variantu A → B, "
"Django automaticky přidá i variantu B → A. "
"Všechny varianty jsou rovnocenné a zobrazí se vzájemně."
),
)
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
@@ -50,7 +67,7 @@ class Product(models.Model):
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
#limitka (volitelné)
#časový limit (volitelné)
limited_to = models.DateTimeField(null=True, blank=True)
default_carrier = models.ForeignKey(
@@ -85,21 +102,22 @@ class ProductImage(models.Model):
class Order(models.Model):
class Status(models.TextChoices):
PENDING = "pending", _("Čeká na platbu")
PAID = "paid", _("Zaplaceno")
CANCELLED = "cancelled", _("Zrušeno")
SHIPPED = "shipped", _("Odesláno")
#COMPLETED = "completed", _("Dokončeno")
CREATED = "created", "Vytvořeno"
CANCELLED = "cancelled", "Zrušeno"
COMPLETED = "completed", "Dokončeno"
REFUNDING = "refunding", "Vrácení v procesu"
REFUNDED = "refunded", "Vráceno"
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED
)
# Stored order grand total; recalculated on save
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
currency = models.CharField(max_length=10, default="CZK")
# fakturační údaje (zkopírované z user profilu při objednávce) FIXME: rozhodnout se co dát do on_delete
# fakturační údaje (zkopírované z user profilu při objednávce)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
)
@@ -134,6 +152,8 @@ class Order(models.Model):
blank=True
)
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
def calculate_total_price(self):
@@ -181,15 +201,24 @@ class Order(models.Model):
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "Zásilkovna"
STORE = "store", "Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices):
PREPARING = "ordered", "Objednávka se připravuje"
SHIPPED = "shipped", "Odesláno"
DELIVERED = "delivered", "Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí"
#RETURNING = "returning", "Vracení objednávky"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
# prodejce to přidá později
zasilkovna = models.ForeignKey(
ZasilkovnaPacket, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers"
zasilkovna = models.ManyToManyField(
ZasilkovnaPacket, blank=True, related_name="carriers"
)
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
@@ -197,7 +226,6 @@ class Carrier(models.Model):
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def get_price(self):
@@ -210,18 +238,30 @@ class Carrier(models.Model):
#tohle bude vyvoláno pomocí admina přes api!!!
def start_ordering_shipping(self):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
#už při vytvoření se volá na api Zásilkovny
self.zasilkovna = ZasilkovnaPacket.objects.create()
# Uživatel může objednat více zásilek pokud potřeba
self.zasilkovna.add(ZasilkovnaPacket.objects.create())
self.returning = False
self.save()
else:
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
#... další logika pro jiné způsoby dopravy
#TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě!
def returning_shipping(self):
self.returning = True
def ready_to_pickup(self):
if self.shipping_method == self.SHIPPING.STORE:
self.state = self.STATE.READY_TO_PICKUP
self.save()
else:
raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.")
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
#volá se na api Zásilkovny
self.zasilkovna.returning_packet()
# def returning_shipping(self, int:id):
# self.returning = True
# if self.shipping_method == self.SHIPPING.ZASILKOVNA:
# #volá se na api Zásilkovny
# self.zasilkovna.get(id=id).returning_packet()
# ------------------ PLATEBNÍ MODELY ------------------
@@ -233,20 +273,33 @@ class Payment(models.Model):
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#active = models.BooleanField(default=True)
#FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
stripe = models.OneToOneField(
StripePayment, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
)
def save(self, *args, **kwargs):
order = Order.objects.get(payment=self)
if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE:
raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.")
if self.order:
order = Order.objects.get(payment=self)
#validace platebních metod
if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE:
raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.")
#validace dobírky (jestli není použitá pro osobní odběr)
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY and Carrier.objects.get(orders=order).shipping_method == Carrier.SHIPPING.STORE:
raise ValueError("Dobírka není možná pro osobní odběr.")
#vytvoření platebních metod pokud nový objekt
if not self.pk:
if self.payment_method == self.PAYMENT.STRIPE:
self.stripe = StripePayment.objects.create(amount=order.total_price)
else:
self.stripe = None
super().save(*args, **kwargs)
@@ -257,7 +310,13 @@ class DiscountCode(models.Model):
description = models.CharField(max_length=255, blank=True)
# sleva v procentech (0100)
percent = models.PositiveIntegerField(max_value=100, help_text="Procento sleva 0-100", null=True, blank=True)
percent = models.PositiveIntegerField(
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text="Procento sleva 0-100",
null=True,
blank=True
)
# nebo fixní částka
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
@@ -288,6 +347,8 @@ class DiscountCode(models.Model):
if self.usage_limit and self.used_count >= self.usage_limit:
return False
return True
def __str__(self):
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
@@ -369,8 +430,10 @@ class OrderItem(models.Model):
if applicable_amount_discounts:
if config.addition_of_coupons_amount:
total_amount = sum(applicable_amount_discounts)
else:
total_amount = max(applicable_amount_discounts)
final_price = final_price - total_amount
if final_price < Decimal('0'):
@@ -379,4 +442,87 @@ class OrderItem(models.Model):
return final_price.quantize(Decimal('0.01'))
def __str__(self):
return f"{self.product.name} x{self.quantity}"
return f"{self.product.name} x{self.quantity}"
def save(self, *args, **kwargs):
if self.pk is None:
if self.order.payment.payment_method:
raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.")
else:
#nová položka objednávky, snížit skladové zásoby
if self.product.stock < self.quantity:
raise ValueError("Nedostatečný skladový zásob pro produkt.")
self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
super().save(*args, **kwargs)
class Refund(models.Model):
order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
class Reason(models.TextChoices):
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt"
WRONG_ITEM = "wrong_item", "Špatná položka"
OTHER = "other", "Jiný důvod"
reason_choice = models.CharField(max_length=30, choices=Reason.choices)
reason_text = models.TextField(blank=True)
verified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
#VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW)
# def save(self, *args, **kwargs):
# # Automaticky aktualizovat stav objednávky na "vráceno"
# if self.pk is None:
# self.order.status = Order.Status.REFUNDING
# self.order.save(update_fields=["status", "updated_at"])
# shipping_method = self.order.carrier.shipping_method
# if shipping_method == Carrier.SHIPPING.ZASILKOVNA:
# carrier = self.order.carrier;
# # poslední odeslána/vytvořená zásilka
# # Iniciovat vrácení přes Zásilkovnu
# carrier.zasilkovna.latest('created_at').returning_packet()
# carrier.save()
# else:
# # Logika pro jiné způsoby dopravy
# pass
# super().save(*args, **kwargs)
def refund_completed(self):
# Aktualizovat stav objednávky na "vráceno"
self.order.payment.stripe.refund()
self.order.status = Order.Status.REFUNDED
self.order.save(update_fields=["status", "updated_at"])
class Invoice(models.Model):
invoice_number = models.CharField(max_length=50, unique=True)
issued_at = models.DateTimeField(auto_now_add=True)
due_date = models.DateTimeField()
pdf_file = models.FileField(upload_to='invoices/')
def __str__(self):
return f"Invoice {self.invoice_number} for Order {self.order.id}"
def generate_invoice_pdf(self):
order = Order.objects.get(invoice=self)
# Render HTML
html_string = render_to_string("invoice/invoice.html", {"invoice": self})
pdf_bytes = HTML(string=html_string).write_pdf()
# Save directly to FileField
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
self.save()