Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
A
aisbf
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nexlab
aisbf
Commits
0f1caa1f
Commit
0f1caa1f
authored
Apr 16, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(payments): implement smart payment retry logic
parent
fa92a96b
Changes
4
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
578 additions
and
1 deletion
+578
-1
aisbf.db
aisbf.db
+0
-0
__init__.py
aisbf/payments/subscription/__init__.py
+2
-1
retry.py
aisbf/payments/subscription/retry.py
+329
-0
test_retry.py
tests/payments/test_retry.py
+247
-0
No files found.
aisbf.db
0 → 100644
View file @
0f1caa1f
aisbf/payments/subscription/__init__.py
View file @
0f1caa1f
...
...
@@ -3,5 +3,6 @@ Subscription management module
"""
from
aisbf.payments.subscription.manager
import
SubscriptionManager
from
aisbf.payments.subscription.renewal
import
RenewalProcessor
from
aisbf.payments.subscription.retry
import
PaymentRetryProcessor
__all__
=
[
'SubscriptionManager'
,
'RenewalProcessor'
]
__all__
=
[
'SubscriptionManager'
,
'RenewalProcessor'
,
'PaymentRetryProcessor'
]
aisbf/payments/subscription/retry.py
0 → 100644
View file @
0f1caa1f
This diff is collapsed.
Click to expand it.
tests/payments/test_retry.py
0 → 100644
View file @
0f1caa1f
import
pytest
from
datetime
import
datetime
,
timedelta
from
aisbf.database
import
DatabaseManager
from
aisbf.payments.migrations
import
PaymentMigrations
from
aisbf.payments.subscription.retry
import
PaymentRetryProcessor
from
aisbf.payments.subscription.manager
import
SubscriptionManager
@
pytest
.
fixture
def
db_manager
(
tmp_path
):
"""Create test database"""
db_path
=
tmp_path
/
"test.db"
db_config
=
{
'type'
:
'sqlite'
,
'sqlite_path'
:
str
(
db_path
)
}
db
=
DatabaseManager
(
db_config
)
migrations
=
PaymentMigrations
(
db
)
migrations
.
run_migrations
()
# Create tiers using database manager
free_tier_id
=
db
.
create_tier
(
name
=
'Free'
,
description
=
'Free tier'
,
price_monthly
=
0
,
price_yearly
=
0
)
pro_tier_id
=
db
.
create_tier
(
name
=
'Pro'
,
description
=
'Pro tier'
,
price_monthly
=
10.00
,
price_yearly
=
100.00
)
# Create test user
user_id
=
db
.
create_user
(
email
=
'test@example.com'
,
username
=
'testuser'
,
password_hash
=
'hash'
)
# Update user tier to Pro and add test data
with
db
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
f
"UPDATE users SET tier_id = {pro_tier_id} WHERE id = {user_id}"
)
cursor
.
execute
(
f
"""
INSERT INTO payment_methods (id, user_id, type, identifier, is_default, is_active)
VALUES (1, {user_id}, 'crypto', 'btc_wallet', 1, 1)
"""
)
cursor
.
execute
(
f
"""
INSERT INTO subscriptions (id, user_id, tier_id, payment_method_id,
billing_cycle, status, current_period_start, current_period_end)
VALUES (1, {user_id}, {pro_tier_id}, 1, 'monthly', 'active',
datetime('now', '-30 days'), datetime('now', '-1 day'))
"""
)
cursor
.
execute
(
f
"""
INSERT INTO user_crypto_wallets (user_id, crypto_type, balance_crypto, balance_fiat)
VALUES ({user_id}, 'btc', 0.001, 50.00)
"""
)
conn
.
commit
()
return
db
@
pytest
.
mark
.
anyio
async
def
test_crypto_retry_with_sufficient_balance
(
db_manager
):
"""Test crypto payment retry when wallet has sufficient balance"""
# Mock subscription manager
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Add retry to queue
await
processor
.
add_to_retry_queue
(
subscription_id
=
1
,
user_id
=
1
,
payment_method_type
=
'crypto'
,
amount
=
10.00
)
# Process retries
result
=
await
processor
.
process_retries
()
assert
result
[
'processed'
]
==
1
assert
result
[
'successful'
]
==
1
# Check wallet balance was deducted
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT balance_fiat FROM user_crypto_wallets WHERE user_id = 1"
)
balance
=
cursor
.
fetchone
()[
0
]
assert
float
(
balance
)
==
40.00
# 50 - 10
@
pytest
.
mark
.
anyio
async
def
test_crypto_retry_insufficient_balance
(
db_manager
):
"""Test crypto payment retry when wallet has insufficient balance"""
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Add retry to queue with amount > balance
await
processor
.
add_to_retry_queue
(
subscription_id
=
1
,
user_id
=
1
,
payment_method_type
=
'crypto'
,
amount
=
100.00
# More than wallet balance
)
# Process retries
result
=
await
processor
.
process_retries
()
assert
result
[
'processed'
]
==
1
assert
result
[
'successful'
]
==
0
# Should skip due to insufficient balance
@
pytest
.
mark
.
anyio
async
def
test_fiat_retry_daily_schedule
(
db_manager
):
"""Test fiat payment retry uses daily schedule"""
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Add fiat retry to queue
await
processor
.
add_to_retry_queue
(
subscription_id
=
1
,
user_id
=
1
,
payment_method_type
=
'stripe'
,
amount
=
10.00
)
# Check next_retry_at is ~24 hours from now
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT next_retry_at FROM payment_retry_queue WHERE subscription_id = 1"
)
next_retry
=
cursor
.
fetchone
()[
0
]
next_retry_dt
=
datetime
.
fromisoformat
(
next_retry
)
expected
=
datetime
.
utcnow
()
+
timedelta
(
days
=
1
)
# Allow 5 second tolerance
assert
abs
((
next_retry_dt
-
expected
)
.
total_seconds
())
<
5
@
pytest
.
mark
.
anyio
async
def
test_max_retries_downgrades_to_free
(
db_manager
):
"""Test that max retry attempts downgrades user to free tier"""
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Get free tier ID
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT id FROM account_tiers WHERE name = 'Free' LIMIT 1"
)
free_tier_id
=
cursor
.
fetchone
()[
0
]
# Add retry with max attempts already reached
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"""
INSERT INTO payment_retry_queue
(subscription_id, user_id, payment_method_type, amount,
attempt_count, max_attempts, next_retry_at, status)
VALUES (1, 1, 'crypto', 100.00, 2, 3, datetime('now'), 'pending')
"""
)
conn
.
commit
()
# Process retries (will fail due to insufficient balance, hitting max attempts)
result
=
await
processor
.
process_retries
()
# Check user was downgraded to free tier
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT tier_id FROM users WHERE id = 1"
)
tier_id
=
cursor
.
fetchone
()[
0
]
assert
tier_id
==
free_tier_id
# Free tier
# Check subscription was cancelled
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT status FROM subscriptions WHERE id = 1"
)
status
=
cursor
.
fetchone
()[
0
]
assert
status
==
'cancelled'
@
pytest
.
mark
.
anyio
async
def
test_retry_increments_attempt_count
(
db_manager
):
"""Test that failed retry increments attempt count"""
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Add retry to queue with future next_retry_at so it gets processed
now
=
datetime
.
utcnow
()
.
isoformat
()
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
f
"""
INSERT INTO payment_retry_queue
(subscription_id, user_id, payment_method_type, amount,
attempt_count, max_attempts, next_retry_at, status)
VALUES (1, 1, 'crypto', 100.00, 0, 3, '{now}', 'pending')
"""
)
conn
.
commit
()
# Process retries (will fail due to insufficient balance)
await
processor
.
process_retries
()
# Check attempt count was incremented
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT attempt_count FROM payment_retry_queue WHERE subscription_id = 1"
)
attempt_count
=
cursor
.
fetchone
()[
0
]
assert
attempt_count
==
1
@
pytest
.
mark
.
anyio
async
def
test_successful_retry_marks_completed
(
db_manager
):
"""Test that successful retry marks entry as completed"""
class
MockSubscriptionManager
:
pass
processor
=
PaymentRetryProcessor
(
db_manager
,
MockSubscriptionManager
())
# Add retry to queue with sufficient balance
await
processor
.
add_to_retry_queue
(
subscription_id
=
1
,
user_id
=
1
,
payment_method_type
=
'crypto'
,
amount
=
10.00
)
# Process retries
await
processor
.
process_retries
()
# Check retry was marked as completed
with
db_manager
.
_get_connection
()
as
conn
:
cursor
=
conn
.
cursor
()
cursor
.
execute
(
"SELECT status FROM payment_retry_queue WHERE subscription_id = 1"
)
status
=
cursor
.
fetchone
()[
0
]
assert
status
==
'completed'
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment