Coverage for polar/webhook/webhooks.py: 44%

372 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 16:17 +0000

1import inspect 1a

2import json 1a

3import typing 1a

4from datetime import datetime 1a

5from inspect import Parameter, Signature 1a

6from typing import Annotated, Any, Literal, assert_never, get_args, get_origin 1a

7 

8from babel.dates import format_date 1a

9from fastapi import FastAPI 1a

10from fastapi.routing import APIRoute 1a

11from makefun import with_signature 1a

12from pydantic import ( 1a

13 Discriminator, 

14 GetJsonSchemaHandler, 

15 TypeAdapter, 

16) 

17from pydantic.json_schema import JsonSchemaValue 1a

18from pydantic_core import core_schema as cs 1a

19 

20from polar.benefit.schemas import Benefit as BenefitSchema 1a

21from polar.benefit.schemas import BenefitGrantWebhook 1a

22from polar.checkout.schemas import Checkout as CheckoutSchema 1a

23from polar.customer.schemas.customer import Customer as CustomerSchema 1a

24from polar.customer.schemas.state import CustomerState as CustomerStateSchema 1a

25from polar.customer_seat.schemas import CustomerSeat as CustomerSeatSchema 1a

26from polar.exceptions import PolarError 1a

27from polar.integrations.discord.webhook import ( 1a

28 DiscordEmbedField, 

29 DiscordPayload, 

30 get_branded_discord_embed, 

31) 

32from polar.kit.schemas import IDSchema, Schema 1a

33from polar.models import ( 1a

34 Benefit, 

35 BenefitGrant, 

36 Checkout, 

37 Customer, 

38 CustomerSeat, 

39 Order, 

40 Organization, 

41 Product, 

42 Refund, 

43 Subscription, 

44 User, 

45) 

46from polar.models.order import OrderStatus 1a

47from polar.models.subscription import SubscriptionStatus 1a

48from polar.models.webhook_endpoint import WebhookEventType, WebhookFormat 1a

49from polar.order.schemas import Order as OrderSchema 1a

50from polar.organization.schemas import Organization as OrganizationSchema 1a

51from polar.product.schemas import Product as ProductSchema 1a

52from polar.refund.schemas import Refund as RefundSchema 1a

53from polar.subscription.schemas import Subscription as SubscriptionSchema 1a

54 

55from .slack import SlackPayload, SlackText, get_branded_slack_payload 1a

56 

57WebhookTypeObject = ( 1a

58 tuple[Literal[WebhookEventType.checkout_created], Checkout] 

59 | tuple[Literal[WebhookEventType.checkout_updated], Checkout] 

60 | tuple[Literal[WebhookEventType.customer_created], Customer] 

61 | tuple[Literal[WebhookEventType.customer_updated], Customer] 

62 | tuple[Literal[WebhookEventType.customer_deleted], Customer] 

63 | tuple[Literal[WebhookEventType.customer_state_changed], CustomerStateSchema] 

64 | tuple[Literal[WebhookEventType.customer_seat_assigned], CustomerSeat] 

65 | tuple[Literal[WebhookEventType.customer_seat_claimed], CustomerSeat] 

66 | tuple[Literal[WebhookEventType.customer_seat_revoked], CustomerSeat] 

67 | tuple[Literal[WebhookEventType.order_created], Order] 

68 | tuple[Literal[WebhookEventType.order_updated], Order] 

69 | tuple[Literal[WebhookEventType.order_paid], Order] 

70 | tuple[Literal[WebhookEventType.order_refunded], Order] 

71 | tuple[Literal[WebhookEventType.subscription_created], Subscription] 

72 | tuple[Literal[WebhookEventType.subscription_updated], Subscription] 

73 | tuple[Literal[WebhookEventType.subscription_active], Subscription] 

74 | tuple[Literal[WebhookEventType.subscription_canceled], Subscription] 

75 | tuple[Literal[WebhookEventType.subscription_revoked], Subscription] 

76 | tuple[Literal[WebhookEventType.subscription_uncanceled], Subscription] 

77 | tuple[Literal[WebhookEventType.refund_created], Refund] 

78 | tuple[Literal[WebhookEventType.refund_updated], Refund] 

79 | tuple[Literal[WebhookEventType.product_created], Product] 

80 | tuple[Literal[WebhookEventType.product_updated], Product] 

81 | tuple[Literal[WebhookEventType.organization_updated], Organization] 

82 | tuple[Literal[WebhookEventType.benefit_created], Benefit] 

83 | tuple[Literal[WebhookEventType.benefit_updated], Benefit] 

84 | tuple[Literal[WebhookEventType.benefit_grant_created], BenefitGrant] 

85 | tuple[Literal[WebhookEventType.benefit_grant_updated], BenefitGrant] 

86 | tuple[Literal[WebhookEventType.benefit_grant_cycled], BenefitGrant] 

87 | tuple[Literal[WebhookEventType.benefit_grant_revoked], BenefitGrant] 

88) 

89 

90 

91class UnsupportedTarget(PolarError): 1a

92 def __init__( 1a

93 self, 

94 target: User | Organization, 

95 schema: type["BaseWebhookPayload"], 

96 format: WebhookFormat, 

97 ) -> None: 

98 self.target = target 

99 self.format = format 

100 message = f"{schema.__name__} payload does not support target {type(target).__name__} for format {format}" 

101 super().__init__(message) 

102 

103 

104class SkipEvent(PolarError): 1a

105 def __init__(self, event: WebhookEventType, format: WebhookFormat) -> None: 1a

106 self.event = event 

107 self.format = format 

