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

1"""Shared helpers for the borrower portal: account lookup, the portal's user shape, token 

2hashing and simulated message delivery (add-borrower-portal-api).""" 

3 

4from __future__ import annotations 

5 

6import hashlib 

7import secrets 

8 

9from django.conf import settings 

10 

11from apps.borrower_portal.models import BorrowerAccount 

12 

13 

14def account_for_principal(principal) -> BorrowerAccount | None: 

15 """The portal account behind a borrower principal (design.md D2). 

16 

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 

32 

33 

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 } 

44 

45 

46def borrower_session(account: BorrowerAccount) -> dict: 

47 """Record a sign-in and return ``{token, user}`` (design.md D4).""" 

48 from django.utils import timezone 

49 

50 from apps.accounts.auth import mint_borrower_token 

51 

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)} 

60 

61 

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 

66 

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 

80 

81 

82def hash_token(raw: str) -> str: 

83 return hashlib.sha256(raw.encode("utf-8")).hexdigest() 

84 

85 

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) 

90 

91 

92RESET_TTL_MINUTES = 60 

93OTP_TTL_MINUTES = 5 

94 

95 

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 

101 

102 from django.utils import timezone 

103 

104 from apps.borrower_portal.models import AuthToken 

105 

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 

118 

119 

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 

123 

124 company = _get_company() 

125 return company.dba or company.name or "Lumen Lending" 

126 

127 

128def portal_url(path: str) -> str: 

129 return f"{settings.BORROWER_POS_URL}{path}" 

130 

131 

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"} 

134 

135 

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``. 

148 

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 

156 

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 

159 

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