Web Commerce Domain - Services Documentation

Sources:

  • api/nextango/apps/web_commerce/services/cart_service.py
  • api/nextango/apps/web_commerce/services/checkout_service.py
  • api/nextango/apps/web_commerce/services/reservation_service.py
  • api/nextango/apps/web_commerce/services/session_service.py
  • api/nextango/apps/web_commerce/services/lifecycle_service.py
  • api/nextango/apps/web_commerce/services/email_service.py

Last Updated: 2026-07-08

Overview

The Web Commerce services layer orchestrates web checkout flows. Following the pos_gateway pattern, services coordinate existing domain services (TransactionService, PaymentService, InventoryAdjustmentService, FulfillmentService) rather than implementing business logic themselves.

Service classes: CartService, CheckoutService, ReservationService, SessionService, LifecycleService, OrderEmailService.


CartService

Purpose: Cart validation and composition against the transactions.Cart model's line_items JSONField.

Source: services/cart_service.py

add_to_cart(cart_id, sku, quantity, store_id)

Adds an item to the cart. Looks up the ProductVariant by SKU, checks InventoryLevel.stock_on_hand - InventoryLevel.stock_reserved for the given store, then either merges the quantity into an existing line item or appends a new one (using variant.effective_price so sale pricing is respected). Returns {'success': False, 'error': ...} if the cart, SKU, or store inventory record is missing, or if requested quantity exceeds availability.

update_cart_item(cart_id, variant_id, quantity, store_id)

Updates a line item's quantity. quantity == 0 removes the item. Otherwise re-checks stock_on_hand - stock_reserved availability and recalculates total_price.

validate_cart(cart_id, store_id)

Re-validates every line item against current inventory and pricing. Returns critical severity errors for a product no longer available, unavailable at the store, or with insufficient inventory; warning severity for a price change since the item was added (compared against variant.effective_price).

validate_cart_availability(cart_items, store)

Focused availability check that runs inside the checkout-completion transaction to prevent overselling. Takes raw cart items and a Store instance; uses InventoryLevel.objects.select_for_update() to lock each row while computing stock_on_hand - stock_reserved. Returns {'all_available': bool, 'items': [...]} with a reason (product_not_found, not_available_at_store, insufficient_stock) for each unavailable item.

remove_from_cart(cart_id, sku)

Removes a line item by SKU.

clear_cart(cart_id)

Sets line_items to an empty list.

get_cart(cart_id)

Returns the raw cart dict (cart_id, line_items, item_count, status).


CheckoutService

Purpose: Orchestrates the checkout flow by composing TransactionService, PaymentService, InventoryAdjustmentService, and FulfillmentService. Never implements business logic directly.

Source: services/checkout_service.py

Ownership gate: _owned_checkout(checkout_session_id, web_session)

Every method below that operates on an existing checkout session loads it through this helper. It raises CheckoutSession.DoesNotExist when web_session is None, when checkout_session_id is malformed, or when the session exists but is not owned by web_session. Callers convert that exception into one generic not-found response, so ownership and existence are never disclosed to a non-owning caller.

initiate_checkout(cart_id, web_session, store_id, fulfillment_method, pickup_scheduled_time=None)

Starts checkout. web_session must be the resolved, authoritative session (never a body-supplied id); the call fails closed with {'success': False, 'error': 'Not authorized for this cart'} unless the cart is ACTIVE and its web_session FK matches. Validates the cart is non-empty and passes CartService.validate_cart(), then creates a CheckoutSession in checkout_initiated status with a 30-minute expiry.

Inventory is not reserved at this step. Reservation happens synchronously inside complete_checkout(), when transaction line items are created. ReservationService.reserve_for_checkout() exists as scaffolding for a possible future initiate-time reservation flow but is not called from this path.

Returns {'success': True, 'checkout_session_id': ..., 'expires_at': ...}.

complete_checkout(checkout_session_id, payment_method_id, payment_data, checkout_form_data=None, web_session=None)