108 message = f"Skipping event {event} for format {format}" 

109 super().__init__(message) 

110 

111 

112class BaseWebhookPayload(Schema): 1a

113 type: WebhookEventType 1a

114 timestamp: datetime 1a

115 data: IDSchema 1a

116 

117 def get_payload(self, format: WebhookFormat, target: User | Organization) -> str: 1a

118 match format: 

119 case WebhookFormat.raw: 

120 return self.get_raw_payload() 

121 case WebhookFormat.discord: 

122 return self.get_discord_payload(target) 

123 case WebhookFormat.slack: 

124 return self.get_slack_payload(target) 

125 case _: 

126 assert_never(format) 

127 

128 def get_raw_payload(self) -> str: 1a

129 return self.model_dump_json() 

130 

131 def get_discord_payload(self, target: User | Organization) -> str: 1a

132 # Generic Discord payload, override in subclasses for more specific payloads 

133 fields: list[DiscordEmbedField] = [ 

134 {"name": "Object", "value": str(self.data.id)}, 

135 ] 

136 if isinstance(target, User): 

137 fields.append({"name": "User", "value": target.email}) 

138 elif isinstance(target, Organization): 

139 fields.append({"name": "Organization", "value": target.name}) 

140 

141 payload: DiscordPayload = { 

142 "content": self.type, 

143 "embeds": [ 

144 get_branded_discord_embed( 

145 { 

146 "title": self.type, 

147 "description": self.type, 

148 "fields": fields, 

149 } 

150 ) 

151 ], 

152 } 

153 

154 return json.dumps(payload) 

155 

156 def get_slack_payload(self, target: User | Organization) -> str: 1a

157 # Generic Slack payload, override in subclasses for more specific payloads 

158 fields: list[SlackText] = [ 

159 {"type": "mrkdwn", "text": f"*Object*\n{self.data.id}"}, 

160 ] 

161 if isinstance(target, User): 

162 fields.append({"type": "mrkdwn", "text": f"*User*\n{target.email}"}) 

163 elif isinstance(target, Organization): 

164 fields.append({"type": "mrkdwn", "text": f"*Organization*\n{target.name}"}) 

165 

166 payload: SlackPayload = get_branded_slack_payload( 

167 { 

168 "text": self.type, 

169 "blocks": [ 

170 { 

171 "type": "section", 

172 "text": { 

173 "type": "mrkdwn", 

174 "text": self.type, 

175 }, 

176 "fields": fields, 

177 } 

178 ], 

179 } 

180 ) 

181 

182 return json.dumps(payload) 

183 

184 @classmethod 1a

185 def __get_pydantic_json_schema__( 1a

186 cls, core_schema: cs.CoreSchema, handler: GetJsonSchemaHandler 

187 ) -> JsonSchemaValue: 

188 json_schema = handler(core_schema) 1c

189 json_schema = handler.resolve_ref_schema(json_schema) 1c

190 

191 # Force the example of the `type` field to be the event type literal value 

192 type_field_annotation = cls.model_fields["type"].annotation 1c

193 if get_origin(type_field_annotation) is Literal: 193 ↛ 197line 193 didn't jump to line 197 because the condition on line 193 was always true1c

194 literal_value = get_args(type_field_annotation)[0] 1c

195 json_schema["properties"]["type"]["examples"] = [literal_value] 1c

196 

197 return json_schema 1c

198 

199 

200class WebhookCheckoutCreatedPayload(BaseWebhookPayload): 1a

201 """ 

202 Sent when a new checkout is created. 

203 

204 **Discord & Slack support:** Basic 

205 """ 

206 

207 type: Literal[WebhookEventType.checkout_created] 1a

208 data: CheckoutSchema 1a

209 

210 

211class WebhookCheckoutUpdatedPayload(BaseWebhookPayload): 1a

212 """ 

213 Sent when a checkout is updated. 

214 

215 **Discord & Slack support:** Basic 

216 """ 

217 

218 type: Literal[WebhookEventType.checkout_updated] 1a

219 data: CheckoutSchema 1a

220 

221 

222class WebhookCustomerCreatedPayload(BaseWebhookPayload): 1a

223 """ 

224 Sent when a new customer is created. 

225 

226 A customer can be created: 

227 

228 * After a successful checkout. 

229 * Programmatically via the API. 

230 

231 **Discord & Slack support:** Basic 

232 """ 

233 

234 type: Literal[WebhookEventType.customer_created] 1a

235 data: CustomerSchema 1a

236 

237 

238class WebhookCustomerUpdatedPayload(BaseWebhookPayload): 1a

239 """ 

240 Sent when a customer is updated. 

241 

242 This event is fired when the customer details are updated. 

243 

244 If you want to be notified when a customer subscription or benefit state changes, you should listen to the `customer_state_changed` event. 

245 

246 **Discord & Slack support:** Basic 

247 """ 

248 

249 type: Literal[WebhookEventType.customer_updated] 1a

250 data: CustomerSchema 1a

251 

252 

253class WebhookCustomerDeletedPayload(BaseWebhookPayload): 1a

254 """ 

255 Sent when a customer is deleted. 

256 

257 **Discord & Slack support:** Basic 

258 """ 

259 

260 type: Literal[WebhookEventType.customer_deleted] 1a

261 data: CustomerSchema 1a

262 

263 

264class WebhookCustomerStateChangedPayload(BaseWebhookPayload): 1a

265 """ 

266 Sent when a customer state has changed. 

267 

268 It's triggered when: 

269 

270 * Customer is created, updated or deleted. 

271 * A subscription is created or updated. 

272 * A benefit is granted or revoked. 

273 

274 **Discord & Slack support:** Basic 

275 """ 

