Files
Claude b38f08189e feat: Add task_number to YAML frontmatter for 26 tasks
Long-term fix for mobile task index - task numbers now in frontmatter.

Numbers added from BACKLOG.md cross-reference:
#2 rank-system-deployment
#3 fire-frost-holdings-restructuring
#14 vaultwarden-ssh-setup
#22 netdata-deployment
#23 department-structure
#26 modpack-version-checker
#32 terraria-branding-training-arc
#35 pokerole-wikijs-deployment
#36 notebooklm-integration
#40 world-backup-automation
#44 nc1-node-usage-stats
#45 steam-and-state-server
#48 n8n-rebuild
#51 ignis-protocol
#55 discord-invite-setup
#65 claude-infrastructure-access
#67 nc1-security-monitoring
#82 plane-decommissioning
#87 arbiter-2-1-cancellation-flow
#89 staff-portal-consolidation
#90 decap-tasks-collection
#91 server-matrix-node-fix
#92 desktop-mcp
#93 trinity-codex
#94 global-restart-scheduler
#98 discord-channel-automation
#99 claude-projects-architecture

Chronicler #69
2026-04-08 14:32:38 +00:00
..

task_number, status, priority, owner, created
task_number status priority owner created
87 complete P1 Michael 2026-03-30

task_number: 87

Task #87: Arbiter 2.1 - Subscription Cancellation & Grace Period System

Status: ARCHITECTURE VALIDATED - Ready to implement after Paymenter research
Owner: Michael "Frostystyle" Krause
Priority: Tier 1 - SOFT LAUNCH BLOCKER
Created: March 30, 2026
Gemini Review: March 30, 2026 - APPROVED with critical edge cases identified
Time Estimate: 4-6 hours implementation + 2 hours Paymenter research


task_number: 87

⚠️ CRITICAL: Soft Launch Blocker

We have a subscription process, but NO unsubscribe process.

Current state:

  • Subscribe → Paymenter → Arbiter 2.0 → Discord role → Access granted
  • Cancel → ??? (undefined)
  • Payment fails → ??? (undefined)
  • Subscription expires → ??? (undefined)
  • Chargeback → ??? (undefined)

Cannot launch without defining offboarding flow.


task_number: 87

Problem Statement

What We Built (Arbiter 2.0)

  • Subscription onboarding complete
  • Email → OAuth → Discord role assignment
  • Admin panel for manual operations
  • Audit logging

What We're Missing (Arbiter 2.1)

  • Cancellation handling - What happens when someone cancels?
  • Payment failure handling - Grace period for failed payments?
  • Subscription expiration - When does access actually end?
  • Discord role removal - Automated or manual?
  • Whitelist management - Keep forever, remove immediately, or grace period?
  • Grace period tracking - How long between cancel and full removal?
  • Email notifications - Cancellation confirmations, payment failure warnings
  • Automated cleanup - Remove roles/whitelists after grace periods

task_number: 87

Policy Decisions (Made March 30, 2026)

Decision 1: Discord Role Removal Timing

DECISION: Remove at end of billing period

Rationale:

  • User paid through end of month → they get what they paid for
  • Fair to customer
  • Industry standard practice

Example:

  • Subscribed March 1 → March 31 ($10)
  • Cancels March 15
  • Discord role stays active until March 31 23:59
  • April 1 00:00 → role removed automatically

Decision 2: Whitelist Grace Period

DECISION: 30-day grace period after cancellation

Rationale:

  • Goodwill gesture (might come back)
  • Gives them time to reconsider
  • Not so long that whitelist gets cluttered
  • Clean removal after reasonable time

Example:

  • Cancels March 15
  • Billing period ends March 31
  • Whitelist stays until April 30
  • May 1 → whitelist removed automatically

Decision 3: Payment Failure Grace Period

DECISION: 7-day grace with email reminders

Rationale:

  • Cards expire, people forget to update
  • 3 days = not enough time
  • 30 days = too generous
  • 7 days = industry standard

Timeline:

  • Day 0: Payment fails → email sent
  • Day 3: Reminder email (4 days left)
  • Day 6: Final warning (24 hours)
  • Day 7: Treat as cancellation (Discord role removed at billing end)

Decision 4: Chargeback Handling

DECISION: Immediate removal + flag account

Rationale:

  • Fraud protection
  • Chargebacks cost us money
  • No grace period justified

Action:

  • Immediate Discord role removal
  • Immediate whitelist removal
  • Flag account (prevent re-subscription without manual review)
  • Manual review by Michael/Meg required to unblock

task_number: 87

Architecture: Arbiter 2.0 → 2.1

THIS IS NOT A REWRITE - IT'S AN ENHANCEMENT

What Stays the Same

All Arbiter 2.0 code (OAuth, linking, role assignment, admin panel)
Database structure (just adding tables)
Webhook handler architecture (just adding event types)
Email system (just adding templates)
Discord integration (just adding role removal)

What Gets Added

New database tables (subscriptions, grace_periods)
New webhook event handlers (4 new functions)
Scheduled cleanup job (new file)
Email templates (5 new files)
Grace period logic
Whitelist Manager API integration

Estimated new code: ~1,000 lines
Existing code preserved: ~2,000 lines


task_number: 87

Technical Implementation Plan

1. Database Schema Enhancement

Add two new tables to existing SQLite database:

-- Track subscription lifecycle and status
CREATE TABLE subscriptions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  paymenter_subscription_id TEXT NOT NULL UNIQUE,
  tier TEXT NOT NULL,
  discord_user_id TEXT,
  status TEXT NOT NULL, -- active, cancelled, expired, suspended, chargeback
  billing_period_start DATETIME,
  billing_period_end DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Track grace periods for automated cleanup
CREATE TABLE grace_periods (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  subscription_id INTEGER NOT NULL,
  type TEXT NOT NULL, -- payment_failure, discord_removal, whitelist_removal
  started_at DATETIME NOT NULL,
  ends_at DATETIME NOT NULL,
  resolved BOOLEAN DEFAULT 0,
  resolved_at DATETIME,
  FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

-- Index for performance
CREATE INDEX idx_grace_periods_ends_at ON grace_periods(ends_at, resolved);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);

Why these tables:

  • subscriptions - Single source of truth for subscription state
  • grace_periods - Automated cleanup jobs query this
  • Separate from link_tokens table (different lifecycle)

2. New Webhook Event Handlers

Enhance existing webhook router:

