Coverage for apps/borrower_portal/services.py: 91%
71 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Shared helpers for the borrower portal: account lookup, the portal's user shape, token
2hashing and simulated message delivery (add-borrower-portal-api)."""
4from __future__ import annotations
6import hashlib
7import secrets
9from django.conf import settings
11from apps.borrower_portal.models import BorrowerAccount
14def account_for_principal(principal) -> BorrowerAccount | None:
15 """The portal account behind a borrower principal (design.md D2).
17 Looked up by email. A Cognito borrower (Applicants group) with no account yet gets one on
18 first use — no password, linked by ``sub``. Dev / local principals without an account get
19 None (the guard answers 401).
20 """
21 email = (principal.borrower_email or "").strip().lower()
22 if not email: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 return None
24 account = BorrowerAccount.objects.filter(email=email).first()
25 if account is not None:
26 return account if account.is_active else None
27 if principal.cognito_sub: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 return BorrowerAccount.objects.create(
29 email=email, name=email.split("@")[0], cognito_sub=principal.cognito_sub
30 )
31 return None
34def borrower_user(account: BorrowerAccount) -> dict:
35 """The portal's ``BorrowerUser`` (borrower-pos ``services/me/me.types.ts``)."""
36 return {
37 "id": account.id,
38 "name": account.name,
39 "email": account.email,
40 "phone": account.phone or "",
41 "kind": "borrower",
42 "notify": {"email": account.notify_email, "sms": account.notify_sms},
43 }
46def borrower_session(account: BorrowerAccount) -> dict:
47 """Record a sign-in and return ``{token, user}`` (design.md D4)."""
48 from django.utils import timezone
50 from apps.accounts.auth import mint_borrower_token
52 now = timezone.now()
53 account.last_login_at = now
54 fields = ["last_login_at", "updated_at"]
55 if account.invited_at and not account.accepted_at:
56 account.accepted_at = now
57 fields.append("accepted_at")
58 account.save(update_fields=fields)
59 return {"token": mint_borrower_token(account), "user": borrower_user(account)}
62def find_borrower_for_login(email: str, password: str) -> BorrowerAccount | None:
63 """The borrower account matching ``email`` + ``password``, or None. A disabled account with
64 the right password raises ACCOUNT_DISABLED, as staff sign-in does."""
65 from domain.result import AppError, DomainError
67 account = BorrowerAccount.objects.filter(email=(email or "").strip().lower()).first()
68 if account is None or not account.check_password(password):
69 return None
70 if not account.is_active:
71 raise DomainError(
72 [
73 AppError(
74 "ACCOUNT_DISABLED",
75 "This account has been disabled. Contact your loan officer.",
76 )
77 ]
78 )
79 return account
82def hash_token(raw: str) -> str:
83 return hashlib.sha256(raw.encode("utf-8")).hexdigest()
86def new_link_token() -> tuple[str, str]:
87 """A random URL-safe token and its hash (only the hash is stored)."""
88 raw = secrets.token_urlsafe(32)
89 return raw, hash_token(raw)
92RESET_TTL_MINUTES = 60
93OTP_TTL_MINUTES = 5
96def issue_auth_token(account: BorrowerAccount, kind: str) -> str:
97 """Create a single-use reset link token or 6-digit code for ``account`` and return the raw
98 value (only its hash is stored). Issuing a new one voids the account's older unused ones of
99 the same kind (design.md D10)."""
100 from datetime import timedelta
102 from django.utils import timezone
104 from apps.borrower_portal.models import AuthToken
106 now = timezone.now()
107 AuthToken.objects.filter(account=account, kind=kind, used_at__isnull=True).update(used_at=now)
108 if kind == AuthToken.KIND_OTP:
109 raw = f"{secrets.randbelow(1_000_000):06d}"
110 ttl = timedelta(minutes=OTP_TTL_MINUTES)
111 else:
112 raw = secrets.token_urlsafe(32)
113 ttl = timedelta(minutes=RESET_TTL_MINUTES)
114 AuthToken.objects.create(
115 account=account, kind=kind, token_hash=hash_token(raw), expires_at=now + ttl
116 )
117 return raw
120def company_name() -> str:
121 """The lender's name for email wording ({company} in Arive's templates)."""
122 from apps.accounts.admin_api import _get_company
124 company = _get_company()
125 return company.dba or company.name or "Lumen Lending"
128def portal_url(path: str) -> str:
129 return f"{settings.BORROWER_POS_URL}{path}"
132# Emails that carry a password or a sign-in / reset secret: no click-tracking link rewriting.
133_NO_CLICK_TRACKING = {"auth:reset", "auth:otp", "pos:invite", "user:invite"}
136def send_borrower_message(
137 *,
138 channel: str,
139 to: str,
140 subject: str | None,
141 body: str = "",
142 trigger: str,
143 loan=None,
144 raise_on_failure: bool = False,
145 content=None,
146):
147 """Send a message to a borrower and record it as a ``Communication``.
149 Email goes through ``apps.communications.delivery.deliver_email`` (Twilio SendGrid when
150 configured, simulated otherwise). SMS has no provider yet and stays simulated. ``trigger``
151 names the event, e.g. ``auth:reset``, ``auth:otp``, ``pos:invite``, ``doc:invite``.
152 Emails built with ``apps.communications.emails`` pass ``content`` (an ``EmailContent``) instead
153 of ``body``.
154 """
155 from apps.communications.models import Communication, CommunicationEvent
157 if channel == "email": 157 ↛ 171line 157 didn't jump to line 171 because the condition on line 157 was always true
158 from apps.communications.delivery import deliver_email
160 return deliver_email(
161 to=to,
162 subject=subject or "",
163 body=body,
164 content=content,
165 trigger=trigger,
166 loan=loan,
167 automated=True,
168 track_clicks=trigger not in _NO_CLICK_TRACKING,
169 raise_on_failure=raise_on_failure,
170 )
171 comm = Communication.objects.create(
172 loan=loan,
173 channel=channel,
174 to=to,
175 subject=None,
176 body=body,
177 status="Sent",
178 provider="simulated",
179 automated=True,
180 trigger=trigger,
181 )
182 CommunicationEvent.objects.create(communication=comm, event_type="sent")
183 return comm