Coverage for functions \ flipdare \ payments \ data \ payment_schedule.py: 88%

126 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12from flipdare.app_log import LOG 

13from flipdare.constants import IS_DEBUG 

14from flipdare.generated.model.payment.payment_model import PaymentModel 

15from flipdare.generated.model.payment.payment_schedule_model import PaymentScheduleModel 

16from flipdare.generated.shared.payment.payment_status import PaymentStatus 

17from flipdare.payments.data.payment_event_context import PaymentEventContext 

18from flipdare.util.time_util import TimeUtil 

19from flipdare.wrapper.payment.payment_event_wrapper import PaymentEventWrapper 

20 

21 

22class PaymentSchedule: 

23 

24 __slots__ = ( 

25 "_can_schedule", 

26 "_events", 

27 "_evt_ctx", 

28 "_has_changed", 

29 "_payment", 

30 "_pledge_id", 

31 "_updated_schedule", 

32 "_updated_status", 

33 ) 

34 

35 _payment: PaymentModel 

36 _pledge_id: str 

37 

38 _events: list[PaymentEventWrapper] 

39 _evt_ctx: PaymentEventContext 

40 _can_schedule: bool 

41 _has_changed: bool 

42 _updated_schedule: PaymentScheduleModel | None 

43 _updated_status: PaymentStatus | None 

44 

45 def __init__( 

46 self, pledge_id: str, payment: PaymentModel, events: list[PaymentEventWrapper] 

47 ) -> None: 

48 self._pledge_id = pledge_id 

49 self._payment = payment 

50 self._events = events 

51 self._evt_ctx = PaymentEventContext(amount=payment.amount, events=events) 

52 self._can_schedule = False 

53 self._has_changed = False 

54 self._updated_schedule = None 

55 self._updated_status = None 

56 self._update_schedule() 

57 

58 @property 

59 def can_schedule(self) -> bool: 

60 return self._can_schedule 

61 

62 @property 

63 def updated_schedule(self) -> PaymentScheduleModel | None: 

64 return self._updated_schedule 

65 

66 @property 

67 def updated_status(self) -> PaymentStatus | None: 

68 return self._updated_status 

69 

70 @property 

71 def _dbg_msg(self) -> str: 

72 payment = self._payment 

73 doc_id = self._pledge_id 

74 payment_method_id = payment.payment_method_id 

75 return f"(pledge {doc_id}, pi_id={payment.payment_intent_id}, pm_id={payment_method_id})" 

76 

77 def _update_schedule(self) -> PaymentScheduleModel | None: # noqa: PLR0915, PLR0912 

78 can_schedule = self._payment.last_event is not None 

79 

80 # we can only schedule if there is a payment event with an amount_captured > 0 

81 if not can_schedule: 

82 if IS_DEBUG: 

83 msg = f"{self._dbg_msg} - cannot schedule payment because there are no payment events with a captured amount yet." 

84 LOG().debug(msg) 

85 self._can_schedule = False 

86 return None 

87 

88 cur_schedule = self._payment.schedule 

89 # we set the dates based on the available_on date. 

90 available_on = cur_schedule.available_on 

91 if available_on is None: 

92 # this means the charge has not been captured. 

93 if IS_DEBUG: 

94 msg = f"{self._dbg_msg} - cannot schedule payment because there is no captured charge yet." 

95 LOG().debug(msg) 

96 

97 self._can_schedule = False 

98 return None 

99 

100 payment_status = self._payment.status 

101 

102 # check existing status 

103 if payment_status == PaymentStatus.CAPTURE and self.is_status_complete( 

104 PaymentStatus.CAPTURE 

105 ): 

106 if IS_DEBUG: 

107 msg = f"{self._dbg_msg} - Payment is already in CAPTURE status and capture is complete. No need to update schedule." 

108 LOG().debug(msg) 

109 

110 self._can_schedule = False 

111 return None 

112 elif payment_status == PaymentStatus.REFUND and self.is_status_complete( 

113 PaymentStatus.REFUND 

114 ): 

115 if IS_DEBUG: 