// routes/webhook.js (EXISTING FILE - ADDING CASES)
app.post('/webhook/paymenter', verifyWebhook, async (req, res) => {
  const { event, data } = req.body;
  
  try {
    switch(event) {
      // EXISTING (Arbiter 2.0)
      case 'subscription.created':
        await handleSubscriptionCreated(data);
        break;
      
      // NEW (Arbiter 2.1)
      case 'subscription.cancelled':
        await handleSubscriptionCancelled(data);
        break;
      
      case 'subscription.expired':
        await handleSubscriptionExpired(data);
        break;
      
      case 'payment.failed':
        await handlePaymentFailed(data);
        break;
      
      case 'subscription.renewed':
        await handleSubscriptionRenewed(data);
        break;
      
      case 'chargeback.received':
        await handleChargeback(data);
        break;
      
      default:
        console.log(`Unknown event: ${event}`);
    }
    
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).send('Error processing webhook');
  }
});

NEW handler functions to create:

handlers/subscriptionCancelled.js

async function handleSubscriptionCancelled(data) {
  const { email, subscription_id, tier, billing_period_end } = data;
  
  // 1. Update subscription status
  await db.run(`
    UPDATE subscriptions 
    SET status = 'cancelled',
        billing_period_end = ?,
        updated_at = CURRENT_TIMESTAMP
    WHERE paymenter_subscription_id = ?
  `, [billing_period_end, subscription_id]);
  
  // 2. Create grace period for Discord role removal (until billing end)
  await db.run(`
    INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
    SELECT id, 'discord_removal', CURRENT_TIMESTAMP, ?
    FROM subscriptions WHERE paymenter_subscription_id = ?
  `, [billing_period_end, subscription_id]);
  
  // 3. Create grace period for whitelist removal (30 days after billing end)
  const whitelistGraceEnd = new Date(billing_period_end);
  whitelistGraceEnd.setDate(whitelistGraceEnd.getDate() + 30);
  
  await db.run(`
    INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
    SELECT id, 'whitelist_removal', CURRENT_TIMESTAMP, ?
    FROM subscriptions WHERE paymenter_subscription_id = ?
  `, [whitelistGraceEnd.toISOString(), subscription_id]);
  
  // 4. Send cancellation confirmation email
  await sendEmail(email, 'subscription_cancelled', {
    tier,
    access_until: billing_period_end,
    whitelist_until: whitelistGraceEnd.toISOString()
  });
  
  // 5. Log to audit trail
  await logAudit('system', email, 'subscription_cancelled', 
    `Tier: ${tier}, Access until: ${billing_period_end}`);
}

handlers/paymentFailed.js

async function handlePaymentFailed(data) {
  const { email, subscription_id, tier, amount, reason } = data;
  
  // 1. Update subscription status
  await db.run(`
    UPDATE subscriptions 
    SET status = 'payment_failed',
        updated_at = CURRENT_TIMESTAMP
    WHERE paymenter_subscription_id = ?
  `, [subscription_id]);
  
  // 2. Create 7-day grace period
  const graceEnd = new Date();
  graceEnd.setDate(graceEnd.getDate() + 7);
  
  await db.run(`
    INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
    SELECT id, 'payment_failure', CURRENT_TIMESTAMP, ?
    FROM subscriptions WHERE paymenter_subscription_id = ?
  `, [graceEnd.toISOString(), subscription_id]);
  
  // 3. Send payment failure notification
  await sendEmail(email, 'payment_failed', {
    tier,
    amount,
    reason,
    update_url: 'https://billing.firefrostgaming.com/payment-methods',
    grace_days: 7
  });
  
  // 4. Log to audit trail
  await logAudit('system', email, 'payment_failed', 
    `Tier: ${tier}, Amount: ${amount}, Reason: ${reason}`);
}

handlers/chargeback.js

async function handleChargeback(data) {
  const { email, subscription_id, tier, amount } = data;
  
  // 1. Update subscription status
  await db.run(`
    UPDATE subscriptions 
    SET status = 'chargeback',
        updated_at = CURRENT_TIMESTAMP
    WHERE paymenter_subscription_id = ?
  `, [subscription_id]);
  
  // 2. Get Discord user ID
  const sub = await db.get(
    'SELECT discord_user_id FROM subscriptions WHERE paymenter_subscription_id = ?',
    [subscription_id]
  );
  
  // 3. IMMEDIATE removal - Discord role
  if (sub.discord_user_id) {
    await removeDiscordRole(sub.discord_user_id, tier);
  }
  
  // 4. IMMEDIATE removal - Whitelist
  await removeFromWhitelist(email);
  
  // 5. Send notification email
  await sendEmail(email, 'account_suspended_chargeback', {
    tier,
    amount,
    contact_email: 'support@firefrostgaming.com'
  });
  
  // 6. Log to audit trail (high priority)
  await logAudit('system', email, 'chargeback_immediate_removal', 
    `Tier: ${tier}, Amount: ${amount} - MANUAL REVIEW REQUIRED`);
  
  // 7. Notify Michael/Meg via Discord webhook
  await sendDiscordAlert('🚨 CHARGEBACK ALERT', 
    `Email: ${email}\nTier: ${tier}\nAmount: ${amount}\n\nAccount suspended. Manual review required.`);
}

handlers/subscriptionExpired.js

async function handleSubscriptionExpired(data) {
  const { email, subscription_id, tier } = data;
  
  // 1. Update subscription status
  await db.run(`
    UPDATE subscriptions 
    SET status = 'expired',
        updated_at = CURRENT_TIMESTAMP
    WHERE paymenter_subscription_id = ?
  `, [subscription_id]);
  
  // 2. Get Discord user ID
  const sub = await db.get(
    'SELECT discord_user_id FROM subscriptions WHERE paymenter_subscription_id = ?',
    [subscription_id]
  );
  
  // 3. Remove Discord role (billing period already ended)
  if (sub.discord_user_id) {
    await removeDiscordRole(sub.discord_user_id, tier);
  }
  
  // 4. Create 30-day whitelist grace period
  const whitelistGraceEnd = new Date();
  whitelistGraceEnd.setDate(whitelistGraceEnd.getDate() + 30);
  
  await db.run(`
    INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
    SELECT id, 'whitelist_removal', CURRENT_TIMESTAMP, ?
    FROM subscriptions WHERE paymenter_subscription_id = ?
  `, [whitelistGraceEnd.toISOString(), subscription_id]);
  
  // 5. Send expiration email
  await sendEmail(email, 'subscription_expired', {
    tier,
    whitelist_until: whitelistGraceEnd.toISOString(),
    resubscribe_url: 'https://firefrostgaming.com/join'
  });
  
  // 6. Log to audit trail
  await logAudit('system', email, 'subscription_expired', 
    `Tier: ${tier}, Whitelist grace until: ${whitelistGraceEnd.toISOString()}`);
}

