added paddle

This commit is contained in:
2025-10-20 19:52:11 +02:00
parent 6093b24734
commit 7f9483c2bf
8 changed files with 309 additions and 96 deletions

View File

@@ -35,7 +35,7 @@ class EmailTests(TestCase):
)
self.document = Document.objects.create(organization=self.organization)
@patch("backend.accounts.utils.send_mail")
@patch('backend.accounts.utils.EmailMultiAlternatives')
def test_send_confirmation_email(self, mock_send_mail):
confirmation = EmailConfirmation.objects.create(email=self.email, uuid=uuid.uuid4(), created_at=now())
send_confirmation_email(self.email)
@@ -44,7 +44,7 @@ class EmailTests(TestCase):
self.assertIsNotNone(confirmation.uuid)
self.assertEqual(mock_send_mail.call_count, 1)
@patch("backend.accounts.utils.send_mail")
@patch('backend.accounts.utils.EmailMultiAlternatives')
def test_send_payment_email(self, mock_send_mail):
send_payment_email(self.email)
self.assertEqual(mock_send_mail.call_count, 1)

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Document, DocumentSegment, Organization, Risk, Control, DocumentTemplate, DocumentRiskControl, DemoCode
from .models import Document, DocumentSegment, Organization, Risk, Control, DocumentTemplate, DocumentRiskControl, DemoCode, PaymentRecord
from django.urls import reverse, path
from django.utils.html import format_html
from .utils import generate_demo_code, get_top_risk, get_controls_for_risk, generate_key_findings, generate_recommendations
@@ -460,7 +460,13 @@ class DemoCodeAdmin(admin.ModelAdmin):
form = GenerateCodesForm()
return render(request, 'admin/generate_codes.html', {'form': form})
class PaymentRecordAdmin(admin.ModelAdmin):
list_display = ('company', 'amount', 'currency', 'payment_date', 'transaction_id')
search_fields = ('company__name', 'transaction_id')
list_filter = ('payment_date',)
admin.site.register(PaymentRecord, PaymentRecordAdmin)
admin.site.register(Document, DocumentAdmin)
admin.site.register(Organization, OrganizationAdmin)
admin.site.register(Risk ,RiskAdmin)

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.3 on 2025-10-20 13:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_document_risk_explanations'),
]
operations = [
migrations.CreateModel(
name='PaymentRecord',
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(max_length=10)),
('payment_date', models.DateTimeField(auto_now_add=True)),
('transaction_id', models.CharField(max_length=255, unique=True)),
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.organization')),
],
),
]

View File

@@ -200,3 +200,14 @@ class DemoCode(models.Model):
def __str__(self):
return (f"{self.code} - {'Used' if self.used else 'Available'}")
class PaymentRecord(models.Model):
company = models.ForeignKey(Organization, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10)
payment_date = models.DateTimeField(auto_now_add=True)
transaction_id = models.CharField(max_length=255, unique=True)
def __str__(self):
return f"{self.company} - {self.amount} {self.currency}"

View File

@@ -1,41 +1,60 @@
{% extends 'base.html' %}
{% block content %}
<section class="py-24 bg-gradient-to-br from-teal-100 to-primary flex items-center justify-center">
<div class="max-w-md w-full mx-auto text-center shadow-2xl border border-accent rounded-2xl p-4 bg-white/90 backdrop-blur">
<h2 class="text-3xl font-extrabold mb-6 text-accent">Payment</h2>
<p class="text-lg text-gray-700 mb-6">Please enter your demo code to proceed to document.</p>
<div class="max-w-md w-full mx-auto shadow-2xl border border-accent rounded-2xl p-4 bg-white/90 backdrop-blur">
<h2 class="text-3xl font-extrabold mb-6 text-accent text-center">Payment</h2>
{% if success %}
<p class="text-green-600 font-semibold mb-4">{{ success }}</p>
{% endif %}
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="relative flex items-center">
<input
type="text"
id="code-input"
name="code"
maxlength="10"
class="w-full px-4 py-3 border-2 border-accent rounded-lg focus:outline-none focus:ring-2 focus:ring-accent text-lg tracking-widest text-center font-mono mb-2"
placeholder="Enter your code"
required
autocomplete="off"
<!-- Tabs -->
<div class="flex justify-center mb-6 border-b border-accent">
<button id="tab-demo" class="px-4 py-2 text-accent font-semibold border-b-2 border-accent">Demo Code</button>
<button id="tab-paddle" class="px-4 py-2 text-gray-500 hover:text-accent font-semibold">Paddle</button>
</div>
<!-- Demo Code Tab -->
<div id="demo-tab-content">
<p class="text-lg text-gray-700 mb-4">Enter your demo code to access the document:</p>
<form method="post" class="space-y-6">
{% csrf_token %}
<div class="relative flex items-center">
<input
type="text"
id="code-input"
name="code"
maxlength="10"
class="w-full px-4 py-3 border-2 border-accent rounded-lg focus:outline-none focus:ring-2 focus:ring-accent text-lg tracking-widest text-center font-mono mb-2"
placeholder="Enter your code"
required
autocomplete="off"
>
<span id="code-status" class="absolute right-3 top-1/2 -translate-y-1/2 text-2xl"></span>
</div>
<button
type="submit"
class="w-full bg-accent text-primary hover:bg-yellow-400 font-bold py-3 px-8 rounded-lg shadow-lg text-lg transition-all duration-200 ease-in-out transform hover:scale-105"
id="submit-btn"
disabled
>
<span id="code-status" class="absolute right-3 top-1/2 -translate-y-1/2 text-2xl"></span>
</div>
Enter Code
</button>
<p id="code-error" class="mt-2 font-semibold text-lg"></p>
{% if error %}
<p id="backend-error" class="text-red-600 mt-2 font-semibold text-lg">{{ error }}</p>
{% endif %}
</form>
</div>
<!-- Paddle Tab -->
<div id="paddle-tab-content" class="hidden text-center">
<p class="text-lg text-gray-700 mb-4">Pay with Paddle to access the document:</p>
<button
type="submit"
class="w-full bg-accent text-primary hover:bg-yellow-400 font-bold py-3 px-8 rounded-lg shadow-lg text-lg transition-all duration-200 ease-in-out transform hover:scale-105"
id="submit-btn"
disabled
id="paddle-button"
class="mt-4 bg-green-600 text-white font-bold py-3 px-8 rounded-lg shadow-lg text-lg transition-all duration-200 ease-in-out transform hover:scale-105"
data-client="{{ pdl_client }}"
data-price="{{ pdl_price }}"
>
Enter Code
Buy Now
</button>
</form>
<p id="code-error" class="mt-6 font-semibold text-lg"></p>
{% if error %}
<p id="backend-error" class="text-red-600 mt-6 font-semibold text-lg">{{ error }}</p>
{% endif %}
</div>
</div>
</section>
<script>
@@ -101,4 +120,76 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
<script>
const demoTabBtn = document.getElementById('tab-demo');
const paddleTabBtn = document.getElementById('tab-paddle');
const demoContent = document.getElementById('demo-tab-content');
const paddleContent = document.getElementById('paddle-tab-content');
demoTabBtn.addEventListener('click', () => {
demoContent.classList.remove('hidden');
paddleContent.classList.add('hidden');
demoTabBtn.classList.add('border-b-2', 'border-accent', 'text-accent');
paddleTabBtn.classList.remove('border-b-2', 'border-accent', 'text-accent');
paddleTabBtn.classList.add('text-gray-500');
});
paddleTabBtn.addEventListener('click', () => {
paddleContent.classList.remove('hidden');
demoContent.classList.add('hidden');
paddleTabBtn.classList.add('border-b-2', 'border-accent', 'text-accent');
demoTabBtn.classList.remove('border-b-2', 'border-accent', 'text-accent');
demoTabBtn.classList.add('text-gray-500');
});
</script>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
const paddleBtn = document.getElementById('paddle-button');
const email = "{{ email|escapejs }}";
const client = paddleBtn.dataset.client;
const price = paddleBtn.dataset.price;
Paddle.Environment.set('sandbox');
Paddle.Initialize({
token: client,
eventCallback: function(event) {
if (event.name === 'checkout.completed') {
handlePaymentSuccess(event.data);
}
}
});
paddleBtn.addEventListener('click', function() {
Paddle.Checkout.open({
customer: { email: email },
items: [{ priceId: price, quantity: 1 }]
});
});
function handlePaymentSuccess(data) {
const payload = {
transaction_id: data.transaction_id,
customer_email: data.customer.email,
amount: data.totals.total,
currency: data.currency_code
};
fetch("{% url 'core:payment_page' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
},
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
})
.then(result => {
if (result.redirect_url) window.location.href = result.redirect_url;
})
.catch(error => console.error('Fetch error:', error));
}
</script>
{% endblock %}

View File

@@ -11,11 +11,11 @@ urlpatterns = [
# url document/ recieves a parameter named 'uuid' and passes it to the view
path('document/<uuid:document_id>/', v.document, name='document'),
path('preview/<str:name>/', v.template_preview, name='template_preview'),
path("payment/", v.payment_page, name="payment_page"),
path("payment/", v.PaymentView.as_view(), name="payment_page"),
path('pdf/<uuid:document_id>/', v.pdf_view, name='pdf_view'),
path('api/validate_form_fields/', v.validate_form_fields, name='validate_form_fields'),
path('no_confidential_data/', v.no_confidential_data, name='no_confidential_data'),
path('validate_code/', v.validate_code, name='validate_code'),
path('validate_code/', v.PaymentView.validate_code, name='validate_code'),
path('terms-and-conditions/', v.terms_and_conditions, name='terms_and_conditions'),
path('refund-policy/', v.refund_policy, name='refund_policy'),
path('privacy-policy/', v.privacy_policy, name='privacy_policy'),

View File

@@ -6,7 +6,7 @@ import time
from django.shortcuts import render, redirect , get_object_or_404
from .forms import OrganizationForm, ContactForm
from .models import Organization,Document, DocumentTemplate, DemoCode, DocumentRiskControl, Risk, Control
from .models import Organization,Document, DocumentTemplate, DemoCode, PaymentRecord, DocumentRiskControl, Risk, Control
from backend.accounts.utils import send_confirmation_email, send_document_email, send_documet_to_expert, send_document_to_reviewer
from django.contrib.admin.views.decorators import staff_member_required
from .utils import generate_pdf, generate_risk_graph, generate_residual_risk_graph
@@ -22,7 +22,11 @@ from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
from backend.accounts.models import ExpertAnalysisEmails
from django.core.mail import send_mail
from django.views import View
import re
from decimal import Decimal
from django.urls import reverse
from urllib.parse import urlencode
# @login_required
# def index(request):
@@ -118,57 +122,141 @@ def pdf_view(request, document_id):
document = get_object_or_404(Document, id=document_id)
return generate_pdf(document)
def payment_page(request):
error = None
email = request.GET.get('email')
if request.method == 'POST':
import re
class PaymentView(View):
template_name = 'payment.html'
def get(self,request):
email = request.GET.get('email','').strip()
status_view = request.GET.get('status')
pdl_client = settings.PADDLE_CLIENT_ID
pdl_price = settings.PADDLE_PRICE_ID
if status_view == 'review':
document = Document.objects.filter(organization__email__iexact=email).first()
return render(request, 'payment_review.html', {'email': email, 'document': document})
if status_view == 'expert':
document = Document.objects.filter(organization__email__iexact=email).first()
return render(request, 'payment_expert_analysis.html', {'email': email, 'document': document})
return render(request, self.template_name, {
'email': email,
'pdl_client': pdl_client,
'pdl_price': pdl_price})
def post(self, request):
content_type = request.META.get('CONTENT_TYPE', '')
if 'application/json' in content_type:
try:
payload = json.loads(request.body)
return self._handle_paddle_payment(payload)
except json.JSONDecodeError:
return JsonResponse({'status': 'error', 'message': 'Invalid JSON'}, status=400)
else:
return self._handle_demo_code(request)
def _handle_demo_code(self, request):
email = request.GET.get('email', '').strip()
error = None
code = re.sub(r'\s+', '', request.POST.get('code', '')).upper()[:10]
try:
payment_code = DemoCode.objects.get(code=code)
if payment_code.used:
error = "CODE INVALID"
else:
org = Organization.objects.filter(email__iexact=email).first()
payment_code.used = True
payment_code.used_at = timezone.now()
payment_code.company = org
payment_code.save()
document = Document.objects.get(organization = org)
url = f"{site_domain}/pdf/{document.id}/"
expert_emails_qs = ExpertAnalysisEmails.objects.values_list('email', flat=True).distinct()
error = "Invalid demo code"
return render(request, self.template_name, {'email': email, 'error': error})
# If document is incomplete, notify reviewers
if document.status == Document.STATUS_INCOMPLETE:
if expert_emails_qs:
for email_addr in expert_emails_qs:
try:
send_document_to_reviewer(email_addr, document)
except Exception:
logger.exception("Failed to send incomplete document email to %s", email_addr)
return render(request, 'payment_review.html', {'email': email, 'document': document})
org = Organization.objects.filter(email__iexact=email).first()
payment_code.used = True
payment_code.used_at = timezone.now()
payment_code.company = org
payment_code.save()
print(org, email)
document = Document.objects.get(organization=org)
return self._handle_document_flow(org, document, email)
# If organization requested expert analysis, mark waiting and notify experts
if org.expert_analysis:
document.status = Document.STATUS_WAITING
document.save(update_fields=['status', 'modified_at'])
expert_emails = [e for e in expert_emails_qs if e]
if expert_emails:
for email_addr in expert_emails:
try:
send_documet_to_expert(email_addr, document)
except Exception:
logger.exception("Failed to send expert analysis email to %s", email_addr)
else:
logger.info("No expert emails configured; skipping expert notifications.")
return render(request, 'payment_expert_analysis.html', {'email': email, 'document': document})
send_document_email(email, url, document)
document.status = Document.STATUS_DONE
document.save(update_fields=['status', 'modified_at'])
return redirect(url)
except DemoCode.DoesNotExist:
error = "Invalid code"
return render(request, 'payment.html', {'error': error})
error = "Invalid demo code"
return render(request, self.template_name, {'email': email, 'error': error})
def _handle_paddle_payment(self, data):
try:
customer_email = data.get('customer_email')
transaction_id = data.get('transaction_id')
amount = Decimal(data.get('amount', 0))
currency = data.get('currency', 'EUR')
org = Organization.objects.filter(email__iexact=customer_email).first()
if not org:
return JsonResponse({'status': 'error', 'message': 'Organization not found'}, status=404)
PaymentRecord.objects.create(
company=org,
amount=amount,
currency=currency,
transaction_id=transaction_id,
payment_date=timezone.now()
)
document = Document.objects.get(organization=org)
return self._handle_document_flow(org, document, customer_email, json_response=True)
except Exception as e:
logger.exception("Error saving Paddle payment: %s", e)
return JsonResponse({'status': 'error', 'message': str(e)}, status=500)
def validate_code(request):
if request.method == "POST":
try:
data = json.loads(request.body)
code = data.get("code", "").strip().upper()
valid = DemoCode.objects.filter(code=code, used=False).exists()
time.sleep(1)
return JsonResponse({"valid": valid})
except Exception:
return JsonResponse({"valid": False})
return JsonResponse({"valid": False})
def _handle_document_flow(self, org, document, email, json_response=False):
site_domain = settings.SITE_DOMAIN
url = f"{site_domain}/pdf/{document.id}/"
expert_emails_qs = ExpertAnalysisEmails.objects.values_list('email', flat=True).distinct()
if document.status == Document.STATUS_INCOMPLETE:
review_url = f"{reverse('core:payment_page')}?{urlencode({'email': email, 'status': 'review'})}"
for email_addr in expert_emails_qs:
try:
send_document_to_reviewer(email_addr, document)
except Exception:
logger.exception("Failed to send incomplete document email to %s", email_addr)
if json_response:
return JsonResponse({'status': 'review', 'redirect_url': review_url})
return render(self.request, 'payment_review.html', {'email': email, 'document': document})
if org.expert_analysis:
document.status = Document.STATUS_WAITING
document.save(update_fields=['status', 'modified_at'])
expert_url = f"{reverse('core:payment_page')}?{urlencode({'email': email, 'status': 'expert'})}"
expert_emails = [e for e in expert_emails_qs if e]
for email_addr in expert_emails:
try:
send_documet_to_expert(email_addr, document)
except Exception:
logger.exception("Failed to send expert analysis email to %s", email_addr)
if json_response:
return JsonResponse({'status': 'expert', 'redirect_url': expert_url})
return render(self.request, 'payment_expert_analysis.html', {'email': email, 'document': document})
send_document_email(email, url, document)
document.status = Document.STATUS_DONE
document.save(update_fields=['status', 'modified_at'])
if json_response:
return JsonResponse({'status': 'ok', 'redirect_url': url})
return redirect(url)
def no_confidential_data(request):
return render(request, "no_confidential_data.html")
@@ -217,16 +305,3 @@ def demo_codes_pdf_view(request):
response['Content-Disposition'] = f'inline; filename=demo_codes_{timezone.now().strftime("%Y%m%d_%H%M%S")}.pdf'
return response
@csrf_exempt
def validate_code(request):
if request.method == "POST":
try:
data = json.loads(request.body)
code = data.get("code", "").strip().upper()
from .models import DemoCode
valid = DemoCode.objects.filter(code=code, used=False).exists()
time.sleep(3)
return JsonResponse({"valid": valid})
except Exception:
return JsonResponse({"valid": False})
return JsonResponse({"valid": False})

View File

@@ -22,6 +22,10 @@ load_dotenv()
#API key
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# Paddle Keys
PADDLE_CLIENT_ID = config('PADDLE_CLIENT_ID','')
PADDLE_PRICE_ID = config('PADDLE_PRICE_ID','')
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -172,6 +176,7 @@ CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC'
SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=False, cast=bool)
SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=False, cast=bool)
CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=False, cast=bool)