Send welcome email on member signup
Python · Django · advanced · modification
Adds the membership signup endpoint: validate signup data, create the user and membership atomically, place an authorization hold on the card, and return the membership details. A welcome email is sent via a `post_save` signal on `User`. The transaction rolls back cleanly when the card is declined (the hold expires automatically), and the welcome email lands in the happy path.
The membership signup endpoint is the front door of the paid product. `charge_card` places an authorization hold on the card; a declined card must leave no trace — no ghost accounts, no phantom memberships, no stray welcome emails. `membership/models.py` (`Membership` with `user` and `plan`, migrated), `membership/serializers.py` (`SignupSerializer`, which validates the fields of requirement 1, rejects an email already in use and caps `email` at 150 characters) and `membership/services.py` (`charge_card`, `PaymentDeclinedError`) exist from the previous PR and are unchanged. The app is in `INSTALLED_APPS`, this endpoint is the only place a `User` is created, and the configured mail backend queues messages and never raises at send time.
Requirements
- The signup endpoint accepts `email`, `password`, `first_name`, `last_name`, `plan` (one of `'basic'` or `'pro'`), and `payment_token`.
- The signup must be atomic: if any step BEFORE the database commit fails — including a declined card — no user or membership row may persist and no welcome email may be sent. Welcome-email delivery failures after commit are logged and retried by the mail backend and must not roll back the signup.
- A welcome email must be sent to the new user's email address upon successful signup.
- The `charge_card(payment_token, amount)` function places an authorization HOLD on the card (not a capture). On success it returns a hold ID string. On decline it raises `PaymentDeclinedError`. The signup must return HTTP 402 when the card is declined. The hold is captured by a separate post-commit capture job; an uncaptured hold expires automatically, so the in-transaction call is reversible if the transaction rolls back.
- The signup must return HTTP 201 with the membership `id` and `status: 'active'` on success.
- Plan prices are fixed: `basic` is 9.00, `pro` is 29.00 (major units), passed to `charge_card` as a Decimal.
Files touched
- membership/views.py
- membership/signals.py
- membership/apps.py
- membership/urls.py
- project/urls.py
--- membership/views.py
+"""Membership signup API."""
+
+from decimal import Decimal
+
+from django.contrib.auth.models import User
+from django.db import transaction
+from rest_framework import status
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
+
+from .serializers import SignupSerializer
+from .models import Membership
+from .services import charge_card, PaymentDeclinedError
+
+PLAN_PRICES = {'basic': Decimal('9.00'), 'pro': Decimal('29.00')}
+
+
+@api_view(['POST'])
+def signup(request):
+ """Create a new member, charge the card, and activate the membership.
+
+ All three steps are wrapped in a single database transaction so a
+ declined card leaves no trace of the signup.
+ """
+ serializer = SignupSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ data = serializer.validated_data
+
+ try: