test(billing): in-app mock harness for preview testing (FE-991, do not merge)

Ports the billing-v1-verify userscript into the app so the preview deployment
exposes the draggable scenario panel with no Tampermonkey/CDP. Inert unless
opted in via ?billingmock. Works in the prod preview build by merging only
team_workspaces_enabled onto the real /api/features (preserving firebase_config
to avoid the logout trap); billing/workspace endpoints get canned responses.
This commit is contained in:
dante01yoon
2026-06-12 22:52:23 +09:00
parent 232310e4b8
commit 4e596e5e5f
2 changed files with 658 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { installBillingMockHarness } from '@/platform/workspace/dev/billingMockHarness'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -37,6 +38,10 @@ import { i18n } from './i18n'
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
// PREVIEW HARNESS (FE-991, do not merge): no-op unless `?billingmock`. Must run
// before /api/features is fetched so the team-workspaces flag merge lands.
installBillingMockHarness()
if (isCloud) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')

View File

@@ -0,0 +1,653 @@
/**
* Billing Rework V1 — in-app mock harness (PREVIEW ONLY).
*
* ⚠️ Lives on the FE-991 integration/preview branch only — DO NOT MERGE to main.
*
* Lets anyone exercise every billing/workspace UX variation on the preview
* deployment without Tampermonkey/CDP. It is the userscript
* (`.claude/skills/billing-v1-verify/assets/billing-mock.user.js`) ported into
* the app, plus the two things needed to work in a PRODUCTION preview build:
*
* 1. `ff:` dev overrides are stripped from prod builds, so the team-workspaces
* flag cannot be flipped via localStorage. Instead we intercept
* `/api/features`, pass the real response through, and merge only
* `team_workspaces_enabled: true` — preserving `firebase_config` so auth
* does NOT break (faking the whole response logs the user out).
* 2. Billing/workspace endpoints (axios → XHR) are served canned responses.
*
* Inert unless opted in with `?billingmock` (or once activated, the
* `cbm.active` localStorage key). The floating panel is draggable + collapsible
* and persists scenario + position across the save+reload it triggers.
*/
const LS = 'comfyBillingMock'
const LS_UI = 'cbm_ui'
const ACTIVE_KEY = 'cbm.active'
type Workspace = 'personal' | 'team'
type Role = 'owner' | 'member'
type Tier = 'free' | 'standard' | 'creator' | 'pro'
type State = 'active' | 'cancelled' | 'inactive' | 'changing'
type Balance = 'funded' | 'low' | 'empty'
type SubscribeOutcome =
| 'subscribed'
| 'needs_payment_method'
| 'pending_payment'
type TopupOutcome = 'completed' | 'pending' | 'failed'
type OpOutcome = 'succeeded' | 'failed'
interface MockCfg {
enabled: boolean
ws: Workspace
role: Role
tier: Tier
state: State
balance: Balance
subscribe: SubscribeOutcome
topup: TopupOutcome
op: OpOutcome
roleChange: '200' | '500'
multiWs: boolean
}
interface UiState {
left?: number
top?: number
collapsed?: boolean
}
const DEFAULTS: MockCfg = {
enabled: true,
ws: 'team',
role: 'owner',
tier: 'creator',
state: 'active',
balance: 'funded',
subscribe: 'subscribed',
topup: 'completed',
op: 'succeeded',
roleChange: '200',
multiWs: false
}
const cfg: MockCfg = { ...DEFAULTS }
function loadCfg(): void {
try {
Object.assign(cfg, JSON.parse(localStorage.getItem(LS) || '{}'))
} catch {
/* ignore */
}
}
function saveCfg(): void {
localStorage.setItem(LS, JSON.stringify(cfg))
}
function loadUi(): UiState {
try {
return JSON.parse(localStorage.getItem(LS_UI) || '{}') as UiState
} catch {
return {}
}
}
function saveUi(ui: UiState): void {
localStorage.setItem(LS_UI, JSON.stringify(ui))
}
// ---- reference data (mirrors tierPricing.ts) -------------------------------
const TIER: Record<Tier, string> = {
free: 'FREE',
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO'
}
// [price_cents, credits] per personal tier (monthly)
const PRICING: Record<Tier, [number, number]> = {
free: [0, 0],
standard: [2000, 4200],
creator: [3500, 7400],
pro: [10000, 21100]
}
const BALANCES: Record<Balance, [number, number, number]> = {
// [amount_micros, cloud_credit (monthly), prepaid (additional)]
funded: [5000000, 2000000, 3000000],
low: [1000000, 0, 1000000],
empty: [0, 0, 0]
}
const FUTURE = '2099-02-20T00:00:00Z'
const CANCEL_AT = '2099-03-01T00:00:00Z'
// ---- response builders -----------------------------------------------------
const slug = (t: Tier) => `${t}-monthly`
function workspaces(): unknown {
const active =
cfg.ws === 'personal'
? {
id: 'ws-active',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
: {
id: 'ws-active',
name: 'My Team',
type: 'team',
role: cfg.role,
subscription_tier: TIER[cfg.tier]
}
const list: unknown[] = [active]
if (cfg.multiWs)
list.push({
id: 'ws-other',
name: 'Acme Studio Workspace — long name to test truncation',
type: 'team',
role: 'member'
})
return { workspaces: list }
}
const subStatus = () =>
({
active: 'active',
cancelled: 'canceled',
inactive: 'ended',
changing: 'scheduled'
})[cfg.state]
// TEAM — /api/billing/status (has_funds, cancel_at)
function billingStatus(): unknown {
return {
is_active: cfg.state !== 'inactive',
has_funds: cfg.balance !== 'empty',
subscription_status: subStatus(),
subscription_tier: TIER[cfg.tier],
subscription_duration: 'MONTHLY',
plan_slug: slug(cfg.tier),
billing_status: cfg.state === 'inactive' ? 'inactive' : 'paid',
renewal_date: FUTURE,
cancel_at: cfg.state === 'cancelled' ? CANCEL_AT : undefined
}
}
// PERSONAL — /customers/cloud-subscription-status (has_fund singular, end_date)
function legacyStatus(): unknown {
return {
is_active: cfg.state !== 'inactive',
subscription_id: 'sub_mock',
subscription_tier: TIER[cfg.tier],
subscription_duration: 'MONTHLY',
has_fund: cfg.balance !== 'empty',
renewal_date: FUTURE,
end_date: cfg.state === 'cancelled' ? CANCEL_AT : null
}
}
function balance(): unknown {
const b = BALANCES[cfg.balance] || BALANCES.funded
return {
amount_micros: b[0],
currency: 'usd',
effective_balance_micros: b[0],
cloud_credit_balance_micros: b[1],
prepaid_balance_micros: b[2]
}
}
function plan(t: Tier, current: boolean): unknown {
const p = PRICING[t]
return {
slug: slug(t),
tier: TIER[t],
duration: 'MONTHLY',
price_cents: p[0],
credits_cents: p[1],
max_seats: t === 'pro' ? 20 : t === 'creator' ? 5 : 1,
availability: {
available: true,
reason: current ? 'same_plan' : undefined
},
seat_summary: {
seat_count: 1,
total_cost_cents: p[0],
total_credits_cents: p[1]
}
}
}
function plans(): unknown {
const keys: Tier[] = ['standard', 'creator', 'pro']
return {
current_plan_slug: cfg.tier === 'free' ? undefined : slug(cfg.tier),
plans: keys.map((t) => plan(t, t === cfg.tier))
}
}
function preview(): unknown {
const p = PRICING[cfg.tier]
return {
allowed: true,
transition_type: 'new_subscription',
effective_at: FUTURE,
is_immediate: true,
cost_today_cents: p[0],
cost_next_period_cents: p[0],
credits_today_cents: p[1],
credits_next_period_cents: p[1],
new_plan: {
slug: slug(cfg.tier),
tier: TIER[cfg.tier],
duration: 'MONTHLY',
price_cents: p[0],
credits_cents: p[1],
seat_summary: {
seat_count: 1,
total_cost_cents: p[0],
total_credits_cents: p[1]
}
}
}
}
function subscribeResp(): unknown {
return {
billing_op_id: 'op-1',
status: cfg.subscribe,
payment_method_url:
cfg.subscribe === 'needs_payment_method'
? 'https://example.com/stripe-portal'
: undefined
}
}
const opStatus = () => ({
id: 'op-1',
status: cfg.op,
started_at: '2026-01-01T00:00:00Z',
completed_at: '2026-01-01T00:01:00Z'
})
function members(): unknown {
const creator = {
id: 'user-creator',
name: 'You (creator)',
email: 'you@comfy.org',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
}
const m1 = {
id: 'user-42',
name: 'Alice',
email: 'alice@example.com',
joined_at: '2026-02-15T00:00:00Z',
role: 'member'
}
const m2 = {
id: 'user-43',
name: 'Bob',
email: 'bob@example.com',
joined_at: '2026-03-20T00:00:00Z',
role: 'owner'
}
const list = cfg.ws === 'team' ? [creator, m1, m2] : [creator]
return {
members: list,
pagination: { offset: 0, limit: 10, total: list.length }
}
}
const createInvite = () => ({
id: 'inv-1',
email: 'newhire@example.com',
token: 'tok-123',
invited_at: '2026-06-01T00:00:00Z',
expires_at: '2026-06-08T00:00:00Z'
})
// ---- route table -----------------------------------------------------------
type Route = [string, RegExp, () => unknown, (() => number)?]
const ROUTES: Route[] = [
['GET', /\/api\/workspaces(\?|$)/, workspaces],
['GET', /\/api\/billing\/status/, billingStatus],
['GET', /\/api\/billing\/balance/, balance],
['GET', /\/api\/billing\/plans/, plans],
['POST', /\/api\/billing\/preview-subscribe/, preview],
['POST', /\/api\/billing\/subscribe/, subscribeResp],
[
'POST',
/\/api\/billing\/subscription\/cancel/,
() => ({ billing_op_id: 'op-1', cancel_at: CANCEL_AT })
],
[
'POST',
/\/api\/billing\/subscription\/resubscribe/,
() => ({ billing_op_id: 'op-1', status: 'active' })
],
[
'POST',
/\/api\/billing\/topup/,
() => ({
billing_op_id: 'op-1',
topup_id: 't1',
status: cfg.topup,
amount_cents: 5000
})
],
[
'POST',
/\/api\/billing\/payment-portal/,
() => ({ url: 'https://example.com/stripe-portal' })
],
['GET', /\/api\/billing\/ops\//, opStatus],
['GET', /\/api\/workspace\/members/, members],
['DELETE', /\/api\/workspace\/members\//, () => ({}), () => 204],
// FE-770 branch only — role change. Toggle 200/500 in the panel.
[
'PATCH',
/\/api\/workspace\/members\//,
() => (cfg.roleChange === '500' ? { error: 'mock failure' } : {}),
() => parseInt(cfg.roleChange, 10)
],
['GET', /\/api\/workspace\/invites/, () => ({ invites: [] })],
['POST', /\/api\/workspace\/invites/, createInvite],
['DELETE', /\/api\/workspace\/invites\//, () => ({}), () => 204],
// PERSONAL legacy
['GET', /\/customers\/cloud-subscription-status/, legacyStatus],
['GET', /\/customers\/balance/, balance]
]
interface Hit {
body: unknown
status: number
}
function lookup(method: string, url: string): Hit | null {
if (!cfg.enabled) return null
for (const r of ROUTES) {
if (r[0] === method && r[1].test(url)) {
return { body: r[2](), status: r[3] ? r[3]() : 200 }
}
}
return null
}
// `/api/features` (or `/features`): merge the team flag onto the REAL response.
const FEATURES_RE = /\/features(\?|#|$)/
// ---- network patches -------------------------------------------------------
function installXhrPatch(): void {
const OPEN = XMLHttpRequest.prototype.open
const SEND = XMLHttpRequest.prototype.send
type Tagged = XMLHttpRequest & { __mk?: { method: string; url: string } }
XMLHttpRequest.prototype.open = function (
this: Tagged,
method: string,
url: string | URL
) {
this.__mk = {
method: String(method || 'GET').toUpperCase(),
url: String(url)
}
// eslint-disable-next-line prefer-rest-params
return OPEN.apply(this, arguments as unknown as Parameters<typeof OPEN>)
}
XMLHttpRequest.prototype.send = function (this: Tagged, ...args) {
const hit = this.__mk && lookup(this.__mk.method, this.__mk.url)
if (!hit)
return SEND.apply(this, args as unknown as Parameters<typeof SEND>)
const xhr = this
const text = hit.body == null ? '' : JSON.stringify(hit.body)
const def = (name: string, get: () => unknown) =>
Object.defineProperty(xhr, name, { configurable: true, get })
try {
def('readyState', () => 4)
def('status', () => hit.status)
def('responseText', () => text)
def('response', () => text)
def('responseURL', () => xhr.__mk?.url ?? '')
} catch {
/* ignore */
}
setTimeout(() => {
xhr.onreadystatechange?.(new Event('readystatechange'))
xhr.dispatchEvent(new Event('readystatechange'))
xhr.dispatchEvent(
new Event(hit.status >= 200 && hit.status < 400 ? 'load' : 'error')
)
xhr.dispatchEvent(new Event('loadend'))
}, 15)
}
}
function installFetchPatch(): void {
const ORIG = window.fetch.bind(window)
const urlOf = (input: RequestInfo | URL): string =>
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
const methodOf = (input: RequestInfo | URL, init?: RequestInit): string =>
String(
init?.method ||
(typeof input === 'object' && 'method' in input
? input.method
: 'GET') ||
'GET'
).toUpperCase()
window.fetch = async function (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = urlOf(input)
// Flag merge: pass the real response through, add only the team flag, and
// bail to the untouched response on any problem (keeps firebase_config).
if (cfg.enabled && FEATURES_RE.test(url)) {
const real = await ORIG(input, init)
if (!real.ok) return real
let json: unknown
try {
json = await real.clone().json()
} catch {
return real
}
if (json && typeof json === 'object') {
;(json as Record<string, unknown>).team_workspaces_enabled = true
}
return new Response(JSON.stringify(json), {
status: real.status,
statusText: real.statusText,
headers: { 'Content-Type': 'application/json' }
})
}
const hit = lookup(methodOf(input, init), url)
if (!hit) return ORIG(input, init)
const text = hit.body == null ? '' : JSON.stringify(hit.body)
return new Response(text, {
status: hit.status,
headers: { 'Content-Type': 'application/json' }
})
}
}
// ---- floating control panel ------------------------------------------------
function buildPanel(): void {
const existing = document.getElementById('cbm-panel')
if (existing) existing.remove()
const ui = loadUi()
const wrap = document.createElement('div')
wrap.id = 'cbm-panel'
const pos =
typeof ui.left === 'number' && typeof ui.top === 'number'
? `left:${ui.left}px;top:${ui.top}px`
: 'top:8px;right:8px'
wrap.style.cssText = `position:fixed;${pos};z-index:2147483647;background:#15161a;color:#e6e6e6;font:12px/1.4 ui-monospace,monospace;border:1px solid #3a3d46;border-radius:8px;padding:8px 10px;width:232px;box-shadow:0 6px 24px rgba(0,0,0,.5)`
const row = (label: string, key: keyof MockCfg, opts: string[]) => {
const id = `cbm-${key}`
const o = opts
.map(
(v) =>
`<option value="${v}"${cfg[key] === v ? ' selected' : ''}>${v}</option>`
)
.join('')
return `<label style="display:flex;justify-content:space-between;align-items:center;gap:6px;margin:3px 0"><span style="opacity:.7">${label}</span><select id="${id}" data-k="${key}" style="background:#0d0e11;color:#e6e6e6;border:1px solid #3a3d46;border-radius:4px;padding:1px 3px;max-width:120px">${o}</select></label>`
}
wrap.innerHTML =
`<div id="cbm-head" title="drag to move" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;cursor:move;user-select:none"><b style="color:#8ab4ff">Billing Mock</b>` +
`<span style="display:flex;align-items:center;gap:8px"><span id="cbm-collapse" title="collapse" style="cursor:pointer;opacity:.7;padding:0 4px;border:1px solid #3a3d46;border-radius:4px;line-height:1.4">${ui.collapsed ? '+' : ''}</span>` +
`<label style="font-size:11px"><input type="checkbox" id="cbm-enabled"${cfg.enabled ? ' checked' : ''}/> on</label>` +
`<span id="cbm-close" title="turn off the harness" style="cursor:pointer;opacity:.7;padding:0 4px">✕</span></span></div>` +
`<div id="cbm-body"${ui.collapsed ? ' style="display:none"' : ''}>` +
row('workspace', 'ws', ['personal', 'team']) +
row('role', 'role', ['owner', 'member']) +
row('tier', 'tier', ['free', 'standard', 'creator', 'pro']) +
row('state', 'state', ['active', 'cancelled', 'inactive', 'changing']) +
row('balance', 'balance', ['funded', 'low', 'empty']) +
`<hr style="border:0;border-top:1px solid #2a2c33;margin:6px 0"/>` +
row('subscribe', 'subscribe', [
'subscribed',
'needs_payment_method',
'pending_payment'
]) +
row('topup', 'topup', ['completed', 'pending', 'failed']) +
row('op-poll', 'op', ['succeeded', 'failed']) +
row('roleChange', 'roleChange', ['200', '500']) +
`<label style="display:flex;gap:6px;margin:4px 0"><input type="checkbox" id="cbm-multiWs"${cfg.multiWs ? ' checked' : ''}/><span style="opacity:.7">2nd workspace (switcher)</span></label>` +
`<div style="font-size:10px;opacity:.55;margin-top:4px">change → saves + reloads · personal=/customers · team=/api/billing</div>` +
`</div>`
document.body.appendChild(wrap)
const apply = () => {
saveCfg()
location.reload()
}
wrap.querySelectorAll('select').forEach((s) => {
s.addEventListener('change', () => {
const k = s.getAttribute('data-k') as keyof MockCfg
;(cfg as unknown as Record<string, string>)[k] = s.value
apply()
})
})
;(
document.getElementById('cbm-enabled') as HTMLInputElement
).addEventListener('change', (e) => {
cfg.enabled = (e.target as HTMLInputElement).checked
apply()
})
;(
document.getElementById('cbm-multiWs') as HTMLInputElement
).addEventListener('change', (e) => {
cfg.multiWs = (e.target as HTMLInputElement).checked
apply()
})
// turn the harness fully off (back to the real preview)
const closeBtn = document.getElementById('cbm-close') as HTMLElement
closeBtn.addEventListener('mousedown', (e) => e.stopPropagation())
closeBtn.addEventListener('click', () => {
localStorage.removeItem(ACTIVE_KEY)
const u = new URL(location.href)
u.searchParams.delete('billingmock')
location.replace(u.href)
})
// collapse / expand (persisted, no reload)
const body = document.getElementById('cbm-body') as HTMLElement
const collapseBtn = document.getElementById('cbm-collapse') as HTMLElement
collapseBtn.addEventListener('mousedown', (e) => e.stopPropagation())
collapseBtn.addEventListener('click', () => {
const u = loadUi()
u.collapsed = !u.collapsed
saveUi(u)
body.style.display = u.collapsed ? 'none' : 'block'
collapseBtn.textContent = u.collapsed ? '+' : ''
})
// drag the header to reposition (persisted, clamped to viewport, no reload)
const head = document.getElementById('cbm-head') as HTMLElement
let dragging = false
let dx = 0
let dy = 0
head.addEventListener('mousedown', (e) => {
const target = e.target as HTMLElement
if (
target.id === 'cbm-collapse' ||
target.id === 'cbm-close' ||
target.tagName === 'INPUT'
)
return
dragging = true
const r = wrap.getBoundingClientRect()
dx = e.clientX - r.left
dy = e.clientY - r.top
wrap.style.right = 'auto'
e.preventDefault()
})
document.addEventListener('mousemove', (e) => {
if (!dragging) return
const left = Math.min(
Math.max(0, e.clientX - dx),
window.innerWidth - wrap.offsetWidth
)
const top = Math.min(
Math.max(0, e.clientY - dy),
window.innerHeight - wrap.offsetHeight
)
wrap.style.left = `${left}px`
wrap.style.top = `${top}px`
})
document.addEventListener('mouseup', () => {
if (!dragging) return
dragging = false
const r = wrap.getBoundingClientRect()
const u = loadUi()
u.left = Math.round(r.left)
u.top = Math.round(r.top)
saveUi(u)
})
}
function isActivated(): boolean {
const params = new URLSearchParams(location.search)
if (params.has('billingmock')) {
localStorage.setItem(ACTIVE_KEY, '1')
return true
}
return localStorage.getItem(ACTIVE_KEY) === '1'
}
let installed = false
/**
* Install the billing mock harness. No-op unless opted in via `?billingmock`
* (or the persisted `cbm.active` flag). MUST be called before the app fetches
* `/api/features` (i.e. at the very top of `main.ts`) so the flag merge lands.
*/
export function installBillingMockHarness(): void {
if (installed || !isActivated()) return
installed = true
loadCfg()
// Dev builds honor `ff:` overrides too — harmless belt-and-braces.
try {
localStorage.setItem('ff:team_workspaces_enabled', 'true')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-active')
} catch {
/* ignore */
}
installXhrPatch()
installFetchPatch()
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', buildPanel)
} else {
buildPanel()
}
}