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

1import json 

2import logging 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy import exists, select 

7from sqlalchemy.orm import Session 

8 

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 

26 

27logger = logging.getLogger(__name__) 

28 

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} 

37 

38 

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 ) 

48 

49 

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

62 

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

67 

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

86 

87 if has_active_attempt: 

88 context.abort_with_error_code( 

89 grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_already_in_progress" 

90 ) 

91 

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

102 

103 if has_recent_attempt: 

104 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "postal_verification_rate_limited") 

105 

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

113 

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

126 

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

129 

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

153 

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 ) 

166 

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

180 

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

186 

187 if not attempt: 

188 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

189 

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

192 

193 attempt.verification_code = generate_postal_verification_code() 

194 attempt.status = PostalVerificationStatus.in_progress 

195 attempt.address_confirmed_at = now() 

196 

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 ) 

205 

206 return postal_verification_pb2.ConfirmPostalAddressRes() 

207 

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

218 

219 has_verification = has_postal_verification(session, user) 

220 

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

228 

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 

233 

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 

249 

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 ) 

255 

256 if next_attempt_allowed_at: 

257 res.next_attempt_allowed_at.CopyFrom(Timestamp_from_datetime(next_attempt_allowed_at)) 

258 

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 

268 

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

278 

279 return res 

280 

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

296 

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

299 

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

314 

315 # Normalize submitted code 

316 submitted_code = request.code.strip().upper() 

317 

318 if submitted_code != attempt.verification_code: 

319 attempt.code_attempts += 1 

320 remaining = POSTAL_VERIFICATION_MAX_ATTEMPTS - attempt.code_attempts 

321 

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 ) 

337 

338 return postal_verification_pb2.VerifyPostalCodeRes( 

339 success=False, 

340 remaining_attempts=remaining, 

341 ) 

342 

343 # Success! 

344 attempt.status = PostalVerificationStatus.succeeded 

345 attempt.verified_at = now() 

346 

347 notify( 

348 session, 

349 user_id=context.user_id, 

350 topic_action=NotificationTopicAction.postal_verification__success, 

351 key="", 

352 ) 

353 

354 return postal_verification_pb2.VerifyPostalCodeRes( 

355 success=True, 

356 remaining_attempts=0, 

357 ) 

358 

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

373 

374 if not attempt: 

375 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

376 

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

384 

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 

388 

389 return empty_pb2.Empty() 

390 

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

405 

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 )