Compare commits
19
Commits
master
..
0ba6710faa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba6710faa | ||
|
|
70181ceda1 | ||
|
|
ea382cf4ba | ||
|
|
2332caca08 | ||
|
|
5596a87cc7 | ||
|
|
3fc73cbf56 | ||
|
|
8630eb7755 | ||
|
|
cd41c75920 | ||
|
|
67acda869a | ||
|
|
c66bfa39f0 | ||
|
|
25bf9c6193 | ||
|
|
16eac8a9e8 | ||
|
|
8b33a179cb | ||
|
|
1033d28e09 | ||
|
|
e70ea3fb96 | ||
|
|
88ee85dda3 | ||
|
|
7ca7721511 | ||
|
|
358231af2e | ||
|
|
eb84d01c03 |
No files matched your search
+1
-4
@@ -33,10 +33,7 @@ EMAIL_HOST_USER=
|
|||||||
EMAIL_HOST_PASSWORD=
|
EMAIL_HOST_PASSWORD=
|
||||||
EMAIL_PORT=2525
|
EMAIL_PORT=2525
|
||||||
EMAIL_USE_TLS=true
|
EMAIL_USE_TLS=true
|
||||||
# Display name has a comma — must be quoted (RFC 5322)
|
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
|
||||||
# DEFAULT_FROM_EMAIL="AI ML Operations, LLC" <info@aimloperations.com>
|
|
||||||
# Absolute origin for email logo / footer links
|
|
||||||
PUBLIC_SITE_URL=https://aimloperations.com
|
|
||||||
|
|
||||||
# Stripe (test keys from https://dashboard.stripe.com/test/apikeys)
|
# Stripe (test keys from https://dashboard.stripe.com/test/apikeys)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|||||||
+1
-3
@@ -34,9 +34,7 @@ EMAIL_HOST_USER=replace-with-smtp-user
|
|||||||
EMAIL_HOST_PASSWORD=replace-with-smtp-password
|
EMAIL_HOST_PASSWORD=replace-with-smtp-password
|
||||||
EMAIL_PORT=2525
|
EMAIL_PORT=2525
|
||||||
EMAIL_USE_TLS=true
|
EMAIL_USE_TLS=true
|
||||||
# Display name has a comma — must be quoted (RFC 5322)
|
# DEFAULT_FROM_EMAIL=AI ML Operations, LLC <info@aimloperations.com>
|
||||||
# DEFAULT_FROM_EMAIL="AI ML Operations, LLC" <info@aimloperations.com>
|
|
||||||
PUBLIC_SITE_URL=https://aimloperations.com
|
|
||||||
|
|
||||||
# Stripe (live keys from https://dashboard.stripe.com/apikeys)
|
# Stripe (live keys from https://dashboard.stripe.com/apikeys)
|
||||||
# Secret key starts with sk_live_; publishable with pk_live_
|
# Secret key starts with sk_live_; publishable with pk_live_
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates libpq5 \
|
&& apt-get install -y --no-install-recommends libpq5 \
|
||||||
&& update-ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|||||||
@@ -202,10 +202,8 @@ EMAIL_PORT = int(env("EMAIL_PORT", "2525"))
|
|||||||
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
||||||
DEFAULT_FROM_EMAIL = env(
|
DEFAULT_FROM_EMAIL = env(
|
||||||
"DEFAULT_FROM_EMAIL",
|
"DEFAULT_FROM_EMAIL",
|
||||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
)
|
)
|
||||||
# Absolute site origin for email logo / footer links (no trailing slash).
|
|
||||||
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "https://aimloperations.com").rstrip("/")
|
|
||||||
|
|
||||||
# Stripe (invoices / subscriptions — customers pay us)
|
# Stripe (invoices / subscriptions — customers pay us)
|
||||||
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
|
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
|
||||||
|
|||||||
@@ -25,16 +25,6 @@ from .models import BillingCustomer, Invoice, RecurringSubscription
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _optional_pk(value) -> int | None:
|
|
||||||
"""Local row ids are integers. Another site's metadata must not crash the webhook."""
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
STRIPE_API_BASE = "https://api.stripe.com/v1"
|
||||||
|
|
||||||
|
|
||||||
@@ -156,23 +146,25 @@ def ensure_stripe_customer(customer: BillingCustomer) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
||||||
"""Create + finalize a Stripe Invoice; store hosted pay URL.
|
"""Create + finalize a Stripe Invoice; store hosted pay URL."""
|
||||||
|
|
||||||
The line item is attached to the draft invoice. Stripe's current
|
|
||||||
Invoices API defaults ``pending_invoice_items_behavior`` to
|
|
||||||
``exclude``, so a pending item created beforehand is left off the
|
|
||||||
invoice. Finalizing that empty invoice yields a $0 total, and Stripe
|
|
||||||
marks any $0 invoice paid.
|
|
||||||
"""
|
|
||||||
stripe_customer_id = ensure_stripe_customer(invoice.customer)
|
stripe_customer_id = ensure_stripe_customer(invoice.customer)
|
||||||
|
|
||||||
|
_stripe_request(
|
||||||
|
"POST",
|
||||||
|
"/invoiceitems",
|
||||||
|
{
|
||||||
|
"customer": stripe_customer_id,
|
||||||
|
"amount": invoice.amount_cents,
|
||||||
|
"currency": invoice.currency,
|
||||||
|
"description": invoice.description,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
create_data: dict = {
|
create_data: dict = {
|
||||||
"customer": stripe_customer_id,
|
"customer": stripe_customer_id,
|
||||||
"collection_method": "send_invoice",
|
"collection_method": "send_invoice",
|
||||||
# Finalize ourselves after the line item is attached. auto_advance
|
"auto_advance": True,
|
||||||
# would let Stripe finalize the still-empty draft on its own.
|
"metadata": {"local_invoice_id": str(invoice.pk)},
|
||||||
"auto_advance": False,
|
|
||||||
"pending_invoice_items_behavior": "exclude",
|
|
||||||
}
|
}
|
||||||
if invoice.due_date:
|
if invoice.due_date:
|
||||||
due_dt = datetime.combine(invoice.due_date, datetime.min.time())
|
due_dt = datetime.combine(invoice.due_date, datetime.min.time())
|
||||||
@@ -183,40 +175,11 @@ def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
|||||||
create_data["days_until_due"] = 30
|
create_data["days_until_due"] = 30
|
||||||
|
|
||||||
stripe_invoice = _stripe_request("POST", "/invoices", create_data)
|
stripe_invoice = _stripe_request("POST", "/invoices", create_data)
|
||||||
invoice_id = stripe_invoice["id"]
|
|
||||||
|
|
||||||
_stripe_request(
|
|
||||||
"POST",
|
|
||||||
"/invoiceitems",
|
|
||||||
{
|
|
||||||
"customer": stripe_customer_id,
|
|
||||||
"invoice": invoice_id,
|
|
||||||
"amount": invoice.amount_cents,
|
|
||||||
"currency": invoice.currency,
|
|
||||||
"description": invoice.description,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
stripe_invoice = _stripe_request(
|
stripe_invoice = _stripe_request(
|
||||||
"POST", f"/invoices/{invoice_id}/finalize", {}
|
"POST", f"/invoices/{stripe_invoice['id']}/finalize", {}
|
||||||
)
|
)
|
||||||
|
|
||||||
total = stripe_invoice.get("total")
|
invoice.stripe_invoice_id = stripe_invoice["id"]
|
||||||
if total != invoice.amount_cents:
|
|
||||||
_void_mismatched_invoice(invoice_id)
|
|
||||||
raise StripeAPIError(
|
|
||||||
f"Stripe invoice total was {total} cents, expected {invoice.amount_cents} cents."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Metadata is set only after the total checks out, so an invoice.paid
|
|
||||||
# webhook for a $0 finalize cannot match this local row.
|
|
||||||
_stripe_request(
|
|
||||||
"POST",
|
|
||||||
f"/invoices/{invoice_id}",
|
|
||||||
{"metadata": {"local_invoice_id": str(invoice.pk)}},
|
|
||||||
)
|
|
||||||
|
|
||||||
invoice.stripe_invoice_id = invoice_id
|
|
||||||
invoice.hosted_invoice_url = stripe_invoice.get("hosted_invoice_url") or ""
|
invoice.hosted_invoice_url = stripe_invoice.get("hosted_invoice_url") or ""
|
||||||
invoice.invoice_pdf_url = stripe_invoice.get("invoice_pdf") or ""
|
invoice.invoice_pdf_url = stripe_invoice.get("invoice_pdf") or ""
|
||||||
status = stripe_invoice.get("status") or Invoice.Status.OPEN
|
status = stripe_invoice.get("status") or Invoice.Status.OPEN
|
||||||
@@ -235,16 +198,6 @@ def create_one_off_invoice(invoice: Invoice) -> Invoice:
|
|||||||
return invoice
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
def _void_mismatched_invoice(invoice_id: str) -> None:
|
|
||||||
"""Void a finalized invoice whose total does not match the local amount."""
|
|
||||||
try:
|
|
||||||
_stripe_request("POST", f"/invoices/{invoice_id}/void", {})
|
|
||||||
except StripeAPIError:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to void Stripe invoice %s after amount mismatch", invoice_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_subscription_checkout(
|
def create_subscription_checkout(
|
||||||
subscription: RecurringSubscription,
|
subscription: RecurringSubscription,
|
||||||
*,
|
*,
|
||||||
@@ -329,18 +282,16 @@ def send_pay_link_email(
|
|||||||
from_email = getattr(
|
from_email = getattr(
|
||||||
settings,
|
settings,
|
||||||
"DEFAULT_FROM_EMAIL",
|
"DEFAULT_FROM_EMAIL",
|
||||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
"AI ML Operations, LLC <info@aimloperations.com>",
|
||||||
)
|
|
||||||
from public.email_branding import email_brand_context
|
|
||||||
|
|
||||||
context = email_brand_context(
|
|
||||||
customer_name=customer_name,
|
|
||||||
description=description,
|
|
||||||
amount_dollars=f"{amount_dollars:.2f}",
|
|
||||||
pay_url=pay_url,
|
|
||||||
kind=kind,
|
|
||||||
subject=subject,
|
|
||||||
)
|
)
|
||||||
|
context = {
|
||||||
|
"customer_name": customer_name,
|
||||||
|
"description": description,
|
||||||
|
"amount_dollars": f"{amount_dollars:.2f}",
|
||||||
|
"pay_url": pay_url,
|
||||||
|
"kind": kind,
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
html_content = get_template("emails/invoice_pay_link.html").render(context)
|
html_content = get_template("emails/invoice_pay_link.html").render(context)
|
||||||
text_content = get_template("emails/invoice_pay_link.txt").render(context)
|
text_content = get_template("emails/invoice_pay_link.txt").render(context)
|
||||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
|
msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
|
||||||
@@ -361,19 +312,7 @@ def apply_stripe_invoice_event(stripe_invoice: dict) -> Invoice | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
status = stripe_invoice.get("status") or invoice.status
|
status = stripe_invoice.get("status") or invoice.status
|
||||||
# A $0 Stripe invoice is auto-marked paid on finalize. Never copy that
|
if status in dict(Invoice.Status.choices):
|
||||||
# onto a local invoice that was supposed to charge money.
|
|
||||||
if (
|
|
||||||
status == Invoice.Status.PAID
|
|
||||||
and invoice.amount_cents > 0
|
|
||||||
and stripe_invoice.get("total") == 0
|
|
||||||
):
|
|
||||||
logger.error(
|
|
||||||
"Refusing paid status for invoice %s: Stripe total is 0, local amount is %s cents",
|
|
||||||
invoice.pk,
|
|
||||||
invoice.amount_cents,
|
|
||||||
)
|
|
||||||
elif status in dict(Invoice.Status.choices):
|
|
||||||
invoice.status = status
|
invoice.status = status
|
||||||
invoice.hosted_invoice_url = (
|
invoice.hosted_invoice_url = (
|
||||||
stripe_invoice.get("hosted_invoice_url") or invoice.hosted_invoice_url
|
stripe_invoice.get("hosted_invoice_url") or invoice.hosted_invoice_url
|
||||||
@@ -421,22 +360,18 @@ def apply_subscription_event(stripe_sub: dict) -> RecurringSubscription | None:
|
|||||||
|
|
||||||
|
|
||||||
def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None:
|
def apply_checkout_session_completed(session: dict) -> RecurringSubscription | None:
|
||||||
"""Attach subscription id after Checkout completes. Unknown sessions are ignored."""
|
"""Attach subscription id after Checkout completes."""
|
||||||
session_id = session.get("id") or ""
|
|
||||||
if session.get("mode") != "subscription":
|
if session.get("mode") != "subscription":
|
||||||
logger.info("Ignoring checkout session %s with mode %s", session_id, session.get("mode") or "")
|
|
||||||
return None
|
return None
|
||||||
local_id = (session.get("metadata") or {}).get("local_subscription_id")
|
local_id = (session.get("metadata") or {}).get("local_subscription_id")
|
||||||
sub = None
|
sub = None
|
||||||
pk = _optional_pk(local_id)
|
if local_id:
|
||||||
if pk is not None:
|
sub = RecurringSubscription.objects.filter(pk=local_id).first()
|
||||||
sub = RecurringSubscription.objects.filter(pk=pk).first()
|
if sub is None and session.get("id"):
|
||||||
if sub is None and session_id:
|
|
||||||
sub = RecurringSubscription.objects.filter(
|
sub = RecurringSubscription.objects.filter(
|
||||||
checkout_session_id=session_id
|
checkout_session_id=session["id"]
|
||||||
).first()
|
).first()
|
||||||
if sub is None:
|
if sub is None:
|
||||||
logger.info("Ignoring checkout session %s with no local subscription", session_id)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stripe_sub_id = session.get("subscription")
|
stripe_sub_id = session.get("subscription")
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
{% extends "emails/base_email.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
{% block title %}{{ subject }}{% endblock %}
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
{% block content %}
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<p style="margin:0 0 16px;color:#e0e0e0;">Hello {{ customer_name }},</p>
|
<title>{{ subject }}</title>
|
||||||
{% if kind == "subscription" %}
|
<style>
|
||||||
<p style="margin:0 0 16px;color:#e0e0e0;">Please complete checkout to start your recurring payment:</p>
|
body { margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4; }
|
||||||
{% else %}
|
.email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #dddddd; }
|
||||||
<p style="margin:0 0 16px;color:#e0e0e0;">You have a new invoice ready for payment:</p>
|
.header { background-color: #0b3d5c; color: #ffffff; padding: 20px; text-align: center; }
|
||||||
{% endif %}
|
.content { padding: 24px; color: #333333; font-size: 15px; line-height: 1.5; }
|
||||||
<p style="margin:0 0 8px;color:#e0e0e0;"><strong>{{ description }}</strong></p>
|
.btn {
|
||||||
<p class="amount" style="font-size:22px;font-weight:700;color:#00f3ff;margin:12px 0;">${{ amount_dollars }} USD</p>
|
display: inline-block; margin: 20px 0; padding: 12px 24px;
|
||||||
<p style="margin:24px 0;">
|
background-color: #0b3d5c; color: #ffffff !important; text-decoration: none;
|
||||||
<a class="email-btn" href="{{ pay_url }}" style="display:inline-block;padding:14px 28px;background-color:#00f3ff;color:#0a0a0a !important;text-decoration:none;border-radius:30px;font-weight:700;font-size:14px;letter-spacing:0.5px;text-transform:uppercase;">
|
border-radius: 4px; font-weight: bold;
|
||||||
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
|
}
|
||||||
</a>
|
.footer { background-color: #f4f4f4; color: #777777; text-align: center; padding: 12px; font-size: 12px; }
|
||||||
</p>
|
.amount { font-size: 22px; font-weight: bold; margin: 12px 0; }
|
||||||
<p class="muted" style="color:#a0a0a0;font-size:13px;line-height:1.5;margin:0 0 16px;">
|
</style>
|
||||||
Or open this link:<br>
|
</head>
|
||||||
<a href="{{ pay_url }}" style="color:#00f3ff;word-break:break-all;">{{ pay_url }}</a>
|
<body>
|
||||||
</p>
|
<div class="email-container">
|
||||||
<p style="margin:0;color:#e0e0e0;">Thank you,<br>{{ brand_legal|default:"AI ML Operations, LLC" }}</p>
|
<div class="header">
|
||||||
{% endblock %}
|
<h1>AI ML Operations</h1>
|
||||||
|
</div>
|
||||||
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
<div class="content">
|
||||||
|
<p>Hello {{ customer_name }},</p>
|
||||||
|
{% if kind == "subscription" %}
|
||||||
|
<p>Please complete checkout to start your recurring payment:</p>
|
||||||
|
{% else %}
|
||||||
|
<p>You have a new invoice ready for payment:</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>{{ description }}</strong></p>
|
||||||
|
<p class="amount">${{ amount_dollars }} USD</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="{{ pay_url }}">
|
||||||
|
{% if kind == "subscription" %}Complete checkout{% else %}Pay invoice{% endif %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #666;">Or open this link:<br>{{ pay_url }}</p>
|
||||||
|
<p>Thank you,<br>AI ML Operations, LLC</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,8 +9,4 @@ Amount: ${{ amount_dollars }} USD
|
|||||||
{{ pay_url }}
|
{{ pay_url }}
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
AI ML Operations, LLC
|
||||||
|
|
||||||
—
|
|
||||||
{{ site_url|default:"https://aimloperations.com" }}
|
|
||||||
Forward-deployed AI engineering
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from financial.models import (
|
from financial.models import (
|
||||||
@@ -218,168 +216,3 @@ class InvoiceAccessTests(TestCase):
|
|||||||
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
self.assertEqual(invoice.status, Invoice.Status.PAID)
|
||||||
self.assertEqual(invoice.hosted_invoice_url, "https://pay.stripe.com/test")
|
self.assertEqual(invoice.hosted_invoice_url, "https://pay.stripe.com/test")
|
||||||
|
|
||||||
def test_webhook_ignores_checkout_from_other_site(self):
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
from financial.models import RecurringSubscription
|
|
||||||
from financial.stripe_billing import apply_checkout_session_completed
|
|
||||||
|
|
||||||
session = {
|
|
||||||
"id": "cs_other_site",
|
|
||||||
"mode": "subscription",
|
|
||||||
"customer": "cus_other",
|
|
||||||
"subscription": "sub_other",
|
|
||||||
"metadata": {
|
|
||||||
"user_id": "999999",
|
|
||||||
"purpose": "personal",
|
|
||||||
"local_subscription_id": "not-ours",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
before = RecurringSubscription.objects.count()
|
|
||||||
self.assertIsNone(apply_checkout_session_completed(session))
|
|
||||||
payload = (
|
|
||||||
'{"id":"evt_other_1","type":"checkout.session.completed",'
|
|
||||||
'"data":{"object":{"id":"cs_other_site","mode":"subscription",'
|
|
||||||
'"metadata":{"user_id":"999999","purpose":"personal",'
|
|
||||||
'"local_subscription_id":"not-ours"}}}}'
|
|
||||||
)
|
|
||||||
with override_settings(STRIPE_WEBHOOK_SECRET=""):
|
|
||||||
response = self.client_http.post(
|
|
||||||
reverse("stripe_webhook"),
|
|
||||||
data=payload,
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(RecurringSubscription.objects.count(), before)
|
|
||||||
|
|
||||||
def test_apply_invoice_webhook_ignores_zero_dollar_paid(self):
|
|
||||||
from financial.models import BillingCustomer, Invoice
|
|
||||||
from financial.stripe_billing import apply_stripe_invoice_event
|
|
||||||
|
|
||||||
customer = BillingCustomer.objects.create(
|
|
||||||
name="Acme", email="acme-zero@example.com"
|
|
||||||
)
|
|
||||||
invoice = Invoice.objects.create(
|
|
||||||
customer=customer,
|
|
||||||
description="Work",
|
|
||||||
amount_cents=204000,
|
|
||||||
stripe_invoice_id="in_zero",
|
|
||||||
status=Invoice.Status.DRAFT,
|
|
||||||
)
|
|
||||||
apply_stripe_invoice_event(
|
|
||||||
{
|
|
||||||
"id": "in_zero",
|
|
||||||
"status": "paid",
|
|
||||||
"total": 0,
|
|
||||||
"metadata": {"local_invoice_id": str(invoice.pk)},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
invoice.refresh_from_db()
|
|
||||||
self.assertEqual(invoice.status, Invoice.Status.DRAFT)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateOneOffInvoiceTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
from financial.models import BillingCustomer, Invoice
|
|
||||||
|
|
||||||
self.customer = BillingCustomer.objects.create(
|
|
||||||
name="Acme", email="billing@example.com"
|
|
||||||
)
|
|
||||||
self.invoice = Invoice.objects.create(
|
|
||||||
customer=self.customer,
|
|
||||||
description="Site build",
|
|
||||||
amount_cents=204000,
|
|
||||||
status=Invoice.Status.DRAFT,
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(STRIPE_SECRET_KEY="sk_test_x")
|
|
||||||
@patch("financial.stripe_billing._stripe_request")
|
|
||||||
def test_line_item_attached_to_draft_before_finalize(self, stripe_request):
|
|
||||||
from financial.models import Invoice
|
|
||||||
from financial.stripe_billing import create_one_off_invoice
|
|
||||||
|
|
||||||
def side_effect(method, path, data=None):
|
|
||||||
if path == "/customers":
|
|
||||||
return {"id": "cus_123"}
|
|
||||||
if path == "/invoices":
|
|
||||||
return {"id": "in_123", "status": "draft"}
|
|
||||||
if path == "/invoiceitems":
|
|
||||||
return {"id": "ii_123"}
|
|
||||||
if path == "/invoices/in_123/finalize":
|
|
||||||
return {
|
|
||||||
"id": "in_123",
|
|
||||||
"status": "open",
|
|
||||||
"total": 204000,
|
|
||||||
"amount_due": 204000,
|
|
||||||
"hosted_invoice_url": "https://pay.stripe.com/i/in_123",
|
|
||||||
"invoice_pdf": "https://pay.stripe.com/i/in_123/pdf",
|
|
||||||
}
|
|
||||||
if path == "/invoices/in_123":
|
|
||||||
return {"id": "in_123"}
|
|
||||||
raise AssertionError(f"unexpected {method} {path}")
|
|
||||||
|
|
||||||
stripe_request.side_effect = side_effect
|
|
||||||
create_one_off_invoice(self.invoice)
|
|
||||||
|
|
||||||
paths = [call.args[1] for call in stripe_request.call_args_list]
|
|
||||||
self.assertEqual(
|
|
||||||
paths,
|
|
||||||
[
|
|
||||||
"/customers",
|
|
||||||
"/invoices",
|
|
||||||
"/invoiceitems",
|
|
||||||
"/invoices/in_123/finalize",
|
|
||||||
"/invoices/in_123",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
invoice_create = stripe_request.call_args_list[1].args[2]
|
|
||||||
self.assertEqual(invoice_create["pending_invoice_items_behavior"], "exclude")
|
|
||||||
self.assertNotIn("metadata", invoice_create)
|
|
||||||
item = stripe_request.call_args_list[2].args[2]
|
|
||||||
self.assertEqual(item["invoice"], "in_123")
|
|
||||||
self.assertEqual(item["amount"], 204000)
|
|
||||||
|
|
||||||
self.invoice.refresh_from_db()
|
|
||||||
self.assertEqual(self.invoice.status, Invoice.Status.OPEN)
|
|
||||||
self.assertEqual(self.invoice.amount_cents, 204000)
|
|
||||||
self.assertEqual(self.invoice.stripe_invoice_id, "in_123")
|
|
||||||
self.assertEqual(
|
|
||||||
self.invoice.hosted_invoice_url, "https://pay.stripe.com/i/in_123"
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(STRIPE_SECRET_KEY="sk_test_x")
|
|
||||||
@patch("financial.stripe_billing._stripe_request")
|
|
||||||
def test_zero_total_finalize_does_not_mark_paid(self, stripe_request):
|
|
||||||
from financial.models import Invoice
|
|
||||||
from financial.stripe_billing import StripeAPIError, create_one_off_invoice
|
|
||||||
|
|
||||||
def side_effect(method, path, data=None):
|
|
||||||
if path == "/customers":
|
|
||||||
return {"id": "cus_123"}
|
|
||||||
if path == "/invoices":
|
|
||||||
return {"id": "in_zero", "status": "draft"}
|
|
||||||
if path == "/invoiceitems":
|
|
||||||
return {"id": "ii_123"}
|
|
||||||
if path == "/invoices/in_zero/finalize":
|
|
||||||
return {
|
|
||||||
"id": "in_zero",
|
|
||||||
"status": "paid",
|
|
||||||
"total": 0,
|
|
||||||
"amount_due": 0,
|
|
||||||
}
|
|
||||||
if path == "/invoices/in_zero/void":
|
|
||||||
return {"id": "in_zero", "status": "void"}
|
|
||||||
raise AssertionError(f"unexpected {method} {path}")
|
|
||||||
|
|
||||||
stripe_request.side_effect = side_effect
|
|
||||||
with self.assertRaises(StripeAPIError):
|
|
||||||
create_one_off_invoice(self.invoice)
|
|
||||||
|
|
||||||
paths = [call.args[1] for call in stripe_request.call_args_list]
|
|
||||||
self.assertIn("/invoices/in_zero/void", paths)
|
|
||||||
self.assertNotIn("/invoices/in_zero", paths)
|
|
||||||
self.invoice.refresh_from_db()
|
|
||||||
self.assertEqual(self.invoice.status, Invoice.Status.DRAFT)
|
|
||||||
self.assertEqual(self.invoice.stripe_invoice_id, "")
|
|
||||||
self.assertEqual(self.invoice.amount_cents, 204000)
|
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -6,7 +5,6 @@ from django.template.loader import get_template
|
|||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .email_branding import email_brand_context
|
|
||||||
from .models import Contact, EmailMessage, PageVisit
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
|
|
||||||
|
|
||||||
@@ -38,12 +36,8 @@ def send_emails(modeladmin, request, queryset):
|
|||||||
for email in queryset:
|
for email in queryset:
|
||||||
success_count: int = 0
|
success_count: int = 0
|
||||||
try:
|
try:
|
||||||
from_email = getattr(
|
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
||||||
settings,
|
d = {"title": email.subject, "content": email.body}
|
||||||
"DEFAULT_FROM_EMAIL",
|
|
||||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
|
||||||
)
|
|
||||||
d = email_brand_context(title=email.subject, content=email.body)
|
|
||||||
|
|
||||||
html_content = get_template("emails/marketing_email.html").render(d)
|
html_content = get_template("emails/marketing_email.html").render(d)
|
||||||
text_content = get_template("emails/marketing_email.txt").render(d)
|
text_content = get_template("emails/marketing_email.txt").render(d)
|
||||||
@@ -83,11 +77,8 @@ class EmailMessageAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def preview_email(self, request, pk):
|
def preview_email(self, request, pk):
|
||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = email_brand_context(
|
context = {"title": email_instance.subject, "content": email_instance.body}
|
||||||
title=email_instance.subject,
|
return TemplateResponse(request, "public/preview_email.html", context)
|
||||||
content=email_instance.body,
|
|
||||||
)
|
|
||||||
return TemplateResponse(request, "emails/marketing_email.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PageVisit)
|
@admin.register(PageVisit)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Shared context for branded HTML/text emails."""
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
|
||||||
|
|
||||||
|
|
||||||
def email_brand_context(**extra):
|
|
||||||
site_url = getattr(settings, "PUBLIC_SITE_URL", "https://aimloperations.com").rstrip(
|
|
||||||
"/"
|
|
||||||
)
|
|
||||||
logo_path = staticfiles_storage.url("public/img/logo.png")
|
|
||||||
if logo_path.startswith("http://") or logo_path.startswith("https://"):
|
|
||||||
logo_url = logo_path
|
|
||||||
else:
|
|
||||||
logo_url = f"{site_url}{logo_path}"
|
|
||||||
return {
|
|
||||||
"site_url": site_url,
|
|
||||||
"logo_url": logo_url,
|
|
||||||
"brand_name": "AI ML Operations",
|
|
||||||
"brand_legal": "AI ML Operations, LLC",
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
@@ -361,10 +361,8 @@
|
|||||||
'Sites are brand-tailored; clients own the site and data. Public site and client portal are ' +
|
'Sites are brand-tailored; clients own the site and data. Public site and client portal are ' +
|
||||||
'always included, along with three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, ' +
|
'always included, along with three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, ' +
|
||||||
'and Grafana metrics/alerts. Pass optional feature ids to add Email/SMS, direct mail, blog, ' +
|
'and Grafana metrics/alerts. Pass optional feature ids to add Email/SMS, direct mail, blog, ' +
|
||||||
'Stripe payments, social, AI social, e-commerce, POS sync, event ticketing, or shipping. ' +
|
'Stripe payments, social consolidation, or AI social generator. Dependencies ' +
|
||||||
'Dependencies (payments→email_sms, ai_social→social, ecommerce→email_sms+payments, ' +
|
'(payments→email_sms, ai_social→social) are auto-selected. Call with an empty features array ' +
|
||||||
'pos_sync/shipping→ecommerce, event_ticketing→email_sms+payments) are auto-selected. ' +
|
|
||||||
'Call with an empty features array ' +
|
|
||||||
'to list catalog pricing and included-with-every-site benefits.',
|
'to list catalog pricing and included-with-every-site benefits.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -373,8 +371,7 @@
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
description:
|
description:
|
||||||
'Optional feature ids to include beyond the required base. Valid ids: ' +
|
'Optional feature ids to include beyond the required base. Valid ids: ' +
|
||||||
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social, ' +
|
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social.',
|
||||||
'ecommerce, pos_sync, event_ticketing, shipping.',
|
|
||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="color-scheme" content="dark">
|
|
||||||
<meta name="supported-color-schemes" content="dark">
|
|
||||||
<title>{% block title %}{{ brand_name|default:"AI ML Operations" }}{% endblock %}</title>
|
|
||||||
<!--[if mso]>
|
|
||||||
<style type="text/css">
|
|
||||||
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
<style type="text/css">
|
|
||||||
body, table, td, a {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-ms-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
table, td {
|
|
||||||
mso-table-lspace: 0pt;
|
|
||||||
mso-table-rspace: 0pt;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
border: 0;
|
|
||||||
height: auto;
|
|
||||||
line-height: 100%;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
-ms-interpolation-mode: bicubic;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
background-color: #0a0a0a;
|
|
||||||
color: #e0e0e0;
|
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
a { color: #00f3ff; }
|
|
||||||
.email-btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 14px 28px;
|
|
||||||
background-color: #00f3ff;
|
|
||||||
color: #0a0a0a !important;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 30px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.muted { color: #a0a0a0; font-size: 13px; line-height: 1.5; }
|
|
||||||
.amount { font-size: 22px; font-weight: 700; color: #00f3ff; margin: 12px 0; }
|
|
||||||
.field-label { color: #a0a0a0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; margin: 0 0 4px; }
|
|
||||||
.field-value { color: #e0e0e0; font-size: 15px; margin: 0 0 16px; line-height: 1.5; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#0a0a0a;">
|
|
||||||
{% block preheader %}{% endblock %}
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0a0a0a;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:32px 16px;">
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background-color:#1a1a1a;border:1px solid rgba(255,255,255,0.1);">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:28px 24px 20px;border-bottom:1px solid rgba(0,243,255,0.35);">
|
|
||||||
{% if logo_url %}
|
|
||||||
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="text-decoration:none;">
|
|
||||||
<img src="{{ logo_url }}" alt="{{ brand_name|default:'AI ML Operations' }}" width="220" style="display:block;width:220px;max-width:80%;height:auto;">
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<p style="margin:0;font-size:18px;font-weight:700;letter-spacing:1px;color:#e0e0e0;text-transform:uppercase;">
|
|
||||||
{{ brand_name|default:"AI ML Operations" }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% block header_extra %}{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="height:3px;line-height:3px;font-size:0;background-color:#bc13fe;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:28px 28px 8px;color:#e0e0e0;font-size:15px;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:8px 28px 28px;color:#a0a0a0;font-size:12px;line-height:1.5;text-align:center;border-top:1px solid rgba(255,255,255,0.08);font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
||||||
{% block footer %}
|
|
||||||
<p style="margin:16px 0 8px;color:#a0a0a0;">
|
|
||||||
{% block footer_note %}{% endblock %}
|
|
||||||
</p>
|
|
||||||
<p style="margin:0 0 4px;color:#a0a0a0;">
|
|
||||||
© {% now "Y" %} {{ brand_legal|default:"AI ML Operations, LLC" }}. All rights reserved.
|
|
||||||
</p>
|
|
||||||
<p style="margin:0;color:#a0a0a0;">
|
|
||||||
<a href="{{ site_url|default:'https://aimloperations.com' }}" style="color:#00f3ff;text-decoration:none;">aimloperations.com</a>
|
|
||||||
· Forward-deployed AI engineering
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,21 +1,110 @@
|
|||||||
{% extends "emails/base_email.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>New Feedback Submission</title>
|
||||||
|
<style>
|
||||||
|
/* Basic reset for email clients */
|
||||||
|
body, table, td, a {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #dddddd;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #007BFF;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #777777;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.feedback-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.feedback-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Email Container -->
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<h1>New Contact Request</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% block title %}New Contact Request{% endblock %}
|
<!-- Content -->
|
||||||
|
<div class="content">
|
||||||
|
<p>Hello,</p>
|
||||||
|
<p>A new feedback item has been submitted. Here are the details:</p>
|
||||||
|
|
||||||
{% block content %}
|
<!-- Feedback Title -->
|
||||||
<p style="margin:0 0 16px;color:#e0e0e0;">Hello,</p>
|
<div class="feedback-title">
|
||||||
<p style="margin:0 0 24px;color:#e0e0e0;">A new contact request was submitted on the site.</p>
|
Subject: <strong>{{ subject }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Subject</p>
|
<!-- Email -->
|
||||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;"><strong>{{ subject }}</strong></p>
|
<div class="feedback-title">
|
||||||
|
Email: <strong>{{ email }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">From</p>
|
<!-- Feedback Text -->
|
||||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;">
|
<div class="feedback-text">
|
||||||
<a href="mailto:{{ email }}" style="color:#00f3ff;text-decoration:none;">{{ email }}</a>
|
<strong>Message:</strong><br>
|
||||||
</p>
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="field-label" style="color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:0.6px;margin:0 0 4px;">Message</p>
|
<p>Thank you for your attention.</p>
|
||||||
<p class="field-value" style="color:#e0e0e0;font-size:15px;margin:0 0 16px;white-space:pre-wrap;">{{ message }}</p>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer_note %}This is an automated message. Please do not reply to this email.{% endblock %}
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message. Please do not reply to this email.</p>
|
||||||
|
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,3 @@
|
|||||||
New Contact Request — {{ brand_legal|default:"AI ML Operations, LLC" }}
|
New Contact Request for AI ML Operations, LLC
|
||||||
|
|
||||||
Subject: {{ subject }}
|
"New Contact. {{ subject }} from {{ email }}. {{ message }}"
|
||||||
From: {{ email }}
|
|
||||||
|
|
||||||
Message:
|
|
||||||
{{ message }}
|
|
||||||
|
|
||||||
—
|
|
||||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
|
||||||
{{ site_url|default:"https://aimloperations.com" }}
|
|
||||||
Forward-deployed AI engineering
|
|
||||||
@@ -1,15 +1,61 @@
|
|||||||
{% extends "emails/base_email.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Email Template</title>
|
||||||
|
<!-- Materialize CSS -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #37474f;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #333;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<h4>{{ title | safe }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock %}
|
<!-- Content -->
|
||||||
|
<div class="content">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% block header_extra %}
|
<!-- Footer -->
|
||||||
{% if title %}
|
<div class="footer">
|
||||||
<p style="margin:16px 0 0;font-size:18px;font-weight:600;color:#e0e0e0;line-height:1.4;">{{ title|safe }}</p>
|
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endblock %}
|
</div>
|
||||||
|
</body>
|
||||||
{% block content %}
|
</html>
|
||||||
{{ content|safe }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer_note %}{% endblock %}
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
{{ title }}
|
{{ subject }}
|
||||||
|
|
||||||
{{ content }}
|
{{ content }}
|
||||||
|
|
||||||
—
|
|
||||||
{{ brand_legal|default:"AI ML Operations, LLC" }}
|
|
||||||
{{ site_url|default:"https://aimloperations.com" }}
|
|
||||||
Forward-deployed AI engineering
|
|
||||||
@@ -1 +1,61 @@
|
|||||||
{% extends "emails/marketing_email.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Email Template</title>
|
||||||
|
<!-- Materialize CSS -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #37474f;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #333;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<h4>{{ title | safe }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
{% block meta_description %}Custom brand-tailored websites with interactive package pricing. You own the site and data. Public site + client portal with UTM/leads included; add Email/SMS, blog, Stripe, e-commerce, POS sync, event tickets, shipping, social, and more. Three-instance hosting, SEO, accessibility, LLM integration, and Grafana alerts on every build.{% endblock %}
|
{% block meta_description %}Custom brand-tailored websites with interactive package pricing. You own the site and data. Public site + client portal with UTM/leads included; add Email/SMS, blog, Stripe, social, and more. Three-instance hosting, SEO, accessibility, LLM integration, and Grafana alerts on every build.{% endblock %}
|
||||||
{% block og_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
{% block og_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
{% block og_description %}Build a custom site package with live pricing. You own the site and data. Base site + portal included; three-instance hosting, UTM/leads, SEO, accessibility, LLM readiness, and Grafana metrics on every project.{% endblock %}
|
{% block og_description %}Build a custom site package with live pricing. You own the site and data. Base site + portal included; three-instance hosting, UTM/leads, SEO, accessibility, LLM readiness, and Grafana metrics on every project.{% endblock %}
|
||||||
{% block twitter_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
{% block twitter_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
|
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">Growth Add-ons</span>
|
<span class="card-title">Growth Add-ons</span>
|
||||||
<p class="card-text">Email/SMS, direct mail, blog, Stripe, e-commerce, POS sync, event tickets, shipping, social, and AI social drafts.</p>
|
<p class="card-text">Email/SMS, direct mail, blog, Stripe payments, social, and AI social drafts.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<h2 class="section-title">Build Your Package</h2>
|
<h2 class="section-title">Build Your Package</h2>
|
||||||
<p class="pricing-intro">
|
<p class="pricing-intro">
|
||||||
Public site and client portal (with UTM) are always included. Toggle add-ons to see one-time
|
Public site and client portal (with UTM) are always included. Toggle add-ons to see one-time
|
||||||
build and monthly totals. Stripe / SMS / postage / EasyPost usage billed separately.
|
build and monthly totals. Stripe / SMS / postage usage billed separately.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="pricing-estimator" id="pricingEstimator"
|
<div class="pricing-estimator" id="pricingEstimator"
|
||||||
|
|||||||
@@ -246,10 +246,6 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertContains(response, "## Web design & hosting")
|
self.assertContains(response, "## Web design & hosting")
|
||||||
self.assertContains(response, "estimate_web_design_cost")
|
self.assertContains(response, "estimate_web_design_cost")
|
||||||
self.assertContains(response, "email_sms")
|
self.assertContains(response, "email_sms")
|
||||||
self.assertContains(response, "ecommerce")
|
|
||||||
self.assertContains(response, "pos_sync")
|
|
||||||
self.assertContains(response, "event_ticketing")
|
|
||||||
self.assertContains(response, "shipping")
|
|
||||||
self.assertContains(response, "three instances")
|
self.assertContains(response, "three instances")
|
||||||
self.assertContains(response, "Tailored to your brand")
|
self.assertContains(response, "Tailored to your brand")
|
||||||
self.assertContains(response, "You own the site")
|
self.assertContains(response, "You own the site")
|
||||||
@@ -344,8 +340,6 @@ class WebMcpTests(TestCase):
|
|||||||
self.assertContains(response, "data-web-design-pricing=")
|
self.assertContains(response, "data-web-design-pricing=")
|
||||||
self.assertContains(response, "email_sms")
|
self.assertContains(response, "email_sms")
|
||||||
self.assertContains(response, "client_portal")
|
self.assertContains(response, "client_portal")
|
||||||
self.assertContains(response, "ecommerce")
|
|
||||||
self.assertContains(response, "event_ticketing")
|
|
||||||
|
|
||||||
|
|
||||||
class WebDesignPricingTests(TestCase):
|
class WebDesignPricingTests(TestCase):
|
||||||
@@ -369,10 +363,6 @@ class WebDesignPricingTests(TestCase):
|
|||||||
self.assertContains(response, "aimlTrackWhenReady")
|
self.assertContains(response, "aimlTrackWhenReady")
|
||||||
self.assertContains(response, "data-tianji-event-build=")
|
self.assertContains(response, "data-tianji-event-build=")
|
||||||
self.assertContains(response, "data-tianji-event-features=")
|
self.assertContains(response, "data-tianji-event-features=")
|
||||||
self.assertContains(response, "E-commerce storefront")
|
|
||||||
self.assertContains(response, "POS API sync")
|
|
||||||
self.assertContains(response, "Event ticketing")
|
|
||||||
self.assertContains(response, "Shipping automation")
|
|
||||||
|
|
||||||
def test_base_estimate_includes_required_features_only(self):
|
def test_base_estimate_includes_required_features_only(self):
|
||||||
from .web_design_pricing import estimate_web_design_cost
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
@@ -402,74 +392,6 @@ class WebDesignPricingTests(TestCase):
|
|||||||
self.assertEqual(estimate["one_time_build"], 1400)
|
self.assertEqual(estimate["one_time_build"], 1400)
|
||||||
self.assertEqual(estimate["monthly"], 80)
|
self.assertEqual(estimate["monthly"], 80)
|
||||||
|
|
||||||
def test_ecommerce_auto_selects_email_sms_and_payments(self):
|
|
||||||
from .web_design_pricing import estimate_web_design_cost
|
|
||||||
|
|
||||||
estimate = estimate_web_design_cost(["ecommerce"])
|
|
||||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
|
||||||
self.assertEqual(
|
|
||||||
selected_ids,
|
|
||||||
{"public_site", "client_portal", "email_sms", "payments", "ecommerce"},
|
|
||||||
)
|
|
||||||
self.assertEqual(estimate["one_time_build"], 2300)
|
|
||||||
self.assertEqual(estimate["monthly"], 100)
|
|
||||||
|
|
||||||
def test_pos_sync_pulls_ecommerce_chain(self):
|
|
||||||
from .web_design_pricing import estimate_web_design_cost
|
|
||||||
|
|
||||||
estimate = estimate_web_design_cost(["pos_sync"])
|
|
||||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
|
||||||
self.assertEqual(
|
|
||||||
selected_ids,
|
|
||||||
{
|
|
||||||
"public_site",
|
|
||||||
"client_portal",
|
|
||||||
"email_sms",
|
|
||||||
"payments",
|
|
||||||
"ecommerce",
|
|
||||||
"pos_sync",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(estimate["one_time_build"], 2800)
|
|
||||||
self.assertEqual(estimate["monthly"], 120)
|
|
||||||
|
|
||||||
def test_event_ticketing_auto_selects_email_sms_and_payments(self):
|
|
||||||
from .web_design_pricing import estimate_web_design_cost
|
|
||||||
|
|
||||||
estimate = estimate_web_design_cost(["event_ticketing"])
|
|
||||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
|
||||||
self.assertEqual(
|
|
||||||
selected_ids,
|
|
||||||
{
|
|
||||||
"public_site",
|
|
||||||
"client_portal",
|
|
||||||
"email_sms",
|
|
||||||
"payments",
|
|
||||||
"event_ticketing",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(estimate["one_time_build"], 2000)
|
|
||||||
self.assertEqual(estimate["monthly"], 80)
|
|
||||||
|
|
||||||
def test_shipping_pulls_ecommerce_chain(self):
|
|
||||||
from .web_design_pricing import estimate_web_design_cost
|
|
||||||
|
|
||||||
estimate = estimate_web_design_cost(["shipping"])
|
|
||||||
selected_ids = {item["id"] for item in estimate["selected"]}
|
|
||||||
self.assertEqual(
|
|
||||||
selected_ids,
|
|
||||||
{
|
|
||||||
"public_site",
|
|
||||||
"client_portal",
|
|
||||||
"email_sms",
|
|
||||||
"payments",
|
|
||||||
"ecommerce",
|
|
||||||
"shipping",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(estimate["one_time_build"], 2700)
|
|
||||||
self.assertEqual(estimate["monthly"], 115)
|
|
||||||
|
|
||||||
|
|
||||||
class TrafficClassificationTests(TestCase):
|
class TrafficClassificationTests(TestCase):
|
||||||
def test_classifies_common_agents(self):
|
def test_classifies_common_agents(self):
|
||||||
|
|||||||
@@ -14,25 +14,20 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from .email_branding import email_brand_context
|
|
||||||
from .forms import FormWithCaptcha
|
from .forms import FormWithCaptcha
|
||||||
from .middleware import get_session_utm
|
from .middleware import get_session_utm
|
||||||
from .models import Contact, EmailMessage, PageVisit
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
|
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
|
||||||
|
|
||||||
def send_contact_email(email, subject, message):
|
def send_contact_email(email, subject, message):
|
||||||
mail_subject = "New Contact Request for AI ML Operations, LLC"
|
subject = "New Contact Request for AI ML Operations, LLC"
|
||||||
from_email = getattr(
|
from_email = "ryan@aimloperations.com"
|
||||||
settings,
|
to="ryan@aimloperations.com"
|
||||||
"DEFAULT_FROM_EMAIL",
|
d = {"subject": subject, "message": message, "email": email}
|
||||||
'"AI ML Operations, LLC" <info@aimloperations.com>',
|
html_content = get_template(r'emails/contact_email.html').render(d)
|
||||||
)
|
text_content = get_template(r'emails/contact_email.txt').render(d)
|
||||||
to = "ryan@aimloperations.com"
|
|
||||||
d = email_brand_context(subject=subject, message=message, email=email)
|
|
||||||
html_content = get_template("emails/contact_email.html").render(d)
|
|
||||||
text_content = get_template("emails/contact_email.txt").render(d)
|
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(mail_subject, text_content, from_email, [to])
|
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
|
||||||
msg.attach_alternative(html_content, "text/html")
|
msg.attach_alternative(html_content, "text/html")
|
||||||
msg.send(fail_silently=True)
|
msg.send(fail_silently=True)
|
||||||
|
|
||||||
@@ -86,11 +81,13 @@ def terms_of_service(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def preview_email(request, pk):
|
def preview_email(request, pk):
|
||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = email_brand_context(
|
context = {
|
||||||
title=email_instance.subject,
|
"title":email_instance.subject,
|
||||||
content=email_instance.body,
|
"content":email_instance.body
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request, 'public/preview_email.html', context
|
||||||
)
|
)
|
||||||
return render(request, "emails/marketing_email.html", context)
|
|
||||||
|
|
||||||
def contact(request):
|
def contact(request):
|
||||||
errors = ''
|
errors = ''
|
||||||
|
|||||||
@@ -80,46 +80,6 @@ WEB_DESIGN_FEATURES = (
|
|||||||
"requires": ("social",),
|
"requires": ("social",),
|
||||||
"requires_note": "Requires Social consolidation — auto-selected.",
|
"requires_note": "Requires Social consolidation — auto-selected.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "ecommerce",
|
|
||||||
"name": "E-commerce storefront",
|
|
||||||
"description": "Catalog, cart, inventory dashboard, Stripe checkout. Requires Email & SMS and Payments.",
|
|
||||||
"build": 700,
|
|
||||||
"monthly": 30,
|
|
||||||
"required": False,
|
|
||||||
"requires": ("email_sms", "payments"),
|
|
||||||
"requires_note": "Requires Email & SMS and Payments (Stripe) — auto-selected.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pos_sync",
|
|
||||||
"name": "POS API sync",
|
|
||||||
"description": "Keep in-store POS inventory in sync with the online catalog.",
|
|
||||||
"build": 500,
|
|
||||||
"monthly": 20,
|
|
||||||
"required": False,
|
|
||||||
"requires": ("ecommerce",),
|
|
||||||
"requires_note": "Requires E-commerce storefront — auto-selected.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "event_ticketing",
|
|
||||||
"name": "Event ticketing",
|
|
||||||
"description": "Events, capacity limits, digital tickets. Requires Email & SMS and Payments.",
|
|
||||||
"build": 400,
|
|
||||||
"monthly": 10,
|
|
||||||
"required": False,
|
|
||||||
"requires": ("email_sms", "payments"),
|
|
||||||
"requires_note": "Requires Email & SMS and Payments (Stripe) — auto-selected.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "shipping",
|
|
||||||
"name": "Shipping automation",
|
|
||||||
"description": "EasyPost labels + Pirate Ship CSV export. Requires E-commerce storefront.",
|
|
||||||
"build": 400,
|
|
||||||
"monthly": 15,
|
|
||||||
"required": False,
|
|
||||||
"requires": ("ecommerce",),
|
|
||||||
"requires_note": "Requires E-commerce storefront — auto-selected.",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
WEB_DESIGN_INCLUDED = (
|
WEB_DESIGN_INCLUDED = (
|
||||||
@@ -151,7 +111,7 @@ WEB_DESIGN_INCLUDED = (
|
|||||||
|
|
||||||
WEB_DESIGN_PRICING_DISCLAIMER = (
|
WEB_DESIGN_PRICING_DISCLAIMER = (
|
||||||
"Draft catalog pricing. Not a formal quote. "
|
"Draft catalog pricing. Not a formal quote. "
|
||||||
"Third-party usage (Stripe fees, SMS, postage, EasyPost labels) not included."
|
"Third-party usage (Stripe fees, SMS, postage) not included."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Email previews
|
|
||||||
|
|
||||||
Static renders of branded HTML emails (dark neon site UX: `#0a0a0a` / `#1a1a1a` / `#00f3ff` / `#bc13fe`).
|
|
||||||
|
|
||||||
| Template | Screenshot |
|
|
||||||
|----------|------------|
|
|
||||||
| Contact notify | [contact.png](contact.png) |
|
|
||||||
| Marketing | [marketing.png](marketing.png) |
|
|
||||||
| Invoice / pay link | [invoice.png](invoice.png) |
|
|
||||||
|
|
||||||
Shared base: `public/templates/emails/base_email.html`.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Reference in new issue
Block a user