diff --git a/docs/tasks/arbiter-2-1-cancellation-flow/README.md b/docs/tasks/arbiter-2-1-cancellation-flow/README.md new file mode 100644 index 0000000..8c14ad2 --- /dev/null +++ b/docs/tasks/arbiter-2-1-cancellation-flow/README.md @@ -0,0 +1,1061 @@ +# Task #87: Arbiter 2.1 - Subscription Cancellation & Grace Period System + +**Status:** IDENTIFIED - Critical for soft launch +**Owner:** Michael "Frostystyle" Krause +**Priority:** Tier 1 - SOFT LAUNCH BLOCKER +**Created:** March 30, 2026 +**Time Estimate:** 4-6 hours +**Architecture Partner:** Gemini AI (review requested) + +--- + +## ⚠️ 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.** + +--- + +## 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 + +--- + +## 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 + +--- + +## 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** + +--- + +## Technical Implementation Plan + +### 1. Database Schema Enhancement + +**Add two new tables to existing SQLite database:** + +```sql +-- 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:** + +```javascript +// 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 +```javascript +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 +```javascript +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 +```javascript +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 +```javascript +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** + +```javascript +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** + +```javascript +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 +```html + + +
+ +Hi there,
+ +We've received your cancellation request for your {{tier}} subscription.
+ +We're sorry to see you go! If you change your mind, you can re-subscribe anytime at firefrostgaming.com/join.
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team 💙
+
Hi there,
+ +We weren't able to process your payment for your {{tier}} subscription.
+ +Reason: {{reason}}
+Amount: ${{amount}}
+ +Please update your payment method within {{grace_days}} days to keep your access.
+ + + +If you have any questions, join us in Discord: firefrostgaming.com/discord
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team 💙
+
Hi there,
+ +This is a friendly reminder that your payment for {{tier}} is still pending.
+ +You have {{days_remaining}} days left to update your payment method.
+ + + +Need help? Join us in Discord: firefrostgaming.com/discord
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team 💙
+
Hi there,
+ +This is your final warning. Your payment for {{tier}} is still pending.
+ ++ You have {{hours_remaining}} hours to update your payment method or your subscription will be cancelled. +
+ + + +If you don't update your payment:
+Need help urgently? Join Discord: firefrostgaming.com/discord
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team 💙
+
Hi there,
+ +Your {{tier}} subscription has been cancelled due to non-payment.
+ +We'd love to have you back! You can re-subscribe anytime:
+ ++ Re-Subscribe +
+ +Questions? Join us in Discord: firefrostgaming.com/discord
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team 💙
+
Hi there,
+ +A chargeback was filed for your {{tier}} subscription (Amount: ${{amount}}).
+ +Chargebacks are costly and disrupt our small community. If this was filed in error or there was a billing issue, please contact us directly:
+ +Email: {{contact_email}}
+ Discord: firefrostgaming.com/discord
We're here to resolve any legitimate billing concerns, but chargebacks require manual review before account access can be restored.
+ +
+ Fire + Frost + Foundation
+ The Firefrost Gaming Team
+