116 msg = f"{self._dbg_msg} - Payment is already in REFUND status and refund is complete. No need to update schedule." 

117 LOG().debug(msg) 

118 

119 self._can_schedule = False 

120 return None 

121 elif payment_status == PaymentStatus.TRANSFER and self.is_status_complete( 

122 PaymentStatus.TRANSFER 

123 ): 

124 if IS_DEBUG: 

125 msg = f"{self._dbg_msg} - Payment is already in TRANSFER status and transfer is complete. No need to update schedule." 

126 LOG().debug(msg) 

127 

128 self._can_schedule = False 

129 return None 

130 

131 # if we get here the status is: 

132 # PaymentStatus.WAITING, PaymentStatus.PENDING, PaymentStatus.HOLD 

133 # or is incomplete. 

134 available_on_dt = TimeUtil.epoch_to_utc_dt(available_on) 

135 delay_days = self._payment.risk_assessment.delay_days 

136 transfer_on_dt = TimeUtil.get_utc_time_days_ago_from(available_on_dt, delay_days) 

137 

138 schedule = PaymentScheduleModel( 

139 available_on=available_on, transfer_on=TimeUtil.dt_to_epoch(transfer_on_dt) 

140 ) 

141 updated_status: PaymentStatus | None = None 

142 if IS_DEBUG: 

143 msg = f"{self._dbg_msg} - current payment status: {payment_status}, available_on: {available_on_dt}, delay_days: {delay_days}" 

144 LOG().debug(msg) 

145 

146 if payment_status == PaymentStatus.REFUND: 

147 # set the refund date to earliest possible date (available_on) to ensure refunds are processed asap 

148 if IS_DEBUG: 

149 msg = f"{self._dbg_msg} - Payment is in REFUND status. Setting refund_on to {available_on_dt}." 

150 LOG().debug(msg) 

151 

152 schedule.transfer_on = None 

153 schedule.refund_on = available_on 

154 updated_status = PaymentStatus.REFUND 

155 # we need to check if we can transfer or we need to capture first.. 

156 # 

157 elif self.is_status_complete(PaymentStatus.CAPTURE): 

158 # if capture is complete, we can transfer immediately. 

159 if IS_DEBUG: 

160 msg = f"{self._dbg_msg} - capture is complete. Setting transfer_on to {transfer_on_dt}." 

161 LOG().debug(msg) 

162 

163 updated_status = PaymentStatus.TRANSFER 

164 else: 

165 # if capture is not complete, we set the capture date to the available_on date to ensure it is captured asap. 

166 if IS_DEBUG: 

167 msg = f"{self._dbg_msg} - capture is not complete. Setting capture_on to {available_on_dt}." 

168 LOG().debug(msg) 

169 

170 schedule.capture_on = available_on 

171 updated_status = PaymentStatus.CAPTURE 

172 

173 self._can_schedule = True 

174 self._has_changed = True 

175 self._updated_schedule = schedule 

176 self._updated_status = updated_status 

177 return schedule 

178 

179 def is_status_complete(self, status: PaymentStatus) -> bool: 

180 evt_ctx = self._evt_ctx 

181 

182 # captured on is the most important date- it determines all the other dates... 

183 captured_on = evt_ctx.captured_on 

184 if captured_on is None: 

185 return False 

186 

187 match status: 

188 case PaymentStatus.WAITING | PaymentStatus.PENDING | PaymentStatus.HOLD: 

189 return False 

190 case PaymentStatus.CAPTURE: 

191 return evt_ctx.capture_complete 

192 case PaymentStatus.TRANSFER: 

193 return evt_ctx.transfer_complete 

194 case PaymentStatus.REFUND: 

195 return evt_ctx.refund_complete 

196 case PaymentStatus.COMPLETE: 

197 return ( 

198 evt_ctx.capture_complete 

199 or evt_ctx.transfer_complete 

200 or evt_ctx.refund_complete 

201 ) 

202 case PaymentStatus.FAILED: 

203 # if it's failed, it's complete in the sense that we won't process it anymore. 

204 return True