From 1a97e82ec8dff9fc1acc52ac649ddc29c6eae5be Mon Sep 17 00:00:00 2001 From: "Claude (Chronicler #62)" Date: Sun, 5 Apr 2026 14:25:41 +0000 Subject: [PATCH] feat(arbiter): implement Task #87 - Lifecycle handlers with Discord role sync WHAT THIS ADDS: - Discord role sync on new subscriptions (checkout.session.completed) - Discord role removal on chargebacks (charge.dispute.created) - Grace period expiration job (hourly cron check) - Automatic downgrade to Awakened when grace period expires NEW FILES: - src/services/discordRoleSync.js - Role add/remove/sync functions - src/sync/graceExpiration.js - Grace period expiration processor MODIFIED FILES: - src/routes/stripe.js - Added role sync calls to webhook handlers - src/discord/events.js - Initialize role sync service on bot ready - src/sync/cron.js - Added grace period check to hourly job - src/index.js - Import discordRoleSync service PHILOSOPHY: 'We Don't Kick People Out' - expired grace periods downgrade to permanent Awakened tier (tier 1, lifetime). Users keep community access, just lose premium perks. ROLE MAPPING (tier_level -> role key): 1=the-awakened, 2=fire-elemental, 3=frost-elemental, 4=fire-knight, 5=frost-knight, 6=fire-master, 7=frost-master, 8=fire-legend, 9=frost-legend, 10=the-sovereign CHARGEBACKS: - Immediate role removal - Added to banned_users table - Full audit logging Signed-off-by: Claude (Chronicler #62) --- services/arbiter-3.0/src/discord/events.js | 3 + services/arbiter-3.0/src/index.js | 1 + services/arbiter-3.0/src/routes/stripe.js | 60 ++++++-- .../src/services/discordRoleSync.js | 144 ++++++++++++++++++ services/arbiter-3.0/src/sync/cron.js | 12 +- .../arbiter-3.0/src/sync/graceExpiration.js | 111 ++++++++++++++ 6 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 services/arbiter-3.0/src/services/discordRoleSync.js create mode 100644 services/arbiter-3.0/src/sync/graceExpiration.js diff --git a/services/arbiter-3.0/src/discord/events.js b/services/arbiter-3.0/src/discord/events.js index a8264b7..a323544 100644 --- a/services/arbiter-3.0/src/discord/events.js +++ b/services/arbiter-3.0/src/discord/events.js @@ -1,4 +1,5 @@ const { handleLinkCommand } = require('./commands'); +const discordRoleSync = require('../services/discordRoleSync'); function registerEvents(client) { client.on('interactionCreate', async interaction => { @@ -10,6 +11,8 @@ function registerEvents(client) { client.on('ready', () => { console.log(`Discord bot logged in as ${client.user.tag}`); + // Initialize role sync service with the ready client + discordRoleSync.init(client); }); } diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 3b0d25c..f980527 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -17,6 +17,7 @@ const stripeRoutes = require('./routes/stripe'); const { registerEvents } = require('./discord/events'); const { linkCommand } = require('./discord/commands'); const { initCron } = require('./sync/cron'); +const discordRoleSync = require('./services/discordRoleSync'); // PostgreSQL connection pool for sessions const pgPool = new Pool({ diff --git a/services/arbiter-3.0/src/routes/stripe.js b/services/arbiter-3.0/src/routes/stripe.js index 4cf68e3..eca2ac2 100644 --- a/services/arbiter-3.0/src/routes/stripe.js +++ b/services/arbiter-3.0/src/routes/stripe.js @@ -2,6 +2,7 @@ * Stripe Integration Routes * Handles checkout sessions, webhooks, and customer portal * Date: April 3, 2026 + * Updated: April 6, 2026 - Added Discord role sync (Task #87) */ const express = require('express'); @@ -9,6 +10,7 @@ const router = express.Router(); const cors = require('cors'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const db = require('../database'); +const { syncRole, removeAllRoles } = require('../services/discordRoleSync'); // CORS configuration for checkout endpoint const corsOptions = { @@ -123,6 +125,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r const session = event.data.object; const discordId = session.client_reference_id; const customerId = session.customer; + let tierLevel = null; if (session.mode === 'subscription') { // RECURRING SUBSCRIPTION (Tiers 2-9) @@ -140,6 +143,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r } const tierData = productRes.rows[0]; + tierLevel = tierData.tier_level; await client.query(` INSERT INTO subscriptions (discord_id, tier_level, status, stripe_subscription_id, stripe_customer_id, mrr_value, is_lifetime) @@ -163,6 +167,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r } const tierData = productRes.rows[0]; + tierLevel = tierData.tier_level; await client.query(` INSERT INTO subscriptions (discord_id, tier_level, status, stripe_payment_intent_id, stripe_customer_id, mrr_value, is_lifetime) @@ -177,8 +182,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r VALUES ('CHECKOUT_COMPLETED', $1, $2) `, [discordId, JSON.stringify({ mode: session.mode, customer: customerId })]); - // TODO: Trigger Discord role sync - // TODO: Trigger Pterodactyl whitelist sync + // Sync Discord role + if (discordId && tierLevel) { + const roleResult = await syncRole(discordId, tierLevel); + console.log(`🎭 Role sync for ${discordId}: ${roleResult.message}`); + } break; } @@ -257,23 +265,55 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r case 'charge.dispute.created': { const dispute = event.data.object; const paymentIntentId = dispute.payment_intent; + const customerId = dispute.customer; - // Immediately ban on chargeback + // Find the subscription by customer ID (more reliable) + const subResult = await client.query(` + SELECT discord_id FROM subscriptions + WHERE stripe_customer_id = $1 + LIMIT 1 + `, [customerId]); + + let discordId = null; + if (subResult.rows.length > 0) { + discordId = subResult.rows[0].discord_id; + } + + // Mark as banned await client.query(` UPDATE subscriptions SET status = 'chargeback_ban', updated_at = CURRENT_TIMESTAMP - WHERE stripe_payment_intent_id = $1 OR stripe_subscription_id IN ( - SELECT id FROM stripe_subscriptions WHERE latest_invoice IN ( - SELECT id FROM stripe_invoices WHERE payment_intent = $1 - ) - ) - `, [paymentIntentId]); + WHERE stripe_customer_id = $1 + `, [customerId]); + + // Add to banned_users table + if (discordId) { + await client.query(` + INSERT INTO banned_users (discord_id, ban_reason, notes) + VALUES ($1, 'chargeback', $2) + ON CONFLICT (discord_id) DO UPDATE SET + ban_reason = 'chargeback', + notes = $2, + banned_at = CURRENT_TIMESTAMP + `, [discordId, JSON.stringify({ + payment_intent: paymentIntentId, + dispute_id: dispute.id + })]); + + // Remove all Discord roles + const roleResult = await removeAllRoles(discordId); + console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`); + } await client.query(` INSERT INTO admin_audit_log (action_type, target_identifier, details) VALUES ('CHARGEBACK_BAN', $1, $2) - `, ['system', JSON.stringify({ payment_intent: paymentIntentId, reason: 'Chargeback dispute created' })]); + `, [discordId || 'unknown', JSON.stringify({ + payment_intent: paymentIntentId, + customer_id: customerId, + reason: 'Chargeback dispute created' + })]); break; } diff --git a/services/arbiter-3.0/src/services/discordRoleSync.js b/services/arbiter-3.0/src/services/discordRoleSync.js new file mode 100644 index 0000000..007efa9 --- /dev/null +++ b/services/arbiter-3.0/src/services/discordRoleSync.js @@ -0,0 +1,144 @@ +/** + * Discord Role Sync Service + * Handles adding/removing Discord roles based on subscription tier + * + * Task #87: Arbiter Lifecycle Handlers + * Date: April 6, 2026 + */ + +const { getRoleMappings } = require('../utils/roleMappings'); + +// Tier level to role key mapping +const TIER_TO_ROLE_KEY = { + 1: 'the-awakened', + 2: 'fire-elemental', + 3: 'frost-elemental', + 4: 'fire-knight', + 5: 'frost-knight', + 6: 'fire-master', + 7: 'frost-master', + 8: 'fire-legend', + 9: 'frost-legend', + 10: 'the-sovereign' +}; + +// All subscriber role keys (for removal) +const ALL_SUBSCRIBER_ROLE_KEYS = Object.values(TIER_TO_ROLE_KEY); + +// Store Discord client reference +let discordClient = null; + +/** + * Initialize the service with Discord client + * Called from index.js after client is ready + */ +function init(client) { + discordClient = client; + console.log('✅ Discord Role Sync service initialized'); +} + +/** + * Get the Discord client + */ +function getClient() { + return discordClient; +} + +/** + * Sync Discord role for a user based on their tier + * Removes old tier roles and adds the new one + * + * @param {string} discordId - User's Discord ID + * @param {number} newTierLevel - New tier level (1-10), or null for complete removal + * @returns {Promise<{success: boolean, message: string}>} + */ +async function syncRole(discordId, newTierLevel) { + if (!discordClient) { + return { success: false, message: 'Discord client not initialized' }; + } + + const guildId = process.env.GUILD_ID; + if (!guildId) { + return { success: false, message: 'GUILD_ID not configured' }; + } + + try { + const guild = discordClient.guilds.cache.get(guildId); + if (!guild) { + return { success: false, message: 'Guild not found in cache' }; + } + + const member = await guild.members.fetch(discordId).catch(() => null); + if (!member) { + return { success: false, message: 'Member not found in guild (may have left)' }; + } + + const roleMappings = getRoleMappings(); + + // Get all role IDs to remove + const rolesToRemove = ALL_SUBSCRIBER_ROLE_KEYS + .map(key => roleMappings[key]) + .filter(id => id && member.roles.cache.has(id)); + + // Remove old roles + if (rolesToRemove.length > 0) { + await member.roles.remove(rolesToRemove); + } + + // Add new role if tier specified + if (newTierLevel !== null) { + const newRoleKey = TIER_TO_ROLE_KEY[newTierLevel]; + const newRoleId = roleMappings[newRoleKey]; + + if (newRoleId) { + await member.roles.add(newRoleId); + return { + success: true, + message: `Synced to tier ${newTierLevel} (${newRoleKey})` + }; + } else { + return { + success: false, + message: `No role mapping found for tier ${newTierLevel}` + }; + } + } + + return { success: true, message: 'All subscriber roles removed' }; + + } catch (error) { + console.error('Discord role sync error:', error); + return { success: false, message: error.message }; + } +} + +/** + * Remove all subscriber roles from a user (for bans/chargebacks) + * + * @param {string} discordId - User's Discord ID + * @returns {Promise<{success: boolean, message: string}>} + */ +async function removeAllRoles(discordId) { + return syncRole(discordId, null); +} + +/** + * Downgrade user to Awakened tier + * Used when grace period expires + * + * @param {string} discordId - User's Discord ID + * @returns {Promise<{success: boolean, message: string}>} + */ +async function downgradeToAwakened(discordId) { + return syncRole(discordId, 1); // Tier 1 = Awakened +} + +module.exports = { + init, + getClient, + syncRole, + removeAllRoles, + downgradeToAwakened, + TIER_TO_ROLE_KEY, + ALL_SUBSCRIBER_ROLE_KEYS +}; diff --git a/services/arbiter-3.0/src/sync/cron.js b/services/arbiter-3.0/src/sync/cron.js index 9f3971f..9c68553 100644 --- a/services/arbiter-3.0/src/sync/cron.js +++ b/services/arbiter-3.0/src/sync/cron.js @@ -1,10 +1,20 @@ const cron = require('node-cron'); const { triggerImmediateSync } = require('./immediate'); +const { processExpiredGracePeriods } = require('./graceExpiration'); function initCron() { + // Hourly whitelist reconciliation cron.schedule('0 * * * *', async () => { - console.log("Starting hourly whitelist reconciliation..."); + console.log("⏰ Starting hourly sync jobs..."); + + // 1. Process expired grace periods + await processExpiredGracePeriods(); + + // 2. Whitelist reconciliation + console.log("Starting whitelist reconciliation..."); await triggerImmediateSync(); + + console.log("✅ Hourly sync jobs complete"); }); } diff --git a/services/arbiter-3.0/src/sync/graceExpiration.js b/services/arbiter-3.0/src/sync/graceExpiration.js new file mode 100644 index 0000000..80c105c --- /dev/null +++ b/services/arbiter-3.0/src/sync/graceExpiration.js @@ -0,0 +1,111 @@ +/** + * Grace Period Expiration Job + * Checks for expired grace periods and downgrades users to Awakened + * + * Philosophy: "We Don't Kick People Out" + * - Expired grace periods downgrade to permanent Awakened tier + * - Users keep community access, just lose premium perks + * + * Task #87: Arbiter Lifecycle Handlers + * Date: April 6, 2026 + */ + +const db = require('../database'); +const { downgradeToAwakened } = require('../services/discordRoleSync'); + +/** + * Process all expired grace periods + * Called hourly from cron.js + * + * @returns {Promise<{processed: number, errors: number}>} + */ +async function processExpiredGracePeriods() { + console.log('🔍 Checking for expired grace periods...'); + + const client = await db.pool.connect(); + let processed = 0; + let errors = 0; + + try { + // Find all expired grace periods + const { rows: expired } = await client.query(` + SELECT s.discord_id, s.tier_level, u.minecraft_username + FROM subscriptions s + LEFT JOIN users u ON s.discord_id = u.discord_id + WHERE s.status = 'grace_period' + AND s.grace_period_ends_at < NOW() + AND s.is_lifetime = FALSE + `); + + if (expired.length === 0) { + console.log('✅ No expired grace periods found'); + return { processed: 0, errors: 0 }; + } + + console.log(`📋 Found ${expired.length} expired grace period(s)`); + + for (const sub of expired) { + try { + await client.query('BEGIN'); + + // Record the tier change in history + await client.query(` + INSERT INTO player_history + (discord_id, previous_tier, new_tier, change_reason) + VALUES ($1, $2, 1, 'grace_period_expired') + `, [sub.discord_id, sub.tier_level]); + + // Downgrade to Awakened (tier 1, lifetime) + await client.query(` + UPDATE subscriptions + SET tier_level = 1, + status = 'lifetime', + is_lifetime = TRUE, + mrr_value = 0, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + payment_failure_reason = NULL, + stripe_subscription_id = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE discord_id = $1 + `, [sub.discord_id]); + + // Log in audit + await client.query(` + INSERT INTO admin_audit_log + (action_type, target_identifier, details) + VALUES ('GRACE_PERIOD_EXPIRED', $1, $2) + `, [sub.discord_id, JSON.stringify({ + previous_tier: sub.tier_level, + new_tier: 1, + minecraft_username: sub.minecraft_username, + reason: 'Automatic downgrade after grace period expiration' + })]); + + await client.query('COMMIT'); + + // Sync Discord role to Awakened + const syncResult = await downgradeToAwakened(sub.discord_id); + if (!syncResult.success) { + console.warn(`⚠️ Role sync failed for ${sub.discord_id}: ${syncResult.message}`); + } + + console.log(`✅ Downgraded ${sub.minecraft_username || sub.discord_id} to Awakened`); + processed++; + + } catch (error) { + await client.query('ROLLBACK'); + console.error(`❌ Error processing ${sub.discord_id}:`, error.message); + errors++; + } + } + + } finally { + client.release(); + } + + console.log(`📊 Grace period processing complete: ${processed} processed, ${errors} errors`); + return { processed, errors }; +} + +module.exports = { processExpiredGracePeriods };