1import User from "../../../seed/models/user.js";
2import { upgradeUserPlan } from "./upgradePlan.js";
3import { clearUserClientCache } from "../../../seed/llm/conversation.js";
4import { getUserMeta, setUserMeta } from "../../../seed/tree/userMetadata.js";
5
6/**
7 * Read the user's energy metadata, ensuring the expected shape exists.
8 * Energy data lives in user.metadata under the "energy" key with structure:
9 * { available: { amount, lastResetAt }, additional: { amount } }
10 */
11function getEnergy(user) {
12 const energy = getUserMeta(user, "energy");
13 if (!energy.available) energy.available = { amount: 0, lastResetAt: null };
14 if (!energy.additional) energy.additional = { amount: 0 };
15 if (typeof energy.available.amount !== "number") energy.available.amount = 0;
16 if (typeof energy.additional.amount !== "number") energy.additional.amount = 0;
17 return energy;
18}
19
20const ALLOWED_PAID_PLANS = ["standard", "premium"];
21const PLAN_DURATION_DAYS = 30;
22const MAX_ENERGY_PURCHASE = 1_000_000; // safety cap
23
24export async function processPurchase({
25 userId,
26 plan,
27 energyAmount,
28}) {
29 const user = await User.findById(userId);
30
31 if (!user) {
32 throw new Error("User not found");
33 }
34
35 if (plan && !ALLOWED_PAID_PLANS.includes(plan)) {
36 throw new Error("Invalid plan");
37 }
38
39 if (energyAmount != null) {
40 if (typeof energyAmount !== "number" || energyAmount < 0) {
41 throw new Error("Invalid energy amount");
42 }
43
44 if (energyAmount > MAX_ENERGY_PURCHASE) {
45 throw new Error(`Energy amount exceeds limit of ${MAX_ENERGY_PURCHASE}`);
46 }
47 }
48
49 if (plan && plan !== "basic") {
50 const currentPlan = getUserMeta(user, "tiers").plan || "basic";
51 if (plan !== currentPlan) {
52 upgradeUserPlan(user, plan);
53 }
54
55 const billing = getUserMeta(user, "billing");
56 const baseTime = Math.max(
57 Date.now(),
58 billing.planExpiresAt?.getTime?.() || (typeof billing.planExpiresAt === "number" ? billing.planExpiresAt : 0)
59 );
60
61 setUserMeta(user, "billing", {
62 ...billing,
63 planExpiresAt: new Date(baseTime + PLAN_DURATION_DAYS * 24 * 60 * 60 * 1000),
64 });
65 }
66
67 if (energyAmount > 0) {
68 const energy = getEnergy(user);
69 energy.additional.amount += energyAmount;
70 setUserMeta(user, "energy", energy);
71 }
72
73 await user.save();
74
75 clearUserClientCache(userId);
76
77 return user;
78}
79
1import { getUserMeta, setUserMeta } from "../../../seed/tree/userMetadata.js";
2
3let DAILY_LIMITS = {};
4export function setEnergyService(energy) { DAILY_LIMITS = energy.DAILY_LIMITS || {}; }
5
6/**
7 * Read the user's energy metadata, ensuring the expected shape exists.
8 * Energy data lives in user.metadata under the "energy" key with structure:
9 * { available: { amount, lastResetAt }, additional: { amount } }
10 */
11function getEnergy(user) {
12 const energy = getUserMeta(user, "energy");
13 if (!energy.available) energy.available = { amount: 0, lastResetAt: null };
14 if (!energy.additional) energy.additional = { amount: 0 };
15 if (typeof energy.available.amount !== "number") energy.available.amount = 0;
16 if (typeof energy.additional.amount !== "number") energy.additional.amount = 0;
17 return energy;
18}
19
20const PLAN_DAILY_VALUE = {
21 basic: 0,
22 standard: 500,
23 premium: 2000,
24};
25
26export function upgradeUserPlan(user, newPlan) {
27 const now = Date.now();
28
29 const oldPlan = getUserMeta(user, "tiers").plan || "basic";
30 const billing = getUserMeta(user, "billing");
31 const expiresAt = billing.planExpiresAt?.getTime?.() || (typeof billing.planExpiresAt === "number" ? billing.planExpiresAt : 0);
32
33 if (PLAN_DAILY_VALUE[newPlan] <= PLAN_DAILY_VALUE[oldPlan]) {
34 throw new Error("Not an upgrade");
35 }
36
37 const energy = getEnergy(user);
38
39 if (expiresAt > now && oldPlan !== "basic" && oldPlan !== "premium") {
40 const remainingDays = Math.ceil((expiresAt - now) / (24 * 60 * 60 * 1000));
41
42 const energyPerDay = PLAN_DAILY_VALUE[oldPlan];
43
44 const compensationEnergy = remainingDays * energyPerDay;
45
46 energy.additional.amount += compensationEnergy;
47 }
48
49 setUserMeta(user, "tiers", { plan: newPlan });
50
51 energy.available.amount = DAILY_LIMITS[newPlan] ?? DAILY_LIMITS.basic;
52 energy.available.lastResetAt = new Date();
53 setUserMeta(user, "energy", energy);
54
55 return user;
56}
57
1import { getUserMeta } from "../../../seed/tree/userMetadata.js";
2
3const PLAN_DAILY_VALUE = {
4 basic: 0,
5 standard: 500,
6 premium: 2000,
7};
8
9export function validatePurchase(user, { plan, energyAmount }) {
10
11 if (plan) {
12 if (!["standard", "premium"].includes(plan)) {
13 throw new Error("Invalid plan");
14 }
15
16 const currentPlan = getUserMeta(user, "tiers").plan || "basic";
17 const oldVal = PLAN_DAILY_VALUE[currentPlan] ?? 0;
18 const newVal = PLAN_DAILY_VALUE[plan] ?? 0;
19
20 if (newVal < oldVal) {
21 throw new Error("Cannot downgrade plan");
22 }
23 }
24
25 if (energyAmount != null) {
26 if (typeof energyAmount !== "number" || energyAmount < 0) {
27 throw new Error("Invalid energy amount");
28 }
29
30 if (energyAmount > 1_000_000) {
31 throw new Error("Energy amount too large");
32 }
33 }
34}
35
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { createPurchaseSession } from "./purchase.js";
4import { setEnergyService } from "./core/upgradePlan.js";
5
6export async function init(core) {
7 if (core.energy) setEnergyService(core.energy);
8 const router = express.Router();
9
10 router.post("/user/:userId/purchase", authenticate, createPurchaseSession);
11
12 // Stripe webhook handler. Lazy-load webhook.js (and the Stripe SDK)
13 // on first request to avoid blocking boot if the SDK init is slow.
14 let _webhookMod = null;
15 const webhookHandler = async (req, res) => {
16 if (!_webhookMod) _webhookMod = await import("./webhook.js");
17 return _webhookMod.stripeWebhook(req, res);
18 };
19
20 // Register payment UI on the energy page
21 try {
22 const { getExtension } = await import("../loader.js");
23 const treeos = getExtension("treeos-base");
24 treeos?.exports?.registerSlot?.("energy-payment", "billing", ({ userId, plan, planExpiresAt }) => {
25 return `
26 <!-- Plans -->
27 <div class="glass-card" style="animation-delay: 0.15s;">
28 <h2>\uD83D\uDCCB Plans</h2>
29 <div class="plan-grid">
30 <div class="plan-box disabled" data-plan="basic">
31 <div class="plan-name">Basic</div>
32 <div class="plan-price">Free</div>
33 <div class="plan-period">350 daily energy</div>
34 <div class="plan-features">
35 <div class="plan-feature">No file uploads</div>
36 <div class="plan-feature dim">Limited access</div>
37 </div>
38 ${plan === "basic" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
39 </div>
40 <div class="plan-box" data-plan="standard">
41 <div class="plan-name">Standard</div>
42 <div class="plan-price">$20</div>
43 <div class="plan-period">per 30 days</div>
44 <div class="plan-features">
45 <div class="plan-feature">1,500 daily energy</div>
46 <div class="plan-feature">File uploads</div>
47 </div>
48 ${plan === "standard" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
49 </div>
50 <div class="plan-box" data-plan="premium">
51 <div class="plan-name">Premium</div>
52 <div class="plan-price">$100</div>
53 <div class="plan-period">per 30 days</div>
54 <div class="plan-features">
55 <div class="plan-feature">8,000 daily energy</div>
56 <div class="plan-feature">Full access</div>
57 <div class="plan-feature highlight">Offline LLM processing</div>
58 </div>
59 ${plan === "premium" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
60 </div>
61 </div>
62 <div class="plan-renew-note" id="planNote" style="display:none;"></div>
63 </div>
64
65 <!-- Buy Energy -->
66 <div class="glass-card" style="animation-delay: 0.2s;">
67 <h2>\uD83D\uDD25 Additional Energy</h2>
68 <div style="font-size: 14px; color: rgba(255,255,255,0.55); margin-bottom: 16px;">Reserve energy \u2014 only used when your plan energy runs out.</div>
69 <div class="energy-btns" id="energyBtns">
70 <button class="energy-buy-btn" data-amount="100">+100</button>
71 <button class="energy-buy-btn" data-amount="500">+500</button>
72 <button class="energy-buy-btn" data-amount="1000">+1000</button>
73 <button class="energy-buy-btn" id="customEnergyBtn">+Custom</button>
74 </div>
75 <div id="energyAdded" style="margin-top: 14px; font-size: 14px; color: rgba(255,255,255,0.6); display: none;">
76 Added: <strong id="energyAddedVal" style="color: white;"></strong>
77 <span style="margin-left: 8px; cursor: pointer; opacity: 0.6;" onclick="resetEnergy()">\u2715 Clear</span>
78 </div>
79 </div>
80
81 <!-- Checkout -->
82 <div class="glass-card" style="animation-delay: 0.25s;">
83 <h2>\uD83D\uDCB3 Checkout</h2>
84 <div id="checkoutContent">
85 <div class="checkout-empty">Select a plan or add energy to continue</div>
86 </div>
87 </div>`;
88 }, { priority: 10 });
89 } catch {}
90
91 return {
92 router,
93 rawWebhook: webhookHandler,
94 };
95}
96
1export default {
2 name: "billing",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Monetization for a TreeOS land. This extension connects Stripe Checkout to the " +
7 "tier and energy systems so land operators can charge for access. Two subscription " +
8 "tiers are available: Standard ($20/month) and Premium ($100/month). Each tier sets " +
9 "a higher daily energy budget through the energy extension. Users can also purchase " +
10 "additional energy units as a one-time top-up alongside or independent of a plan " +
11 "upgrade. A single checkout session can combine a plan change and an energy boost. " +
12 "\n\n" +
13 "The purchase flow creates a Stripe Checkout session with the plan and energy amounts " +
14 "encoded in session metadata. On successful payment, Stripe fires a webhook. The " +
15 "webhook handler verifies the signature, logs a contribution to the kernel audit " +
16 "trail with full Stripe references (session ID, payment intent, event ID, currency, " +
17 "total), then calls processPurchase to apply the changes. Duplicate webhook deliveries " +
18 "are caught by the contribution model's unique constraint and silently ignored. " +
19 "\n\n" +
20 "Plan upgrades are prorated. If a user upgrades from Standard to Premium mid-cycle, " +
21 "the remaining days on the old plan are converted to bonus energy and added to their " +
22 "additional energy pool. The new plan's expiration extends from whichever is later: " +
23 "now or the old expiration date. Downgrades are blocked at the validation layer. " +
24 "When a plan expires, the energy extension's daily reset detects it and reverts the " +
25 "user to Basic tier, clearing all LLM slot assignments in the process. " +
26 "\n\n" +
27 "Validation runs before the Stripe session is created. Invalid plans, negative energy " +
28 "amounts, and amounts above the safety cap of one million units are rejected before " +
29 "the user ever sees a payment form. The webhook handler is lazy-loaded on first " +
30 "request to avoid blocking boot with Stripe SDK initialization.",
31
32 npm: ["stripe@^20.3.1"],
33
34 needs: {
35 models: ["User"],
36 },
37
38 optional: {
39 services: ["energy"],
40 extensions: ["treeos-base"],
41 },
42
43 provides: {
44 env: [
45 { key: "STRIPE_SECRET_KEY", required: true, secret: true, description: "Stripe secret key for payment processing" },
46 { key: "STRIPE_WEBHOOK_SECRET", required: true, secret: true, description: "Stripe webhook signing secret" },
47 ],
48 models: {},
49 routes: "./purchase.js",
50 tools: false,
51 jobs: false,
52 orchestrator: false,
53 energyActions: {},
54 sessionTypes: {},
55 },
56};
57
1{
2 "name": "treeos-ext-billing",
3 "version": "1.0.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "treeos-ext-billing",
9 "version": "1.0.0",
10 "dependencies": {
11 "stripe": "^20.3.1"
12 }
13 },
14 "node_modules/stripe": {
15 "version": "20.4.1",
16 "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
17 "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==",
18 "license": "MIT",
19 "engines": {
20 "node": ">=16"
21 },
22 "peerDependencies": {
23 "@types/node": ">=16"
24 },
25 "peerDependenciesMeta": {
26 "@types/node": {
27 "optional": true
28 }
29 }
30 }
31 }
32}
33
1{
2 "type": "module",
3 "name": "treeos-ext-billing",
4 "version": "1.0.0",
5 "private": true,
6 "dependencies": {
7 "stripe": "^20.3.1"
8 }
9}
1import log from "../../seed/log.js";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import Stripe from "stripe";
4import User from "../../seed/models/user.js";
5import { validatePurchase } from "./core/validatePurchase.js";
6import { getLandUrl } from "../../canopy/identity.js";
7
8const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY) : null;
9
10export async function createPurchaseSession(req, res) {
11 if (!stripe) return sendError(res, 500, ERR.INTERNAL, "Stripe is not configured");
12 try {
13 const { userId, plan, energyAmount } = req.body;
14
15 const user = await User.findById(userId);
16 if (!user) {
17 return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
18 }
19
20 try {
21 validatePurchase(user, { plan, energyAmount });
22 } catch (err) {
23 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
24 }
25
26 let totalCents = 0;
27
28 if (plan === "standard") totalCents += 2000;
29 if (plan === "premium") totalCents += 10000;
30
31 if (energyAmount > 0) {
32 totalCents += energyAmount * 1;
33 }
34
35 if (totalCents <= 0) {
36 return sendError(res, 400, ERR.INVALID_INPUT, "Nothing to purchase");
37 }
38
39 let productName = "Purchase";
40
41 if (plan && energyAmount > 0) {
42 if (plan === "standard") {
43 productName = "Standard Plan, 1 Month + Energy";
44 } else if (plan === "premium") {
45 productName = "Premium Plan, 1 Month + Energy";
46 } else {
47 productName = "Plan + Energy Purchase";
48 }
49 } else if (plan === "standard") {
50 productName = "Standard Plan, 1 Month";
51 } else if (plan === "premium") {
52 productName = "Premium Plan, 1 Month";
53 } else if (energyAmount > 0) {
54 productName = "Additional Energy Boost";
55 }
56
57 const successUrl = `${getLandUrl()}/dashboard`;
58 const cancelUrl = successUrl;
59
60 const session = await stripe.checkout.sessions.create({
61 mode: "payment",
62 payment_method_types: ["card"],
63
64 line_items: [
65 {
66 price_data: {
67 currency: "usd",
68 product_data: {
69 name: productName,
70 },
71 unit_amount: totalCents,
72 },
73 quantity: 1,
74 },
75 ],
76
77 metadata: {
78 userId,
79 plan: plan || "",
80 energyAmount: String(energyAmount || 0),
81 },
82
83 success_url: successUrl,
84 cancel_url: cancelUrl,
85 });
86
87 sendOk(res, { url: session.url });
88
89 } catch (err) {
90 log.error("Billing", "Stripe session error:", err);
91 sendError(res, 500, ERR.INTERNAL, "Failed to create checkout session");
92 }
93}
94
1import log from "../../seed/log.js";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import Stripe from "stripe";
4import { processPurchase } from "./core/processPurchase.js";
5import { logContribution } from "../../seed/tree/contributions.js";
6
7const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY) : null;
8
9export async function stripeWebhook(req, res) {
10 if (!stripe) return sendError(res, 500, ERR.INTERNAL, "Stripe is not configured");
11 const sig = req.headers["stripe-signature"];
12
13 let event;
14
15 try {
16 event = stripe.webhooks.constructEvent(
17 req.body,
18 sig,
19 process.env.STRIPE_WEBHOOK_SECRET
20 );
21 } catch (err) {
22 log.error("Billing", "Webhook signature failed");
23 return res.status(400).send("Webhook Error");
24 }
25
26 if (event.type === "checkout.session.completed") {
27 const session = event.data.object;
28
29 const userId = session.metadata.userId;
30 const plan = session.metadata.plan || null;
31 const energyAmount = Number(session.metadata.energyAmount || 0);
32
33 try {
34 await logContribution({
35 userId,
36 nodeId: "SYSTEM",
37 nodeVersion: "0",
38 action: "purchase",
39
40 purchaseMeta: {
41 stripeSessionId: session.id,
42 paymentIntentId: session.payment_intent,
43 stripeEventId: event.id,
44 plan,
45 energyAmount,
46 totalCents: session.amount_total,
47 currency: session.currency,
48 },
49 });
50 } catch (err) {
51 if (
52 err?.code === 11000 ||
53 err?.message?.toLowerCase().includes("duplicate")
54 ) {
55 log.verbose("Billing", "Duplicate purchase webhook ignored:", session.id);
56 return sendOk(res, { received: true });
57 }
58
59 log.error("Billing", "Contribution logging failed:", err);
60 return sendError(res, 500, ERR.INTERNAL, "Contribution logging failed");
61 }
62
63 await processPurchase({
64 userId,
65 plan,
66 energyAmount,
67 });
68 }
69
70 sendOk(res, { received: true });
71}
72
Loading comments...