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

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

81 

82 if has_active_attempt: 

83 context.abort_with_error_code( 

84 grpc.StatusCode.FAILED_PRECONDITION, "postal_verification_already_in_progress" 

85 ) 

86 

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

97 

98 if has_recent_attempt: 

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

100 

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

108 

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

121 

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

124 

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

148 

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 ) 

161 

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

175 

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

181 

182 if not attempt: 

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

184 

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

187 

188 attempt.verification_code = generate_postal_verification_code() 

189 attempt.status = PostalVerificationStatus.in_progress 

190 attempt.address_confirmed_at = now() 

191 

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 ) 

200 

201 return postal_verification_pb2.ConfirmPostalAddressRes() 

202 

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

213 

214 has_verification = has_postal_verification(session, user) 

215 

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

223 

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 

228 

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 

244 

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 ) 

250 

251 if next_attempt_allowed_at: 

252 res.next_attempt_allowed_at.CopyFrom(Timestamp_from_datetime(next_attempt_allowed_at)) 

253 

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 

263 

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

273 

274 return res 

275 

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

291 

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

294 

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

309 

310 # Normalize submitted code 

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

312 

313 if submitted_code != attempt.verification_code: 

314 attempt.code_attempts += 1 

315 remaining = POSTAL_VERIFICATION_MAX_ATTEMPTS - attempt.code_attempts 

316 

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 ) 

332 

333 return postal_verification_pb2.VerifyPostalCodeRes( 

334 success=False, 

335 remaining_attempts=remaining, 

336 ) 

337 

338 # Success! 

339 attempt.status = PostalVerificationStatus.succeeded 

340 attempt.verified_at = now() 

341 

342 notify( 

343 session, 

344 user_id=context.user_id, 

345 topic_action=NotificationTopicAction.postal_verification__success, 

346 key="", 

347 ) 

348 

349 return postal_verification_pb2.VerifyPostalCodeRes( 

350 success=True, 

351 remaining_attempts=0, 

352 ) 

353 

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

368 

369 if not attempt: 

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

371 

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

379 

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 

383 

384 return empty_pb2.Empty() 

385 

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

400 

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 )