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,30 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-29 14:53
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloaderRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('url', models.URLField()),
('download_time', models.DateTimeField(auto_now_add=True)),
('format', models.CharField(max_length=50)),
('length_of_media', models.IntegerField(help_text='Length of media in seconds')),
('file_size', models.BigIntegerField(help_text='File size in bytes')),
],
options={
'abstract': False,
},
),
]

View File

@@ -1,23 +1,2 @@
from django.contrib import admin
from .models import Order
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "amount", "currency", "status", "created_at")
list_filter = ("status", "currency", "created_at")
search_fields = ("id", "stripe_session_id", "stripe_payment_intent")
readonly_fields = ("created_at", "stripe_session_id", "stripe_payment_intent")
fieldsets = (
(None, {
"fields": ("amount", "currency", "status")
}),
("Stripe info", {
"fields": ("stripe_session_id", "stripe_payment_intent"),
"classes": ("collapse",),
}),
("Metadata", {
"fields": ("created_at",),
}),
)
ordering = ("-created_at",)

54
backend/thirdparty/stripe/client.py vendored Normal file
View File

@@ -0,0 +1,54 @@
import stripe
from django.conf import settings
import json
import os
FRONTEND_URL = os.getenv("FRONTEND_URL") if not settings.DEBUG else os.getenv("DEBUG_DOMAIN")
SSL = "https://" if os.getenv("USE_SSL") == "true" else "http://"
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class StripeClient:
def create_checkout_session(order):
"""
Vytvoří Stripe Checkout Session pro danou objednávku.
Args:
order (Order): Instance objednávky pro kterou se vytváří session.
Returns:
stripe.checkout.Session: Vytvořená Stripe Checkout Session.
"""
session = stripe.checkout.Session.create(
mode="payment",
payment_method_types=["card"],
success_url=f"{SSL}{FRONTEND_URL}/payment/success?order={order.id}", #jenom na grafickou část (webhook reálně ověří stav)
cancel_url=f"{SSL}{FRONTEND_URL}/payment/cancel?order={order.id}",
client_reference_id=str(order.id),
line_items=[{
"price_data": {
"currency": "czk",
"product_data": {
"name": f"Objednávka {order.id}",
},
"unit_amount": int(order.total_price * 100), # cena v haléřích
},
"quantity": 1,
}],
)
return session
def refund_order(stripe_payment_intent):
try:
refund = stripe.Refund.create(
payment_intent=stripe_payment_intent
)
return refund
except Exception as e:
return json.dumps({"error": str(e)})

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='czk', max_length=10)),
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('stripe_session_id', models.CharField(blank=True, max_length=255, null=True)),
('stripe_payment_intent', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -1,25 +1,68 @@
from django.db import models
from django.apps import apps
# Create your models here.
#TODO: logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
class StripePayment(models.Model):
STATUS_CHOICES = [
("pending", "Pending"),
("paid", "Paid"),
("failed", "Failed"),
("cancelled", "Cancelled"),
]
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10, default="czk")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
from .client import StripeClient
class StripeModel(models.Model):
class STATUS_CHOICES(models.TextChoices):
PENDING = "pending", "Čeká se na platbu"
PAID = "paid", "Zaplaceno"
FAILED = "failed", "Neúspěšné"
CANCELLED = "cancelled", "Zrušeno"
REFUNDING = "refunding", "Platba se vrací"
REFUNDED = "refunded", "Platba úspěšně vrácena"
status = models.CharField(max_length=20, choices=STATUS_CHOICES.choices, default=STATUS_CHOICES.PENDING)
stripe_session_id = models.CharField(max_length=255, blank=True, null=True)
stripe_payment_intent = models.CharField(max_length=255, blank=True, null=True)
stripe_session_url = models.URLField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
def __str__(self):
return f"Order {self.id} - {self.status}"
return f"Order {self.id} - {self.status}"
def save(self, *args, **kwargs):
#if new
if self.pk:
Order = apps.get_model('commerce', 'Order')
Payment = apps.get_model('commerce', 'Payment')
order = Order.objects.get(payment=Payment.objects.get(stripe=self))
session = StripeClient.create_checkout_session(order)# <-- předáme self.StripePayment
self.stripe_session_id = session.id
self.stripe_payment_intent = session.payment_intent
self.stripe_session_url = session.url
else:
self.updated_at = models.DateTimeField(auto_now=True)
super().save(*args, **kwargs)
def paid(self):
self.status = self.STATUS_CHOICES.PAID
self.save()
def refund(self):
StripeClient.refund_order(self.stripe_payment_intent)
self.status = self.STATUS_CHOICES.REFUNDING
self.save()
def refund_confirmed(self):
self.status = self.STATUS_CHOICES.REFUNDED
self.save()
def cancel(self):
StripeClient.cancel_checkout_session(self.stripe_session_id)
self.status = self.STATUS_CHOICES.CANCELLED
self.save()

9
backend/thirdparty/stripe/stripe.md vendored Normal file
View File

