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:
self._create_wallet_tables(cursor, auto_increment, timestamp_default, boolean_type, text_type, decimal_type)
self._add_stripe_customer_id_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._insert_default_data(cursor)
......@@ -244,6 +246,7 @@ class PaymentMigrations:
last_error {text_type},
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT {timestamp_default},
last_attempt_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
......@@ -289,6 +292,7 @@ class PaymentMigrations:
current_period_end TIMESTAMP NOT NULL,
cancel_at_period_end {boolean_type} DEFAULT 0,
pending_tier_id INTEGER,
cancelled_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
......@@ -604,6 +608,28 @@ class PaymentMigrations:
except Exception as 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):
"""Ensure payment_methods has a gateway column.
......
......@@ -276,7 +276,7 @@ class PaymentRetryProcessor:
# Get free tier ID
with self.db._get_connection() as conn:
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()
if free_tier:
......
......@@ -150,20 +150,23 @@ async def test_max_retries_downgrades_to_free(db_manager):
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:
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]
# 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:
cursor = conn.cursor()
cursor.execute("""
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)
VALUES (1, 1, 'crypto', 100.00, 2, 3, datetime('now'), 'pending')
VALUES (1, 1, 'stripe', 100.00, 2, 3, datetime('now'), 'pending')
""")
conn.commit()
......@@ -195,19 +198,22 @@ async def test_retry_increments_attempt_count(db_manager):
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()
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,
(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')
VALUES (1, 1, 'stripe', 100.00, 0, 3, '{now}', 'pending')
""")
conn.commit()
# Process retries (will fail due to insufficient balance)
# Process retries (gateway charge fails -> attempt count increments)
await processor.process_retries()
# 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