276 

277 type: Literal[WebhookEventType.customer_state_changed] 1a

278 data: CustomerStateSchema 1a

279 

280 

281class WebhookCustomerSeatAssignedPayload(BaseWebhookPayload): 1a

282 """ 

283 Sent when a new customer seat is assigned. 

284 

285 This event is triggered when a seat is assigned to a customer by the organization. 

286 The customer will receive an invitation email to claim the seat. 

287 

288 """ 

289 

290 type: Literal[WebhookEventType.customer_seat_assigned] 1a

291 data: CustomerSeatSchema # type: ignore[assignment] 1a

292 

293 

294class WebhookCustomerSeatClaimedPayload(BaseWebhookPayload): 1a

295 """ 

296 Sent when a customer seat is claimed. 

297 

298 This event is triggered when a customer accepts the seat invitation and claims their access. 

299 

300 """ 

301 

302 type: Literal[WebhookEventType.customer_seat_claimed] 1a

303 data: CustomerSeatSchema # type: ignore[assignment] 1a

304 

305 

306class WebhookCustomerSeatRevokedPayload(BaseWebhookPayload): 1a

307 """ 

308 Sent when a customer seat is revoked. 

309 

310 This event is triggered when access to a seat is revoked, either manually by the organization or automatically when a subscription is canceled. 

311 """ 

312 

313 type: Literal[WebhookEventType.customer_seat_revoked] 1a

314 data: CustomerSeatSchema # type: ignore[assignment] 1a

315 

316 

317class WebhookOrderPayloadBase(BaseWebhookPayload): 1a

318 """ 

319 Base class for order payloads. 

320 """ 

321 

322 type: ( 1a

323 Literal[WebhookEventType.order_created] 

324 | Literal[WebhookEventType.order_updated] 

325 | Literal[WebhookEventType.order_paid] 

326 ) 

327 data: OrderSchema 1a

328 

329 def get_discord_payload(self, target: User | Organization) -> str: 1a

330 if isinstance(target, User): 

331 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

332 

333 amount_display = self.data.get_amount_display() 

334 

335 fields: list[DiscordEmbedField] = [ 

336 {"name": "Product", "value": self.data.description}, 

337 {"name": "Amount", "value": amount_display}, 

338 {"name": "Customer", "value": self.data.customer.email}, 

339 ] 

340 if self.data.subscription is not None: 

341 fields.append({"name": "Subscription", "value": "Yes"}) 

342 

343 payload: DiscordPayload = { 

344 "content": "New Order", 

345 "embeds": [ 

346 get_branded_discord_embed( 

347 { 

348 "title": "New Order", 

349 "description": f"New order has been made to {target.name}.", 

350 "fields": fields, 

351 } 

352 ) 

353 ], 

354 } 

355 

356 return json.dumps(payload) 

357 

358 def get_slack_payload(self, target: User | Organization) -> str: 1a

359 if isinstance(target, User): 

360 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

361 

362 amount_display = self.data.get_amount_display() 

363 

364 fields: list[SlackText] = [ 

365 {"type": "mrkdwn", "text": f"*Product*\n{self.data.description}"}, 

366 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"}, 

367 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.customer.email}"}, 

368 ] 

369 if self.data.subscription is not None: 

370 fields.append({"type": "mrkdwn", "text": "*Subscription*\nYes"}) 

371 

372 payload: SlackPayload = get_branded_slack_payload( 

373 { 

374 "text": "New Order", 

375 "blocks": [ 

376 { 

377 "type": "section", 

378 "text": { 

379 "type": "mrkdwn", 

380 "text": f"New order has been made to {target.name}.", 

381 }, 

382 "fields": fields, 

383 } 

384 ], 

385 } 

386 ) 

387 

388 return json.dumps(payload) 

389 

390 

391class WebhookOrderCreatedPayload(WebhookOrderPayloadBase): 1a

392 """ 

393 Sent when a new order is created. 

394 

395 A new order is created when: 

396 

397 * A customer purchases a one-time product. In this case, `billing_reason` is set to `purchase`. 

398 * A customer starts a subscription. In this case, `billing_reason` is set to `subscription_create`. 

399 * A subscription is renewed. In this case, `billing_reason` is set to `subscription_cycle`. 

400 * A subscription is upgraded or downgraded with an immediate proration invoice. In this case, `billing_reason` is set to `subscription_update`. 

401 

402 <Warning>The order might not be paid yet, so the `status` field might be `pending`.</Warning> 

403 

404 **Discord & Slack support:** Full 

405 """ 

406 

407 type: Literal[WebhookEventType.order_created] 1a

408 

409 

410class WebhookOrderUpdatedPayload(WebhookOrderPayloadBase): 1a

411 """ 

412 Sent when an order is updated. 

413 

414 An order is updated when: 

415 

416 * Its status changes, e.g. from `pending` to `paid`. 

417 * It's refunded, partially or fully. 

418 

419 **Discord & Slack support:** Full 

420 """ 

421 

422 type: Literal[WebhookEventType.order_updated] 1a

423 

424 

425class WebhookOrderPaidPayload(WebhookOrderPayloadBase): 1a

426 """ 

427 Sent when an order is paid. 

428 

429 When you receive this event, the order is fully processed and payment has been received. 

430 

431 **Discord & Slack support:** Full 

432 """ 

433 

434 type: Literal[WebhookEventType.order_paid] 1a

435 

436 

437class WebhookOrderRefundedPayload(BaseWebhookPayload): 1a

438 """ 

439 Sent when an order is fully or partially refunded. 

440 

441 **Discord & Slack support:** Full 

442 """ 

443 

444 type: Literal[WebhookEventType.order_refunded] 1a

445 data: OrderSchema 1a

446 

447 def get_discord_payload(self, target: User | Organization) -> str: 1a

448 if isinstance(target, User): 

449 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

450 

451 amount_display = self.data.get_refunded_amount_display() 

452 

453 fields: list[DiscordEmbedField] = [ 

454 {"name": "Product", "value": self.data.description}, 

455 {"name": "Refunded", "value": amount_display}, 

456 {"name": "Customer", "value": self.data.customer.email}, 

457 ] 

458 if self.data.subscription is not None: 

459 fields.append({"name": "Subscription", "value": "Yes"}) 

460 

461 if self.data.status == OrderStatus.refunded: 

462 fields.append({"name": "Fully Refunded", "value": "Yes"}) 

463 

464 payload: DiscordPayload = { 

465 "content": "Order Refunded", 

466 "embeds": [ 

467 get_branded_discord_embed( 

468 { 

469 "title": "Order Refunded", 

470 "description": f"Order refunded for {target.name}.", 

471 "fields": fields, 

472 } 

473 ) 

474 ], 

475 } 

476 

477 return json.dumps(payload) 

478 

479 def get_slack_payload(self, target: User | Organization) -> str: 1a

480 if isinstance(target, User): 

481 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

482 

483 amount_display = self.data.get_refunded_amount_display() 

484 

485 fields: list[SlackText] = [ 

486 {"type": "mrkdwn", "text": f"*Product*\n{self.data.description}"}, 

487 {"type": "mrkdwn", "text": f"*Refunded*\n{amount_display}"}, 

488 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.customer.email}"}, 

489 ] 

490 if self.data.subscription is not None: 

491 fields.append({"type": "mrkdwn", "text": "*Subscription*\nYes"}) 

492 

493 if self.data.status == OrderStatus.refunded: 

494 fields.append({"type": "mrkdwn", "text": "*Fully Refunded*\nYes"}) 

495 

496 payload: SlackPayload = get_branded_slack_payload( 

497 { 

498 "text": "Order Refunded", 

499 "blocks": [ 

500 { 

501 "type": "section", 

502 "text": { 

503 "type": "mrkdwn", 

504 "text": f"Order refunded for {target.name}.", 

505 }, 

506 "fields": fields, 

507 } 

508 ], 

509 } 

510 ) 

511 

512 return json.dumps(payload) 

513 

514 

515class WebhookSubscriptionCreatedPayload(BaseWebhookPayload): 1a

516 """ 

517 Sent when a new subscription is created. 

518 

519 When this event occurs, the subscription `status` might not be `active` yet, as we can still have to wait for the first payment to be processed. 

520 

521 **Discord & Slack support:** Full 

522 """ 

523 

524 type: Literal[WebhookEventType.subscription_created] 1a

525 data: SubscriptionSchema 1a

526 

527 def get_discord_payload(self, target: User | Organization) -> str: 1a

528 if isinstance(target, User): 

529 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

530 

531 amount_display = self.data.get_amount_display() 

532 

533 fields: list[DiscordEmbedField] = [ 

534 {"name": "Product", "value": self.data.product.name}, 

535 {"name": "Amount", "value": amount_display}, 

536 {"name": "Customer", "value": self.data.user.email}, 

537 ] 

538 payload: DiscordPayload = { 

539 "content": "New Subscription", 

540 "embeds": [ 

541 get_branded_discord_embed( 

542 { 

543 "title": "New Subscription", 

544 "description": f"New subscription has been made to {target.name}.", 

545 "fields": fields, 

546 } 

547 ) 

548 ], 

549 } 

550 

551 return json.dumps(payload) 

552 

553 def get_slack_payload(self, target: User | Organization) -> str: 1a

554 if isinstance(target, User): 

555 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

556 

557 amount_display = self.data.get_amount_display() 

558 

559 fields: list[SlackText] = [ 

560 {"type": "mrkdwn", "text": f"*Product*\n{self.data.product.name}"}, 

561 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"}, 

562 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.user.email}"}, 

563 ] 

564 payload: SlackPayload = get_branded_slack_payload( 

565 { 

566 "text": "New Subscription", 

567 "blocks": [ 

568 { 

569 "type": "section", 

570 "text": { 

571 "type": "mrkdwn", 

572 "text": f"New subscription has been made to {target.name}.", 

573 }, 

574 "fields": fields, 

575 } 

576 ], 

577 } 

578 ) 

579 

580 return json.dumps(payload) 

581 

582 

583class WebhookSubscriptionUpdatedPayloadBase(BaseWebhookPayload): 1a

584 """ 

585 Base class for subscription updated payloads. 

586 """ 

587 

588 type: ( 1a

589 Literal[WebhookEventType.subscription_updated] 

590 | Literal[WebhookEventType.subscription_active] 

591 | Literal[WebhookEventType.subscription_canceled] 

592 | Literal[WebhookEventType.subscription_uncanceled] 

593 | Literal[WebhookEventType.subscription_revoked] 

594 ) 

595 data: SubscriptionSchema 1a

596 

597 def _get_active_discord_payload(self, target: User | Organization) -> str: 1a

598 fields = self._get_discord_fields(target) 