@@ -0,0 +1,9 @@
# Stripe Tutorial
## Example of redirecting the webhook events to local Django endpoint
```
stripe listen --forward-to localhost:8000/api/stripe/webhook/
```
# POUŽÍVEJTE SANDBOX/TESING REŽIM PŘI DEVELOPMENTU!!!

View File

@@ -6,73 +6,73 @@ from rest_framework import generics
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from .models import Order
from .serializers import OrderSerializer
import os
import logging
from .models import StripeTransaction
from commerce.models import Order, Payment
logger = logging.getLogger(__name__)
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class CreateCheckoutSessionView(APIView):
class StripeWebhook(APIView):
@extend_schema(
tags=["stripe"],
)
def post(self, request):
serializer = OrderSerializer(data=request.data) #obecný serializer
serializer.is_valid(raise_exception=True)
payload = request.body
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
order = Order.objects.create(
amount=serializer.validated_data["amount"],
currency=serializer.validated_data.get("currency", "czk"),
)
try:
#build stripe event
event = stripe.Webhook.construct_event(
payload, sig_header, os.getenv("STRIPE_WEBHOOK_SECRET")
)
# Vytvoření Stripe Checkout Session
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": order.currency,
"product_data": {"name": f"Order {order.id}"},
"unit_amount": int(order.amount * 100), # v centech
},
"quantity": 1,
}],
mode="payment",
success_url=request.build_absolute_uri(f"/payment/success/{order.id}"),
cancel_url=request.build_absolute_uri(f"/payment/cancel/{order.id}"),
)
except ValueError as e:
logger.error(f"Invalid payload: {e}")
return HttpResponse(status=400)
except stripe.error as e:
# stripe error
logger.error(f"Stripe error: {e}")
return HttpResponse(status=400)
order.stripe_session_id = session.id
order.stripe_payment_intent = session.payment_intent
order.save()
data = OrderSerializer(order).data
data["checkout_url"] = session.url
return Response(data)
session = event['data']['object']
# ZAPLACENO
if event['type'] == 'checkout.session.completed':
stripe_transaction = StripeTransaction.objects.get(stripe_session_id=session.id)
if stripe_transaction:
stripe_transaction.paid()
@csrf_exempt
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
event = None
logger.info(f"Transaction {stripe_transaction.id} marked as paid.")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
else:
logger.warning(f"No transaction found for session ID: {session.id}")
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
order = Order.objects.filter(stripe_session_id=session.get("id")).first()
if order:
order.status = "paid"
# EXPIRACE (zrušení objednávky) uživatel nezaplatil do 24 hodin!
elif event['type'] == 'checkout.session.expired':
order = Order.objects.get(payment=Payment.objects.get(stripe=StripeTransaction.objects.get(stripe_session_id=session.id)))
order.status = Order.STATUS_CHOICES.CANCELLED
order.save()
return HttpResponse(status=200)
elif event['type'] == 'payment_intent.payment_failed':
#nothing to do for now
pass
# REFUND POTVRZEN
elif event['type'] == 'payment_intent.refunded':
session = event['data']['object']
stripe_transaction = StripeTransaction.objects.get(stripe_payment_intent=session.id)
if stripe_transaction:
stripe_transaction.refund_confirmed()
logger.info(f"Transaction {stripe_transaction.id} marked as refunded.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -21,10 +21,11 @@ from django.db import models
from django.utils import timezone
from django.core.validators import RegexValidator
from django.core.files.base import ContentFile
from django.apps import apps
from .client import PacketaAPI
from commerce.models import Order, Carrier
from configuration.models import Configuration
from configuration.models import ShopConfiguration
packeta_client = PacketaAPI() # single reusable instance
@@ -55,6 +56,13 @@ class ZasilkovnaPacket(models.Model):
help_text="Hmotnost zásilky v gramech"
)
# 🚚 návratové směrovací kódy (pro vrácení zásilky)
return_routing = models.JSONField(
default=list,
blank=True,
help_text="Seznam 2 routing stringů pro vrácení zásilky"
)
class PDF_SIZE(models.TextChoices):
A6_ON_A6 = ("A6 on A6", "105x148 mm (A6) label on a page of the same size")
A7_ON_A7 = ("A7 on A7", "105x74 mm (A7) label on a page of the same size")
@@ -63,19 +71,16 @@ class ZasilkovnaPacket(models.Model):
A8_ON_A8 = ("A8 on A8", "50x74 mm (A8) label on a page of the same size")
size_of_pdf = models.CharField(max_length=20, choices=PDF_SIZE.choices, default=PDF_SIZE.A6_ON_A6)
# 🚚 návratové směrovací kódy (pro vrácení zásilky)
return_routing = models.JSONField(
default=list,
blank=True,
help_text="Seznam 2 routing stringů pro vrácení zásilky"
)
def save(self, *args, **kwargs):
# On first save, create the packet remotely if packet_id is not set
# workaroud to avoid circular import
Carrier = apps.get_model('commerce', 'Carrier')
Order = apps.get_model('commerce', 'Order')
carrier = Carrier.objects.get(zasilkovna=self)
order = Order.objects.get(carrier=carrier)
cash_on_delivery = order.payment.payment_method == order.payment.PAYMENT.CASH_ON_DELIVERY
if not self.packet_id:
response = packeta_client.create_packet(
address_id=self.addressId,
@@ -85,14 +90,13 @@ class ZasilkovnaPacket(models.Model):
surname=order.last_name,
company=order.company,
email=order.email,
addressId=Configuration.get_solo().zasilkovna_address_id,
addressId=ShopConfiguration.get_solo().zasilkovna_address_id,
#FIXME: udělat logiku pro počítaní dobírky a hodnoty zboží
cod=100.00,
value=100.00,
cod=order.total_price if cash_on_delivery else 0, # dobírka
value=order.total_price,
currency=Configuration.get_solo().currency,
eshop= Configuration.get_solo().name,
currency=ShopConfiguration.get_solo().currency,
eshop= ShopConfiguration.get_solo().name,
)
self.packet_id = response['packet_id']
self.barcode = response['barcode']

