feat(arbiter): Task #126 lifecycle handlers — We Don't Kick People Out

Implements the Firefrost core policy that Awakened ($1) is lifetime access.
Paying customers are never removed from access — only demoted to Awakened
when a paid subscription ends. Hard bans reserved for actionable violations
(chargebacks and refunds).

Changes:
- customer.subscription.deleted: demote to Awakened instead of grace period
  * Uses existing 'lifetime' status + is_lifetime=TRUE (no schema changes,
    no touches to other filters in servers.js/roles.js/financials.js)
  * Calls downgradeToAwakened() to strip tier role and assign Awakened
  * Audit log entry tagged with policy
- New charge.refunded handler: hard ban with Trinity appeal eligibility
  * Mirrors charge.dispute.created pattern
  * appeal_eligible: true flag in banned_users.notes
  * removeAllRoles() (NOT demote — refund is a hard ban)
- Added downgradeToAwakened to stripe.js imports

Left unchanged (intentionally):
- invoice.payment_failed: marks past_due, trust Stripe's retry logic
- invoice.payment_succeeded: clears grace period (harmless legacy field reset)
- charge.dispute.created: already correct under new policy
- checkout.session.completed: syncRole already handles Model A (Awakened
  automatically assigned on any first purchase via tier role replacement)

Co-authored with Michael (The Wizard)
Task #126 — Soft launch blocker
This commit is contained in:
Claude
2026-04-11 23:04:30 +00:00
parent c07c29c60c
commit 9777a7af9d

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