From d1eafb683b4252d616e46d7107d24b6c123fd132 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 17:41:06 -0500 Subject: [PATCH 1/6] Fix account plan display by including billing info in handleMe and improving client-side robustness --- AGENTS.md | 4 ++++ public/users/accounts.html | 2 +- src/index.ts | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 580aa90..95d9032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,7 @@ This application uses Cloudflare Developer Platform, including Workers and Durab - All non-admin API paths start with `/${usersPath}/api/` - All admin API paths start with `/${usersPath}/admin/api/` + +## Implementation + +- When coding new features, create tests that cover the new code diff --git a/public/users/accounts.html b/public/users/accounts.html index 75f076e..3b361d0 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -316,7 +316,7 @@

Team Members

async function loadAccountDetails(ssrData) { try { let data = ssrData; - if (!data) { + if (!data || !data.billing) { const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`); if (res.ok) { data = await res.json(); diff --git a/src/index.ts b/src/index.ts index 943a647..067b835 100644 --- a/src/index.ts +++ b/src/index.ts @@ -327,8 +327,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, }; From 95565d53f1d72b7cf0a0baadcdea959d1919823e Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 17:45:41 -0500 Subject: [PATCH 2/6] Synchronize plan field and billing state in AccountDO and SystemDO index --- src/AccountDO.ts | 16 ++++++++++++++++ src/index.ts | 11 +++++++---- test/accountdo.spec.ts | 7 +++++-- test/admin.spec.ts | 18 ++++++++++++++++++ test/billing.spec.ts | 4 ++++ 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/AccountDO.ts b/src/AccountDO.ts index ce99d2b..21dbe75 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -83,6 +83,20 @@ export class AccountDO extends DurableObject { valToStore = value.substring(0, 50); } this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(valToStore)); + + // If plan is updated manually (e.g. via Admin API), update billing state too + if (key === 'plan' && typeof value === 'string') { + const currentState = this.getBillingState(); + if (currentState.plan_slug !== value) { + const newState = { + ...currentState, + plan_slug: value, + // We don't automatically set next_billing_date or status here + // as this is a manual override, but we keep the slug in sync. + }; + this.sql.exec("INSERT OR REPLACE INTO account_info (key, value) VALUES ('billing', ?)", JSON.stringify(newState)); + } + } } }); return { success: true }; @@ -226,6 +240,8 @@ 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)); + // Keep plan field in sync + this.sql.exec("INSERT OR REPLACE INTO account_info (key, value) VALUES ('plan', ?)", JSON.stringify(state.plan_slug)); }); } diff --git a/src/index.ts b/src/index.ts index 067b835..485c4d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,13 +463,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); 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..c45ed2d 100644 --- a/test/admin.spec.ts +++ b/test/admin.spec.ts @@ -170,6 +170,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..e392557 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 () => { From 3b1f2f2a2c9ac988349ec03b9f20e90a4f6bc9d0 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 17:48:22 -0500 Subject: [PATCH 3/6] Refactor AccountDO and UserDO to use individual columns for account_info and profile tables --- src/AccountDO.ts | 83 ++++++++++++++++++++++++++++----------------- src/UserDO.ts | 79 ++++++++++++++++++++++++++++-------------- test/userdo.spec.ts | 7 +++- 3 files changed, 111 insertions(+), 58 deletions(-) diff --git a/src/AccountDO.ts b/src/AccountDO.ts index 21dbe75..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,42 +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); - } - this.sql.exec('INSERT OR REPLACE INTO account_info (key, value) VALUES (?, ?)', key, JSON.stringify(valToStore)); + 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 (key === 'plan' && typeof value === 'string') { + if ('plan' in data) { const currentState = this.getBillingState(); - if (currentState.plan_slug !== value) { + if (currentState.plan_slug !== data.plan) { const newState = { ...currentState, - plan_slug: value, - // We don't automatically set next_billing_date or status here - // as this is a manual override, but we keep the slug in sync. + plan_slug: data.plan, }; - this.sql.exec("INSERT OR REPLACE INTO account_info (key, value) VALUES ('billing', ?)", JSON.stringify(newState)); + this.sql.exec('UPDATE account_info SET billing = ? WHERE id = 1', JSON.stringify(newState)); } } - } - }); + }); + } return { success: true }; } catch (e: any) { throw new Error(e.message); @@ -225,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 { @@ -239,9 +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)); - // Keep plan field in sync - this.sql.exec("INSERT OR REPLACE INTO account_info (key, value) VALUES ('plan', ?)", JSON.stringify(state.plan_slug)); + 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/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 () => { From 6b8c572ef5edaf4f40738404f76ce27e03cf2044 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 22 Feb 2026 17:59:03 -0500 Subject: [PATCH 4/6] Expose dynamic billing plans to UI and replace hardcoded plan lists --- public/users/accounts.html | 28 +++++++++++++++----- public/users/admin/index.html | 50 +++++++++++++++++++++++++---------- src/index.ts | 6 +++++ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/public/users/accounts.html b/public/users/accounts.html index 3b361d0..72a75a0 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -6,7 +6,12 @@ Account Settings - + Team Members document.getElementById('display-account-name').textContent = data.name; initialAccountInfo.name = data.name; } - if (data.billing && data.billing.plan_details) { - document.getElementById('account-plan-display').value = data.billing.plan_details.name; - document.getElementById('display-account-plan').textContent = data.billing.plan_details.name; - } else if (data.billing && data.billing.state) { - document.getElementById('account-plan-display').value = data.billing.state.plan_slug; - document.getElementById('display-account-plan').textContent = data.billing.state.plan_slug; + + // Get plan name from available plans + let availablePlans = []; + const ssrPlans = document.body.getAttribute('data-ssr-plans'); + if (ssrPlans && !ssrPlans.startsWith('{{ssr:')) { + try { + availablePlans = JSON.parse(ssrPlans); + } catch (e) {} } + const planSlug = data.billing?.state?.plan_slug || data.plan; + const plan = availablePlans.find((p) => p.slug === planSlug); + const planName = plan ? plan.name : planSlug || 'Free'; + + document.getElementById('account-plan-display').value = planName; + document.getElementById('display-account-plan').textContent = planName; + // Load avatar const avatarRes = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`); if (avatarRes.ok) { diff --git a/public/users/admin/index.html b/public/users/admin/index.html index 54c83d7..d6623e1 100644 --- a/public/users/admin/index.html +++ b/public/users/admin/index.html @@ -120,7 +120,7 @@ } - + Edit Account