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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal file
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal 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
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal file
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal 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 };
|
||||
Reference in New Issue
Block a user