Completes checkout inside a single transaction.atomic() block. Sequence:

  1. Loads the checkout session via _owned_checkout(); returns "Checkout session not found" if unowned or missing.
  2. Re-checks session expiry and completion status.
  3. Re-validates inventory availability with CartService.validate_cart_availability() to prevent overselling between checkout initiation and completion; returns {'success': False, 'error': 'insufficient_inventory', ...} with per-item detail if anything has gone unavailable.
  4. Creates the SaleTransaction via TransactionService.create_transaction(employee=None, ...), sets fulfillmentMethod, sourceCheckoutId, payment_timing, and a guest_access_token for non-customer (guest) checkouts.
  5. Adds each cart line item via TransactionService.add_product_to_transaction_without_recalc(), then reserves inventory per line item via InventoryAdjustmentService.process_movement(); the reservation takes effect immediately within the checkout.
  6. Applies a promo code if provided in checkout_form_data['promo_code']: adds it to appliedPromoCodes, then calls PromotionService.track_campaign_impression() for each active campaign linked to the code (non-fatal, logged as a warning on failure).
  7. Calls sale_transaction.recalculate_totals(), which internally calls PromotionService.evaluate_transaction(). If no explicit promo code was provided, this triggers automatic promotion evaluation: active campaigns in date range are filtered by store and validated against product/collection scope, and the highest-value matching discount is applied without customer action.
  8. Snapshots the auto-applied promotions (_capture_applied_promotions()) and distributes their per-item discount to each SaleLineItem.lineDiscount (_distribute_line_item_discounts()).
  9. Processes payment when payment_timing == 'immediate': validates the payment method server-side (PaymentMethodService.validate_method_for_transaction(), the authoritative check; the storefront's method list from OnlinePaymentMethodsView is UX only), charges via PaymentOrchestrationService.charge_card_not_present(), then applies the result via PaymentService.apply_payment_to_transaction(). A non-Stripe processor reaching this path raises ProcessorNotImplemented (HTTP 501) rather than leaking a raw exception. A requires_action result (3DS) returns early inside the atomic block: the transaction is saved with PAYMENT_REQUIRES_ACTION status, resolved fulfillment data is snapshotted onto CheckoutSession.fulfillment_data, and the caller returns requires_action: true with the Stripe client_secret. Inventory stays reserved; fulfillment is not yet created.
  10. Commits reserved inventory via InventoryAdjustmentService.commit_reserved_inventory() when payment was collected; otherwise inventory stays reserved (not committed) until pickup, for payment_timing == 'at_pickup'.
  11. Creates Fulfillment/Shipment records: FulfillmentService.create_pickup_fulfillment() for pickup orders with pickup_customer_info, or FulfillmentService.create_from_checkout() plus create_initial_shipment() for shipping orders. Optionally saves a new address to the customer's profile when save_to_profile is set.
  12. Creates a PickupSchedule for pickup orders with a pickup_scheduled_time (failure here does not fail the checkout).
  13. Sends an order confirmation email asynchronously after the checkout commits, gated by the ENABLE_ORDER_EMAILS environment variable.
  14. Marks the checkout session completed, links the transaction, and defers cart clearing and reservation-context commit to run asynchronously after the checkout commits, past the customer-perceived completion of the request.

A payment failure raises an internal _CheckoutPaymentFailedError inside the atomic block, which rolls back the transaction, line items, and inventory reservations before the outer handler persists payment_pending status on the checkout session and returns {'success': False, 'error': 'Payment failed', 'details': ...}.

Returns on success: {'success': True, 'transaction_id': ..., 'checkout_session_id': ..., 'total_amount': ..., 'receipt_token': ...}. receipt_token is None when the fulfillment has no customer email or receipt-token generation is unavailable in the environment.

cancel_checkout(checkout_session_id, web_session=None)

Loads the checkout session via _owned_checkout(). Rejects cancelling a completed session. Releases reservations via ReservationService.release_reservations() and sets status to abandoned.

get_checkout_status(checkout_session_id, web_session=None)

Loads the checkout session via _owned_checkout() and returns its status, including pickup_display_time converted to the store's local timezone (store.timezone; falls back to UTC formatting on a conversion error) and store_pay_at_store_enabled.


ReservationService

Purpose: Manages inventory reservations for web checkout using InventoryLevel.stock_reserved directly, with CheckoutReservationContext as the audit trail. Distinct from the POS InventoryReservation, which ties to TerminalSession.

Source: services/reservation_service.py

Raises InsufficientInventoryError when requested quantity exceeds available inventory.

reserve_for_checkout(checkout_session)

For each cart line item, locks the matching InventoryLevel row with select_for_update(), checks stock_on_hand - stock_reserved >= requested, increments stock_reserved, and creates a CheckoutReservationContext audit record. Runs inside transaction.atomic(): any failure rolls back all reservations for the call. Not called from the normal checkout path (see CheckoutService.initiate_checkout()); reservation there happens through InventoryAdjustmentService instead.

release_reservations(checkout_session)

Finds every active CheckoutReservationContext for the session, decrements InventoryLevel.stock_reserved for each, and marks the context released. Atomic; a no-op returning released_count: 0 when there is nothing active to release.

commit_reservations(checkout_session)

Marks every active reservation context as committed. Does not touch InventoryLevel values; inventory decrement is InventoryAdjustmentService's responsibility.

expire_old_reservations()

Finds active reservation contexts past expires_at, releases their inventory, and marks them expired. Intended to be called periodically (see Tasks).


SessionService

Purpose: Resolves session identity, manages the guest/customer cart association, and issues the cart-session token used as a cookie-independent fallback credential.

Source: services/session_service.py

resolve_web_session(request)

Returns the valid, unexpired WebSession for the current Django session_key, or None. Cookie-only: does not consider the X-Cart-Session-Token header.

resolve_authorized_web_session(request)

The identity used by every cart/checkout/payment authorization gate. Tries X-Cart-Session-Token first (guest-only, matched by SHA-256 hash against WebSession.cart_token_hash), falling back to resolve_web_session(). The token is preferred because in cookie-hostile contexts (embedded webviews, ITP) the cookie round-trips intermittently while a client-stored token is stable; preferring it lets every gate resolve the same guest session consistently. A post-login request has no resolvable token (it is cleared on merge) and falls back to the cookie automatically.

enforce_single_active_cart(web_session, keep_cart)

Abandons every ACTIVE cart on web_session except keep_cart, so at most one ACTIVE cart exists per web session. Called at every cart-creation point (guest session create, store change, session-status resolution) to prevent divergence between the cart a session reports and the cart it stored.

create_guest_session(request, email=None, store_id=None)

Creates or reuses a WebSession for the current Django session, creates or reuses its ACTIVE cart, calls enforce_single_active_cart(), and mints a cart-session token. A dashboard-type session is left untouched (its TTL is not truncated) and reused for storefront tracking rather than overwritten. Session identity is stored under request.session['storefront_session_id'], distinct from the dashboard auth layer's web_session_id key, even though both share the same cookie domain.

change_store(request, new_store_id)

Validates the new store, creates a fresh ACTIVE cart tied to the resolved WebSession, abandons prior ACTIVE carts via enforce_single_active_cart(), updates the session's store_id, and re-mints the cart-session token.

merge_guest_cart_on_login(guest_web_session, customer, new_web_session=None)

Merges a guest cart into the logging-in customer's cart. If a guest cart exists, invalidates its cart_token_hash immediately regardless of merge outcome, so a reused token can never resolve a post-merge session. Merges line_items by variant_id (summing quantities on match), marks the guest cart merged, and re-points the resulting cart's web_session FK to new_web_session when provided.

get_or_create_cart_for_customer(customer, web_session=None)

Returns the customer's ACTIVE cart, creating one if none exists. If a cart exists but is owned by a different session, re-points its web_session FK to the logging-in session (multi-device policy: the cart follows the most recent login).

associate_cart_with_session(cart_id, web_session_id)

Checks whether a CheckoutSession already links the cart and web session; otherwise returns a message indicating the association happens when checkout is actually initiated. Optional helper, not required by the checkout flow.


LifecycleService

Purpose: Coordinates periodic cleanup of expired sessions, abandoned carts, and old audit records. Pure orchestration; no business logic of its own.

Source: services/lifecycle_service.py

cleanup_expired_checkout_sessions()

Finds checkout_initiated/payment_pending/payment_processing sessions past expires_at, releases their reservations via ReservationService.release_reservations(), and marks each expired. Returns {'sessions_cleaned': N, 'reservations_released': N}.

cleanup_abandoned_carts(age_hours=24)

Marks ACTIVE carts with no active checkout session and updated_at older than age_hours as abandoned.

cleanup_stale_guest_sessions(age_days=30)

Deletes guest WebSession records inactive for age_days with no active checkout session, abandoning their ACTIVE carts first.

cleanup_old_reservation_contexts(age_days=90)

Deletes committed or released CheckoutReservationContext records older than age_days.


OrderEmailService

Source: services/email_service.py

Purpose: Sends order confirmation and shipment notification emails for web commerce orders. Uses the Resend API by default, with Django SMTP (Hostinger) as an alternative selected by the ORDER_EMAIL_PROVIDER environment variable.

Called from the checkout flow:

  • send_order_confirmation(): called after complete_checkout() succeeds, when a Fulfillment record exists.
  • send_pickup_order_confirmation(): called for pickup orders; content adapts based on whether the order is paid in full or has a balance due at pickup.
  • send_order_ready_email(): called when a pickup order is marked ready for collection, triggered via a Celery task.

send_order_confirmation(transaction, fulfillment)

Sends an order confirmation email with a tracking link (guest orders only, via guest_access_token) and full shipping details: order number, date, total, a line items table, subtotal, tax, shipping cost, and shipping address. Returns bool.

send_pickup_order_confirmation(transaction, fulfillment, pickup_schedule=None)

Sends a pickup order confirmation. A fully-paid order gets a "PAID IN FULL" banner and subject line; a balance-due order gets a "Balance Due" banner with the amount. Includes store name, address, phone, and pickup time converted to store.timezone. Returns bool.

send_order_ready_email(transaction, fulfillment)

Sends a notification that a pickup order is ready for collection. Returns bool.

Private helpers

_send_email() routes to Resend or Hostinger based on ORDER_EMAIL_PROVIDER (hostinger selects SMTP; any other value, default resend, selects the Resend API). _send_via_resend() and _send_via_hostinger() implement each transport.


Was this page helpful?