Fix retry processor schema/code bugs and align retry tests

Real bugs surfaced by the retry tests (code referenced schema that did not
exist / wrong table):
- subscriptions was missing cancelled_at (written when a renewal exhausts its
  retries and cancels the subscription).
- payment_retry_queue was missing last_attempt_at (written when scheduling the
  next retry).
- retry.py queried a non-existent `tiers` table for the free tier; corrected to
  account_tiers (and is_default = 1 for cross-DB boolean compatibility).

Migration changes add the two columns to the CREATE statements and provide
idempotent, cross-DB (SQLite PRAGMA / MySQL INFORMATION_SCHEMA) ALTERs for
existing databases.

test_retry: crypto retries are intentionally skipped without incrementing while
the wallet is unfunded, so the increment/downgrade tests now drive a failing
gateway charge instead, and expect the same free tier the code selects.

Verified all payment migration DDL (CREATE + new ALTERs + INFORMATION_SCHEMA
existence checks) executes cleanly on MariaDB/MySQL as well as SQLite.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent eb63b216
...@@ -65,6 +65,8 @@ class PaymentMigrations: ...@@ -65,6 +65,8 @@ class PaymentMigrations:
self._create_wallet_tables(cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type) self._create_wallet_tables(cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type)
self._add_stripe_customer_id_column(cursor) self._add_stripe_customer_id_column(cursor)
self._add_payment_method_gateway_column(cursor) self._add_payment_method_gateway_column(cursor)
self._add_payment_retry_last_attempt_column(cursor)
self._add_subscription_cancelled_at_column(cursor)
self._migrate_per_payment_addresses(cursor) self._migrate_per_payment_addresses(cursor)
self._insert_default_data(cursor) self._insert_default_data(cursor)
...@@ -244,6 +246,7 @@ class PaymentMigrations: ...@@ -244,6 +246,7 @@ class PaymentMigrations:
last_error {text_type}, last_error {text_type},
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT {timestamp_default}, created_at TIMESTAMP DEFAULT {timestamp_default},
last_attempt_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL, completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
) )
...@@ -289,6 +292,7 @@ class PaymentMigrations: ...@@ -289,6 +292,7 @@ class PaymentMigrations:
current_period_end TIMESTAMP NOT NULL, current_period_end TIMESTAMP NOT NULL,
cancel_at_period_end {boolean_type} DEFAULT 0, cancel_at_period_end {boolean_type} DEFAULT 0,
pending_tier_id INTEGER, pending_tier_id INTEGER,
cancelled_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT {timestamp_default}, created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (user_id) REFERENCES users(id),
...@@ -604,6 +608,28 @@ class PaymentMigrations: ...@@ -604,6 +608,28 @@ class PaymentMigrations:
except Exception as e: except Exception as e:
logger.warning(f"Legacy queue table migration check: {e}") logger.warning(f"Legacy queue table migration check: {e}")
def _add_subscription_cancelled_at_column(self, cursor):
"""Ensure subscriptions has a cancelled_at column (written when a renewal
exhausts its retries and the subscription is cancelled)."""
try:
if (self._table_exists(cursor, 'subscriptions')
and not self._column_exists(cursor, 'subscriptions', 'cancelled_at')):
cursor.execute("ALTER TABLE subscriptions ADD COLUMN cancelled_at TIMESTAMP NULL")
logger.info("✅ Added cancelled_at column to subscriptions table")
except Exception as e:
logger.warning(f"Migration check for subscriptions.cancelled_at column: {e}")
def _add_payment_retry_last_attempt_column(self, cursor):
"""Ensure payment_retry_queue has a last_attempt_at column (written by the
retry processor when scheduling the next attempt)."""
try:
if (self._table_exists(cursor, 'payment_retry_queue')
and not self._column_exists(cursor, 'payment_retry_queue', 'last_attempt_at')):
cursor.execute("ALTER TABLE payment_retry_queue ADD COLUMN last_attempt_at TIMESTAMP NULL")
logger.info("✅ Added last_attempt_at column to payment_retry_queue table")
except Exception as e:
logger.warning(f"Migration check for payment_retry_queue.last_attempt_at column: {e}")
def _add_payment_method_gateway_column(self, cursor): def _add_payment_method_gateway_column(self, cursor):
"""Ensure payment_methods has a gateway column. """Ensure payment_methods has a gateway column.
......
...@@ -276,7 +276,7 @@ class PaymentRetryProcessor: ...@@ -276,7 +276,7 @@ class PaymentRetryProcessor:
# Get free tier ID # Get free tier ID
with self.db._get_connection() as conn: with self.db._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id FROM tiers WHERE is_default = TRUE OR name = 'Free' LIMIT 1") cursor.execute("SELECT id FROM account_tiers WHERE is_default = 1 OR name = 'Free' LIMIT 1")
free_tier = cursor.fetchone() free_tier = cursor.fetchone()
if free_tier: if free_tier:
......
...@@ -150,20 +150,23 @@ async def test_max_retries_downgrades_to_free(db_manager): ...@@ -150,20 +150,23 @@ async def test_max_retries_downgrades_to_free(db_manager):
processor = PaymentRetryProcessor(db_manager, MockSubscriptionManager()) processor = PaymentRetryProcessor(db_manager, MockSubscriptionManager())
# Get free tier ID # Get the tier the downgrade will pick (same selection logic as the code).
with db_manager._get_connection() as conn: with db_manager._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id FROM account_tiers WHERE name = 'Free' LIMIT 1") cursor.execute("SELECT id FROM account_tiers WHERE is_default = 1 OR name = 'Free' LIMIT 1")
free_tier_id = cursor.fetchone()[0] free_tier_id = cursor.fetchone()[0]
# Add retry with max attempts already reached # The fixture already creates subscription #1 (on the Pro tier) and a payment
# method. Queue a retry already at the last attempt. Use a gateway type so the
# early crypto-skip is bypassed and the failing charge drives the attempt
# count to the max, triggering the downgrade.
with db_manager._get_connection() as conn: with db_manager._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
INSERT INTO payment_retry_queue INSERT INTO payment_retry_queue
(subscription_id, user_id, payment_method_type, amount, (subscription_id, user_id, payment_method_type, amount,
attempt_count, max_attempts, next_retry_at, status) attempt_count, max_attempts, next_retry_at, status)
VALUES (1, 1, 'crypto', 100.00, 2, 3, datetime('now'), 'pending') VALUES (1, 1, 'stripe', 100.00, 2, 3, datetime('now'), 'pending')
""") """)
conn.commit() conn.commit()
...@@ -195,7 +198,10 @@ async def test_retry_increments_attempt_count(db_manager): ...@@ -195,7 +198,10 @@ async def test_retry_increments_attempt_count(db_manager):
processor = PaymentRetryProcessor(db_manager, MockSubscriptionManager()) processor = PaymentRetryProcessor(db_manager, MockSubscriptionManager())
# Add retry to queue with future next_retry_at so it gets processed # Add retry to queue with due next_retry_at so it gets processed. Use a
# gateway type (not crypto): crypto retries are skipped without incrementing
# while the wallet is unfunded, whereas a gateway charge that fails increments
# the attempt counter.
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
with db_manager._get_connection() as conn: with db_manager._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
...@@ -203,11 +209,11 @@ async def test_retry_increments_attempt_count(db_manager): ...@@ -203,11 +209,11 @@ async def test_retry_increments_attempt_count(db_manager):
INSERT INTO payment_retry_queue INSERT INTO payment_retry_queue
(subscription_id, user_id, payment_method_type, amount, (subscription_id, user_id, payment_method_type, amount,
attempt_count, max_attempts, next_retry_at, status) attempt_count, max_attempts, next_retry_at, status)
VALUES (1, 1, 'crypto', 100.00, 0, 3, '{now}', 'pending') VALUES (1, 1, 'stripe', 100.00, 0, 3, '{now}', 'pending')
""") """)
conn.commit() conn.commit()
# Process retries (will fail due to insufficient balance) # Process retries (gateway charge fails -> attempt count increments)
await processor.process_retries() await processor.process_retries()
# Check attempt count was incremented # Check attempt count was incremented
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment