diff --git a/pyproject.toml b/pyproject.toml
index 79a80f4..7202bcc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,6 +78,7 @@ Repository = "https://github.com/JacobCoffee/django-program"
[tool.codespell]
skip = "uv.lock"
+ignore-words-list = "checkin"
[tool.coverage.report]
exclude_lines = [
diff --git a/src/django_program/manage/templates/django_program/manage/terminal_pos.html b/src/django_program/manage/templates/django_program/manage/terminal_pos.html
index 4057902..6f33bea 100644
--- a/src/django_program/manage/templates/django_program/manage/terminal_pos.html
+++ b/src/django_program/manage/templates/django_program/manage/terminal_pos.html
@@ -1311,15 +1311,8 @@
Cart
async function applyVoucher() {
var code = voucherInput.value.trim();
if (!code) return;
-
- // For now, store the voucher code locally. The actual discount would need
- // to be validated server-side when the backend supports it. We show the
- // voucher as "pending" so the operator knows it was entered.
- appliedVoucher = {code: code, discount: 0};
- voucherAppliedText.textContent = "Voucher " + code + " (pending server validation)";
- voucherApplied.classList.add("visible");
- voucherRow.style.display = "none";
- renderCart();
+ voucherInput.value = "";
+ alert("Vouchers are not yet supported for terminal sales. Use the online checkout flow for voucher redemptions.");
}
function removeVoucher() {
@@ -1365,14 +1358,34 @@ Cart
showPaymentStatus("Creating payment...", "processing");
- // Build payload for CreatePaymentIntentView.
- // The view accepts either order_id (to pay an existing order) or amount
- // (as a Decimal string for walk-up sales). reader_id is always required.
+ // If we have cart items but no order yet, checkout first to create a proper order
+ if (!currentOrderId && cart.length > 0) {
+ var checkoutPayload = {action: "checkout", billing_name: "", billing_email: ""};
+ if (currentAttendee && currentAttendee.attendee && currentAttendee.attendee.access_code) {
+ checkoutPayload.attendee_access_code = currentAttendee && currentAttendee.attendee && currentAttendee.attendee.access_code;
+ var nameEl = document.getElementById("attendeeName");
+ var emailEl = document.getElementById("attendeeEmail");
+ if (nameEl) checkoutPayload.billing_name = nameEl.textContent || "";
+ if (emailEl) checkoutPayload.billing_email = emailEl.textContent || "";
+ }
+ var checkoutData = await apiFetch(API.cart, {
+ method: "POST",
+ body: JSON.stringify(checkoutPayload)
+ });
+ if (checkoutData.order_id) {
+ currentOrderId = checkoutData.order_id;
+ }
+ }
+
+ // Build payload for CreatePaymentIntentView
var intentPayload = {reader_id: connectedReader.id};
if (currentOrderId) {
intentPayload.order_id = currentOrderId;
} else {
intentPayload.amount = total.toFixed(2);
+ if (currentAttendee && currentAttendee.attendee && currentAttendee.attendee.access_code) {
+ intentPayload.attendee_access_code = currentAttendee && currentAttendee.attendee && currentAttendee.attendee.access_code;
+ }
}
var intentData = await apiFetch(API.createIntent, {
diff --git a/src/django_program/registration/views_terminal.py b/src/django_program/registration/views_terminal.py
index 37f1736..38f2a5f 100644
--- a/src/django_program/registration/views_terminal.py
+++ b/src/django_program/registration/views_terminal.py
@@ -42,11 +42,17 @@
def _parse_json_body(request: HttpRequest) -> dict[str, object] | None:
- """Parse JSON from request body, returning None on failure."""
+ """Parse JSON from request body, returning None on failure.
+
+ Returns None if the body is not valid JSON or is not an object (dict).
+ """
try:
- return json.loads(request.body) # type: ignore[no-any-return]
+ payload = json.loads(request.body)
except json.JSONDecodeError, ValueError:
return None
+ if not isinstance(payload, dict):
+ return None
+ return payload
def _generate_order_reference() -> str:
@@ -128,9 +134,9 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR
return result
order, amount = result
- return self._create_and_dispatch(request, reader_id, order, amount)
+ return self._create_and_dispatch(request, reader_id, order, amount, body)
- def _resolve_order_and_amount(self, body: dict[str, object]) -> tuple[Order | None, Decimal] | JsonResponse:
+ def _resolve_order_and_amount(self, body: dict[str, object]) -> tuple[Order | None, Decimal] | JsonResponse: # noqa: PLR0911
"""Resolve the order and amount from the request body."""
order_id = body.get("order_id")
raw_amount = body.get("amount")
@@ -143,7 +149,18 @@ def _resolve_order_and_amount(self, body: dict[str, object]) -> tuple[Order | No
order = Order.objects.get(pk=int(order_id), conference=self.conference) # type: ignore[arg-type]
except Order.DoesNotExist, TypeError, ValueError:
return JsonResponse({"error": "Order not found"}, status=404)
- return order, order.total
+ if order.status != Order.Status.PENDING:
+ return JsonResponse(
+ {"error": f"Order is {order.get_status_display()}, not pending"},
+ status=409,
+ )
+ paid = order.payments.filter(status=Payment.Status.SUCCEEDED).aggregate(total=models.Sum("amount"))[
+ "total"
+ ] or Decimal("0.00")
+ remaining = order.total - paid
+ if remaining <= 0:
+ return JsonResponse({"error": "Order is already fully paid"}, status=409)
+ return order, remaining
try:
amount = Decimal(str(raw_amount))
@@ -159,6 +176,7 @@ def _create_and_dispatch(
reader_id: str,
order: Order | None,
amount: Decimal,
+ body: dict[str, object] | None = None,
) -> JsonResponse:
"""Create PaymentIntent, dispatch to reader, and record in DB."""
try:
@@ -188,23 +206,33 @@ def _create_and_dispatch(
metadata=metadata,
description=description,
)
- reader_result = client.process_terminal_payment(
- reader_id=reader_id,
- payment_intent_id=intent.id,
- )
+ except ValueError:
+ return JsonResponse({"error": "Invalid payment amount"}, status=400)
except stripe.StripeError as exc:
return _stripe_error_response(exc)
with transaction.atomic():
if order is None:
+ order_user = request.user
+ attendee_code = str((body or {}).get("attendee_access_code", "")).strip()
+ if attendee_code:
+ try:
+ attendee = CheckInService.lookup_attendee(
+ conference=self.conference,
+ access_code=attendee_code,
+ )
+ order_user = attendee.user
+ except Attendee.DoesNotExist:
+ return JsonResponse({"error": "Attendee not found"}, status=404)
+
order = Order.objects.create(
conference=self.conference,
- user=request.user,
+ user=order_user,
status=Order.Status.PENDING,
subtotal=amount,
total=amount,
- billing_name=str(getattr(request.user, "get_full_name", lambda: "")()),
- billing_email=str(getattr(request.user, "email", "")),
+ billing_name=str(getattr(order_user, "get_full_name", lambda: "")()),
+ billing_email=str(getattr(order_user, "email", "")),
reference=_generate_order_reference(),
)
@@ -225,14 +253,6 @@ def _create_and_dispatch(
capture_status=TerminalPayment.CaptureStatus.AUTHORIZED,
)
- reader_action = {}
- if hasattr(reader_result, "action") and reader_result.action:
- action = reader_result.action
- reader_action = {
- "status": getattr(action, "status", None),
- "type": getattr(action, "type", None),
- }
-
return JsonResponse(
{
"payment_intent_id": intent.id,
@@ -240,7 +260,6 @@ def _create_and_dispatch(
"order_id": order.pk,
"order_reference": str(order.reference),
"client_secret": intent.client_secret,
- "reader_action": reader_action,
}
)
@@ -333,7 +352,6 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR
return JsonResponse({"error": "Invalid JSON body"}, status=400)
payment_intent_id = str(body.get("payment_intent_id", "")).strip()
- reader_id = str(body.get("reader_id", "")).strip()
if not payment_intent_id:
return JsonResponse({"error": "payment_intent_id is required"}, status=400)
@@ -351,8 +369,9 @@ def post(self, request: HttpRequest, **kwargs: str) -> JsonResponse: # noqa: AR
return JsonResponse({"error": str(exc)}, status=400)
try:
- if reader_id:
- client.cancel_reader_action(reader_id)
+ stored_reader_id = str(terminal_payment.reader_id)
+ if stored_reader_id:
+ client.cancel_reader_action(stored_reader_id)
client.client.v1.payment_intents.cancel(payment_intent_id)
except stripe.StripeError as exc:
return _stripe_error_response(exc)
@@ -619,9 +638,7 @@ def _handle_update(self, request: HttpRequest, body: dict[str, object]) -> JsonR
}
)
- def _add_cart_item(
- self, cart: Cart, item_data: object
- ) -> dict[str, object] | JsonResponse | None:
+ def _add_cart_item(self, cart: Cart, item_data: object) -> dict[str, object] | JsonResponse | None:
"""Process a single cart item from the request payload."""
if not isinstance(item_data, dict):
return None
@@ -629,7 +646,7 @@ def _add_cart_item(
addon_id = item_data.get("addon_id")
try:
quantity = int(item_data.get("quantity", 1)) # type: ignore[arg-type]
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return JsonResponse({"error": "Invalid quantity value"}, status=400)
if ticket_type_id:
@@ -638,34 +655,38 @@ def _add_cart_item(
return self._add_addon_item(cart, addon_id, quantity)
return None
- def _add_ticket_item(
- self, cart: Cart, ticket_type_id: object, quantity: int
- ) -> dict[str, object] | None:
+ def _add_ticket_item(self, cart: Cart, ticket_type_id: object, quantity: int) -> dict[str, object] | None:
"""Create a ticket CartItem and return its data."""
try:
tt = TicketType.objects.get(pk=int(ticket_type_id), conference=self.conference) # type: ignore[arg-type]
- except (TicketType.DoesNotExist, TypeError, ValueError):
+ except TicketType.DoesNotExist, TypeError, ValueError:
return None
ci = CartItem.objects.create(cart=cart, ticket_type=tt, quantity=quantity)
line_total = tt.price * quantity
return {
- "id": ci.pk, "ticket_type_id": tt.pk, "name": str(tt.name),
- "quantity": quantity, "unit_price": str(tt.price), "line_total": str(line_total),
+ "id": ci.pk,
+ "ticket_type_id": tt.pk,
+ "name": str(tt.name),
+ "quantity": quantity,
+ "unit_price": str(tt.price),
+ "line_total": str(line_total),
}
- def _add_addon_item(
- self, cart: Cart, addon_id: object, quantity: int
- ) -> dict[str, object] | None:
+ def _add_addon_item(self, cart: Cart, addon_id: object, quantity: int) -> dict[str, object] | None:
"""Create an addon CartItem and return its data."""
try:
addon = AddOn.objects.get(pk=int(addon_id), conference=self.conference) # type: ignore[arg-type]
- except (AddOn.DoesNotExist, TypeError, ValueError):
+ except AddOn.DoesNotExist, TypeError, ValueError:
return None
ci = CartItem.objects.create(cart=cart, addon=addon, quantity=quantity)
line_total = addon.price * quantity
return {
- "id": ci.pk, "addon_id": addon.pk, "name": str(addon.name),
- "quantity": quantity, "unit_price": str(addon.price), "line_total": str(line_total),
+ "id": ci.pk,
+ "addon_id": addon.pk,
+ "name": str(addon.name),
+ "quantity": quantity,
+ "unit_price": str(addon.price),
+ "line_total": str(line_total),
}
def _handle_checkout(self, request: HttpRequest, body: dict[str, object]) -> JsonResponse:
diff --git a/src/django_program/registration/webhooks.py b/src/django_program/registration/webhooks.py
index ad61550..3ef1191 100644
--- a/src/django_program/registration/webhooks.py
+++ b/src/django_program/registration/webhooks.py
@@ -235,6 +235,16 @@ def process_webhook(self) -> None:
payment.stripe_charge_id = str(latest_charge)
update.append("stripe_charge_id")
payment.save(update_fields=update)
+ elif Payment.objects.filter(
+ order=order,
+ stripe_payment_intent_id=intent_id,
+ status=Payment.Status.SUCCEEDED,
+ ).exists():
+ logger.info(
+ "payment_intent.succeeded %s already captured (terminal), skipping duplicate",
+ intent_id,
+ )
+ return
else:
payment_kwargs: dict[str, object] = {
"order": order,