From 9777a7af9d170350e67d04b31c41978a54ddfba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 23:04:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(arbiter):=20Task=20#126=20lifecycle=20hand?= =?UTF-8?q?lers=20=E2=80=94=20We=20Don't=20Kick=20People=20Out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/arbiter-3.0/src/routes/stripe.js | 109 ++++++++++++++++++++-- 1 file changed, 101 insertions(+), 8 deletions(-) diff --git a/services/arbiter-3.0/src/routes/stripe.js b/services/arbiter-3.0/src/routes/stripe.js index df34fba..e15eec0 100644 --- a/services/arbiter-3.0/src/routes/stripe.js +++ b/services/arbiter-3.0/src/routes/stripe.js @@ -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}`); }