Skip to content

Commit

Permalink
server/transaction: handle refunded transactions that have been paid …
Browse files Browse the repository at this point in the history
…out during payout

Fix #4428
  • Loading branch information
frankie567 committed Nov 12, 2024
1 parent 7cd399e commit 0fbee46
Show file tree
Hide file tree
Showing 3 changed files with 281 additions and 16 deletions.
3 changes: 3 additions & 0 deletions server/polar/models/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,6 @@ def reversed_amount(self) -> int:
@property
def transferable_amount(self) -> int:
return self.amount + self.reversed_amount

def __repr__(self) -> str:
return f"Transaction(id={self.id!r}, type={self.type!r}, amount={self.amount!r}, currency={self.currency!r})"
59 changes: 43 additions & 16 deletions server/polar/transaction/service/payout.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,13 @@ async def create_payout(
unpaid_balance_transactions = await self._get_unpaid_balance_transactions(
session, account
)
payout_fees = balance_amount - balance_amount_after_fees

if account.account_type == AccountType.stripe:
transaction = await self._prepare_stripe_payout(
session,
transaction=transaction,
account=account,
unpaid_balance_transactions=unpaid_balance_transactions,
payout_fees=payout_fees,
)
elif account.account_type == AccountType.open_collective:
transaction.processor = PaymentProcessor.open_collective
Expand Down Expand Up @@ -406,7 +404,6 @@ async def _prepare_stripe_payout(
transaction: Transaction,
account: Account,
unpaid_balance_transactions: Sequence[Transaction],
payout_fees: int,
) -> Transaction:
"""
The Stripe payout is a two-steps process:
Expand All @@ -421,22 +418,51 @@ async def _prepare_stripe_payout(
transaction.processor = PaymentProcessor.stripe
transfer_group = str(transaction.id)

# Balances that we'll be able to pull money from
payment_balance_transactions = [
balance_transaction
for balance_transaction in unpaid_balance_transactions
if balance_transaction.payment_transaction is not None
and balance_transaction.payment_transaction.charge_id is not None
]

# Balances that are not tied to a payment. Typically, this is:
# * Payout fees we just created
# * Refunds that have been issued after the payment has been paid out
outstanding_balance_transactions = [
balance_transaction
for balance_transaction in unpaid_balance_transactions
if balance_transaction not in payment_balance_transactions
and balance_transaction.balance_reversal_transaction
not in payment_balance_transactions
]

# This is the amount we should subtract from the total transfer
outstanding_amount = abs(
sum(
balance_transaction.amount
for balance_transaction in outstanding_balance_transactions
)
)

# Compute transfers out of each payment balance, making sure to subtract the outstanding amount
transfers: list[tuple[str, int, Transaction]] = []
for balance_transaction in unpaid_balance_transactions:
if (
balance_transaction.payment_transaction is not None
and balance_transaction.payment_transaction.charge_id is not None
):
source_transaction = balance_transaction.payment_transaction.charge_id
transfer_amount = max(
balance_transaction.transferable_amount - payout_fees, 0
for balance_transaction in payment_balance_transactions:
assert balance_transaction.payment_transaction is not None
assert balance_transaction.payment_transaction.charge_id is not None
source_transaction = balance_transaction.payment_transaction.charge_id
transfer_amount = max(
balance_transaction.transferable_amount - outstanding_amount, 0
)
if transfer_amount > 0:
transfers.append(
(source_transaction, transfer_amount, balance_transaction)
)
if transfer_amount > 0:
transfers.append(
(source_transaction, transfer_amount, balance_transaction)
)
payout_fees -= balance_transaction.transferable_amount - transfer_amount
outstanding_amount -= (
balance_transaction.transferable_amount - transfer_amount
)

# Make sure the expected amount of the payout actually matches the sum of the transfers
transfers_sum = sum(amount for _, amount, _ in transfers)
if transfers_sum != -transaction.amount:
raise UnmatchingTransfersAmount(-transaction.amount, transfers_sum)
Expand Down Expand Up @@ -516,6 +542,7 @@ async def _get_unpaid_balance_transactions(
Transaction.payout_transaction_id.is_(None),
)
.options(
selectinload(Transaction.balance_reversal_transaction),
selectinload(Transaction.balance_reversal_transactions),
selectinload(Transaction.payment_transaction),
)
Expand Down
235 changes: 235 additions & 0 deletions server/tests/transaction/service/test_payout.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,38 @@ async def create_payment_transaction(
return transaction


async def create_refund_transaction(
save_fixture: SaveFixture,
*,
amount: int = -1000,
charge_id: str = "STRIPE_CHARGE_ID",
refund_id: str = "STRIPE_REFUND_ID",
) -> Transaction:
transaction = Transaction(
type=TransactionType.refund,
account=None,
processor=PaymentProcessor.stripe,
currency="usd",
amount=amount,
account_currency="usd",
account_amount=amount,
tax_amount=0,
charge_id=charge_id,
refund_id=refund_id,
)
await save_fixture(transaction)
return transaction


async def create_balance_transaction(
save_fixture: SaveFixture,
*,
account: Account,
currency: str = "usd",
amount: int = 1000,
payment_transaction: Transaction | None = None,
balance_reversal_transaction: Transaction | None = None,
payout_transaction: Transaction | None = None,
) -> Transaction:
transaction = Transaction(
type=TransactionType.balance,
Expand All @@ -77,6 +102,8 @@ async def create_balance_transaction(
account_amount=amount,
tax_amount=0,
payment_transaction=payment_transaction,
balance_reversal_transaction=balance_reversal_transaction,
payout_transaction=payout_transaction,
)
await save_fixture(transaction)
return transaction
Expand Down Expand Up @@ -302,6 +329,214 @@ async def test_stripe_different_currencies(

stripe_service_mock.create_payout.assert_not_called()

async def test_stripe_refund(
self,
session: AsyncSession,
save_fixture: SaveFixture,
user: User,
stripe_service_mock: MagicMock,
) -> None:
account = Account(
status=Account.Status.ACTIVE,
account_type=AccountType.stripe,
admin_id=user.id,
country="US",
currency="usd",
is_details_submitted=True,
is_charges_enabled=True,
is_payouts_enabled=True,
processor_fees_applicable=True,
stripe_id="STRIPE_ACCOUNT_ID",
)
await save_fixture(account)

payment_transaction_1 = await create_payment_transaction(
save_fixture, charge_id="CHARGE_ID_1"
)
balance_transaction_1 = await create_balance_transaction(
save_fixture, account=account, payment_transaction=payment_transaction_1
)

payment_transaction_2 = await create_payment_transaction(
save_fixture, charge_id="CHARGE_ID_2"
)
balance_transaction_2 = await create_balance_transaction(
save_fixture, account=account, payment_transaction=payment_transaction_2
)

assert payment_transaction_1.charge_id is not None
refund_transaction_1 = await create_refund_transaction(
save_fixture,
amount=-payment_transaction_1.amount,
charge_id=payment_transaction_1.charge_id,
)
balance_transaction_3 = await create_balance_transaction(
save_fixture,
account=account,
amount=refund_transaction_1.amount,
balance_reversal_transaction=balance_transaction_1,
)

stripe_service_mock.transfer.return_value = SimpleNamespace(
id="STRIPE_TRANSFER_ID", balance_transaction="STRIPE_BALANCE_TRANSACTION_ID"
)

# then
session.expunge_all()

payout = await payout_transaction_service.create_payout(
session, account=account
)

assert payout.account == account
assert payout.processor == PaymentProcessor.stripe
assert payout.payout_id is None
assert payout.currency == "usd"
assert payout.amount < 0
assert payout.account_currency == "usd"
assert payout.account_amount < 0

assert len(payout.paid_transactions) == 3 + len(
payout.account_incurred_transactions
)
assert payout.paid_transactions[0].id == balance_transaction_1.id
assert payout.paid_transactions[1].id == balance_transaction_2.id
assert payout.paid_transactions[2].id == balance_transaction_3.id

assert len(payout.incurred_transactions) > 0
assert (
len(payout.account_incurred_transactions)
== len(payout.incurred_transactions) / 2
)

transfer_mock: MagicMock = stripe_service_mock.transfer
assert transfer_mock.call_count == 1
for call in transfer_mock.call_args_list:
assert call[0][0] == account.stripe_id
assert call[1]["source_transaction"] in [
payment_transaction_1.charge_id,
payment_transaction_2.charge_id,
]
# assert call[1]["transfer_group"] == str(payout.id)
assert call[1]["metadata"]["payout_transaction_id"] == str(payout.id)

stripe_service_mock.create_payout.assert_not_called()

async def test_stripe_refund_of_paid_payment(
self,
session: AsyncSession,
save_fixture: SaveFixture,
user: User,
stripe_service_mock: MagicMock,
) -> None:
account = Account(
status=Account.Status.ACTIVE,
account_type=AccountType.stripe,
admin_id=user.id,
country="US",
currency="usd",
is_details_submitted=True,
is_charges_enabled=True,
is_payouts_enabled=True,
processor_fees_applicable=True,
stripe_id="STRIPE_ACCOUNT_ID",
)
await save_fixture(account)

previous_payout = Transaction(
type=TransactionType.payout,
account=account,
processor=PaymentProcessor.stripe,
currency="usd",
amount=-1000,
account_currency="usd",
account_amount=-1000,
tax_amount=0,
)
await save_fixture(previous_payout)

payment_transaction_1 = await create_payment_transaction(
save_fixture, charge_id="CHARGE_ID_1"
)
balance_transaction_1 = await create_balance_transaction(
save_fixture,
account=account,
payment_transaction=payment_transaction_1,
payout_transaction=previous_payout,
)

payment_transaction_2 = await create_payment_transaction(
save_fixture, charge_id="CHARGE_ID_2"
)
balance_transaction_2 = await create_balance_transaction(
save_fixture, account=account, payment_transaction=payment_transaction_2
)

payment_transaction_3 = await create_payment_transaction(
save_fixture, charge_id="CHARGE_ID_3"
)
balance_transaction_3 = await create_balance_transaction(
save_fixture, account=account, payment_transaction=payment_transaction_3
)

assert payment_transaction_1.charge_id is not None
refund_transaction_1 = await create_refund_transaction(
save_fixture,
amount=-payment_transaction_1.amount,
charge_id=payment_transaction_1.charge_id,
)
balance_transaction_4 = await create_balance_transaction(
save_fixture,
account=account,
amount=refund_transaction_1.amount,
balance_reversal_transaction=balance_transaction_1,
)

stripe_service_mock.transfer.return_value = SimpleNamespace(
id="STRIPE_TRANSFER_ID", balance_transaction="STRIPE_BALANCE_TRANSACTION_ID"
)

# then
session.expunge_all()

payout = await payout_transaction_service.create_payout(
session, account=account
)

assert payout.account == account
assert payout.processor == PaymentProcessor.stripe
assert payout.payout_id is None
assert payout.currency == "usd"
assert payout.amount < 0
assert payout.account_currency == "usd"
assert payout.account_amount < 0

assert len(payout.paid_transactions) == 3 + len(
payout.account_incurred_transactions
)
assert payout.paid_transactions[0].id == balance_transaction_2.id
assert payout.paid_transactions[1].id == balance_transaction_3.id
assert payout.paid_transactions[2].id == balance_transaction_4.id

assert len(payout.incurred_transactions) > 0
assert (
len(payout.account_incurred_transactions)
== len(payout.incurred_transactions) / 2
)

transfer_mock: MagicMock = stripe_service_mock.transfer
assert transfer_mock.call_count == 1
for call in transfer_mock.call_args_list:
assert call[0][0] == account.stripe_id
assert call[1]["source_transaction"] in [
payment_transaction_2.charge_id,
payment_transaction_3.charge_id,
]
# assert call[1]["transfer_group"] == str(payout.id)
assert call[1]["metadata"]["payout_transaction_id"] == str(payout.id)

stripe_service_mock.create_payout.assert_not_called()

async def test_open_collective(
self, session: AsyncSession, save_fixture: SaveFixture, user: User
) -> None:
Expand Down

0 comments on commit 0fbee46

Please sign in to comment.