Coverage for polar/transaction/service/refund.py: 28%

92 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 12:42 +0000

1import itertools 

2import math 

3 

4from sqlalchemy import select 

5from sqlalchemy.orm import joinedload 

6 

7from polar.models import Refund, Transaction 

8from polar.models.refund import RefundStatus 

9from polar.models.transaction import TransactionType 

10from polar.postgres import AsyncSession 

11from polar.transaction.repository import RefundTransactionRepository 

12 

13from .balance import balance_transaction as balance_transaction_service 

14from .base import BaseTransactionService, BaseTransactionServiceError 

15from .processor_fee import ( 

16 processor_fee_transaction as processor_fee_transaction_service, 

17) 

18 

19 

20class RefundTransactionError(BaseTransactionServiceError): ... 20 ↛ 23line 20 didn't jump to line 23 because

21 

22 

23class NotSucceededRefundError(RefundTransactionError): 

24 def __init__(self, refund: Refund) -> None: 

25 self.refund = refund 

26 super().__init__(f"Refund {refund.id} is not succeeded") 

27 

28 

29class RefundTransactionAlreadyExistsError(RefundTransactionError): 

30 def __init__(self, refund: Refund) -> None: 

31 self.refund = refund 

32 super().__init__(f"Refund transaction already exists for {refund.id}") 

33 

34 

35class NotCanceledRefundError(RefundTransactionError): 

36 def __init__(self, refund: Refund) -> None: 

37 self.refund = refund 

38 super().__init__(f"Refund {refund.id} is not canceled or failed") 

39 

40 

41class RefundTransactionDoesNotExistError(RefundTransactionError): 

42 def __init__(self, refund: Refund) -> None: 

43 self.refund = refund 

44 super().__init__(f"Refund transaction does not exist for {refund.id}") 

45 

46 

47class RefundTransactionService(BaseTransactionService): 

48 async def create( 

49 self, 

50 session: AsyncSession, 

51 *, 

52 charge_id: str, 

53 payment_transaction: Transaction, 

54 refund: Refund, 

55 ) -> Transaction: 

56 if not refund.succeeded: 

57 raise NotSucceededRefundError(refund) 

58 

59 repository = RefundTransactionRepository.from_session(session) 

60 if await repository.get_by_refund_id(refund.processor_id) is not None: 

61 raise RefundTransactionAlreadyExistsError(refund) 

62 

63 refund_transaction = Transaction( 

64 type=TransactionType.refund, 

65 processor=refund.processor, 

66 currency=refund.currency, 

67 amount=-refund.amount, 

68 account_currency=refund.currency, 

69 account_amount=-refund.amount, 

70 tax_amount=-refund.tax_amount, 

71 tax_country=payment_transaction.tax_country, 

72 tax_state=payment_transaction.tax_state, 

73 presentment_currency=refund.currency, 

74 presentment_amount=-refund.amount, 

75 presentment_tax_amount=-refund.tax_amount, 

76 customer_id=payment_transaction.customer_id, 

77 charge_id=charge_id, 

78 refund_id=refund.processor_id, 

79 polar_refund_id=refund.id, 

80 payment_customer_id=payment_transaction.payment_customer_id, 

81 payment_organization_id=payment_transaction.payment_organization_id, 

82 payment_user_id=payment_transaction.payment_user_id, 

83 pledge_id=payment_transaction.pledge_id, 

84 issue_reward_id=payment_transaction.issue_reward_id, 

85 order_id=payment_transaction.order_id, 

86 ) 

87 

88 # Compute and link fees 

89 transaction_fees = await processor_fee_transaction_service.create_refund_fees( 

90 session, refund=refund, refund_transaction=refund_transaction 

91 ) 

92 refund_transaction.incurred_transactions = transaction_fees 

93 session.add(refund_transaction) 

94 

95 # Create reversal balances if it was already balanced 

96 await self._create_reversal_balances( 

97 session, 

98 payment_transaction=payment_transaction, 

99 refund_amount=refund.amount, 

100 ) 

