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:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user