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) <claude@firefrostgaming.com>
This commit is contained in:
Claude (Chronicler #62)
2026-04-05 14:25:41 +00:00
parent bc66fec77a
commit 1a97e82ec8
6 changed files with 320 additions and 11 deletions

View File

@@ -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);
});
}

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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
};

View File

@@ -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");
});
}

View File

@@ -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 };