Coverage for app/backend/src/couchers/servicers/postal_verification.py: 93%
128 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +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 # Postcards cost us money to send, so as with phone verification, donors only
64 last_donated = session.execute(select(User.last_donated).where(User.id == context.user_id)).scalar_one()
65 if last_donated is None:
66 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated_postal")
68 # Check if there's an active attempt
69 has_active_attempt = session.execute(
70 select(
71 exists(
72 select(PostalVerificationAttempt)
73 .where(PostalVerificationAttempt.user_id == context.user_id)
74 .where(
75 PostalVerificationAttempt.status.in_(
76 [
77 PostalVerificationStatus.pending_address_confirmation,
78 PostalVerificationStatus.in_progress,
79 PostalVerificationStatus.awaiting_verification,
80 ]
81 )
82 )
83 )
84 )
85 ).scalar()
87 if has_active_attempt:
88 context.abort_with_error_code(
89 grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_already_in_progress"
90 )
92 # Check rate limit: one initiation per 30 days
93 has_recent_attempt = session.execute(
94 select(
95 exists(
96 select(PostalVerificationAttempt)
97 .where(PostalVerificationAttempt.user_id == context.user_id)
98 .where(PostalVerificationAttempt.created > now() - POSTAL_VERIFICATION_RATE_LIMIT)
99 )
100 )
101 ).scalar()
103 if has_recent_attempt:
104 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "postal_verification_rate_limited")
106 # Validate required fields
107 if not request.address.address_line_1:
108 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "address_line_1_required")
109 if not request.address.city:
110 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "city_required")
111 if not request.address.country_code:
112 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "country_required")
114 # Validate address
115 try:
116 validated = validate_address(
117 address_line_1=request.address.address_line_1,
118 address_line_2=request.address.address_line_2 or None,
119 city=request.address.city,
120 state=request.address.state or None,
121 postal_code=request.address.postal_code or None,
122 country=request.address.country_code,
123 )
124 except AddressValidationError:
125 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_invalid")
127 if not validated.is_deliverable: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "postal_address_undeliverable")
130 # Create attempt
131 attempt = PostalVerificationAttempt(
132 user_id=context.user_id,
133 status=PostalVerificationStatus.pending_address_confirmation,
134 address_line_1=validated.address_line_1,
135 address_line_2=validated.address_line_2,
136 city=validated.city,
137 state=validated.state,
138 postal_code=validated.postal_code,
139 country_code=validated.country_code,
140 original_address_json=json.dumps(
141 {
142 "address_line_1": request.address.address_line_1,
143 "address_line_2": request.address.address_line_2,
144 "city": request.address.city,
145 "state": request.address.state,
146 "postal_code": request.address.postal_code,
147 "country_code": request.address.country_code,
148 }
149 ),
150 )
151 session.add(attempt)
152 session.flush()
154 return postal_verification_pb2.InitiatePostalVerificationRes(
155 postal_verification_attempt_id=attempt.id,
156 corrected_address=postal_verification_pb2.PostalAddress(
157 address_line_1=validated.address_line_1,
158 address_line_2=validated.address_line_2 or "",
159 city=validated.city,
160 state=validated.state or "",
161 postal_code=validated.postal_code or "",
162 country_code=validated.country_code,
163 ),
164 address_was_corrected=validated.was_corrected,
165 )
167 def ConfirmPostalAddress(
168 self,
169 request: postal_verification_pb2.ConfirmPostalAddressReq,
170 context: CouchersContext,
171 session: Session,
172 ) -> postal_verification_pb2.ConfirmPostalAddressRes:
173 """
174 Step 2: User confirms address, we generate code and send postcard.
175 """
176 # Gate the step that actually commits to sending a (paid) postcard, not just the initial
177 # address validation - otherwise turning the flag off wouldn't stop postcards mid-flow.
178 if not context.get_boolean_value("postal_verification_enabled", default=False):
179 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "postal_verification_disabled")
181 attempt = session.execute(
182 select(PostalVerificationAttempt)
183 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
184 .where(PostalVerificationAttempt.user_id == context.user_id)
185 ).scalar_one_or_none()
187 if not attempt:
188 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
190 if attempt.status != PostalVerificationStatus.pending_address_confirmation: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_wrong_state")
193 attempt.verification_code = generate_postal_verification_code()
194 attempt.status = PostalVerificationStatus.in_progress
195 attempt.address_confirmed_at = now()
197 # Queue background job to send postcard
198 queue_job(
199 session,
200 job=send_postal_verification_postcard,
201 payload=jobs_pb2.SendPostalVerificationPostcardPayload(
202 postal_verification_attempt_id=attempt.id,
203 ),
204 )
206 return postal_verification_pb2.ConfirmPostalAddressRes()
208 def GetPostalVerificationStatus(
209 self,
210 request: postal_verification_pb2.GetPostalVerificationStatusReq,
211 context: CouchersContext,
212 session: Session,
213 ) -> postal_verification_pb2.GetPostalVerificationStatusRes:
214 """
215 Returns the user's postal verification status and current/latest attempt details.
216 """
217 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
219 has_verification = has_postal_verification(session, user)
221 # Always get the latest attempt for determining can_initiate and has_active_attempt
222 latest_attempt = session.execute(
223 select(PostalVerificationAttempt)
224 .where(PostalVerificationAttempt.user_id == user.id)
225 .order_by(PostalVerificationAttempt.created.desc())
226 .limit(1)
227 ).scalar_one_or_none()
229 # Check if user can initiate a new attempt (based on latest attempt)
230 can_initiate = True
231 next_attempt_allowed_at = None
232 has_active_attempt = False
234 if latest_attempt:
235 # Can't initiate if there's an active attempt
236 if latest_attempt.status in [
237 PostalVerificationStatus.pending_address_confirmation,
238 PostalVerificationStatus.in_progress,
239 PostalVerificationStatus.awaiting_verification,
240 ]:
241 can_initiate = False
242 has_active_attempt = True
243 else:
244 # Check rate limit
245 time_since_last = now() - latest_attempt.created
246 if time_since_last < POSTAL_VERIFICATION_RATE_LIMIT: 246 ↛ 250line 246 didn't jump to line 250 because the condition on line 246 was always true
247 can_initiate = False
248 next_attempt_allowed_at = latest_attempt.created + POSTAL_VERIFICATION_RATE_LIMIT
250 res = postal_verification_pb2.GetPostalVerificationStatusRes(
251 has_postal_verification=has_verification,
252 can_initiate_new_attempt=can_initiate,
253 has_active_attempt=has_active_attempt,
254 )
256 if next_attempt_allowed_at:
257 res.next_attempt_allowed_at.CopyFrom(Timestamp_from_datetime(next_attempt_allowed_at))
259 # Get specific attempt if requested, otherwise use latest
260 if request.postal_verification_attempt_id: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 attempt = session.execute(
262 select(PostalVerificationAttempt)
263 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
264 .where(PostalVerificationAttempt.user_id == context.user_id)
265 ).scalar_one_or_none()
266 else:
267 attempt = latest_attempt
269 if attempt:
270 res.postal_verification_attempt_id = attempt.id
271 res.status = postalverificationstatus2pb.get(
272 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN
273 )
274 res.address.CopyFrom(_attempt_to_address_pb(attempt))
275 res.created.CopyFrom(Timestamp_from_datetime(attempt.created))
276 if attempt.postcard_sent_at:
277 res.postcard_sent_at.CopyFrom(Timestamp_from_datetime(attempt.postcard_sent_at))
279 return res
281 def VerifyPostalCode(
282 self,
283 request: postal_verification_pb2.VerifyPostalCodeReq,
284 context: CouchersContext,
285 session: Session,
286 ) -> postal_verification_pb2.VerifyPostalCodeRes:
287 """
288 User submits the code from the postcard.
289 Looks up the user's active attempt (awaiting_verification status).
290 """
291 attempt = session.execute(
292 select(PostalVerificationAttempt)
293 .where(PostalVerificationAttempt.user_id == context.user_id)
294 .where(PostalVerificationAttempt.status == PostalVerificationStatus.awaiting_verification)
295 ).scalar_one_or_none()
297 if not attempt: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
298 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
300 # Check code expiry
301 if attempt.postcard_sent_at and (now() - attempt.postcard_sent_at) > POSTAL_VERIFICATION_CODE_LIFETIME:
302 attempt.status = PostalVerificationStatus.failed
303 notify(
304 session,
305 user_id=context.user_id,
306 topic_action=NotificationTopicAction.postal_verification__failed,
307 key="",
308 data=notification_data_pb2.PostalVerificationFailed(
309 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED
310 ),
311 )
312 session.commit()
313 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_code_expired")
315 # Normalize submitted code
316 submitted_code = request.code.strip().upper()
318 if submitted_code != attempt.verification_code:
319 attempt.code_attempts += 1
320 remaining = POSTAL_VERIFICATION_MAX_ATTEMPTS - attempt.code_attempts
322 if remaining <= 0:
323 attempt.status = PostalVerificationStatus.failed
324 notify(
325 session,
326 user_id=context.user_id,
327 topic_action=NotificationTopicAction.postal_verification__failed,
328 key="",
329 data=notification_data_pb2.PostalVerificationFailed(
330 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS
331 ),
332 )
333 return postal_verification_pb2.VerifyPostalCodeRes(
334 success=False,
335 remaining_attempts=0,
336 )
338 return postal_verification_pb2.VerifyPostalCodeRes(
339 success=False,
340 remaining_attempts=remaining,
341 )
343 # Success!
344 attempt.status = PostalVerificationStatus.succeeded
345 attempt.verified_at = now()
347 notify(
348 session,
349 user_id=context.user_id,
350 topic_action=NotificationTopicAction.postal_verification__success,
351 key="",
352 )
354 return postal_verification_pb2.VerifyPostalCodeRes(
355 success=True,
356 remaining_attempts=0,
357 )
359 def CancelPostalVerification(
360 self,
361 request: postal_verification_pb2.CancelPostalVerificationReq,
362 context: CouchersContext,
363 session: Session,
364 ) -> empty_pb2.Empty:
365 """
366 Cancels an active postal verification attempt.
367 """
368 attempt = session.execute(
369 select(PostalVerificationAttempt)
370 .where(PostalVerificationAttempt.id == request.postal_verification_attempt_id)
371 .where(PostalVerificationAttempt.user_id == context.user_id)
372 ).scalar_one_or_none()
374 if not attempt:
375 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found")
377 # Can cancel any active attempt (not terminal states)
378 if attempt.status not in [ 378 ↛ 383line 378 didn't jump to line 383 because the condition on line 378 was never true
379 PostalVerificationStatus.pending_address_confirmation,
380 PostalVerificationStatus.in_progress,
381 PostalVerificationStatus.awaiting_verification,
382 ]:
383 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_cannot_cancel")
385 attempt.status = PostalVerificationStatus.cancelled
386 # Clear the verification code (required by db constraint and makes sense - code is no longer valid)
387 attempt.verification_code = None
389 return empty_pb2.Empty()
391 def ListPostalVerificationAttempts(
392 self,
393 request: postal_verification_pb2.ListPostalVerificationAttemptsReq,
394 context: CouchersContext,
395 session: Session,
396 ) -> postal_verification_pb2.ListPostalVerificationAttemptsRes:
397 """
398 Returns all postal verification attempts for the user.
399 """
400 attempts = session.execute(
401 select(PostalVerificationAttempt)
402 .where(PostalVerificationAttempt.user_id == context.user_id)
403 .order_by(PostalVerificationAttempt.created.desc())
404 ).scalars()
406 return postal_verification_pb2.ListPostalVerificationAttemptsRes(
407 attempts=[
408 postal_verification_pb2.PostalVerificationAttemptSummary(
409 postal_verification_attempt_id=attempt.id,
410 status=postalverificationstatus2pb.get(
411 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN
412 ),
413 address=_attempt_to_address_pb(attempt),
414 created=Timestamp_from_datetime(attempt.created),
415 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None,
416 )
417 for attempt in attempts
418 ]
419 )