101 return refund_transaction 

102 

103 async def revert( 

104 self, 

105 session: AsyncSession, 

106 *, 

107 charge_id: str, 

108 payment_transaction: Transaction, 

109 refund: Refund, 

110 ) -> Transaction: 

111 if refund.status not in {RefundStatus.canceled, RefundStatus.failed}: 

112 raise NotCanceledRefundError(refund) 

113 

114 repository = RefundTransactionRepository.from_session(session) 

115 refund_transaction = await repository.get_by_refund_id(refund.processor_id) 

116 if refund_transaction is None: 

117 raise RefundTransactionDoesNotExistError(refund) 

118 

119 refund_reversal_transaction = Transaction( 

120 type=TransactionType.refund_reversal, 

121 processor=refund.processor, 

122 currency=refund.currency, 

123 amount=refund.amount, 

124 account_currency=refund.currency, 

125 account_amount=refund.amount, 

126 tax_amount=refund.tax_amount, 

127 tax_country=payment_transaction.tax_country, 

128 tax_state=payment_transaction.tax_state, 

129 presentment_currency=refund.currency, 

130 presentment_amount=refund.amount, 

131 presentment_tax_amount=refund.tax_amount, 

132 customer_id=payment_transaction.customer_id, 

133 charge_id=charge_id, 

134 refund_id=refund.processor_id, 

135 polar_refund_id=refund.id, 

136 payment_customer_id=payment_transaction.payment_customer_id, 

137 payment_organization_id=payment_transaction.payment_organization_id, 

138 payment_user_id=payment_transaction.payment_user_id, 

139 pledge_id=payment_transaction.pledge_id, 

140 issue_reward_id=payment_transaction.issue_reward_id, 

141 order_id=payment_transaction.order_id, 

142 ) 

143 session.add(refund_reversal_transaction) 

144 

145 # Create reversal balances if it was already balanced 

146 await self._create_revert_reversal_balances( 

147 session, 

148 payment_transaction=payment_transaction, 

149 refund_amount=refund.amount, 

150 ) 

151 return refund_reversal_transaction 

152 

153 async def create_reversal_balances_for_payment( 

154 self, session: AsyncSession, *, payment_transaction: Transaction 

155 ) -> list[tuple[Transaction, Transaction]]: 

156 """ 

157 Create reversal balances for a refunded payment transaction. 

158 

159 Mostly useful when releasing held balances: if a payment transaction has 

160 been refunded before the Account creation, we need to create the reversal 

161 balances so the refund is correctly accounted for. 

162 """ 

163 statement = select(Transaction).where( 

164 Transaction.type == TransactionType.refund, 

165 Transaction.charge_id == payment_transaction.charge_id, 

166 ) 

167 

168 result = await session.execute(statement) 

169 refunds = result.scalars().all() 

170 

171 reversal_balances: list[tuple[Transaction, Transaction]] = [] 

172 for refund in refunds: 

173 reversal_balances += await self._create_reversal_balances( 

174 session, 

175 payment_transaction=payment_transaction, 

176 refund_amount=refund.amount, 

177 ) 

178 

179 return reversal_balances 

180 

181 async def _create_reversal_balances( 

182 self, 

183 session: AsyncSession, 

184 *, 

185 payment_transaction: Transaction, 

186 refund_amount: int, 

187 ) -> list[tuple[Transaction, Transaction]]: 

188 total_amount = payment_transaction.amount 

189 

190 reversal_balances: list[tuple[Transaction, Transaction]] = [] 

191 balance_transactions_couples = await self._get_balance_transactions_for_payment( 

192 session, payment_transaction=payment_transaction 

193 ) 

194 for balance_transactions_couple in balance_transactions_couples: 

195 outgoing, _ = balance_transactions_couple 

196 # Refund each balance proportionally 

197 balance_refund_amount = abs( 

198 int(math.floor(outgoing.amount * refund_amount) / total_amount) 

199 ) 