3. Scheduled Cleanup Job

NEW FILE: jobs/cleanupExpiredSubscriptions.js

const cron = require('node-cron');
const db = require('../database');
const { removeDiscordRole } = require('../services/discordService');
const { removeFromWhitelist } = require('../services/whitelistService');
const { sendEmail } = require('../services/emailService');
const { logAudit } = require('../services/auditService');

// Run daily at 4:00 AM
cron.schedule('0 4 * * *', async () => {
  console.log('[Cleanup Job] Starting subscription cleanup...');
  
  try {
    // 1. Remove Discord roles for expired billing periods
    const expiredBilling = await db.all(`
      SELECT s.*, gp.id as grace_id
      FROM subscriptions s
      JOIN grace_periods gp ON s.id = gp.subscription_id
      WHERE gp.type = 'discord_removal'
      AND gp.ends_at < datetime('now')
      AND gp.resolved = 0
      AND s.discord_user_id IS NOT NULL
    `);
    
    console.log(`[Cleanup Job] Found ${expiredBilling.length} expired billing periods`);
    
    for (const sub of expiredBilling) {
      try {
        await removeDiscordRole(sub.discord_user_id, sub.tier);
        await db.run('UPDATE grace_periods SET resolved = 1, resolved_at = CURRENT_TIMESTAMP WHERE id = ?', 
          [sub.grace_id]);
        await logAudit('system', sub.email, 'discord_role_removed_billing_expired', 
          `Tier: ${sub.tier}`);
        console.log(`[Cleanup Job] Removed Discord role: ${sub.email}`);
      } catch (error) {
        console.error(`[Cleanup Job] Error removing Discord role for ${sub.email}:`, error);
      }
    }
    
    // 2. Remove whitelists after 30-day grace period
    const expiredWhitelists = await db.all(`
      SELECT s.*, gp.id as grace_id
      FROM subscriptions s
      JOIN grace_periods gp ON s.id = gp.subscription_id
      WHERE gp.type = 'whitelist_removal'
      AND gp.ends_at < datetime('now')
      AND gp.resolved = 0
    `);
    
    console.log(`[Cleanup Job] Found ${expiredWhitelists.length} expired whitelists`);
    
    for (const sub of expiredWhitelists) {
      try {
        await removeFromWhitelist(sub.email);
        await db.run('UPDATE grace_periods SET resolved = 1, resolved_at = CURRENT_TIMESTAMP WHERE id = ?', 
          [sub.grace_id]);
        await logAudit('system', sub.email, 'whitelist_removed_grace_expired', 
          `Grace period: 30 days`);
        console.log(`[Cleanup Job] Removed whitelist: ${sub.email}`);
      } catch (error) {
        console.error(`[Cleanup Job] Error removing whitelist for ${sub.email}:`, error);
      }
    }
    
    // 3. Send payment failure reminders (Day 3 - 4 days left)
    const day3Reminders = await db.all(`
      SELECT s.*, gp.id as grace_id
      FROM subscriptions s
      JOIN grace_periods gp ON s.id = gp.subscription_id
      WHERE gp.type = 'payment_failure'
      AND gp.ends_at BETWEEN datetime('now', '+3 days') AND datetime('now', '+4 days')
      AND gp.resolved = 0
    `);
    
    console.log(`[Cleanup Job] Found ${day3Reminders.length} Day 3 payment failure reminders`);
    
    for (const sub of day3Reminders) {
      try {
        await sendEmail(sub.email, 'payment_failure_reminder_day3', {
          tier: sub.tier,
          days_remaining: 4,
          update_url: 'https://billing.firefrostgaming.com/payment-methods'
        });
        console.log(`[Cleanup Job] Sent Day 3 reminder: ${sub.email}`);
      } catch (error) {
        console.error(`[Cleanup Job] Error sending Day 3 reminder to ${sub.email}:`, error);
      }
    }
    
    // 4. Send payment failure final warnings (Day 6 - 24 hours left)
    const finalWarnings = await db.all(`
      SELECT s.*, gp.id as grace_id
      FROM subscriptions s
      JOIN grace_periods gp ON s.id = gp.subscription_id
      WHERE gp.type = 'payment_failure'
      AND gp.ends_at BETWEEN datetime('now', '+1 day') AND datetime('now', '+25 hours')
      AND gp.resolved = 0
    `);
    
    console.log(`[Cleanup Job] Found ${finalWarnings.length} final payment warnings`);
    
    for (const sub of finalWarnings) {
      try {
        await sendEmail(sub.email, 'payment_failure_final_warning', {
          tier: sub.tier,
          hours_remaining: 24,
          update_url: 'https://billing.firefrostgaming.com/payment-methods'
        });
        console.log(`[Cleanup Job] Sent final warning: ${sub.email}`);
      } catch (error) {
        console.error(`[Cleanup Job] Error sending final warning to ${sub.email}:`, error);
      }
    }
    
    // 5. Convert expired payment failures to cancellations
    const expiredPaymentFailures = await db.all(`
      SELECT s.*, gp.id as grace_id
      FROM subscriptions s
      JOIN grace_periods gp ON s.id = gp.subscription_id
      WHERE gp.type = 'payment_failure'
      AND gp.ends_at < datetime('now')
      AND gp.resolved = 0
    `);
    
    console.log(`[Cleanup Job] Found ${expiredPaymentFailures.length} expired payment failures`);
    
    for (const sub of expiredPaymentFailures) {
      try {
        // Treat as cancellation
        await db.run(`
          UPDATE subscriptions 
          SET status = 'cancelled',
              updated_at = CURRENT_TIMESTAMP
          WHERE id = ?
        `, [sub.id]);
        
        // Mark grace period resolved
        await db.run('UPDATE grace_periods SET resolved = 1, resolved_at = CURRENT_TIMESTAMP WHERE id = ?', 
          [sub.grace_id]);
        
        // Create Discord role removal grace (until billing end) and whitelist grace (30 days)
        if (sub.billing_period_end) {
          await db.run(`
            INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
            VALUES (?, 'discord_removal', CURRENT_TIMESTAMP, ?)
          `, [sub.id, sub.billing_period_end]);
          
          const whitelistGraceEnd = new Date(sub.billing_period_end);
          whitelistGraceEnd.setDate(whitelistGraceEnd.getDate() + 30);
          
          await db.run(`
            INSERT INTO grace_periods (subscription_id, type, started_at, ends_at)
            VALUES (?, 'whitelist_removal', CURRENT_TIMESTAMP, ?)
          `, [sub.id, whitelistGraceEnd.toISOString()]);
        }
        
        // Send access removed email
        await sendEmail(sub.email, 'access_removed_payment_failure', {
          tier: sub.tier,
          resubscribe_url: 'https://firefrostgaming.com/join'
        });
        
        await logAudit('system', sub.email, 'payment_failure_converted_to_cancellation', 
          `Tier: ${sub.tier}, 7-day grace expired`);
        
        console.log(`[Cleanup Job] Converted payment failure to cancellation: ${sub.email}`);
      } catch (error) {
        console.error(`[Cleanup Job] Error converting payment failure for ${sub.email}:`, error);
      }
    }
    
    console.log('[Cleanup Job] Subscription cleanup complete.');
  } catch (error) {
    console.error('[Cleanup Job] Fatal error:', error);
  }
});

console.log('[Cleanup Job] Scheduled daily cleanup at 4:00 AM');

4. Whitelist Manager API Integration

NEW FILE: services/whitelistService.js

const axios = require('axios');

const WHITELIST_MANAGER_URL = process.env.WHITELIST_MANAGER_URL || 'https://whitelist.firefrostgaming.com';
const WHITELIST_API_KEY = process.env.WHITELIST_API_KEY;

async function removeFromWhitelist(emailOrUsername) {
  try {
    const response = await axios.post(
      `${WHITELIST_MANAGER_URL}/api/bulk-remove`,
      {
        player: emailOrUsername
      },
      {
        headers: {
          'Authorization': `Bearer ${WHITELIST_API_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    );
    
    return response.data;
  } catch (error) {
    console.error('Whitelist removal error:', error);
    throw error;
  }
}

module.exports = {
  removeFromWhitelist
};

NOTE: This requires Whitelist Manager to expose an API endpoint for Arbiter to call. May need to add /api/bulk-remove endpoint to Whitelist Manager.

5. Email Templates

NEW FILES: emails/

subscription_cancelled.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Subscription Cancelled</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">🔥 Subscription Cancelled</h1>
  
  <p>Hi there,</p>
  
  <p>We've received your cancellation request for your <strong>{{tier}}</strong> subscription.</p>
  
  <h2>What Happens Next:</h2>
  <ul>
    <li><strong>Discord Access:</strong> You'll keep your subscriber role until <strong>{{access_until}}</strong></li>
    <li><strong>Server Whitelist:</strong> You'll stay whitelisted until <strong>{{whitelist_until}}</strong></li>
    <li><strong>Minecraft Rank:</strong> Your in-game rank will be removed when Discord access ends</li>
  </ul>
  
  <p>We're sorry to see you go! If you change your mind, you can re-subscribe anytime at <a href="https://firefrostgaming.com/join">firefrostgaming.com/join</a>.</p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team 💙
  </p>
</body>
</html>

payment_failed.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Payment Failed</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">⚠️ Payment Failed</h1>
  
  <p>Hi there,</p>
  
  <p>We weren't able to process your payment for your <strong>{{tier}}</strong> subscription.</p>
  
  <p><strong>Reason:</strong> {{reason}}</p>
  <p><strong>Amount:</strong> ${{amount}}</p>
  
  <h2>What You Need to Do:</h2>
  <p>Please update your payment method within <strong>{{grace_days}} days</strong> to keep your access.</p>
  
  <p style="margin: 30px 0;">
    <a href="{{update_url}}" style="background: #FF6B35; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">Update Payment Method</a>
  </p>
  
  <h2>What Happens If You Don't Update:</h2>
  <ul>
    <li>After {{grace_days}} days, your subscription will be cancelled</li>
    <li>Your Discord role and in-game rank will be removed</li>
    <li>You'll have 30 days of whitelist access after cancellation</li>
  </ul>
  
  <p>If you have any questions, join us in Discord: <a href="https://firefrostgaming.com/discord">firefrostgaming.com/discord</a></p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team 💙
  </p>
</body>
</html>

payment_failure_reminder_day3.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Payment Failure Reminder</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">⏰ Reminder: Payment Still Pending</h1>
  
  <p>Hi there,</p>
  
  <p>This is a friendly reminder that your payment for <strong>{{tier}}</strong> is still pending.</p>
  
  <p><strong>You have {{days_remaining}} days left</strong> to update your payment method.</p>
  
  <p style="margin: 30px 0;">
    <a href="{{update_url}}" style="background: #FF6B35; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">Update Payment Method</a>
  </p>
  
  <p>Need help? Join us in Discord: <a href="https://firefrostgaming.com/discord">firefrostgaming.com/discord</a></p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team 💙
  </p>
</body>
</html>

payment_failure_final_warning.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Final Warning - Payment Required</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">🚨 Final Warning: {{hours_remaining}} Hours Left</h1>
  
  <p>Hi there,</p>
  
  <p><strong>This is your final warning.</strong> Your payment for <strong>{{tier}}</strong> is still pending.</p>
  
  <p style="background: #FFF3CD; border-left: 4px solid #FF6B35; padding: 15px; margin: 20px 0;">
    <strong>You have {{hours_remaining}} hours</strong> to update your payment method or your subscription will be cancelled.
  </p>
  
  <p style="margin: 30px 0;">
    <a href="{{update_url}}" style="background: #FF6B35; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Update Payment Method Now</a>
  </p>
  
  <p>If you don't update your payment:</p>
  <ul>
    <li>Your subscription will be cancelled</li>
    <li>Discord role and in-game rank will be removed</li>
    <li>You'll keep whitelist access for 30 days</li>
  </ul>
  
  <p>Need help urgently? Join Discord: <a href="https://firefrostgaming.com/discord">firefrostgaming.com/discord</a></p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team 💙
  </p>
</body>
</html>

access_removed_payment_failure.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Access Removed</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">Access Removed</h1>
  
  <p>Hi there,</p>
  
  <p>Your <strong>{{tier}}</strong> subscription has been cancelled due to non-payment.</p>
  
  <h2>What's Changed:</h2>
  <ul>
    <li>Discord subscriber role has been removed</li>
    <li>In-game rank has been removed</li>
    <li>You'll stay whitelisted for 30 days (through your billing period end)</li>
  </ul>
  
  <h2>Want to Come Back?</h2>
  <p>We'd love to have you back! You can re-subscribe anytime:</p>
  
  <p style="margin: 30px 0;">
    <a href="{{resubscribe_url}}" style="background: #4ECDC4; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">Re-Subscribe</a>
  </p>
  
  <p>Questions? Join us in Discord: <a href="https://firefrostgaming.com/discord">firefrostgaming.com/discord</a></p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team 💙
  </p>
</body>
</html>

account_suspended_chargeback.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Account Suspended</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #FF6B35;">🚨 Account Suspended - Chargeback Detected</h1>
  
  <p>Hi there,</p>
  
  <p>A chargeback was filed for your <strong>{{tier}}</strong> subscription (Amount: ${{amount}}).</p>
  
  <h2>Immediate Actions Taken:</h2>
  <ul>
    <li>Discord subscriber role removed</li>
    <li>In-game rank removed</li>
    <li>Server whitelist removed</li>
    <li>Account flagged for manual review</li>
  </ul>
  
  <h2>What You Should Know:</h2>
  <p>Chargebacks are costly and disrupt our small community. If this was filed in error or there was a billing issue, please contact us directly:</p>
  
  <p><strong>Email:</strong> {{contact_email}}<br>
  <strong>Discord:</strong> <a href="https://firefrostgaming.com/discord">firefrostgaming.com/discord</a></p>
  
  <p>We're here to resolve any legitimate billing concerns, but chargebacks require manual review before account access can be restored.</p>
  
  <p style="margin-top: 30px;">
    <strong>Fire + Frost + Foundation</strong><br>
    The Firefrost Gaming Team
  </p>
</body>
</html>

task_number: 87

Paymenter Webhook Events Research

CRITICAL: Verify what events Paymenter actually sends

Research needed (when home):

  1. Log into Paymenter admin panel
  2. Navigate to webhook settings
  3. Document available webhook events
  4. Test each event by triggering it (test subscription)
  5. Log the exact payload format

Expected events (to verify):

  • subscription.created (already handling)
  • subscription.cancelled
  • subscription.expired
  • subscription.renewed
  • payment.failed
  • payment.succeeded
  • chargeback.received

If Paymenter doesn't send an event we need:

  • Option A: Request feature from Paymenter developers
  • Option B: Build polling system (check subscription status every hour)
  • Option C: Manual admin actions via Arbiter admin panel

task_number: 87

Testing Procedure

1. Unit Testing (Handlers)

Test each handler in isolation:

// Test cancellation handler
const testCancellation = {
  email: 'test@example.com',
  subscription_id: 'sub_test123',
  tier: 'Elemental',
  billing_period_end: '2026-04-30T23:59:59Z'
};

await handleSubscriptionCancelled(testCancellation);

// Verify database state
const sub = await db.get('SELECT * FROM subscriptions WHERE email = ?', 
  ['test@example.com']);
assert(sub.status === 'cancelled');

// Verify grace periods created
const gracePeriods = await db.all(
  'SELECT * FROM grace_periods WHERE subscription_id = ?',
  [sub.id]
);
assert(gracePeriods.length === 2); // Discord + Whitelist

2. Integration Testing (Full Flow)

Test complete cancellation flow:

  1. Create test subscription in Paymenter
  2. Verify Arbiter 2.0 onboarding works
  3. Cancel subscription in Paymenter
  4. Verify webhook received by Arbiter
  5. Verify database updated correctly
  6. Verify email sent
  7. Verify grace periods created
  8. Fast-forward system time (testing only)
  9. Run cleanup job manually
  10. Verify Discord role removed
  11. Verify whitelist removed

3. Edge Case Testing

Test failure scenarios:

  • Webhook signature invalid
  • Database write fails
  • Discord API down
  • Email service down
  • Whitelist Manager API down
  • Multiple rapid cancellations (race conditions)
  • Cancel before OAuth linking completes
  • Re-subscribe during grace period

task_number: 87

Deployment Strategy

Phase 1: Arbiter 2.0 Deployment (Current)

  • Deploy existing onboarding code
  • Validate with test subscriptions
  • Confirm OAuth flow works
  • Verify Discord role assignment

Phase 2: Arbiter 2.1 Development (This Task)

  • Add database tables
  • Implement new handlers
  • Create email templates
  • Build cleanup job
  • Unit test all handlers

Phase 3: Arbiter 2.1 Staging Test

  • Deploy to test environment
  • Create test subscriptions
  • Test cancellation flows
  • Verify grace periods
  • Test cleanup job

Phase 4: Arbiter 2.1 Production Deployment

  • Deploy to Command Center
  • Monitor logs carefully
  • Test with real subscription (Michael's test account)
  • Verify all flows work
  • Document any issues

task_number: 87

Dependencies

Blocks:

  • Soft launch (cannot launch without cancellation flow)

Blocked By:

  • Arbiter 2.0 deployment (must validate Phase 1 first)
  • Paymenter webhook event research (need to know what events exist)
  • Whitelist Manager API endpoint (needs /api/bulk-remove)

Related Tasks:

  • Task #83: Paymenter → Pterodactyl integration (auto-provisioning)
  • Task #7: Whitelist Manager (needs API enhancement)
  • Task #86: Whitelist Manager Panel compatibility (should fix first)
  • Task #2: LuckPerms rank system (Discord → in-game sync)

task_number: 87

Success Criteria

Arbiter 2.1 is complete when:

  • All 6 webhook events handled (cancelled, expired, failed, renewed, chargeback, succeeded)
  • Database tables created and indexed
  • Grace period logic working (7-day payment failure, 30-day whitelist)
  • Discord role removal automated
  • Whitelist removal automated (via API call)
  • 5 email templates created and sending correctly
  • Scheduled cleanup job running daily at 4 AM
  • Payment failure reminders sending (Day 3, Day 6)
  • Chargeback immediate removal working
  • Admin panel shows subscription status
  • Audit logging for all state changes
  • Complete test flow successful (subscribe → cancel → grace → cleanup)

task_number: 87

Future Enhancements (Arbiter 2.2+)

Not in scope for 2.1, but nice to have later:

  • Re-subscription detection (welcome back bonus?)
  • Subscription pause/resume feature
  • Downgrade/upgrade handling (tier changes)
  • Family/group subscriptions
  • Referral tracking (who invited who?)
  • Lifetime subscription support
  • Gift subscriptions
  • Subscription analytics dashboard
  • Churn prediction (at-risk subscriber detection)
  • Win-back campaigns (automated re-engagement emails)

task_number: 87

  • Arbiter 2.0: docs/implementation/discord-oauth-arbiter/
  • Gemini Consultation: docs/consultations/gemini-discord-oauth-2026-03-30/
  • Task Master List: docs/core/tasks.md
  • Infrastructure Manifest: docs/core/infrastructure-manifest.md

task_number: 87

Architecture Review Request

CRITICAL: Before building, get Gemini's review

Questions for Gemini:

  1. Is the grace period architecture sound?
  2. Are the database tables properly designed?
  3. Should we use separate cleanup job or integrate into webhook handlers?
  4. Is the chargeback handling appropriate?
  5. Any edge cases we're missing?
  6. Security concerns with automated role/whitelist removal?
  7. Better approach to Whitelist Manager integration?
  8. Should grace periods be configurable (admin panel)?

task_number: 87

Gemini AI Architectural Review (March 30, 2026)

Status: ARCHITECTURE APPROVED
Reviewer: Gemini AI
Session: Continuation of Arbiter 2.0 development consultation
Outcome: Validated with critical edge cases identified and solutions provided


task_number: 87

Executive Summary

Gemini's Assessment:

"This is a brilliant enhancement, Michael. The 'We Don't Kick People Out' policy is incredibly community-forward and will build massive loyalty. Arbiter 2.1 is exactly the right scope for this."

Overall Verdict: Architecture is sound, proceed with implementation

Critical Issues Identified: 2 (both resolved with provided solutions)
Code Blocks Provided: 4 (ready to implement)
Additional Research Required: Paymenter webhook event verification


task_number: 87

Gemini's Answers to Architecture Questions

Q1: Is the 3-day grace period system sound?

Answer: Yes, conceptually sound - with ONE critical caveat

⚠️ CRITICAL ISSUE: Stripe Smart Retries

The Problem:

  • Stripe automatically retries failed payments over 1-7 days
  • Default retry schedule: Day 1, 3, 5, 7
  • Arbiter downgrades user on Day 3
  • Stripe successfully charges on Day 5
  • Result: User stuck on Awakened tier while PAYING for monthly tier

The Fix: Must listen for payment.succeeded or subscription.renewed webhooks to:

  • Clear grace period flag if late payment succeeds
  • Re-upgrade user to their paid tier
  • Update Discord role back to monthly tier

Implementation Impact:

  • Add subscription.renewed handler
  • Add invoice.payment_succeeded handler
  • Clear grace_period_start on successful payment
  • Restore monthly_tier if was in grace period

Q2: Database design - permanent_tier + monthly_tier columns?

Answer: Yes, clean and highly effective approach

Gemini's Validation:

"This is a clean, highly effective approach that avoids the complexity of relational entitlement tables."

Required Enhancement: Need helper function to always return highest tier for Discord sync.

Provided Solution:

// src/utils/tierResolver.js
const tierHierarchy = [
    'awakened', 'fire_elemental', 'frost_elemental', 
    'fire_knight', 'frost_knight', 'fire_master', 
    'frost_master', 'fire_legend', 'frost_legend', 'sovereign'
];

function getHighestTier(permanent, monthly) {
    const permIndex = tierHierarchy.indexOf(permanent || 'awakened');
    const monthlyIndex = tierHierarchy.indexOf(monthly);
    
    return permIndex > monthlyIndex ? permanent : monthly;
}

module.exports = { getHighestTier };

Example:

  • User has Sovereign ($499 permanent) + Master ($15 monthly)
  • Cancel Master → Discord shows Sovereign (not downgrade)
  • Helper ensures Discord always reflects highest paid tier

Q3: Should cleanup be daily at 4 AM or more frequent?

Answer: 4:00 AM is perfect

Gemini's Reasoning:

"4:00 AM is perfect. It batches your database writes, aligns with your new backup schedule, and avoids processing during peak server hours. Webhooks will handle immediate cancellations; the cron job is just the 'sweeper' for expired grace periods."

Advantages:

  • Batches database writes
  • Aligns with existing backup schedule (4 AM)
  • Avoids peak server hours
  • Webhooks handle immediate events
  • Cron is just the cleanup "sweeper"

Q4: Is the chargeback handling appropriate?

Answer: Yes, immediate permanent ban is correct

No concerns raised - Gemini validated this approach.

Q5: Any edge cases we're missing?

Answer: ⚠️ YES - Two critical edge cases identified

Edge Case #1: The "Double Buy"

Problem:

  • User in 3-day grace period panics
  • Instead of updating card, buys BRAND NEW subscription
  • Database tries to track TWO active monthly subscriptions
  • Chaos ensues

Solution: UPSERT using email as unique key

// This tries to insert. If email already exists, it updates the tier and clears any grace period.
const upsertStmt = db.prepare(`
    INSERT INTO link_tokens (token, email, monthly_tier, subscription_id) 
    VALUES (?, ?, ?, ?)
    ON CONFLICT(email) DO UPDATE SET 
        monthly_tier = excluded.monthly_tier,
        subscription_id = excluded.subscription_id,
        grace_period_start = NULL
`);

upsertStmt.run(newToken, customerEmail, newTier, newSubscriptionId);

Gemini's Guidance:

"Use the email as the unique key and UPDATE the existing row. Let Stripe and Paymenter handle the financial mess of the old, failing subscription. Arbiter should just execute a clean 'Upsert' (Update or Insert)."

Result:

  • User's row overwritten with new subscription
  • Instantly pulled out of grace period
  • No orphaned database records
  • Clean database state

Edge Case #2: Chargeback Flagging

Problem:

  • User issues chargeback, gets banned
  • Arbiter continues processing webhooks for that user
  • Could accidentally re-activate banned account

Solution: Add is_banned column, check before processing webhooks

// src/routes/webhook.js (Addition)
router.post('/webhook/billing', validateBillingPayload, async (req, res) => {
    const { event, customer_email, tier, is_one_time } = req.body;

    // 1. Check for bans first
    const user = db.prepare('SELECT is_banned FROM link_tokens WHERE email = ?').get(customer_email);
    if (user && user.is_banned) return res.status(403).send('User is banned.');

    // 2. Continue with webhook processing...
});

Database Schema Addition:

ALTER TABLE link_tokens ADD COLUMN is_banned INTEGER DEFAULT 0;

Implementation:

  • Chargeback webhook sets is_banned = 1
  • All future webhooks check is_banned first
  • If banned, return 403 and ignore webhook
  • Manual unban required (update database directly)

Q6: Security concerns with auto-downgrade to Awakened?

Answer: No security concerns

Gemini's Analysis:

"From a security standpoint, no. Since Awakened is $1, someone trying to 'game' the system by buying a $5 tier with a burner card to get Awakened for free actually paid more than the $1 Awakened price. There is no financial exploit here."

Only Requirement:

  • Ensure Awakened tier doesn't consume expensive server resources per user
  • Monitor resource usage as user base grows

Q7: Better approach to handling one-time vs recurring?

Answer: Two-column approach is simplest and most sustainable

Gemini's Validation:

"Your two-column approach is the simplest and most sustainable. When Paymenter sends a webhook, you just need to check if product_id matches your one-time items or your recurring items, and update the respective column."

Implementation:

// Check product type and update appropriate column
if (isOneTimePurchase(product_id)) {
  // Update permanent_tier (Awakened or Sovereign)
  db.prepare('UPDATE link_tokens SET permanent_tier = ? WHERE email = ?')
    .run(tier, email);
} else {
  // Update monthly_tier (Elemental, Knight, Master, Legend)
  db.prepare('UPDATE link_tokens SET monthly_tier = ? WHERE email = ?')
    .run(tier, email);
}

Q8: Should grace periods be configurable (admin panel)?

Not addressed in review - Implies hardcoded is acceptable for v1


task_number: 87

Critical Additional Question: Paymenter vs Stripe Webhooks

Gemini's Strong Recommendation: 🚨 CRITICAL DECISION POINT

Question from Michael:

"If Paymenter doesn't send granular events (just generic 'subscription updated'), should I listen to Stripe webhooks directly instead? Or build a polling system?"

Gemini's Answer:

DO NOT BUILD POLLING SYSTEM

Why NOT polling:

"Do not build a polling system. Polling is notoriously fragile, consumes unnecessary server resources, and requires you to write complex error-handling logic for API rate limits and timeouts."

  • Fragile and unreliable
  • Wastes server resources
  • Complex error handling (rate limits, timeouts)
  • High maintenance burden
  • Not sustainable

LISTEN TO STRIPE WEBHOOKS DIRECTLY (if Paymenter inadequate)

Why Stripe webhooks:

"If Paymenter's webhooks turn out to be too generic, listen to Stripe's webhooks directly. Stripe has one of the best developer experiences in the industry."

Advantages:

  • Event-driven architecture (Arbiter waits and reacts)
  • Best developer experience in industry
  • Granular events available:
    • invoice.payment_failed
    • invoice.payment_succeeded
    • customer.subscription.deleted
    • charge.dispute.created (chargebacks)
  • Lightweight and sustainable
  • Arbiter sits quietly until told exactly what happened

Implementation Plan:

  1. Add /webhook/stripe endpoint to Arbiter
  2. Configure Stripe dashboard to send specific events
  3. Get webhook signing secret from Stripe
  4. Add to Arbiter .env
  5. Verify webhook signatures (security)

Decision Tree:

Research Paymenter webhooks
  ↓
Are events granular enough?
  ├─ YES → Use Paymenter webhooks
  └─ NO → Add Stripe webhook endpoint

task_number: 87

Code Blocks Provided by Gemini

All code ready to implement - no modifications needed

1. Database Schema Updates

// src/database.js (Run this once to update the schema)
db.exec(`
  ALTER TABLE link_tokens ADD COLUMN permanent_tier TEXT DEFAULT 'awakened';
  ALTER TABLE link_tokens ADD COLUMN monthly_tier TEXT DEFAULT NULL;
  ALTER TABLE link_tokens ADD COLUMN grace_period_start DATETIME DEFAULT NULL;
  ALTER TABLE link_tokens ADD COLUMN is_banned INTEGER DEFAULT 0;
`);

Purpose:

  • permanent_tier - Awakened or Sovereign (never expires)
  • monthly_tier - Elemental/Knight/Master/Legend (can be cancelled)
  • grace_period_start - Timestamp when payment failed
  • is_banned - Chargeback permanent ban flag

2. Tier Resolution Helper

// src/utils/tierResolver.js
const { rolesConfig } = require('../discordService');

// Define hierarchy (higher index = higher tier)
const tierHierarchy = [
    'awakened', 'fire_elemental', 'frost_elemental', 
    'fire_knight', 'frost_knight', 'fire_master', 
    'frost_master', 'fire_legend', 'frost_legend', 'sovereign'
];

function getHighestTier(permanent, monthly) {
    const permIndex = tierHierarchy.indexOf(permanent || 'awakened');
    const monthlyIndex = tierHierarchy.indexOf(monthly);
    
    return permIndex > monthlyIndex ? permanent : monthly;
}

module.exports = { getHighestTier };

Usage:

const highestTier = getHighestTier(user.permanent_tier, user.monthly_tier);
await updateDiscordRole(user.discord_id, highestTier);

3. Webhook Handler Skeleton

// src/routes/webhook.js (Additions)
router.post('/webhook/billing', validateBillingPayload, async (req, res) => {
    const { event, customer_email, tier, is_one_time } = req.body;

    // 1. Check for bans first
    const user = db.prepare('SELECT is_banned FROM link_tokens WHERE email = ?').get(customer_email);
    if (user && user.is_banned) return res.status(403).send('User is banned.');

    // 2. Route the events
    if (event === 'invoice.payment_failed') {
        // Start grace period, send Day 0 email
        db.prepare('UPDATE link_tokens SET grace_period_start = CURRENT_TIMESTAMP WHERE email = ?').run(customer_email);
        await sendGracePeriodEmail(customer_email, 0);
    } 
    else if (event === 'invoice.payment_succeeded' || event === 'subscription.renewed') {
        // Clear grace period, ensure roles are correct
        db.prepare('UPDATE link_tokens SET grace_period_start = NULL WHERE email = ?').run(customer_email);
        // Sync roles logic here
    }
    // ... handle cancellations and chargebacks
});

4. 4:00 AM Grace Period Sweeper Job

// src/jobs/gracePeriodSweeper.js
const db = require('../database');
const { updateSubscriptionRoles } = require('../discordService');

function processGracePeriods() {
    // Find users whose grace period started more than 3 days ago
    const expiredUsers = db.prepare(`
        SELECT * FROM link_tokens 
        WHERE grace_period_start IS NOT NULL 
        AND grace_period_start <= datetime('now', '-3 days')
    `).all();

    expiredUsers.forEach(async (user) => {
        // 1. Remove their monthly tier
        db.prepare(`
            UPDATE link_tokens 
            SET monthly_tier = NULL, grace_period_start = NULL 
            WHERE email = ?
        `).run(user.email);

        // 2. Sync Discord to their permanent tier (Awakened or Sovereign)
        await updateSubscriptionRoles(user.discord_id, user.permanent_tier);
        
        // 3. Send Day 3 welcome-to-awakened email
        // await sendDowngradeEmail(user.email);
    });
}

// Set interval or run via cron
setInterval(processGracePeriods, 1000 * 60 * 60 * 24); // Daily check

Note: For production, use node-cron instead of setInterval

5. UPSERT for "Double Buy" Edge Case

// This tries to insert. If the email already exists, it updates the tier and clears any grace period.
const upsertStmt = db.prepare(`
    INSERT INTO link_tokens (token, email, monthly_tier, subscription_id) 
    VALUES (?, ?, ?, ?)
    ON CONFLICT(email) DO UPDATE SET 
        monthly_tier = excluded.monthly_tier,
        subscription_id = excluded.subscription_id,
        grace_period_start = NULL
`);

upsertStmt.run(newToken, customerEmail, newTier, newSubscriptionId);

task_number: 87

Paymenter Webhook Research Requirements

CRITICAL: Must verify before implementation

Required Webhook Events:

Must confirm Paymenter sends these events (or equivalents):

  1. invoice.payment_failed

    • Triggers Day 0 of grace period
    • Starts 3-day countdown
  2. invoice.payment_succeeded

    • Clears grace period if payment clears late
    • Re-upgrades user to monthly tier
    • CRITICAL for Stripe smart retry compatibility
  3. subscription.cancelled

    • User voluntarily cancelled
    • Drop to permanent tier at end of billing cycle
  4. chargeback.created or dispute.created

    • Triggers immediate permanent ban
    • NOTE: Paymenter might not send this
    • May need Stripe webhooks for accurate chargeback data

Research Procedure (When Home):

  1. Log into Paymenter admin

    • URL: billing.firefrostgaming.com/admin
  2. Navigate to webhook settings

    • Look for "Webhooks", "API", or "Integrations"
    • Document all available event types
  3. Check Paymenter documentation

    • Official docs for webhook events
    • Payload structure examples
    • Event naming conventions
  4. Test payment failure (if test mode available)

    • Create test subscription
    • Force payment failure
    • Capture exact webhook payload
    • Verify event name and data structure
  5. Make decision:

    • Granular events exist → Use Paymenter webhooks
    • Only generic events → Add /webhook/stripe endpoint

If Stripe webhooks needed:

  1. Stripe Dashboard → Developers → Webhooks
  2. Add endpoint: https://discord-bot.firefrostgaming.com/webhook/stripe
  3. Select events:
    • invoice.payment_failed
    • invoice.payment_succeeded
    • customer.subscription.deleted
    • charge.dispute.created
  4. Get webhook signing secret
  5. Add to Arbiter .env: STRIPE_WEBHOOK_SECRET=whsec_...
  6. Implement signature verification

task_number: 87

Email Communication Strategy

Gemini's Critical Guidance on Day 3 Email:

"You must clearly state in the Day 3 downgrade email: 'Your payment failed, but because you are part of the Firefrost family, we've secured your spot in the Awakened tier permanently so you don't lose access to the community.' Turn a billing failure into a positive community moment!"

Email Tone Requirements:

Day 0 (Payment Failed):

  • Factual, helpful
  • "Please update your payment method"
  • Link to billing portal

Day 1 (Reminder):

  • Friendly reminder
  • "2 days left to update"
  • Reassurance that access continues

Day 2 (Final Warning):

  • Urgent but kind
  • "24 hours until downgrade"
  • Emphasize they WON'T lose access

Day 3 (Downgraded to Awakened):

  • POSITIVE FRAMING
  • "We secured your spot in Awakened permanently"
  • "Part of the Firefrost family forever"
  • "You can upgrade anytime"
  • Turn failure into community win

task_number: 87

Implementation Priority Order

Based on Gemini's review, build in this order:

Phase 1: Database & Core Logic

  1. Add database columns (permanent_tier, monthly_tier, grace_period_start, is_banned)
  2. Build tier resolver helper
  3. Implement UPSERT logic (double buy protection)

Phase 2: Paymenter Research 4. ⚠️ Research Paymenter webhook events (BLOCKING) 5. ⚠️ Decide: Paymenter webhooks vs Stripe webhooks 6. ⚠️ Test payment failure in test environment

Phase 3: Webhook Handlers 7. Add ban check to all webhook handlers 8. Implement invoice.payment_failed handler 9. Implement invoice.payment_succeeded handler (Stripe retry fix) 10. Implement subscription.cancelled handler 11. Implement chargeback.created handler

Phase 4: Cleanup & Email 12. Build 4 AM grace period sweeper job 13. Create email templates (Day 0, 1, 2, 3) 14. Implement email sending logic

Phase 5: Testing 15. Unit test each handler 16. Integration test full grace period flow 17. Test Stripe retry scenario (critical) 18. Test double buy scenario 19. Test chargeback ban


task_number: 87

Success Criteria (Updated)

Arbiter 2.1 is complete when:

  • Database schema updated (4 new columns)
  • Tier resolver helper working
  • UPSERT prevents double subscription tracking
  • Ban check on all webhook handlers
  • Payment failure starts 3-day grace period
  • Late payment (Stripe retry) clears grace period and re-upgrades
  • Day 3 auto-downgrade to Awakened working
  • Email reminders send (Day 0, 1, 2, 3)
  • 4 AM cleanup job processing grace periods
  • Chargeback sets is_banned flag
  • Banned users cannot re-activate via webhooks
  • Discord role always shows highest tier
  • Cancel monthly tier → revert to permanent tier
  • Complete test flow successful (payment fail → grace → retry → resolve)
  • Stripe smart retry scenario tested and working

task_number: 87

Open Questions for Implementation

Need answers before building:

  1. Paymenter webhook events - What does it actually send?
  2. Paymenter vs Stripe - Which webhook source to use?
  3. Email service integration - Using Mailcow SMTP from Arbiter 2.0?
  4. Discord role names - Exact role IDs for all tiers?
  5. Test environment - Paymenter test mode available?

task_number: 87

Gemini's Final Notes

Progress Log Entry:

"Evaluated Paymenter webhook limitations. Decided strictly against a polling architecture to minimize server overhead and maintenance burden; will route directly to Stripe webhooks for granular events (invoice.payment_failed, chargeback.created) if Paymenter lacks specificity. Resolved the 'Double Buy' edge case by implementing an SQLite UPSERT keyed to the user's email, ensuring the database flawlessly reflects only the most recent active subscription state."

Closing Guidance:

"Take your time digging into the Paymenter logs. Just update the Arbiter 2.1 doc with what you find, and ping me whenever you are ready to start snapping the code together! 🔥❄️💙"


task_number: 87

Fire + Frost + Foundation = Where Love Builds Legacy 💙🔥❄️


task_number: 87

Document Status: ARCHITECTURE VALIDATED - Ready for implementation
Task Status: Paymenter research required, then build
Ready to Build: After webhook event verification
Gemini Consultation: COMPLETE - All questions answered