599 payload: DiscordPayload = { 

600 "content": "Subscription is now active.", 

601 "embeds": [ 

602 get_branded_discord_embed( 

603 { 

604 "title": "Active Subscription", 

605 "description": "Subscription is now active.", 

606 "fields": fields, 

607 } 

608 ) 

609 ], 

610 } 

611 

612 return json.dumps(payload) 

613 

614 def _get_active_slack_payload(self, target: User | Organization) -> str: 1a

615 fields = self._get_slack_fields(target) 

616 payload: SlackPayload = get_branded_slack_payload( 

617 { 

618 "text": "Subscription is now active.", 

619 "blocks": [ 

620 { 

621 "type": "section", 

622 "text": { 

623 "type": "mrkdwn", 

624 "text": "Subscription is now active.", 

625 }, 

626 "fields": fields, 

627 } 

628 ], 

629 } 

630 ) 

631 

632 return json.dumps(payload) 

633 

634 def _get_canceled_discord_payload(self, target: User | Organization) -> str: 1a

635 fields = self._get_discord_fields(target) 

636 if self.data.ends_at: 

637 ends_at = format_date(self.data.ends_at, locale="en_US") 

638 else: 

639 ends_at = format_date(self.data.ended_at, locale="en_US") 

640 fields.append({"name": "Ends At", "value": ends_at}) 

641 

642 payload: DiscordPayload = { 

643 "content": "Subscription has been canceled.", 

644 "embeds": [ 

645 get_branded_discord_embed( 

646 { 

647 "title": "Canceled Subscription", 

648 "description": "Subscription has been canceled.", 

649 "fields": fields, 

650 } 

651 ) 

652 ], 

653 } 

654 

655 return json.dumps(payload) 

656 

657 def _get_canceled_slack_payload(self, target: User | Organization) -> str: 1a

658 fields = self._get_slack_fields(target) 

659 if self.data.ends_at: 

660 ends_at = format_date(self.data.ends_at, locale="en_US") 

661 else: 

662 ends_at = format_date(self.data.ended_at, locale="en_US") 

663 fields.append({"type": "mrkdwn", "text": f"*Ends At*\n{ends_at}"}) 

664 

665 payload: SlackPayload = get_branded_slack_payload( 

666 { 

667 "text": "Subscription has been canceled.", 

668 "blocks": [ 

669 { 

670 "type": "section", 

671 "text": { 

672 "type": "mrkdwn", 

673 "text": "Subscription has been canceled.", 

674 }, 

675 "fields": fields, 

676 } 

677 ], 

678 } 

679 ) 

680 

681 return json.dumps(payload) 

682 

683 def _get_uncanceled_discord_payload(self, target: User | Organization) -> str: 1a

684 payload: DiscordPayload = { 

685 "content": "Subscription has been uncanceled.", 

686 "embeds": [ 

687 get_branded_discord_embed( 

688 { 

689 "title": "Uncanceled Subscription", 

690 "description": "Subscription has been restored.", 

691 "fields": self._get_discord_fields(target), 

692 } 

693 ) 

694 ], 

695 } 

696 

697 return json.dumps(payload) 

698 

699 def _get_uncanceled_slack_payload(self, target: User | Organization) -> str: 1a

700 payload: SlackPayload = get_branded_slack_payload( 

701 { 

702 "text": "Subscription has been uncanceled.", 

703 "blocks": [ 

704 { 

705 "type": "section", 

706 "text": { 

707 "type": "mrkdwn", 

708 "text": "Subscription has been restored.", 

709 }, 

710 "fields": self._get_slack_fields(target), 

711 } 

712 ], 

713 } 

714 ) 

715 

716 return json.dumps(payload) 

717 

718 def _get_revoked_discord_payload(self, target: User | Organization) -> str: 1a

719 payload: DiscordPayload = { 

720 "content": "Subscription has been revoked.", 

721 "embeds": [ 

722 get_branded_discord_embed( 

723 { 

724 "title": "Revoked Subscription", 

725 "description": "Subscription has been revoked.", 

726 "fields": self._get_discord_fields(target), 

727 } 

728 ) 

729 ], 

730 } 

731 

732 return json.dumps(payload) 

733 

734 def _get_revoked_slack_payload(self, target: User | Organization) -> str: 1a

735 payload: SlackPayload = get_branded_slack_payload( 

736 { 

737 "text": "Subscription has been revoked.", 

738 "blocks": [ 

739 { 

740 "type": "section", 

741 "text": { 

742 "type": "mrkdwn", 

743 "text": "Subscription has been revoked.", 

744 }, 

745 "fields": self._get_slack_fields(target), 

746 } 

747 ], 

748 } 

749 ) 

750 

751 return json.dumps(payload) 

752 

753 def _get_discord_fields( 1a

754 self, target: User | Organization 

755 ) -> list[DiscordEmbedField]: 

756 amount_display = self.data.get_amount_display() 

757 fields: list[DiscordEmbedField] = [ 

758 {"name": "Product", "value": self.data.product.name}, 

759 {"name": "Amount", "value": amount_display}, 

760 {"name": "Customer", "value": self.data.user.email}, 

761 {"name": "Status", "value": self.data.status}, 

762 ] 

763 return fields 

764 

765 def _get_slack_fields(self, target: User | Organization) -> list[SlackText]: 1a

766 amount_display = self.data.get_amount_display() 

767 fields: list[SlackText] = [ 

768 {"type": "mrkdwn", "text": f"*Product*\n{self.data.product.name}"}, 

769 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"}, 

770 {"type": "mrkdwn", "text": f"*Customer*\n{self.data.user.email}"}, 

771 {"type": "mrkdwn", "text": f"*Status*\n{self.data.status}"}, 

772 ] 

773 return fields 

774 

775 

776class WebhookSubscriptionUpdatedPayload(WebhookSubscriptionUpdatedPayloadBase): 1a

777 """ 

778 Sent when a subscription is updated. This event fires for all changes to the subscription, including renewals. 

779 

780 If you want more specific events, you can listen to `subscription.active`, `subscription.canceled`, and `subscription.revoked`. 

781 

782 To listen specifically for renewals, you can listen to `order.created` events and check the `billing_reason` field. 

783 

784 **Discord & Slack support:** On cancellation and revocation. Renewals are skipped. 

785 """ 

786 

787 type: Literal[WebhookEventType.subscription_updated] 1a

788 data: SubscriptionSchema 1a

789 

790 def get_discord_payload(self, target: User | Organization) -> str: 1a

791 if isinstance(target, User): 

792 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

793 

794 if SubscriptionStatus.is_revoked(self.data.status): 

795 return self._get_revoked_discord_payload(target) 

796 

797 # Avoid to send notifications for subscription renewals (not interesting) 

798 # TODO: Notify about upgrades and downgrades 

799 if not self.data.ends_at and not self.data.ended_at: 

800 raise SkipEvent(self.type, WebhookFormat.discord) 

801 

802 return self._get_canceled_discord_payload(target) 

803 

804 def get_slack_payload(self, target: User | Organization) -> str: 1a

805 if isinstance(target, User): 

806 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

807 

808 if SubscriptionStatus.is_revoked(self.data.status): 

809 return self._get_revoked_discord_payload(target) 

810 

811 # Avoid to send notifications for subscription renewals (not interesting) 

812 # TODO: Notify about upgrades and downgrades 

813 if not self.data.ends_at and not self.data.ended_at: 

814 raise SkipEvent(self.type, WebhookFormat.slack) 

815 

816 return self._get_canceled_slack_payload(target) 

817 

818 

819class WebhookSubscriptionActivePayload(WebhookSubscriptionUpdatedPayloadBase): 1a

820 """ 

821 Sent when a subscription becomes active, 

822 whether because it's a new paid subscription or because payment was recovered. 

823 

824 **Discord & Slack support:** Full 

825 """ 

826 

827 type: Literal[WebhookEventType.subscription_active] 1a

828 data: SubscriptionSchema 1a

829 

830 def get_discord_payload(self, target: User | Organization) -> str: 1a

831 if isinstance(target, User): 

832 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

833 

834 return self._get_active_discord_payload(target) 

835 

836 def get_slack_payload(self, target: User | Organization) -> str: 1a

837 if isinstance(target, User): 

838 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

839 

840 return self._get_active_slack_payload(target) 

841 

842 

843class WebhookSubscriptionCanceledPayload(WebhookSubscriptionUpdatedPayloadBase): 1a

844 """ 

845 Sent when a subscription is canceled. 

846 Customers might still have access until the end of the current period. 

847 

848 **Discord & Slack support:** Full 

849 """ 

850 

851 type: Literal[WebhookEventType.subscription_canceled] 1a

852 data: SubscriptionSchema 1a

853 

854 def get_discord_payload(self, target: User | Organization) -> str: 1a

855 if isinstance(target, User): 

856 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

857 

858 return self._get_canceled_discord_payload(target) 

859 

860 def get_slack_payload(self, target: User | Organization) -> str: 1a

861 if isinstance(target, User): 

862 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

863 

864 return self._get_canceled_slack_payload(target) 

865 

866 

867class WebhookSubscriptionUncanceledPayload(WebhookSubscriptionUpdatedPayloadBase): 1a

868 """ 

869 Sent when a subscription is uncanceled. 

870 

871 **Discord & Slack support:** Full 

872 """ 

873 

874 type: Literal[WebhookEventType.subscription_uncanceled] 1a

875 data: SubscriptionSchema 1a

876 

877 def get_discord_payload(self, target: User | Organization) -> str: 1a

878 if isinstance(target, User): 

879 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

880 

881 return self._get_uncanceled_discord_payload(target) 

882 

883 def get_slack_payload(self, target: User | Organization) -> str: 1a

884 if isinstance(target, User): 

885 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

886 

887 return self._get_uncanceled_slack_payload(target) 

888 

889 

890class WebhookSubscriptionRevokedPayload(WebhookSubscriptionUpdatedPayloadBase): 1a

891 """ 

892 Sent when a subscription is revoked, the user loses access immediately. 

893 Happens when the subscription is canceled, or payment is past due. 

894 

895 **Discord & Slack support:** Full 

896 """ 

897 

898 type: Literal[WebhookEventType.subscription_revoked] 1a

899 data: SubscriptionSchema 1a

900 

901 def get_discord_payload(self, target: User | Organization) -> str: 1a

902 if isinstance(target, User): 

903 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

904 

905 return self._get_revoked_discord_payload(target) 

906 

907 def get_slack_payload(self, target: User | Organization) -> str: 1a

908 if isinstance(target, User): 

909 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

910 

911 return self._get_revoked_slack_payload(target) 

912 

913 

914class WebhookRefundBase(BaseWebhookPayload): 1a

915 """ 

916 Base Refund 

917 """ 

918 

919 type: ( 1a

920 Literal[WebhookEventType.refund_created] 

921 | Literal[WebhookEventType.refund_updated] 

922 ) 

923 data: RefundSchema 1a

924 

925 def _get_discord_fields( 1a

926 self, target: User | Organization 

927 ) -> list[DiscordEmbedField]: 

928 amount_display = self.data.get_amount_display() 