200 reversal_balances.append( 

201 await balance_transaction_service.create_reversal_balance( 

202 session, 

203 balance_transactions=balance_transactions_couple, 

204 amount=balance_refund_amount, 

205 ) 

206 ) 

207 return reversal_balances 

208 

209 async def _create_revert_reversal_balances( 

210 self, 

211 session: AsyncSession, 

212 *, 

213 payment_transaction: Transaction, 

214 refund_amount: int, 

215 ) -> list[tuple[Transaction, Transaction]]: 

216 total_amount = payment_transaction.amount 

217 

218 revert_reversal_balances: list[tuple[Transaction, Transaction]] = [] 

219 reverse_balance_transactions_couples = ( 

220 await self._get_reverse_balance_transactions_for_payment( 

221 session, payment_transaction=payment_transaction 

222 ) 

223 ) 

224 for reverse_balance_transactions_couple in reverse_balance_transactions_couples: 

225 outgoing, incoming = reverse_balance_transactions_couple 

226 assert outgoing.account is not None 

227 # Reverse each balance proportionally 

228 balance_reversal_amount = abs( 

229 int(math.floor(outgoing.amount * refund_amount) / total_amount) 

230 ) 

231 ( 

232 outgoing_reversal, 

233 incoming_reversal, 

234 ) = await balance_transaction_service.create_balance( 

235 session, 

236 source_account=None, 

237 destination_account=outgoing.account, 

238 amount=balance_reversal_amount, 

239 pledge=outgoing.pledge, 

240 order=outgoing.order, 

241 issue_reward=outgoing.issue_reward, 

242 ) 

243 

244 # Tie the reversal to the original transactions, not the refunds 

245 # This way, it'll get picked up when transferring the payment 

246 # Basically, it'll do (+100 - 100 + 100) 

247 outgoing_reversal.balance_reversal_transaction = ( 

248 incoming.balance_reversal_transaction 

249 ) 

250 incoming_reversal.balance_reversal_transaction = ( 

251 outgoing.balance_reversal_transaction 

252 ) 

253 session.add(outgoing_reversal) 

254 session.add(incoming_reversal) 

255 revert_reversal_balances.append((outgoing_reversal, incoming_reversal)) 

256 

257 return revert_reversal_balances 

258 

259 async def _get_reverse_balance_transactions_for_payment( 

260 self, session: AsyncSession, *, payment_transaction: Transaction 

261 ) -> list[tuple[Transaction, Transaction]]: 

262 """ 

263 Get the balance transactions that have been reversed by the refund. 

264 """ 

265 balance_transactions_statement = select(Transaction.id).where( 

266 Transaction.type == TransactionType.balance, 

267 Transaction.payment_transaction_id == payment_transaction.id, 

268 ) 

269 statement = ( 

270 select(Transaction) 

271 .where( 

272 Transaction.type == TransactionType.balance, 

273 Transaction.balance_reversal_transaction_id.in_( 

274 balance_transactions_statement 

275 ), 

276 # WARNING: not a bulletproof solution 

277 # In most cases, reversal balances should either be platform fees or refunds, 

278 # but other situations may appear in the future. 

279 Transaction.platform_fee_type.is_(None), 

280 ) 

281 .order_by( 

282 Transaction.balance_correlation_key, 

283 Transaction.account_id.nulls_last(), 

284 ) 

285 .options( 

286 joinedload(Transaction.account), 

287 joinedload(Transaction.pledge), 

288 joinedload(Transaction.order), 

289 joinedload(Transaction.issue_reward), 

290 joinedload(Transaction.balance_reversal_transaction), 

291 ) 

292 ) 

293 

294 result = await session.execute(statement) 

295 transactions = list(result.scalars().all()) 

296 return [ 

297 (t1, t2) 

298 for _, (t1, t2) in itertools.groupby( 

299 transactions, key=lambda t: t.balance_correlation_key 

300 ) 

301 ] 

302 

303 

304refund_transaction = RefundTransactionService(Transaction)