@@ -282,9 +280,7 @@
Create New Account
Initial Plan
- Free
- Pro
- Enterprise
+
@@ -336,6 +332,29 @@
Current Members
const API_BASE = '/users/admin/api';
let currentUsers = [];
let currentAccounts = [];
+ let availablePlans = [];
+
+ function getPlans() {
+ if (availablePlans.length > 0) return availablePlans;
+ const ssrPlans = document.body.getAttribute('data-ssr-plans');
+ if (ssrPlans && !ssrPlans.startsWith('{{ssr:')) {
+ try {
+ availablePlans = JSON.parse(ssrPlans);
+ return availablePlans;
+ } catch (e) {
+ console.error('Failed to parse SSR plans', e);
+ }
+ }
+ return [];
+ }
+
+ function populatePlanSelect(selectId, selectedValue) {
+ const select = document.getElementById(selectId);
+ const plans = getPlans();
+ select.innerHTML = plans
+ .map((p) => `${p.name} `)
+ .join('');
+ }
async function fetchAPI(endpoint, options = {}) {
try {
@@ -401,14 +420,17 @@ Current Members
function renderAccounts(accounts) {
const tbody = document.getElementById('accounts-table');
+ const plans = getPlans();
tbody.innerHTML = accounts
- .map(
- (a) => `
+ .map((a) => {
+ const plan = plans.find((p) => p.slug === a.plan);
+ const planName = plan ? plan.name : a.plan;
+ return `
${a.id.substring(0, 8)}...
${a.name || '-'}
${a.status || '-'}
- ${a.plan || '-'}
+ ${planName || '-'}
${a.member_count || 0}
${new Date(a.created_at).toLocaleDateString()}
@@ -417,8 +439,8 @@ Current Members
Delete
- `,
- )
+ `;
+ })
.join('');
}
@@ -453,7 +475,7 @@ Current Members
document.getElementById('edit-account-id').value = account.id;
document.getElementById('edit-account-name').value = account.name || '';
document.getElementById('edit-account-status').value = account.status || 'active';
- document.getElementById('edit-account-plan').value = account.plan || 'free';
+ populatePlanSelect('edit-account-plan', account.plan || 'free');
document.getElementById('edit-account-modal').showModal();
}
@@ -469,7 +491,7 @@ Current Members
});
document.getElementById('create-account-name').value = '';
- document.getElementById('create-account-plan').value = 'free';
+ populatePlanSelect('create-account-plan', 'free');
document.getElementById('create-account-modal').showModal();
}
diff --git a/src/AccountDO.ts b/src/AccountDO.ts
index ce99d2b..97f0d28 100644
--- a/src/AccountDO.ts
+++ b/src/AccountDO.ts
@@ -26,8 +26,11 @@ export class AccountDO extends DurableObject {
// Initialize database schema
this.sql.exec(`
CREATE TABLE IF NOT EXISTS account_info (
- key TEXT PRIMARY KEY,
- value TEXT
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ name TEXT,
+ plan TEXT,
+ billing TEXT,
+ personal INTEGER
);
CREATE TABLE IF NOT EXISTS members (
@@ -36,6 +39,9 @@ export class AccountDO extends DurableObject {
joined_at INTEGER
);
`);
+
+ // Ensure the single row exists
+ this.sql.exec('INSERT OR IGNORE INTO account_info (id, plan) VALUES (1, "free")');
}
async getImage(key: string) {
@@ -63,28 +69,57 @@ export class AccountDO extends DurableObject {
}
async getInfo() {
- const info: Record = {};
try {
- const result = this.sql.exec('SELECT key, value FROM account_info');
- for (const row of result) {
- // @ts-ignore
- info[row.key] = JSON.parse(row.value as string);
- }
- } catch (e) {}
- return info;
+ const result = this.sql.exec('SELECT * FROM account_info WHERE id = 1');
+ const row = result.next().value as any;
+ if (!row) return {};
+
+ return {
+ name: row.name,
+ plan: row.plan,
+ personal: row.personal === 1,
+ billing: row.billing ? JSON.parse(row.billing) : undefined,
+ };
+ } catch (e) {
+ return {};
+ }
}
async updateInfo(data: Record) {
try {
- this.ctx.storage.transactionSync(() => {
- for (const [key, value] of Object.entries(data)) {
- let valToStore = value;
- if (key === 'name' && typeof value === 'string') {
- valToStore = value.substring(0, 50);
+ const updates: string[] = [];
+ const values: any[] = [];
+
+ if ('name' in data) {
+ updates.push('name = ?');
+ values.push(typeof data.name === 'string' ? data.name.substring(0, 50) : data.name);
+ }
+ if ('plan' in data) {
+ updates.push('plan = ?');
+ values.push(data.plan);
+ }
+ if ('personal' in data) {
+ updates.push('personal = ?');
+ values.push(data.personal ? 1 : 0);
+ }
+
+ if (updates.length > 0) {
+ this.ctx.storage.transactionSync(() => {
+ this.sql.exec(`UPDATE account_info SET ${updates.join(', ')} WHERE id = 1`, ...values);
+
+ // If plan is updated manually (e.g. via Admin API), update billing state too
+ if ('plan' in data) {
+ const currentState = this.getBillingState();
+ if (currentState.plan_slug !== data.plan) {
+ const newState = {
+ ...currentState,
+ plan_slug: data.plan,
+ };
+ this.sql.exec('UPDATE account_info SET billing = ? WHERE id = 1', JSON.stringify(newState));
+ }
}
- this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(valToStore));
- }
- });
+ });
+ }
return { success: true };
} catch (e: any) {
throw new Error(e.message);
@@ -211,10 +246,10 @@ export class AccountDO extends DurableObject {
private getBillingState(): any {
try {
- const result = this.sql.exec("SELECT value FROM account_info WHERE key = 'billing'");
- for (const row of result) {
- // @ts-ignore
- return JSON.parse(row.value as string);
+ const result = this.sql.exec('SELECT billing FROM account_info WHERE id = 1');
+ const row = result.next().value as any;
+ if (row && row.billing) {
+ return JSON.parse(row.billing);
}
} catch (e) {}
return {
@@ -225,7 +260,7 @@ export class AccountDO extends DurableObject {
private setBillingState(state: any) {
this.ctx.storage.transactionSync(() => {
- this.sql.exec("INSERT OR REPLACE INTO account_info (key, value) VALUES ('billing', ?)", JSON.stringify(state));
+ this.sql.exec('UPDATE account_info SET billing = ?, plan = ? WHERE id = 1', JSON.stringify(state), state.plan_slug);
});
}
diff --git a/src/UserDO.ts b/src/UserDO.ts
index 117bd5b..ed22899 100644
--- a/src/UserDO.ts
+++ b/src/UserDO.ts
@@ -23,8 +23,12 @@ export class UserDO extends DurableObject {
// Initialize database schema
this.sql.exec(`
CREATE TABLE IF NOT EXISTS profile (
- key TEXT PRIMARY KEY,
- value TEXT
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ name TEXT,
+ email TEXT,
+ picture TEXT,
+ provider TEXT,
+ verified_email INTEGER
);
CREATE TABLE IF NOT EXISTS sessions (
@@ -46,6 +50,9 @@ export class UserDO extends DurableObject {
PRIMARY KEY (provider, subject_id)
);
`);
+
+ // Ensure the single row exists
+ this.sql.exec('INSERT OR IGNORE INTO profile (id) VALUES (1)');
}
/**
@@ -68,16 +75,8 @@ export class UserDO extends DurableObject {
return { valid: false, error: 'Expired' };
}
- let profile: Record = {};
-
// Get profile data from local 'profile' table
- const customProfileResult = this.sql.exec('SELECT key, value FROM profile');
- for (const row of customProfileResult) {
- try {
- // @ts-ignore
- profile[row.key] = JSON.parse(row.value as string);
- } catch (e) {}
- }
+ const profile = await this.getProfile();
// Determine login context (provider and subject_id)
const sessionMeta = session.meta ? JSON.parse(session.meta) : {};
@@ -116,15 +115,21 @@ export class UserDO extends DurableObject {
* @returns A Promise resolving to a JSON response containing the profile key-value pairs.
*/
async getProfile() {
- const profile: Record = {};
try {
- const result = this.sql.exec('SELECT key, value FROM profile');
- for (const row of result) {
- // @ts-ignore
- profile[row.key] = JSON.parse(row.value as string);
- }
- } catch (e) {}
- return profile;
+ const result = this.sql.exec('SELECT * FROM profile WHERE id = 1');
+ const row = result.next().value as any;
+ if (!row) return {};
+
+ return {
+ name: row.name,
+ email: row.email,
+ picture: row.picture,
+ provider: row.provider,
+ verified_email: row.verified_email === 1,
+ };
+ } catch (e) {
+ return {};
+ }
}
/**
@@ -136,11 +141,35 @@ export class UserDO extends DurableObject {
*/
async updateProfile(data: Record) {
try {
- this.ctx.storage.transactionSync(() => {
- for (const [key, value] of Object.entries(data)) {
- this.sql.exec('INSERT OR REPLACE INTO profile (key, value) VALUES (?, ?)', key, JSON.stringify(value));
- }
- });
+ const updates: string[] = [];
+ const values: any[] = [];
+
+ if ('name' in data) {
+ updates.push('name = ?');
+ values.push(data.name);
+ }
+ if ('email' in data) {
+ updates.push('email = ?');
+ values.push(data.email);
+ }
+ if ('picture' in data) {
+ updates.push('picture = ?');
+ values.push(data.picture);
+ }
+ if ('provider' in data) {
+ updates.push('provider = ?');
+ values.push(data.provider);
+ }
+ if ('verified_email' in data) {
+ updates.push('verified_email = ?');
+ values.push(data.verified_email ? 1 : 0);
+ }
+
+ if (updates.length > 0) {
+ this.ctx.storage.transactionSync(() => {
+ this.sql.exec(`UPDATE profile SET ${updates.join(', ')} WHERE id = 1`, ...values);
+ });
+ }
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
@@ -308,7 +337,7 @@ export class UserDO extends DurableObject {
await this.env.IMAGE_STORAGE.delete(r2Key);
if (key === 'avatar') {
- this.sql.exec("DELETE FROM profile WHERE key = 'picture'");
+ this.sql.exec('UPDATE profile SET picture = NULL WHERE id = 1');
}
return { success: true };
diff --git a/src/billing/plansConfig.ts b/src/billing/plansConfig.ts
index e9f7523..e8d1770 100644
--- a/src/billing/plansConfig.ts
+++ b/src/billing/plansConfig.ts
@@ -24,6 +24,21 @@ const plans: PlanConfig[] = [
{ charge_amount: 29000, charge_period: 365 }, // $290.00 / year
],
},
+ {
+ slug: 'enterprise',
+ name: 'Enterprise',
+ capabilities: {
+ can_access_basic: true,
+ can_access_pro: true,
+ can_access_enterprise: true,
+ },
+ downgrade_to_slug: 'pro',
+ grace_period: 14,
+ schedules: [
+ { charge_amount: 49900, charge_period: 30, is_default: true }, // $499.00 / month
+ { charge_amount: 499000, charge_period: 365 }, // $4990.00 / year
+ ],
+ },
];
export function initPlans() {
diff --git a/src/index.ts b/src/index.ts
index 943a647..f3dbb89 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,8 @@ import { AccountDO } from './AccountDO';
import { SystemDO } from './SystemDO';
import { CredentialDO } from './CredentialDO';
import { CookieManager } from './CookieManager';
+import { initPlans } from './billing/plansConfig';
+import { Plan } from './billing/Plan';
const DEFAULT_USERS_PATH = '/users/';
@@ -17,6 +19,8 @@ export default {
* Main Worker fetch handler.
*/
async fetch(request: Request, env: StartupAPIEnv, ctx): Promise {
+ initPlans();
+
// Prevent infinite loops when serving assets
if (request.headers.has('x-skip-worker')) {
return env.ASSETS.fetch(request);
@@ -189,6 +193,7 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri
let html = await response.text();
html = renderSSR(html, {
+ plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '"'),
providers: getActiveProviders(env).join(','),
});
@@ -300,6 +305,7 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo
valid: true,
profile: { ...initialProfile },
credential,
+ plans: Plan.getAll(),
};
const image = await userStub.getImage('avatar');
@@ -327,8 +333,10 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo
const accountId = env.ACCOUNT.idFromString(currentMembership.account_id);
const accountStub = env.ACCOUNT.get(accountId);
const accountInfo = await accountStub.getInfo();
+ const billing = await accountStub.getBillingInfo();
data.account = {
...accountInfo,
+ billing,
id: currentMembership.account_id,
role: currentMembership.role,
};
@@ -461,13 +469,16 @@ async function handleAccountDetails(
const data = await request.json();
const result = await accountStub.updateInfo(data);
- // Sync with SystemDO index if name changed
- if (data.name) {
+ // Sync with SystemDO index if name or plan changed
+ if (data.name || data.plan) {
try {
const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
- await systemStub.updateAccount(accountId, { name: data.name });
+ const updates: any = {};
+ if (data.name) updates.name = data.name;
+ if (data.plan) updates.plan = data.plan;
+ await systemStub.updateAccount(accountId, updates);
} catch (e) {
- console.error('Failed to sync account name to SystemDO', e);
+ console.error('Failed to sync account updates to SystemDO', e);
}
}
return Response.json(result);
@@ -899,6 +910,7 @@ async function handleSSR(
// Prepare SSR values
const replacements: Record = {
+ plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '"'),
providers: getActiveProviders(env).join(','),
profile_json: JSON.stringify(data).replace(/"/g, '"'),
credentials_json: JSON.stringify(credentials).replace(/"/g, '"'),
diff --git a/test/accountdo.spec.ts b/test/accountdo.spec.ts
index b5c8b34..909c607 100644
--- a/test/accountdo.spec.ts
+++ b/test/accountdo.spec.ts
@@ -11,8 +11,11 @@ describe('AccountDO Durable Object', () => {
await stub.updateInfo(infoData);
// Get info
- const data = await stub.getInfo();
- expect(data).toEqual(infoData);
+ const data: any = await stub.getInfo();
+ expect(data.name).toBe(infoData.name);
+ expect(data.plan).toBe(infoData.plan);
+ expect(data.billing).toBeDefined();
+ expect(data.billing.plan_slug).toBe('pro');
});
it('should manage members', async () => {
diff --git a/test/admin.spec.ts b/test/admin.spec.ts
index 65967b9..5b89125 100644
--- a/test/admin.spec.ts
+++ b/test/admin.spec.ts
@@ -97,6 +97,8 @@ describe('Admin Administration', () => {
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('Admin Dashboard ');
+ expect(html).toContain('data-ssr-plans');
+ expect(html).not.toContain('{{ssr:plans_json}}');
});
it('SystemDO should list users and accounts', async () => {
@@ -170,6 +172,24 @@ describe('Admin Administration', () => {
const accountStub = env.ACCOUNT.get(env.ACCOUNT.idFromString(result.id));
const info = await accountStub.getInfo();
expect(info.name).toBe(accountName);
+
+ // 5. Update account plan via admin API
+ await SELF.fetch(`http://example.com/users/admin/api/accounts/${result.id}`, {
+ method: 'PUT',
+ headers: {
+ Cookie: cookieHeader,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: accountName,
+ plan: 'pro',
+ }),
+ });
+
+ // 6. Verify AccountDO billing info is updated
+ const billingInfo: any = await accountStub.getBillingInfo();
+ expect(billingInfo.state.plan_slug).toBe('pro');
+ expect(billingInfo.plan_details.name).toBe('Pro');
});
it('should create a new account with an owner via admin API', async () => {
diff --git a/test/billing.spec.ts b/test/billing.spec.ts
index 3d972ec..6833e81 100644
--- a/test/billing.spec.ts
+++ b/test/billing.spec.ts
@@ -27,6 +27,10 @@ describe('Billing Logic in AccountDO', () => {
// Verify persistence
const info: any = await stub.getBillingInfo();
expect(info.state.plan_slug).toBe('pro');
+
+ // Verify getInfo also has the correct plan
+ const accountInfo: any = await stub.getInfo();
+ expect(accountInfo.plan).toBe('pro');
});
it('should fail to subscribe to invalid plan', async () => {
@@ -48,4 +52,15 @@ describe('Billing Logic in AccountDO', () => {
expect(result.state.status).toBe('canceled');
expect(result.state.next_plan_slug).toBe('free'); // Based on plansConfig.ts
});
+
+ it('should support enterprise plan', async () => {
+ const id = env.ACCOUNT.newUniqueId();
+ const stub = env.ACCOUNT.get(id);
+
+ await stub.subscribe('enterprise');
+ const info: any = await stub.getBillingInfo();
+ expect(info.state.plan_slug).toBe('enterprise');
+ expect(info.plan_details.name).toBe('Enterprise');
+ expect(info.plan_details.capabilities.can_access_enterprise).toBe(true);
+ });
});
diff --git a/test/userdo.spec.ts b/test/userdo.spec.ts
index 4ee5745..05f520c 100644
--- a/test/userdo.spec.ts
+++ b/test/userdo.spec.ts
@@ -12,7 +12,12 @@ describe('UserDO Durable Object', () => {
// Get profile
const data = await stub.getProfile();
- expect(data).toEqual(profileData);
+ expect(data).toEqual({
+ ...profileData,
+ picture: null,
+ provider: null,
+ verified_email: false,
+ });
});
it('should create session', async () => {