View File

@@ -0,0 +1,38 @@
from rest_framework import serializers
from .models import ZasilkovnaPacket, ZasilkovnaShipment
class ZasilkovnaPacketSerializer(serializers.ModelSerializer):
class Meta:
model = ZasilkovnaPacket
fields = [
"id",
"created_at",
"packet_id",
"barcode",
"state",
"weight",
"return_routing",
]
read_only_fields = fields
class TrackingURLSerializer(serializers.Serializer):
barcode = serializers.CharField(read_only=True)
tracking_url = serializers.URLField(read_only=True)
class ZasilkovnaShipmentSerializer(serializers.ModelSerializer):
packets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
class Meta:
model = ZasilkovnaShipment
fields = [
"id",
"created_at",
"shipment_id",
"barcode",
"packets",
]
read_only_fields = fields

View File

@@ -0,0 +1,15 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ZasilkovnaShipmentViewSet, ZasilkovnaPacketViewSet
router = DefaultRouter()
router.register(r"shipments", ZasilkovnaShipmentViewSet, basename="zasilkovna-shipment")
router.register(r"packets", ZasilkovnaPacketViewSet, basename="zasilkovna-packet")
app_name = "zasilkovna"
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -1,8 +1,80 @@
#views.py
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
"""
TODO: OBJEDNAVANÍ SE VYVOLÁVA V CARRIER V COMMERCE.MODELS.PY
získaní labelu,
info o kurýrovi, vracení balíku,
vytvoření hromadné expedice
"""
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse
from .models import ZasilkovnaShipment, ZasilkovnaPacket
from .serializers import (
ZasilkovnaShipmentSerializer,
ZasilkovnaPacketSerializer,
TrackingURLSerializer,
)
@extend_schema_view(
list=extend_schema(
tags=["Zásilkovna"],
summary="List shipments",
description="Returns a paginated list of Packeta (Zásilkovna) shipments.",
responses={200: ZasilkovnaShipmentSerializer},
),
retrieve=extend_schema(
tags=["Zásilkovna"],
summary="Retrieve a shipment",
description="Returns detail for a single shipment.",
responses={200: ZasilkovnaShipmentSerializer},
),
)
class ZasilkovnaShipmentViewSet(viewsets.ReadOnlyModelViewSet):
queryset = ZasilkovnaShipment.objects.all().order_by("-created_at")
serializer_class = ZasilkovnaShipmentSerializer
@extend_schema_view(
retrieve=extend_schema(
tags=["Zásilkovna"],
summary="Retrieve a packet",
description="Returns detail for a single packet.",
responses={200: ZasilkovnaPacketSerializer},
)
)
class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = ZasilkovnaPacket.objects.all()
serializer_class = ZasilkovnaPacketSerializer
@extend_schema(
tags=["Zásilkovna"],
summary="Get public tracking URL",
description=(
"Returns the public Zásilkovna tracking URL derived from the packet's barcode."
),
responses={200: OpenApiResponse(response=TrackingURLSerializer)},
)
@action(detail=True, methods=["get"], url_path="tracking-url")
def tracking_url(self, request, pk=None):
packet: ZasilkovnaPacket = self.get_object()
data = {
"barcode": packet.barcode,
"tracking_url": packet.get_tracking_url(),
}
return Response(data)
@extend_schema(
tags=["Zásilkovna"],
summary="Cancel packet",
description=(
"Cancels the packet through the Packeta API and updates its state to CANCELED. "
"No request body is required."
),
request=None,
responses={200: OpenApiResponse(response=ZasilkovnaPacketSerializer)},
)
@action(detail=True, methods=["patch"], url_path="cancel")
def cancel(self, request, pk=None):
packet: ZasilkovnaPacket = self.get_object()
packet.cancel_packet()
serializer = self.get_serializer(packet)
return Response(serializer.data, status=status.HTTP_200_OK)