929 fields: list[DiscordEmbedField] = [ 

930 {"name": "Amount", "value": amount_display}, 

931 {"name": "Status", "value": self.data.status}, 

932 ] 

933 return fields 

934 

935 def _get_slack_fields(self, target: User | Organization) -> list[SlackText]: 1a

936 amount_display = self.data.get_amount_display() 

937 fields: list[SlackText] = [ 

938 {"type": "mrkdwn", "text": f"*Amount*\n{amount_display}"}, 

939 {"type": "mrkdwn", "text": f"*Status*\n{self.data.status}"}, 

940 ] 

941 return fields 

942 

943 

944class WebhookRefundCreatedPayload(WebhookRefundBase): 1a

945 """ 

946 Sent when a refund is created regardless of status. 

947 

948 **Discord & Slack support:** Full 

949 """ 

950 

951 type: Literal[WebhookEventType.refund_created] 1a

952 data: RefundSchema 1a

953 

954 def get_discord_payload(self, target: User | Organization) -> str: 1a

955 if isinstance(target, User): 

956 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

957 

958 payload: DiscordPayload = { 

959 "content": "Refund Created", 

960 "embeds": [ 

961 get_branded_discord_embed( 

962 { 

963 "title": "Refund Created", 

964 "description": f"Refund has been created for {target.name}.", 

965 "fields": self._get_discord_fields(target), 

966 } 

967 ) 

968 ], 

969 } 

970 return json.dumps(payload) 

971 

972 def get_slack_payload(self, target: User | Organization) -> str: 1a

973 if isinstance(target, User): 

974 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

975 

976 payload: SlackPayload = get_branded_slack_payload( 

977 { 

978 "text": "Refund Created", 

979 "blocks": [ 

980 { 

981 "type": "section", 

982 "text": { 

983 "type": "mrkdwn", 

984 "text": f"Refund has been created for {target.name}.", 

985 }, 

986 "fields": self._get_slack_fields(target), 

987 } 

988 ], 

989 } 

990 ) 

991 return json.dumps(payload) 

992 

993 

994class WebhookRefundUpdatedPayload(WebhookRefundBase): 1a

995 """ 

996 Sent when a refund is updated. 

997 

998 **Discord & Slack support:** Full 

999 """ 

1000 

1001 type: Literal[WebhookEventType.refund_updated] 1a

1002 data: RefundSchema 1a

1003 

1004 def get_discord_payload(self, target: User | Organization) -> str: 1a

1005 if isinstance(target, User): 

1006 raise UnsupportedTarget(target, self.__class__, WebhookFormat.discord) 

1007 

1008 payload: DiscordPayload = { 

1009 "content": "Refund Updated", 

1010 "embeds": [ 

1011 get_branded_discord_embed( 

1012 { 

1013 "title": "Refund Updated", 

1014 "description": f"Refund has been updated for {target.name}.", 

1015 "fields": self._get_discord_fields(target), 

1016 } 

1017 ) 

1018 ], 

1019 } 

1020 return json.dumps(payload) 

1021 

1022 def get_slack_payload(self, target: User | Organization) -> str: 1a

1023 if isinstance(target, User): 

1024 raise UnsupportedTarget(target, self.__class__, WebhookFormat.slack) 

1025 

1026 payload: SlackPayload = get_branded_slack_payload( 

1027 { 

1028 "text": "Refund Updated", 

1029 "blocks": [ 

1030 { 

1031 "type": "section", 

1032 "text": { 

1033 "type": "mrkdwn", 

1034 "text": f"Refund has been updated for {target.name}.", 

1035 }, 

1036 "fields": self._get_slack_fields(target), 

1037 } 

1038 ], 

1039 } 

1040 ) 

1041 return json.dumps(payload) 

1042 

1043 

1044class WebhookProductCreatedPayload(BaseWebhookPayload): 1a

1045 """ 

1046 Sent when a new product is created. 

1047 

1048 **Discord & Slack support:** Basic 

1049 """ 

1050 

1051 type: Literal[WebhookEventType.product_created] 1a

1052 data: ProductSchema 1a

1053 

1054 

1055class WebhookProductUpdatedPayload(BaseWebhookPayload): 1a

1056 """ 

1057 Sent when a product is updated. 

1058 

1059 **Discord & Slack support:** Basic 

1060 """ 

1061 

1062 type: Literal[WebhookEventType.product_updated] 1a

1063 data: ProductSchema 1a

1064 

1065 

1066class WebhookOrganizationUpdatedPayload(BaseWebhookPayload): 1a

1067 """ 

1068 Sent when a organization is updated. 

1069 

1070 **Discord & Slack support:** Basic 

1071 """ 

1072 

1073 type: Literal[WebhookEventType.organization_updated] 1a

1074 data: OrganizationSchema 1a

1075 

1076 

1077class WebhookBenefitCreatedPayload(BaseWebhookPayload): 1a

1078 """ 

1079 Sent when a new benefit is created. 

1080 

1081 **Discord & Slack support:** Basic 

1082 """ 

1083 

1084 type: Literal[WebhookEventType.benefit_created] 1a

1085 data: BenefitSchema 1a

1086 

1087 

1088class WebhookBenefitUpdatedPayload(BaseWebhookPayload): 1a

1089 """ 

1090 Sent when a benefit is updated. 

1091 

1092 **Discord & Slack support:** Basic 

1093 """ 

1094 

1095 type: Literal[WebhookEventType.benefit_updated] 1a

1096 data: BenefitSchema 1a

1097 

1098 

1099class WebhookBenefitGrantCreatedPayload(BaseWebhookPayload): 1a

