EXTENSION for TreeOS
billing
Monetization for a TreeOS land. This extension connects Stripe Checkout to the tier and energy systems so land operators can charge for access. Two subscription tiers are available: Standard ($20/month) and Premium ($100/month). Each tier sets a higher daily energy budget through the energy extension. Users can also purchase additional energy units as a one-time top-up alongside or independent of a plan upgrade. A single checkout session can combine a plan change and an energy boost. The purchase flow creates a Stripe Checkout session with the plan and energy amounts encoded in session metadata. On successful payment, Stripe fires a webhook. The webhook handler verifies the signature, logs a contribution to the kernel audit trail with full Stripe references (session ID, payment intent, event ID, currency, total), then calls processPurchase to apply the changes. Duplicate webhook deliveries are caught by the contribution model's unique constraint and silently ignored. Plan upgrades are prorated. If a user upgrades from Standard to Premium mid-cycle, the remaining days on the old plan are converted to bonus energy and added to their additional energy pool. The new plan's expiration extends from whichever is later: now or the old expiration date. Downgrades are blocked at the validation layer. When a plan expires, the energy extension's daily reset detects it and reverts the user to Basic tier, clearing all LLM slot assignments in the process. Validation runs before the Stripe session is created. Invalid plans, negative energy amounts, and amounts above the safety cap of one million units are rejected before the user ever sees a payment form. The webhook handler is lazy-loaded on first request to avoid blocking boot with Stripe SDK initialization.
v1.0.1 by TreeOS Site 0 downloads 9 files 532 lines 16.9 KB published 38d ago
treeos ext install billing
View changelog

Manifest

Provides

  • routes

Requires

  • models: User

Optional

  • services: energy
  • extensions: treeos-base
SHA256: b94d8a206f8f9bb942c1b8c68ba0c1305835354141d1d57b6c9d5d40f5a08c49

Environment Variables

KeyRequiredDescription
STRIPE_SECRET_KEY secret Yes Stripe secret key for payment processing
STRIPE_WEBHOOK_SECRET secret Yes Stripe webhook signing secret

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star billing

Comments

Loading comments...

Post comments from the CLI: treeos ext comment billing "your comment"
Max 3 comments per extension. One star and one flag per user.