Coverage for app/backend/src/couchers/servicers/postal_verification.py: 92%
125 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import json
2import logging
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy import exists, select
7from sqlalchemy.orm import Session
9from couchers.constants import (
10 POSTAL_VERIFICATION_CODE_LIFETIME,
11 POSTAL_VERIFICATION_MAX_ATTEMPTS,
12 POSTAL_VERIFICATION_RATE_LIMIT,
13)
14from couchers.context import CouchersContext
15from couchers.helpers.postal_verification import generate_postal_verification_code, has_postal_verification
16from couchers.jobs.enqueue import queue_job
17from couchers.jobs.handlers import send_postal_verification_postcard
18from couchers.models import User
19from couchers.models.notifications import NotificationTopicAction
20from couchers.models.postal_verification import PostalVerificationAttempt, PostalVerificationStatus
21from couchers.notifications.notify import notify
22from couchers.postal.address_validation import AddressValidationError, validate_address
23from couchers.proto import notification_data_pb2, postal_verification_pb2, postal_verification_pb2_grpc
24from couchers.proto.internal import jobs_pb2
25from couchers.utils import Timestamp_from_datetime, now
27logger = logging.getLogger(__name__)
29postalverificationstatus2pb = {
30 PostalVerificationStatus.pending_address_confirmation: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_PENDING_ADDRESS_CONFIRMATION,
31 PostalVerificationStatus.in_progress: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS,
32 PostalVerificationStatus.awaiting_verification: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION,
33 PostalVerificationStatus.succeeded: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_SUCCEEDED,
34 PostalVerificationStatus.failed: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_FAILED,
35 PostalVerificationStatus.cancelled: postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED,
36}
39def _attempt_to_address_pb(attempt: PostalVerificationAttempt) -> postal_verification_pb2.PostalAddress:
40 return postal_verification_pb2.PostalAddress(
41 address_line_1=attempt.address_line_1,
42 address_line_2=attempt.address_line_2 or "",
43 city=attempt.city,
44 state=attempt.state or "",
45 postal_code=attempt.postal_code or "",
46 country_code=attempt.country_code,
47 )
50class PostalVerification(postal_verification_pb2_grpc.PostalVerificationServicer):
51 def InitiatePostalVerification(
52 self,
53 request: postal_verification_pb2.InitiatePostalVerificationReq,
54 context: CouchersContext,
55 session: Session,
56 ) -> postal_verification_pb2.InitiatePostalVerificationRes:
57 """
58 Step 1: User submits address for validation.
59 """
60 if not context.get_boolean_value("postal_verification_enabled", default=False):
61 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "postal_verification_disabled")
63 # Check if there's an active attempt
64 has_active_attempt = session.execute(
65 select(
66 exists(
67 select(PostalVerificationAttempt)
68 .where(PostalVerificationAttempt.user_id == context.user_id)
69 .where(
70 PostalVerificationAttempt.status.in_(
71 [
72 PostalVerificationStatus.pending_address_confirmation,
73 PostalVerificationStatus.in_progress,
74 PostalVerificationStatus.awaiting_verification,
75 ]
76 )
77 )
78 )
79 )
80 ).scalar()
82 if has_active_attempt:
83 context.abort_with_error_code(
84 grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_already_in_progress"
85 )
87 # Check rate limit: one initiation per 30 days
88 has_recent_attempt = session.execute(
89 select(
90 exists(
91 select(PostalVerificationAttempt)
92 .where(PostalVerificationAttempt.user_id == context.user_id)
93 .where(PostalVerificationAttempt.created > now() - POSTAL_VERIFICATION_RATE_LIMIT)
94 )
95 )
96 ).scalar()
98 if has_recent_attempt:
99 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "postal_verification_rate_limited")
101 # Validate required fields
102 if not request.address.address_line_1:
103 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "address_line_1_required")
104 if not request.address.city:
105 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "city_required")
106 if not request.address.country_code:
107 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "country_required")
109 # Validate address
110 try:
111 validated = validate_address(
112 address_line_1=request.address.address_line_1,
113 address_line_2=request.address.address_line_2 or None,
114 city=request.address.city,
115 state=request.address.state or None,
116 postal_code=request.address.postal_code or None,
117 country=request.address.country_code,
118 )
119 except AddressValidationError:
120 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_invalid")
122 if not validated.is_deliverable: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_undeliverable")
125 # Create attempt
126 attempt = PostalVerificationAttempt(
127 user_id=context.user_id,
128 status=PostalVerificationStatus.pending_address_confirmation,
129 address_line_1=validated.address_line_1,
130 address_line_2=validated.address_line_2,
131 city=validated.city,
132 state=validated.state,
133 postal_code=validated.postal_code,
134 country_code=validated.country_code,
135 original_address_json=json.dumps(
136 {
137 "address_line_1": request.address.address_line_1,
138 "address_line_2": request.address.address_line_2,
139 "city": request.address.city,
140 "state": request.address.state,
141 "postal_code": request.address.postal_code,
142 "country_code": request.address.country_code,
143 }
144 ),
145 )
146 session.add(attempt)
147 session.flush()
149 return postal_verification_pb2.InitiatePostalVerificationRes(
150 postal_verification_attempt_id=attempt.id,
151 corrected_address=postal_verification_pb2.PostalAddress(
152 address_line_1=validated.address_line_1,
153 address_line_2=validated.address_line_2 or "",
154 city=validated.city,
155 state=validated.state or "",
156 postal_code=validated.postal_code or "",
157 country_code=validated.country_code,
158 ),
159 address_was_corrected=validated.was_corrected,
160 )
162 def ConfirmPostalAddress(
163 self,
164 request: postal_verification_pb2.ConfirmPostalAddressReq,
165 context: CouchersContext,
166 session: Session,
167 ) -> postal_verification_pb2.ConfirmPostalAddressRes:
168 """
169 Step 2: User confirms address, we generate code and send postcard.
170 """
171 # Gate the step that actually commits to sending a (paid) postcard, not just the initial
172 # address validation - otherwise turning the flag off wouldn't stop postcards mid-flow.
173 if not context.get_boolean_value("postal_verification_enabled", default=False):
174 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "postal_verification_disabled")
176 attempt = session.execute(
177 select(PostalVerificationAttempt)
178 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
179 .where(PostalVerificationAttempt.user_id == context.user_id)
180 ).scalar_one_or_none()
182 if not attempt:
183 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
185 if attempt.status != PostalVerificationStatus.pending_address_confirmation: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_wrong_state")
188 attempt.verification_code = generate_postal_verification_code()
189 attempt.status = PostalVerificationStatus.in_progress
190 attempt.address_confirmed_at = now()
192 # Queue background job to send postcard
193 queue_job(
194 session,
195 job=send_postal_verification_postcard,
196 payload=jobs_pb2.SendPostalVerificationPostcardPayload(
197 postal_verification_attempt_id=attempt.id,
198 ),
199 )
201 return postal_verification_pb2.ConfirmPostalAddressRes()
203 def GetPostalVerificationStatus(
204 self,
205 request: postal_verification_pb2.GetPostalVerificationStatusReq,
206 context: CouchersContext,
207 session: Session,
208 ) -> postal_verification_pb2.GetPostalVerificationStatusRes:
209 """
210 Returns the user's postal verification status and current/latest attempt details.
211 """
212 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
214 has_verification = has_postal_verification(session, user)
216 # Always get the latest attempt for determining can_initiate and has_active_attempt
217 latest_attempt = session.execute(
218 select(PostalVerificationAttempt)
219 .where(PostalVerificationAttempt.user_id == user.id)
220 .order_by(PostalVerificationAttempt.created.desc())
221 .limit(1)
222 ).scalar_one_or_none()
224 # Check if user can initiate a new attempt (based on latest attempt)
225 can_initiate = True
226 next_attempt_allowed_at = None
227 has_active_attempt = False
229 if latest_attempt:
230 # Can't initiate if there's an active attempt
231 if latest_attempt.status in [
232 PostalVerificationStatus.pending_address_confirmation,
233 PostalVerificationStatus.in_progress,
234 PostalVerificationStatus.awaiting_verification,
235 ]:
236 can_initiate = False
237 has_active_attempt = True
238 else:
239 # Check rate limit
240 time_since_last = now() - latest_attempt.created
241 if time_since_last < POSTAL_VERIFICATION_RATE_LIMIT: 241 ↛ 245line 241 didn't jump to line 245 because the condition on line 241 was always true
242 can_initiate = False
243 next_attempt_allowed_at = latest_attempt.created + POSTAL_VERIFICATION_RATE_LIMIT
245 res = postal_verification_pb2.GetPostalVerificationStatusRes(
246 has_postal_verification=has_verification,
247 can_initiate_new_attempt=can_initiate,
248 has_active_attempt=has_active_attempt,
249 )
251 if next_attempt_allowed_at:
252 res.next_attempt_allowed_at.CopyFrom(Timestamp_from_datetime(next_attempt_allowed_at))
254 # Get specific attempt if requested, otherwise use latest
255 if request.postal_verification_attempt_id: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true
256 attempt = session.execute(
257 select(PostalVerificationAttempt)
258 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
259 .where(PostalVerificationAttempt.user_id == context.user_id)
260 ).scalar_one_or_none()
261 else:
262 attempt = latest_attempt
264 if attempt:
265 res.postal_verification_attempt_id = attempt.id
266 res.status = postalverificationstatus2pb.get(
267 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN
268 )
269 res.address.CopyFrom(_attempt_to_address_pb(attempt))
270 res.created.CopyFrom(Timestamp_from_datetime(attempt.created))
271 if attempt.postcard_sent_at:
272 res.postcard_sent_at.CopyFrom(Timestamp_from_datetime(attempt.postcard_sent_at))
274 return res
276 def VerifyPostalCode(
277 self,
278 request: postal_verification_pb2.VerifyPostalCodeReq,
279 context: CouchersContext,
280 session: Session,
281 ) -> postal_verification_pb2.VerifyPostalCodeRes:
282 """
283 User submits the code from the postcard.
284 Looks up the user's active attempt (awaiting_verification status).
285 """
286 attempt = session.execute(
287 select(PostalVerificationAttempt)
288 .where(PostalVerificationAttempt.user_id == context.user_id)
289 .where(PostalVerificationAttempt.status == PostalVerificationStatus.awaiting_verification)
290 ).scalar_one_or_none()
292 if not attempt: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
295 # Check code expiry
296 if attempt.postcard_sent_at and (now() - attempt.postcard_sent_at) > POSTAL_VERIFICATION_CODE_LIFETIME:
297 attempt.status = PostalVerificationStatus.failed
298 notify(
299 session,
300 user_id=context.user_id,
301 topic_action=NotificationTopicAction.postal_verification__failed,
302 key="",
303 data=notification_data_pb2.PostalVerificationFailed(
304 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED
305 ),
306 )
307 session.commit()
308 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_code_expired")
310 # Normalize submitted code
311 submitted_code = request.code.strip().upper()
313 if submitted_code != attempt.verification_code:
314 attempt.code_attempts += 1
315 remaining = POSTAL_VERIFICATION_MAX_ATTEMPTS - attempt.code_attempts
317 if remaining <= 0:
318 attempt.status = PostalVerificationStatus.failed
319 notify(
320 session,
321 user_id=context.user_id,
322 topic_action=NotificationTopicAction.postal_verification__failed,
323 key="",
324 data=notification_data_pb2.PostalVerificationFailed(
325 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS
326 ),
327 )
328 return postal_verification_pb2.VerifyPostalCodeRes(
329 success=False,
330 remaining_attempts=0,
331 )
333 return postal_verification_pb2.VerifyPostalCodeRes(
334 success=False,
335 remaining_attempts=remaining,
336 )
338 # Success!
339 attempt.status = PostalVerificationStatus.succeeded
340 attempt.verified_at = now()
342 notify(
343 session,
344 user_id=context.user_id,
345 topic_action=NotificationTopicAction.postal_verification__success,
346 key="",
347 )
349 return postal_verification_pb2.VerifyPostalCodeRes(
350 success=True,
351 remaining_attempts=0,
352 )
354 def CancelPostalVerification(
355 self,
356 request: postal_verification_pb2.CancelPostalVerificationReq,
357 context: CouchersContext,
358 session: Session,
359 ) -> empty_pb2.Empty:
360 """
361 Cancels an active postal verification attempt.
362 """
363 attempt = session.execute(
364 select(PostalVerificationAttempt)
365 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
366 .where(PostalVerificationAttempt.user_id == context.user_id)
367 ).scalar_one_or_none()
369 if not attempt:
370 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
372 # Can cancel any active attempt (not terminal states)
373 if attempt.status not in [ 373 ↛ 378line 373 didn't jump to line 378 because the condition on line 373 was never true
374 PostalVerificationStatus.pending_address_confirmation,
375 PostalVerificationStatus.in_progress,
376 PostalVerificationStatus.awaiting_verification,
377 ]:
378 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_cannot_cancel")
380 attempt.status = PostalVerificationStatus.cancelled
381 # Clear the verification code (required by db constraint and makes sense - code is no longer valid)
382 attempt.verification_code = None
384 return empty_pb2.Empty()
386 def ListPostalVerificationAttempts(
387 self,
388 request: postal_verification_pb2.ListPostalVerificationAttemptsReq,
389 context: CouchersContext,
390 session: Session,
391 ) -> postal_verification_pb2.ListPostalVerificationAttemptsRes:
392 """
393 Returns all postal verification attempts for the user.
394 """
395 attempts = session.execute(
396 select(PostalVerificationAttempt)
397 .where(PostalVerificationAttempt.user_id == context.user_id)
398 .order_by(PostalVerificationAttempt.created.desc())
399 ).scalars()
401 return postal_verification_pb2.ListPostalVerificationAttemptsRes(
402 attempts=[
403 postal_verification_pb2.PostalVerificationAttemptSummary(
404 postal_verification_attempt_id=attempt.id,
405 status=postalverificationstatus2pb.get(
406 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN
407 ),
408 address=_attempt_to_address_pb(attempt),
409 created=Timestamp_from_datetime(attempt.created),
410 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None,
411 )
412 for attempt in attempts
413 ]
414 )