diff --git a/app/core/settings.py b/app/core/settings.py
index 655ecc1..5851517 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -456,6 +456,16 @@
refund_authorizer_secret_key=env("REFUND_AUTHORIZER_SECRET_KEY", default="local_refund_authorizer_secret_key"),
)
+# Notification Settings
+NOTIFICATION = types.SimpleNamespace(
+ # NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다.
+ payment_completed_alimtalk_template_code=env.str(
+ "PAYMENT_COMPLETED_ALIMTALK_TEMPLATE_CODE", default="pycon_2026_paid"
+ ),
+ # DB에 등록된 결제 완료 이메일 템플릿 코드로 교체 완료
+ payment_completed_email_template_code=env.str("PAYMENT_COMPLETED_EMAIL_TEMPLATE_CODE", default="payment_completed"),
+)
+
# External API Key Settings (등록 데스크 등)
EXT_API_KEYS = {
"registration_desk": env("API_KEY_REGISTRATION_DESK", default=None),
diff --git a/app/notification/migrations/0003_seed_payment_completed_email_template.py b/app/notification/migrations/0003_seed_payment_completed_email_template.py
new file mode 100644
index 0000000..2e9aebc
--- /dev/null
+++ b/app/notification/migrations/0003_seed_payment_completed_email_template.py
@@ -0,0 +1,51 @@
+import json
+from pathlib import Path
+
+from django.conf import settings
+from django.db import migrations
+
+# DB에 등록할 결제 완료 이메일 템플릿 코드로 교체 완료 (payment_completed.html)
+# 만일 추후 변경 시 settings.NOTIFICATION.payment_completed_email_template_code 및 환경변수도 함께 수정 필요
+_TEMPLATE_CODE = "payment_completed"
+
+_EMAIL_SUBJECT = "파이콘 한국 티켓 결제가 완료되었습니다!"
+
+_HTML_TEMPLATE_PATH = Path(__file__).parent.parent / "templates" / "payment_completed.html"
+
+
+def seed_payment_completed_email_template(apps, schema_editor):
+ EmailNotificationTemplate = apps.get_model("notification", "EmailNotificationTemplate")
+ EmailNotificationTemplate.objects.get_or_create(
+ code=_TEMPLATE_CODE,
+ defaults={
+ "title": "결제 완료 이메일",
+ # migration 실행 시점의 환경변수(EMAIL_HOST_USER)를 발신 주소로 사용.
+ # 값이 비어있으면 이메일 발송 시 오류가 발생하므로 배포 전 EMAIL_HOST_USER 설정 필요.
+ "sent_from": settings.EMAIL_HOST_USER,
+ "data": json.dumps(
+ {
+ "title": _EMAIL_SUBJECT,
+ "body": _HTML_TEMPLATE_PATH.read_text(encoding="utf-8"),
+ },
+ ensure_ascii=False,
+ ),
+ },
+ )
+
+
+def reverse_seed_payment_completed_email_template(apps, schema_editor):
+ EmailNotificationTemplate = apps.get_model("notification", "EmailNotificationTemplate")
+ EmailNotificationTemplate.objects.filter(code=_TEMPLATE_CODE).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notification", "0002_emailnotificationhistorysentto_failure_reason_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ seed_payment_completed_email_template, # 실행할 로직
+ reverse_seed_payment_completed_email_template, # 실행할 로직에서 failure 발생 시 되돌릴 역방향 로직
+ ),
+ ]
diff --git a/app/notification/templates/payment_completed.html b/app/notification/templates/payment_completed.html
new file mode 100644
index 0000000..3d01480
--- /dev/null
+++ b/app/notification/templates/payment_completed.html
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+ 파이콘 한국 스토어 결제 완료 안내 | PyCon Korea Store — Order Confirmation
+
+
+
+
+
+
+
+
+
+
+
+ 파이콘 한국 스토어 | PyCon Korea Store
+
+
+ 파이콘 한국과 함께해 주셔서 감사합니다.
+
+
+ Thank you for your order.
+
+ |
+
+
+
+
+
+
+
+
+ 주문 정보 | ORDER INFO
+
+ |
+
+
+ |
+ 주문 명 (Order name)
+ |
+
+ {{ order_name }}
+ |
+
+
+ |
+ 결제 일시 (Paid at)
+ |
+
+ {{ first_paid_at }}
+ |
+
+
+ |
+ 결제 금액 (Paid amount)
+ |
+
+ {{ first_paid_price }}
+ |
+
+
+
+
+ 주문자 정보 | CUSTOMER INFO
+
+ |
+
+
+ |
+ 이름 (Name)
+ |
+
+ {{ customer_name }}
+ |
+
+
+ |
+ 이메일 (Email)
+ |
+
+ {{ customer_email }}
+ |
+
+
+ |
+ 연락처 (Phone)
+ |
+
+ {{ customer_phone }}
+ |
+
+
+ |
+
+
+
+
+
+ 주문 상세 확인, 영수증, 환불 관련 안내 | ORDER & REFUND
+ INFO
+
+
+ 주문 상세 내역과 영수증, 환불 관련 안내는
+ 파이콘 한국 홈페이지에서 확인해주세요.
+
+
+ For more details, please visit the
+ PyCon Korea homepage.
+
+
+ |
+
+
+
+ |
+
+ 본 메일은 발신 전용입니다. 문의는
+ pycon@pycon.kr
+ 으로 부탁드립니다.
+
+
+ This is a send-only email. For inquiries, please contact
+ pycon@pycon.kr.
+
+
+ © 파이썬 한국 사용자 모임 (Python Korea)
+
+ |
+
+
+ |
+
+
+
+
diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py
index f12394c..d8a3d51 100644
--- a/app/shop/payment_history/serializers.py
+++ b/app/shop/payment_history/serializers.py
@@ -6,6 +6,7 @@
from rest_framework import serializers
from shop.order.models import Order, OrderProductRelation, SingleProductCart
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition
+from shop.payment_history.tasks import send_payment_completed_notifications
class PortOneV1PaymentStatus(models.TextChoices):
@@ -143,13 +144,19 @@ def create(self, validated_data: dict) -> PaymentHistory:
product_rel.status = OrderProductRelation.OrderProductStatus.paid
product_rel.save()
- return PaymentHistory.objects.create(
+ payment_history = PaymentHistory.objects.create(
order=order,
imp_id=validated_data["imp_uid"],
status=next_status,
price=payment_info["amount"],
)
+ # 결제 완료 알림(알림톡 + 이메일)을 트랜잭션 커밋 후 비동기로 발송.
+
+ transaction.on_commit(lambda: send_payment_completed_notifications.delay(str(order.id)))
+
+ return payment_history
+
@staticmethod
def _lock_or_promote_order(obj_id: str) -> Order:
"""Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격.
diff --git a/app/shop/payment_history/tasks.py b/app/shop/payment_history/tasks.py
new file mode 100644
index 0000000..53f7a39
--- /dev/null
+++ b/app/shop/payment_history/tasks.py
@@ -0,0 +1,104 @@
+import logging
+
+from celery import shared_task
+from django.conf import settings
+from notification.models import (
+ EmailNotificationHistory,
+ EmailNotificationTemplate,
+ NHNCloudKakaoAlimTalkNotificationHistory,
+ NHNCloudKakaoAlimTalkNotificationTemplate,
+)
+from shop.order.models import Order
+
+slack_logger = logging.getLogger("slack_logger")
+logger = logging.getLogger(__name__)
+
+
+@shared_task(ignore_result=True)
+def send_payment_completed_notifications(order_id: str) -> None:
+ """결제 완료 시 알림톡 + 이메일 자동 발송 (비동기 Celery task).
+
+ - customer_info가 없는 주문은 발송을 건너뛰고 slack_logger로 누락 사실을 기록합니다.
+ - 알림톡과 이메일은 독립적으로 시도되며, 한 채널 실패가 다른 채널 발송을 막지 않습니다.
+ - 실제 외부 API 호출은 send_notification_to_recipient task에서 채널별로 처리됩니다.
+ """
+
+ if (
+ order := Order.objects.filter_active()
+ .select_related("customer_info")
+ .prefetch_related("products", Order.prefetchs["_payment_histories_by_latest"])
+ .filter(id=order_id)
+ .first()
+ ) is not None:
+ if (customer_info := getattr(order, "customer_info", None)) is not None:
+ context = {
+ "order_name": order.name,
+ "first_paid_at": order.first_paid_at,
+ "first_paid_price": order.first_paid_price,
+ "customer_name": customer_info.name,
+ "customer_phone": customer_info.phone,
+ "customer_email": customer_info.email,
+ }
+
+ _send_alimtalk(order_id, customer_info.phone, context)
+ _send_email(order_id, customer_info.email, context)
+
+ else:
+ slack_logger.error(
+ "결제 완료 알림 발송 누락: customer_info가 없는 주문입니다. order_id=%s",
+ order_id,
+ )
+ return
+
+ else:
+ slack_logger.error("결제 완료 알림 발송 실패: 주문을 찾을 수 없습니다. order_id=%s", order_id)
+ return
+
+
+def _send_alimtalk(order_id: str, recipient_phone: str, context: dict) -> None:
+ try:
+ template_code = settings.NOTIFICATION.payment_completed_alimtalk_template_code
+ template = NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active().filter(code=template_code).first()
+ if template is None:
+ slack_logger.error(
+ "결제 완료 알림톡 발송 실패: 템플릿을 찾을 수 없습니다. template_code=%s order_id=%s",
+ template_code,
+ order_id,
+ )
+ return
+
+ history = NHNCloudKakaoAlimTalkNotificationHistory.objects.create_for_recipients(
+ template=template,
+ recipients=[{"recipient": recipient_phone, "context": context}],
+ )
+ history.send()
+ except Exception:
+ slack_logger.exception(
+ "결제 완료 알림톡 발송 중 예외 발생. order_id=%s",
+ order_id,
+ )
+
+
+def _send_email(order_id: str, recipient_email: str, context: dict) -> None:
+ try:
+ template_code = settings.NOTIFICATION.payment_completed_email_template_code
+ template = EmailNotificationTemplate.objects.filter_active().filter(code=template_code).first()
+ if template is None:
+ # 이메일 템플릿이 아직 DB에 없는 경우 error 로그 남기기
+ logger.error(
+ "결제 완료 이메일 발송 건너뜀: 템플릿을 찾을 수 없습니다. template_code=%s order_id=%s",
+ template_code,
+ order_id,
+ )
+ return
+
+ history = EmailNotificationHistory.objects.create_for_recipients(
+ template=template,
+ recipients=[{"recipient": recipient_email, "context": context}],
+ )
+ history.send()
+ except Exception:
+ slack_logger.exception(
+ "결제 완료 이메일 발송 중 예외 발생. order_id=%s",
+ order_id,
+ )
diff --git a/app/shop/payment_history/test/__init__.py b/app/shop/payment_history/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/shop/payment_history/test/tasks_test.py b/app/shop/payment_history/test/tasks_test.py
new file mode 100644
index 0000000..ffc913e
--- /dev/null
+++ b/app/shop/payment_history/test/tasks_test.py
@@ -0,0 +1,200 @@
+import logging
+import types
+from unittest.mock import patch
+
+import pytest
+from core.models import BaseAbstractModelQuerySet
+from notification.models import (
+ EmailNotificationHistory,
+ EmailNotificationTemplate,
+ NHNCloudKakaoAlimTalkNotificationHistory,
+ NHNCloudKakaoAlimTalkNotificationTemplate,
+)
+from notification.models.base import NotificationStatus
+from shop.order.models import CustomerInfo, Order
+from shop.payment_history.tasks import send_payment_completed_notifications
+from user.models import UserExt
+
+_EMAIL_TEMPLATE_CODE = "payment_completed_email"
+_ALIMTALK_TEMPLATE_CODE = "payment_completed_alimtalk"
+
+_EMAIL_LOGGER = "shop.payment_history.tasks"
+_SLACK_LOGGER = "slack_logger"
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def user(db):
+ return UserExt.objects.create_user(username="buyer", email="buyer@example.com", password="x") # nosec B106
+
+
+@pytest.fixture
+def order(user):
+ return Order.objects.create(user=user, name="파이콘 한국 2026 티켓")
+
+
+@pytest.fixture
+def order_with_customer(order):
+ CustomerInfo.objects.create(
+ order=order,
+ name="홍길동",
+ phone="01012345678",
+ email="customer@example.com",
+ )
+ return order
+
+
+@pytest.fixture
+def email_template(db):
+ return EmailNotificationTemplate.objects.create(
+ code=_EMAIL_TEMPLATE_CODE,
+ title="결제 완료 이메일",
+ sent_from="noreply@pycon.kr",
+ data='{"title":"결제가 완료되었습니다","body":"안녕하세요 {{ name }}님, {{ phone }}, {{ email }}"}',
+ )
+
+
+@pytest.fixture
+def alimtalk_template(db):
+ # 알림톡 템플릿은 NHN Cloud 동기화 전용이라 .create()가 차단됨 — bulk_create로 우회.
+ template = NHNCloudKakaoAlimTalkNotificationTemplate(
+ code=_ALIMTALK_TEMPLATE_CODE,
+ title="결제 완료 알림톡",
+ sent_from="sender_key_abc",
+ data='{"templateContent":"안녕하세요 #{name}님","buttons":[]}',
+ )
+ [created] = BaseAbstractModelQuerySet(model=NHNCloudKakaoAlimTalkNotificationTemplate).bulk_create([template])
+ return created
+
+
+@pytest.fixture
+def override_email_setting(settings):
+ settings.NOTIFICATION = types.SimpleNamespace(
+ payment_completed_alimtalk_template_code=_ALIMTALK_TEMPLATE_CODE,
+ payment_completed_email_template_code=_EMAIL_TEMPLATE_CODE,
+ )
+
+
+@pytest.fixture
+def override_email_setting_with_error(settings):
+ settings.NOTIFICATION = types.SimpleNamespace(
+ payment_completed_alimtalk_template_code="", payment_completed_email_template_code="nonexistent"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Happy path — 이메일
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_creates_email_history_when_template_exists(order_with_customer, email_template, override_email_setting):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ history = EmailNotificationHistory.objects.filter_active().get()
+ sent_to = history.sent_to_list.get()
+ assert sent_to.recipient == "customer@example.com"
+ assert sent_to.context == {"name": "홍길동", "phone": "01012345678", "email": "customer@example.com"}
+ assert sent_to.status == NotificationStatus.CREATED
+
+
+# ---------------------------------------------------------------------------
+# Happy path — 알림톡
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_creates_alimtalk_history_when_template_exists(order_with_customer, alimtalk_template, override_email_setting):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ history = NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().get()
+ sent_to = history.sent_to_list.get()
+ assert sent_to.recipient == "01012345678"
+ assert sent_to.status == NotificationStatus.CREATED
+
+
+# ---------------------------------------------------------------------------
+# 주문 / customer_info 누락
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_logs_error_and_creates_no_history_when_order_not_found(caplog):
+ with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER):
+ send_payment_completed_notifications("00000000-0000-0000-0000-000000000000")
+
+ assert any("주문을 찾을 수 없습니다" in r.getMessage() for r in caplog.records)
+ assert EmailNotificationHistory.objects.count() == 0
+ assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_logs_error_and_creates_no_history_when_customer_info_missing(order, caplog):
+ with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER):
+ send_payment_completed_notifications(str(order.id))
+
+ assert any("customer_info가 없는" in r.getMessage() for r in caplog.records)
+ assert EmailNotificationHistory.objects.count() == 0
+ assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0
+
+
+# ---------------------------------------------------------------------------
+# 템플릿 누락
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_logs_warning_and_creates_no_history_when_email_template_not_found(
+ order_with_customer, caplog, override_email_setting_with_error
+):
+ with caplog.at_level(logging.WARNING, logger=_EMAIL_LOGGER):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ assert any("이메일 발송 건너뜀" in r.getMessage() for r in caplog.records)
+ assert EmailNotificationHistory.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_logs_error_and_creates_no_history_when_alimtalk_template_not_found(
+ order_with_customer, caplog, override_email_setting_with_error
+):
+ with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ assert any("알림톡 발송 실패" in r.getMessage() for r in caplog.records)
+ assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0
+
+
+# ---------------------------------------------------------------------------
+# 채널 독립성 — 한 채널 실패가 다른 채널을 막지 않아야 함
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_alimtalk_failure_does_not_prevent_email(
+ order_with_customer, email_template, alimtalk_template, override_email_setting
+):
+ with patch(
+ "notification.models.NHNCloudKakaoAlimTalkNotificationHistory.objects.create_for_recipients",
+ side_effect=Exception("alimtalk boom"),
+ ):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ assert EmailNotificationHistory.objects.filter_active().count() == 1
+
+
+@pytest.mark.django_db
+def test_email_failure_does_not_prevent_alimtalk(
+ order_with_customer, email_template, alimtalk_template, override_email_setting
+):
+ with patch(
+ "notification.models.EmailNotificationHistory.objects.create_for_recipients",
+ side_effect=Exception("email boom"),
+ ):
+ send_payment_completed_notifications(str(order_with_customer.id))
+
+ assert NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().count() == 1