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:
@@ -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