1100 """ 

1101 Sent when a new benefit grant is created. 

1102 

1103 **Discord & Slack support:** Basic 

1104 """ 

1105 

1106 type: Literal[WebhookEventType.benefit_grant_created] 1a

1107 data: BenefitGrantWebhook 1a

1108 

1109 

1110class WebhookBenefitGrantUpdatedPayload(BaseWebhookPayload): 1a

1111 """ 

1112 Sent when a benefit grant is updated. 

1113 

1114 **Discord & Slack support:** Basic 

1115 """ 

1116 

1117 type: Literal[WebhookEventType.benefit_grant_updated] 1a

1118 data: BenefitGrantWebhook 1a

1119 

1120 

1121class WebhookBenefitGrantCycledPayload(BaseWebhookPayload): 1a

1122 """ 

1123 Sent when a benefit grant is cycled, 

1124 meaning the related subscription has been renewed for another period. 

1125 

1126 **Discord & Slack support:** Basic 

1127 """ 

1128 

1129 type: Literal[WebhookEventType.benefit_grant_cycled] 1a

1130 data: BenefitGrantWebhook 1a

1131 

1132 

1133class WebhookBenefitGrantRevokedPayload(BaseWebhookPayload): 1a

1134 """ 

1135 Sent when a benefit grant is revoked. 

1136 

1137 **Discord & Slack support:** Basic 

1138 """ 

1139 

1140 type: Literal[WebhookEventType.benefit_grant_revoked] 1a

1141 data: BenefitGrantWebhook 1a

1142 

1143 

1144WebhookPayload = Annotated[ 1a

1145 WebhookCheckoutCreatedPayload 

1146 | WebhookCheckoutUpdatedPayload 

1147 | WebhookCustomerCreatedPayload 

1148 | WebhookCustomerUpdatedPayload 

1149 | WebhookCustomerDeletedPayload 

1150 | WebhookCustomerStateChangedPayload 

1151 | WebhookCustomerSeatAssignedPayload 

1152 | WebhookCustomerSeatClaimedPayload 

1153 | WebhookCustomerSeatRevokedPayload 

1154 | WebhookOrderCreatedPayload 

1155 | WebhookOrderUpdatedPayload 

1156 | WebhookOrderPaidPayload 

1157 | WebhookOrderRefundedPayload 

1158 | WebhookSubscriptionCreatedPayload 

1159 | WebhookSubscriptionUpdatedPayload 

1160 | WebhookSubscriptionActivePayload 

1161 | WebhookSubscriptionCanceledPayload 

1162 | WebhookSubscriptionUncanceledPayload 

1163 | WebhookSubscriptionRevokedPayload 

1164 | WebhookRefundCreatedPayload 

1165 | WebhookRefundUpdatedPayload 

1166 | WebhookProductCreatedPayload 

1167 | WebhookProductUpdatedPayload 

1168 | WebhookOrganizationUpdatedPayload 

1169 | WebhookBenefitCreatedPayload 

1170 | WebhookBenefitUpdatedPayload 

1171 | WebhookBenefitGrantCreatedPayload 

1172 | WebhookBenefitGrantUpdatedPayload 

1173 | WebhookBenefitGrantCycledPayload 

1174 | WebhookBenefitGrantRevokedPayload, 

1175 Discriminator(discriminator="type"), 

1176] 

1177WebhookPayloadTypeAdapter: TypeAdapter[WebhookPayload] = TypeAdapter(WebhookPayload) 1a

1178 

1179 

1180class WebhookAPIRoute(APIRoute): 1a

1181 """ 

1182 Since FastAPI documents webhook through API routes with a body field, 

1183 we might be in a situation where it generates `-Input` and `-Output` variants 

1184 of the schemas because it sees them as "input" schemas. 

1185 

1186 But we don't want that. 

1187 

1188 The trick here is to force the body field to be in "serialization" mode, so we 

1189 prevent Pydantic to generate the `-Input` and `-Output` variants. 

1190 """ 

1191 

1192 def __init__(self, *args: Any, **kwargs: Any) -> None: 1a

1193 super().__init__(*args, **kwargs) 

1194 if self.body_field is not None: 1194 ↛ exitline 1194 didn't return from function '__init__' because the condition on line 1194 was always true

1195 self.body_field.mode = "serialization" 

1196 

1197 

1198def document_webhooks(app: FastAPI) -> None: 1a

1199 def _endpoint(body: Any) -> None: ... 1199 ↛ exitline 1199 didn't return from function '_endpoint' because

1200 

1201 webhooks_schemas: tuple[type[BaseWebhookPayload]] = typing.get_args( 

1202 typing.get_args(WebhookPayload)[0] 

1203 ) 

1204 for webhook_schema in webhooks_schemas: 

1205 signature = Signature( 

1206 [ 

1207 Parameter( 

1208 name="body", 

1209 kind=Parameter.POSITIONAL_OR_KEYWORD, 

1210 annotation=webhook_schema, 

1211 ) 

1212 ] 

1213 ) 

1214 

1215 event_type_annotation = webhook_schema.model_fields["type"].annotation 

1216 event_type: WebhookEventType = get_args(event_type_annotation)[0] 

1217 

1218 endpoint = with_signature(signature)(_endpoint) 

1219 

1220 app.webhooks.add_api_route( 

1221 event_type, 

1222 endpoint, 

1223 methods=["POST"], 

1224 summary=event_type, 

1225 description=inspect.getdoc(webhook_schema), 

1226 route_class_override=WebhookAPIRoute, 

1227 )