Merge branch 'task-126-lifecycle-handlers'

Task #126 lifecycle handlers deployed to Command Center at 18:05 UTC.
Verified clean startup. Webhook endpoint returns expected HTTP 400 on
unsigned requests. Backup preserved at:
/opt/arbiter-3.0/src/routes/stripe.js.backup-task126-20260411-180439
This commit is contained in:
Claude
2026-04-11 23:05:33 +00:00

View File

@@ -11,7 +11,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');
const { syncRole, removeAllRoles, downgradeToAwakened } = require('../services/discordRoleSync');
// CORS configuration for checkout endpoint
const corsOptions = {
@@ -300,20 +300,45 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
}
case 'customer.subscription.deleted': {
// Task #126: "We don't kick people out."
// Awakened ($1) is lifetime. When a paid subscription ends (cancellation
// or Stripe gave up on retries), demote to Awakened rather than remove.
// This event fires at actual period end, so timing is already correct.
const subscription = event.data.object;
const subId = subscription.id;
// Start grace period (don't remove immediately)
const gracePeriodEnds = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // 3 days
// Find the Discord ID for this subscription before updating status
const subRow = await client.query(
'SELECT discord_id FROM subscriptions WHERE stripe_subscription_id = $1',
[subId]
);
await client.query(`
UPDATE subscriptions
SET status = 'grace_period',
grace_period_started_at = CURRENT_TIMESTAMP,
grace_period_ends_at = $1,
SET status = 'lifetime',
tier_level = 1,
is_lifetime = TRUE,
grace_period_started_at = NULL,
grace_period_ends_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $2 AND is_lifetime = FALSE
`, [gracePeriodEnds, subId]);
WHERE stripe_subscription_id = $1 AND is_lifetime = FALSE
`, [subId]);
// Demote Discord role to Awakened (strips tier role, assigns Awakened)
if (subRow.rows.length > 0) {
const discordId = subRow.rows[0].discord_id;
const roleResult = await downgradeToAwakened(discordId);
console.log(`⬇️ Subscription ended for ${discordId}, demoted to Awakened: ${roleResult.message}`);
await client.query(`
INSERT INTO admin_audit_log (action_type, target_identifier, details)
VALUES ('SUBSCRIPTION_ENDED_DEMOTED', $1, $2)
`, [discordId, JSON.stringify({
stripe_subscription_id: subId,
reason: 'subscription_deleted',
policy: 'we_dont_kick_people_out'
})]);
}
break;
}
@@ -412,6 +437,74 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
break;
}
case 'charge.refunded': {
// Task #126: Refund requested = hard ban, with Trinity appeal process.
// Policy: "We only remove all access for actionable hard bans."
// A refund is the customer asking for their money back — mirrors chargeback.
const charge = event.data.object;
const customerId = charge.customer;
const paymentIntentId = charge.payment_intent;
if (!customerId) {
console.log(`⚠️ charge.refunded with no customer_id (likely guest checkout), skipping`);
break;
}
// Find the subscription by customer ID
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 subscription as refund_ban
await client.query(`
UPDATE subscriptions
SET status = 'refund_ban',
updated_at = CURRENT_TIMESTAMP
WHERE stripe_customer_id = $1
`, [customerId]);
// Add to banned_users with appeal eligibility noted
if (discordId) {
await client.query(`
INSERT INTO banned_users (discord_id, ban_reason, notes)
VALUES ($1, 'refund', $2)
ON CONFLICT (discord_id) DO UPDATE SET
ban_reason = 'refund',
notes = $2,
banned_at = CURRENT_TIMESTAMP
`, [discordId, JSON.stringify({
payment_intent: paymentIntentId,
charge_id: charge.id,
amount_refunded: charge.amount_refunded,
appeal_eligible: true,
appeal_process: 'Trinity review'
})]);
// Remove ALL roles including Awakened (hard ban)
const roleResult = await removeAllRoles(discordId);
console.log(`🚫 Refund ban for ${discordId}: ${roleResult.message}`);
}
await client.query(`
INSERT INTO admin_audit_log (action_type, target_identifier, details)
VALUES ('REFUND_BAN', $1, $2)
`, [discordId || 'unknown', JSON.stringify({
payment_intent: paymentIntentId,
customer_id: customerId,
amount_refunded: charge.amount_refunded,
reason: 'Refund processed',
appeal_eligible: true
})]);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}