cyclecad 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
- package/BILLING-INDEX.md +293 -0
- package/BILLING-INTEGRATION-GUIDE.md +414 -0
- package/COLLABORATION-INDEX.md +440 -0
- package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
- package/DOCKER-BUILD-MANIFEST.txt +483 -0
- package/DOCKER-FILES-REFERENCE.md +440 -0
- package/DOCKER-INFRASTRUCTURE.md +475 -0
- package/DOCKER-README.md +435 -0
- package/Dockerfile +33 -55
- package/PWA-FILES-CREATED.txt +350 -0
- package/QUICK-START-TESTING.md +126 -0
- package/STEP-IMPORT-QUICKSTART.md +347 -0
- package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
- package/app/css/mobile.css +1074 -0
- package/app/icons/generate-icons.js +203 -0
- package/app/index.html +93 -0
- package/app/js/billing-ui.js +990 -0
- package/app/js/brep-kernel.js +933 -981
- package/app/js/collab-client.js +750 -0
- package/app/js/mobile-nav.js +623 -0
- package/app/js/mobile-toolbar.js +476 -0
- package/app/js/modules/billing-module.js +724 -0
- package/app/js/modules/step-module-enhanced.js +938 -0
- package/app/js/offline-manager.js +705 -0
- package/app/js/responsive-init.js +360 -0
- package/app/js/touch-handler.js +429 -0
- package/app/manifest.json +211 -0
- package/app/offline.html +508 -0
- package/app/sw.js +571 -0
- package/app/tests/billing-tests.html +779 -0
- package/app/tests/brep-tests.html +980 -0
- package/app/tests/collab-tests.html +743 -0
- package/app/tests/mobile-tests.html +1299 -0
- package/app/tests/pwa-tests.html +1134 -0
- package/app/tests/step-tests.html +1042 -0
- package/app/tests/test-agent-v3.html +719 -0
- package/docker-compose.yml +225 -0
- package/docs/BILLING-HELP.json +260 -0
- package/docs/BILLING-README.md +639 -0
- package/docs/BILLING-TUTORIAL.md +736 -0
- package/docs/BREP-HELP.json +326 -0
- package/docs/BREP-TUTORIAL.md +802 -0
- package/docs/COLLABORATION-HELP.json +228 -0
- package/docs/COLLABORATION-TUTORIAL.md +818 -0
- package/docs/DOCKER-HELP.json +224 -0
- package/docs/DOCKER-TUTORIAL.md +974 -0
- package/docs/MOBILE-HELP.json +243 -0
- package/docs/MOBILE-RESPONSIVE-README.md +378 -0
- package/docs/MOBILE-TUTORIAL.md +747 -0
- package/docs/PWA-HELP.json +228 -0
- package/docs/PWA-README.md +662 -0
- package/docs/PWA-TUTORIAL.md +757 -0
- package/docs/STEP-HELP.json +481 -0
- package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
- package/docs/TESTING-GUIDE.md +528 -0
- package/docs/TESTING-HELP.json +182 -0
- package/fusion-vs-cyclecad.html +1771 -0
- package/nginx.conf +237 -0
- package/package.json +1 -1
- package/server/Dockerfile.converter +51 -0
- package/server/Dockerfile.signaling +28 -0
- package/server/billing-server.js +487 -0
- package/server/converter-enhanced.py +528 -0
- package/server/requirements-converter.txt +29 -0
- package/server/signaling-server.js +801 -0
- package/tests/docker-tests.sh +389 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing Module - Stripe integration for cycleCAD Pro/Enterprise
|
|
3
|
+
* Manages subscriptions, usage limits, feature gates, and trial periods
|
|
4
|
+
*
|
|
5
|
+
* Public API:
|
|
6
|
+
* window.cycleCAD.modules.billing.getCurrentTier() → {tier, features, limits, usage}
|
|
7
|
+
* window.cycleCAD.modules.billing.checkLimit(feature) → {allowed, current, limit, message}
|
|
8
|
+
* window.cycleCAD.modules.billing.upgrade() → redirect to Stripe Checkout
|
|
9
|
+
* window.cycleCAD.modules.billing.getUsage() → {projects, parts, storage, ...}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BillingModule = {
|
|
13
|
+
id: 'billing',
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
|
|
16
|
+
// Stripe configuration (set via localStorage or env)
|
|
17
|
+
config: {
|
|
18
|
+
stripePublishableKey: localStorage.getItem('stripe_publishable_key') || 'pk_test_51234567890',
|
|
19
|
+
stripePriceIds: {
|
|
20
|
+
proMonthly: localStorage.getItem('stripe_price_pro_monthly') || 'price_1234_pro_monthly',
|
|
21
|
+
proYearly: localStorage.getItem('stripe_price_pro_yearly') || 'price_1234_pro_yearly',
|
|
22
|
+
enterpriseMonthly: localStorage.getItem('stripe_price_enterprise_monthly') || 'price_1234_ent_monthly',
|
|
23
|
+
enterpriseYearly: localStorage.getItem('stripe_price_enterprise_yearly') || 'price_1234_ent_yearly'
|
|
24
|
+
},
|
|
25
|
+
serverUrl: localStorage.getItem('billing_server_url') || 'http://localhost:3001',
|
|
26
|
+
trialDays: 14,
|
|
27
|
+
gracePeriodDays: 7
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Tier definitions with limits
|
|
31
|
+
tiers: {
|
|
32
|
+
free: {
|
|
33
|
+
id: 'free',
|
|
34
|
+
name: 'Free',
|
|
35
|
+
price: 0,
|
|
36
|
+
currency: 'EUR',
|
|
37
|
+
description: 'Perfect for getting started',
|
|
38
|
+
features: [
|
|
39
|
+
'Up to 3 projects',
|
|
40
|
+
'100 parts per project',
|
|
41
|
+
'1 GB storage',
|
|
42
|
+
'Basic 3D viewer',
|
|
43
|
+
'STEP import (up to 30 MB)',
|
|
44
|
+
'20 AI requests per day',
|
|
45
|
+
'Community support'
|
|
46
|
+
],
|
|
47
|
+
limits: {
|
|
48
|
+
projects: 3,
|
|
49
|
+
partsPerProject: 100,
|
|
50
|
+
stepImportMB: 30,
|
|
51
|
+
collaborators: 0,
|
|
52
|
+
storageGB: 1,
|
|
53
|
+
aiRequestsPerDay: 20,
|
|
54
|
+
camOperations: false,
|
|
55
|
+
customMaterials: false,
|
|
56
|
+
apiAccess: false,
|
|
57
|
+
prioritySupport: false,
|
|
58
|
+
customBranding: false,
|
|
59
|
+
sso: false,
|
|
60
|
+
selfHosted: false
|
|
61
|
+
},
|
|
62
|
+
color: '#6B7280'
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
pro: {
|
|
66
|
+
id: 'pro',
|
|
67
|
+
name: 'Pro',
|
|
68
|
+
price: 4900,
|
|
69
|
+
priceYearly: 46800,
|
|
70
|
+
currency: 'EUR',
|
|
71
|
+
description: 'For professional designers',
|
|
72
|
+
features: [
|
|
73
|
+
'Unlimited projects',
|
|
74
|
+
'Unlimited parts',
|
|
75
|
+
'50 GB storage',
|
|
76
|
+
'Full feature set',
|
|
77
|
+
'STEP import (up to 500 MB)',
|
|
78
|
+
'500 AI requests per day',
|
|
79
|
+
'CAM operations',
|
|
80
|
+
'Custom materials',
|
|
81
|
+
'API access',
|
|
82
|
+
'Priority email support',
|
|
83
|
+
'Monthly billing or yearly (save 20%)'
|
|
84
|
+
],
|
|
85
|
+
limits: {
|
|
86
|
+
projects: Infinity,
|
|
87
|
+
partsPerProject: Infinity,
|
|
88
|
+
stepImportMB: 500,
|
|
89
|
+
collaborators: 10,
|
|
90
|
+
storageGB: 50,
|
|
91
|
+
aiRequestsPerDay: 500,
|
|
92
|
+
camOperations: true,
|
|
93
|
+
customMaterials: true,
|
|
94
|
+
apiAccess: true,
|
|
95
|
+
prioritySupport: true,
|
|
96
|
+
customBranding: false,
|
|
97
|
+
sso: false,
|
|
98
|
+
selfHosted: false
|
|
99
|
+
},
|
|
100
|
+
color: '#3B82F6'
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
enterprise: {
|
|
104
|
+
id: 'enterprise',
|
|
105
|
+
name: 'Enterprise',
|
|
106
|
+
price: 29900,
|
|
107
|
+
priceYearly: 286800,
|
|
108
|
+
currency: 'EUR',
|
|
109
|
+
description: 'For teams and manufacturers',
|
|
110
|
+
features: [
|
|
111
|
+
'Everything in Pro',
|
|
112
|
+
'Unlimited collaborators',
|
|
113
|
+
'500 GB storage',
|
|
114
|
+
'Unlimited AI requests',
|
|
115
|
+
'Unlimited STEP import',
|
|
116
|
+
'CAM to real-time fab network',
|
|
117
|
+
'Custom branding',
|
|
118
|
+
'Single Sign-On (SSO)',
|
|
119
|
+
'Self-hosted option',
|
|
120
|
+
'99.9% SLA',
|
|
121
|
+
'Dedicated technical support',
|
|
122
|
+
'Training and consulting included'
|
|
123
|
+
],
|
|
124
|
+
limits: {
|
|
125
|
+
projects: Infinity,
|
|
126
|
+
partsPerProject: Infinity,
|
|
127
|
+
stepImportMB: Infinity,
|
|
128
|
+
collaborators: Infinity,
|
|
129
|
+
storageGB: 500,
|
|
130
|
+
aiRequestsPerDay: Infinity,
|
|
131
|
+
camOperations: true,
|
|
132
|
+
customMaterials: true,
|
|
133
|
+
apiAccess: true,
|
|
134
|
+
prioritySupport: true,
|
|
135
|
+
customBranding: true,
|
|
136
|
+
sso: true,
|
|
137
|
+
selfHosted: true,
|
|
138
|
+
sla: '99.9%'
|
|
139
|
+
},
|
|
140
|
+
color: '#8B5CF6'
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// User state (cached in localStorage)
|
|
145
|
+
state: {
|
|
146
|
+
userId: null,
|
|
147
|
+
email: null,
|
|
148
|
+
tier: 'free',
|
|
149
|
+
status: 'active', // active, trialing, canceled, payment_failed
|
|
150
|
+
trialEndsAt: null,
|
|
151
|
+
currentPeriodEnd: null,
|
|
152
|
+
currentPeriodStart: null,
|
|
153
|
+
cancelAtPeriodEnd: false,
|
|
154
|
+
billingCycle: 'monthly', // monthly or yearly
|
|
155
|
+
stripeCustomerId: null,
|
|
156
|
+
usage: {
|
|
157
|
+
projects: 0,
|
|
158
|
+
partsInProject: {},
|
|
159
|
+
totalParts: 0,
|
|
160
|
+
storageGB: 0,
|
|
161
|
+
aiRequests: 0,
|
|
162
|
+
aiRequestsToday: 0,
|
|
163
|
+
lastAiRequestReset: Date.now(),
|
|
164
|
+
stepImportsThisMonth: 0,
|
|
165
|
+
stepImportBytesThisMonth: 0,
|
|
166
|
+
lastImportReset: Date.now()
|
|
167
|
+
},
|
|
168
|
+
lastSyncedAt: null,
|
|
169
|
+
offlineMode: false
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Initialize billing module
|
|
174
|
+
* Load user state from localStorage or API
|
|
175
|
+
*/
|
|
176
|
+
async init() {
|
|
177
|
+
console.log('[Billing] Initializing...');
|
|
178
|
+
|
|
179
|
+
// Load from localStorage
|
|
180
|
+
const saved = localStorage.getItem('billing_state');
|
|
181
|
+
if (saved) {
|
|
182
|
+
try {
|
|
183
|
+
this.state = { ...this.state, ...JSON.parse(saved) };
|
|
184
|
+
console.log('[Billing] Loaded cached state:', this.state.tier);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.warn('[Billing] Failed to parse cached state:', e);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Try to sync with server
|
|
191
|
+
try {
|
|
192
|
+
const response = await fetch(`${this.config.serverUrl}/billing/user`, {
|
|
193
|
+
method: 'GET',
|
|
194
|
+
credentials: 'include'
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (response.ok) {
|
|
198
|
+
const data = await response.json();
|
|
199
|
+
this.state = { ...this.state, ...data };
|
|
200
|
+
this.saveState();
|
|
201
|
+
console.log('[Billing] Synced with server');
|
|
202
|
+
this.state.offlineMode = false;
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.warn('[Billing] Server sync failed, using offline mode:', e.message);
|
|
206
|
+
this.state.offlineMode = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Start daily AI request counter reset
|
|
210
|
+
this.startDailyReset();
|
|
211
|
+
|
|
212
|
+
// Load stripe.js
|
|
213
|
+
if (!window.Stripe) {
|
|
214
|
+
const script = document.createElement('script');
|
|
215
|
+
script.src = 'https://js.stripe.com/v3/';
|
|
216
|
+
script.async = true;
|
|
217
|
+
document.head.appendChild(script);
|
|
218
|
+
await new Promise(resolve => script.onload = resolve);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.stripe = Stripe(this.config.stripePublishableKey);
|
|
222
|
+
console.log('[Billing] Module ready');
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get current tier configuration
|
|
227
|
+
*/
|
|
228
|
+
getCurrentTier() {
|
|
229
|
+
const tier = this.tiers[this.state.tier] || this.tiers.free;
|
|
230
|
+
return {
|
|
231
|
+
tier: this.state.tier,
|
|
232
|
+
name: tier.name,
|
|
233
|
+
features: tier.features,
|
|
234
|
+
limits: tier.limits,
|
|
235
|
+
usage: this.state.usage,
|
|
236
|
+
status: this.state.status,
|
|
237
|
+
trialEndsAt: this.state.trialEndsAt,
|
|
238
|
+
currentPeriodEnd: this.state.currentPeriodEnd,
|
|
239
|
+
billingCycle: this.state.billingCycle,
|
|
240
|
+
daysUntilTrialExpires: this.state.trialEndsAt ?
|
|
241
|
+
Math.ceil((this.state.trialEndsAt - Date.now()) / (1000 * 60 * 60 * 24)) : null,
|
|
242
|
+
isTrialing: this.state.status === 'trialing',
|
|
243
|
+
isCanceled: this.state.status === 'canceled',
|
|
244
|
+
isPaymentFailed: this.state.status === 'payment_failed'
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if a feature is allowed under current tier
|
|
250
|
+
* Returns {allowed, current, limit, message, upgradeRequired}
|
|
251
|
+
*/
|
|
252
|
+
checkLimit(feature, count = 1) {
|
|
253
|
+
const tier = this.tiers[this.state.tier] || this.tiers.free;
|
|
254
|
+
const limits = tier.limits;
|
|
255
|
+
const usage = this.state.usage;
|
|
256
|
+
|
|
257
|
+
// Map feature names to limit keys and usage keys
|
|
258
|
+
const featureLimits = {
|
|
259
|
+
'projects': { limit: limits.projects, usage: usage.projects, display: 'Projects' },
|
|
260
|
+
'parts': { limit: limits.partsPerProject, usage: usage.totalParts, display: 'Parts' },
|
|
261
|
+
'storage': { limit: limits.storageGB, usage: usage.storageGB, display: 'Storage (GB)' },
|
|
262
|
+
'ai-requests': { limit: limits.aiRequestsPerDay, usage: usage.aiRequestsToday, display: 'AI requests today' },
|
|
263
|
+
'step-import': { limit: limits.stepImportMB, usage: 0, display: 'STEP import size (MB)' },
|
|
264
|
+
'collaborators': { limit: limits.collaborators, usage: 0, display: 'Collaborators' },
|
|
265
|
+
'cam-operations': { limit: limits.camOperations ? 1 : 0, usage: 1, display: 'CAM operations' },
|
|
266
|
+
'custom-materials': { limit: limits.customMaterials ? 1 : 0, usage: 1, display: 'Custom materials' },
|
|
267
|
+
'api-access': { limit: limits.apiAccess ? 1 : 0, usage: 1, display: 'API access' }
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const featureConfig = featureLimits[feature];
|
|
271
|
+
if (!featureConfig) {
|
|
272
|
+
return { allowed: true, message: 'Feature not tracked' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { limit, usage: currentUsage, display } = featureConfig;
|
|
276
|
+
const newTotal = currentUsage + count;
|
|
277
|
+
const allowed = limit === Infinity || newTotal <= limit;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
allowed,
|
|
281
|
+
current: currentUsage,
|
|
282
|
+
limit,
|
|
283
|
+
message: allowed ?
|
|
284
|
+
`${display}: ${currentUsage}/${limit === Infinity ? '∞' : limit}` :
|
|
285
|
+
`Upgrade to ${this.state.tier === 'free' ? 'Pro' : 'Enterprise'} to increase ${display} limit`,
|
|
286
|
+
upgradeRequired: !allowed,
|
|
287
|
+
percentUsed: limit === Infinity ? 0 : Math.round((currentUsage / limit) * 100)
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Show upgrade prompt modal
|
|
293
|
+
*/
|
|
294
|
+
showUpgradePrompt(feature, context = '') {
|
|
295
|
+
const message = this.checkLimit(feature).message;
|
|
296
|
+
const html = `
|
|
297
|
+
<div class="billing-upgrade-modal">
|
|
298
|
+
<div class="modal-backdrop"></div>
|
|
299
|
+
<div class="modal-content">
|
|
300
|
+
<h3>Upgrade Your Plan</h3>
|
|
301
|
+
<p>${message}</p>
|
|
302
|
+
${context ? `<p class="context">${context}</p>` : ''}
|
|
303
|
+
<div class="tier-options">
|
|
304
|
+
<button class="tier-btn pro-btn" onclick="window.cycleCAD.modules.billing.startCheckout('pro', 'monthly')">
|
|
305
|
+
Upgrade to Pro<br><small>€49/month</small>
|
|
306
|
+
</button>
|
|
307
|
+
<button class="tier-btn enterprise-btn" onclick="window.cycleCAD.modules.billing.startCheckout('enterprise', 'monthly')">
|
|
308
|
+
Upgrade to Enterprise<br><small>€299/month</small>
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
<button class="cancel-btn" onclick="this.closest('.billing-upgrade-modal').remove()">Cancel</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
const container = document.createElement('div');
|
|
317
|
+
container.innerHTML = html;
|
|
318
|
+
document.body.appendChild(container);
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Start Stripe Checkout for a tier
|
|
323
|
+
*/
|
|
324
|
+
async startCheckout(tier, billingCycle = 'monthly') {
|
|
325
|
+
if (!['pro', 'enterprise'].includes(tier)) {
|
|
326
|
+
console.error('[Billing] Invalid tier:', tier);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const tierConfig = this.tiers[tier];
|
|
332
|
+
const priceId = billingCycle === 'yearly' ?
|
|
333
|
+
this.config.stripePriceIds[`${tier}Yearly`] :
|
|
334
|
+
this.config.stripePriceIds[`${tier}Monthly`];
|
|
335
|
+
|
|
336
|
+
console.log('[Billing] Starting checkout for', tier, billingCycle);
|
|
337
|
+
|
|
338
|
+
const response = await fetch(`${this.config.serverUrl}/billing/create-checkout`, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
341
|
+
credentials: 'include',
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
priceId,
|
|
344
|
+
tier,
|
|
345
|
+
billingCycle,
|
|
346
|
+
trialDays: this.state.tier === 'free' ? this.config.trialDays : 0
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!response.ok) {
|
|
351
|
+
throw new Error(`HTTP ${response.status}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const { sessionId } = await response.json();
|
|
355
|
+
await this.stripe.redirectToCheckout({ sessionId });
|
|
356
|
+
} catch (e) {
|
|
357
|
+
console.error('[Billing] Checkout failed:', e);
|
|
358
|
+
alert('Failed to start checkout. Please try again.');
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Open Stripe Customer Portal to manage subscription
|
|
364
|
+
*/
|
|
365
|
+
async openCustomerPortal() {
|
|
366
|
+
try {
|
|
367
|
+
console.log('[Billing] Opening customer portal...');
|
|
368
|
+
|
|
369
|
+
const response = await fetch(`${this.config.serverUrl}/billing/create-portal`, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
credentials: 'include'
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
throw new Error(`HTTP ${response.status}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const { url } = await response.json();
|
|
379
|
+
window.open(url, '_blank');
|
|
380
|
+
} catch (e) {
|
|
381
|
+
console.error('[Billing] Failed to open portal:', e);
|
|
382
|
+
alert('Failed to open billing portal. Please try again.');
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Track usage of a feature
|
|
388
|
+
*/
|
|
389
|
+
trackUsage(feature, amount = 1) {
|
|
390
|
+
const now = Date.now();
|
|
391
|
+
|
|
392
|
+
// Reset daily AI counter if needed
|
|
393
|
+
if (now - this.state.usage.lastAiRequestReset > 24 * 60 * 60 * 1000) {
|
|
394
|
+
this.state.usage.aiRequestsToday = 0;
|
|
395
|
+
this.state.usage.lastAiRequestReset = now;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Reset monthly STEP import if needed (simplified: monthly = 30 days)
|
|
399
|
+
if (now - this.state.usage.lastImportReset > 30 * 24 * 60 * 60 * 1000) {
|
|
400
|
+
this.state.usage.stepImportsThisMonth = 0;
|
|
401
|
+
this.state.usage.stepImportBytesThisMonth = 0;
|
|
402
|
+
this.state.usage.lastImportReset = now;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
switch (feature) {
|
|
406
|
+
case 'ai-request':
|
|
407
|
+
this.state.usage.aiRequests++;
|
|
408
|
+
this.state.usage.aiRequestsToday++;
|
|
409
|
+
break;
|
|
410
|
+
case 'project-created':
|
|
411
|
+
this.state.usage.projects++;
|
|
412
|
+
break;
|
|
413
|
+
case 'part-added':
|
|
414
|
+
this.state.usage.totalParts++;
|
|
415
|
+
this.state.usage.partsInProject[this.getCurrentProjectId()] =
|
|
416
|
+
(this.state.usage.partsInProject[this.getCurrentProjectId()] || 0) + 1;
|
|
417
|
+
break;
|
|
418
|
+
case 'storage-added':
|
|
419
|
+
this.state.usage.storageGB += amount;
|
|
420
|
+
break;
|
|
421
|
+
case 'step-import':
|
|
422
|
+
this.state.usage.stepImportsThisMonth++;
|
|
423
|
+
this.state.usage.stepImportBytesThisMonth += amount;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.saveState();
|
|
428
|
+
this.dispatchUsageEvent(feature, amount);
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get current usage stats
|
|
433
|
+
*/
|
|
434
|
+
getUsage() {
|
|
435
|
+
return { ...this.state.usage };
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check if user has a feature (returns boolean)
|
|
440
|
+
*/
|
|
441
|
+
hasFeature(feature) {
|
|
442
|
+
const tier = this.tiers[this.state.tier] || this.tiers.free;
|
|
443
|
+
return tier.limits[feature] === true || tier.limits[feature] > 0;
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get remaining usage before hitting limit
|
|
448
|
+
*/
|
|
449
|
+
getRemainingQuota(feature) {
|
|
450
|
+
const check = this.checkLimit(feature);
|
|
451
|
+
if (check.limit === Infinity) return Infinity;
|
|
452
|
+
return Math.max(0, check.limit - check.current);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Apply a promo code
|
|
457
|
+
*/
|
|
458
|
+
async applyPromoCode(code) {
|
|
459
|
+
try {
|
|
460
|
+
const response = await fetch(`${this.config.serverUrl}/billing/apply-promo`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
headers: { 'Content-Type': 'application/json' },
|
|
463
|
+
credentials: 'include',
|
|
464
|
+
body: JSON.stringify({ code })
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const data = await response.json();
|
|
468
|
+
if (data.valid) {
|
|
469
|
+
console.log('[Billing] Promo code valid:', data.discount);
|
|
470
|
+
return { valid: true, discount: data.discount, message: data.message };
|
|
471
|
+
} else {
|
|
472
|
+
return { valid: false, message: data.message || 'Invalid promo code' };
|
|
473
|
+
}
|
|
474
|
+
} catch (e) {
|
|
475
|
+
console.error('[Billing] Promo validation failed:', e);
|
|
476
|
+
return { valid: false, message: 'Failed to validate promo code' };
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Cancel subscription (redirects to portal)
|
|
482
|
+
*/
|
|
483
|
+
async cancelSubscription() {
|
|
484
|
+
const confirmed = confirm(
|
|
485
|
+
'Are you sure you want to cancel your subscription? ' +
|
|
486
|
+
'You will lose access to premium features at the end of your billing cycle.'
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
if (confirmed) {
|
|
490
|
+
this.openCustomerPortal();
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Change billing cycle (monthly ↔ yearly)
|
|
496
|
+
*/
|
|
497
|
+
async changeBillingCycle(newCycle) {
|
|
498
|
+
if (!['monthly', 'yearly'].includes(newCycle)) {
|
|
499
|
+
console.error('[Billing] Invalid cycle:', newCycle);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(`${this.config.serverUrl}/billing/change-billing-cycle`, {
|
|
505
|
+
method: 'POST',
|
|
506
|
+
headers: { 'Content-Type': 'application/json' },
|
|
507
|
+
credentials: 'include',
|
|
508
|
+
body: JSON.stringify({ cycle: newCycle })
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
if (response.ok) {
|
|
512
|
+
this.state.billingCycle = newCycle;
|
|
513
|
+
this.saveState();
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
} catch (e) {
|
|
517
|
+
console.error('[Billing] Failed to change cycle:', e);
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get usage as percentage for progress indicators
|
|
524
|
+
*/
|
|
525
|
+
getUsagePercentage(feature) {
|
|
526
|
+
const check = this.checkLimit(feature);
|
|
527
|
+
if (check.limit === Infinity) return 0;
|
|
528
|
+
return Math.round((check.current / check.limit) * 100);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Export usage data as CSV
|
|
533
|
+
*/
|
|
534
|
+
exportUsageCSV() {
|
|
535
|
+
const lines = [
|
|
536
|
+
['Metric', 'Current', 'Limit', 'Usage %'],
|
|
537
|
+
['Projects', this.state.usage.projects, this.tiers[this.state.tier].limits.projects || '∞', this.getUsagePercentage('projects')],
|
|
538
|
+
['Total Parts', this.state.usage.totalParts, this.tiers[this.state.tier].limits.partsPerProject || '∞', this.getUsagePercentage('parts')],
|
|
539
|
+
['Storage (GB)', this.state.usage.storageGB.toFixed(2), this.tiers[this.state.tier].limits.storageGB, this.getUsagePercentage('storage')],
|
|
540
|
+
['AI Requests (Today)', this.state.usage.aiRequestsToday, this.tiers[this.state.tier].limits.aiRequestsPerDay, this.getUsagePercentage('ai-requests')],
|
|
541
|
+
['Total AI Requests', this.state.usage.aiRequests, '∞', 0],
|
|
542
|
+
['STEP Imports (This Month)', this.state.usage.stepImportsThisMonth, '∞', 0],
|
|
543
|
+
['STEP Import Data (MB)', (this.state.usage.stepImportBytesThisMonth / 1024 / 1024).toFixed(2), this.tiers[this.state.tier].limits.stepImportMB || '∞', this.getUsagePercentage('step-import')],
|
|
544
|
+
[''],
|
|
545
|
+
['Generated at', new Date().toISOString()],
|
|
546
|
+
['User Tier', this.state.tier],
|
|
547
|
+
['Subscription Status', this.state.status]
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
const csv = lines.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
|
551
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
552
|
+
const url = URL.createObjectURL(blob);
|
|
553
|
+
const a = document.createElement('a');
|
|
554
|
+
a.href = url;
|
|
555
|
+
a.download = `cyclecad-usage-${new Date().toISOString().split('T')[0]}.csv`;
|
|
556
|
+
a.click();
|
|
557
|
+
URL.revokeObjectURL(url);
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Get list of past invoices
|
|
562
|
+
*/
|
|
563
|
+
async getInvoices() {
|
|
564
|
+
try {
|
|
565
|
+
const response = await fetch(`${this.config.serverUrl}/billing/invoices`, {
|
|
566
|
+
method: 'GET',
|
|
567
|
+
credentials: 'include'
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
if (response.ok) {
|
|
571
|
+
return await response.json();
|
|
572
|
+
}
|
|
573
|
+
} catch (e) {
|
|
574
|
+
console.error('[Billing] Failed to fetch invoices:', e);
|
|
575
|
+
}
|
|
576
|
+
return [];
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Internal: Save state to localStorage
|
|
581
|
+
*/
|
|
582
|
+
saveState() {
|
|
583
|
+
localStorage.setItem('billing_state', JSON.stringify(this.state));
|
|
584
|
+
this.state.lastSyncedAt = Date.now();
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Internal: Start daily AI request counter reset
|
|
589
|
+
*/
|
|
590
|
+
startDailyReset() {
|
|
591
|
+
setInterval(() => {
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
if (now - this.state.usage.lastAiRequestReset > 24 * 60 * 60 * 1000) {
|
|
594
|
+
this.state.usage.aiRequestsToday = 0;
|
|
595
|
+
this.state.usage.lastAiRequestReset = now;
|
|
596
|
+
this.saveState();
|
|
597
|
+
this.dispatchUsageEvent('daily-reset', 0);
|
|
598
|
+
}
|
|
599
|
+
}, 60000); // Check every minute
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Internal: Get current project ID
|
|
604
|
+
*/
|
|
605
|
+
getCurrentProjectId() {
|
|
606
|
+
return window.cycleCAD?.state?.currentProject || 'default';
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Internal: Dispatch custom event
|
|
611
|
+
*/
|
|
612
|
+
dispatchUsageEvent(feature, amount) {
|
|
613
|
+
window.dispatchEvent(new CustomEvent('billing-usage', {
|
|
614
|
+
detail: { feature, amount, usage: this.state.usage }
|
|
615
|
+
}));
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Get UI component for pricing table
|
|
620
|
+
*/
|
|
621
|
+
getUI() {
|
|
622
|
+
return {
|
|
623
|
+
id: 'billing-panel',
|
|
624
|
+
title: 'Billing & Pricing',
|
|
625
|
+
html: `
|
|
626
|
+
<div class="billing-panel">
|
|
627
|
+
<div class="current-plan">
|
|
628
|
+
<h4>Current Plan</h4>
|
|
629
|
+
<div class="plan-card ${this.state.tier}">
|
|
630
|
+
<div class="plan-name">${this.tiers[this.state.tier].name}</div>
|
|
631
|
+
<div class="plan-price">
|
|
632
|
+
€${this.state.tier === 'free' ? '0' :
|
|
633
|
+
this.state.billingCycle === 'yearly' ?
|
|
634
|
+
(this.tiers[this.state.tier].priceYearly / 100 / 12).toFixed(0) :
|
|
635
|
+
(this.tiers[this.state.tier].price / 100).toFixed(0)}/month
|
|
636
|
+
</div>
|
|
637
|
+
<div class="plan-status">${this.state.status}</div>
|
|
638
|
+
${this.state.currentPeriodEnd ?
|
|
639
|
+
`<div class="next-billing">Next billing: ${new Date(this.state.currentPeriodEnd).toLocaleDateString()}</div>` : ''}
|
|
640
|
+
${this.state.trialEndsAt ?
|
|
641
|
+
`<div class="trial-countdown">Trial expires in ${Math.ceil((this.state.trialEndsAt - Date.now()) / (1000 * 60 * 60 * 24))} days</div>` : ''}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
<div class="usage-section">
|
|
646
|
+
<h4>Usage</h4>
|
|
647
|
+
${this.getUsageBarHTML('Projects', this.getUsagePercentage('projects'),
|
|
648
|
+
`${this.state.usage.projects}/${this.tiers[this.state.tier].limits.projects}`)}
|
|
649
|
+
${this.getUsageBarHTML('Storage', this.getUsagePercentage('storage'),
|
|
650
|
+
`${this.state.usage.storageGB.toFixed(1)} GB / ${this.tiers[this.state.tier].limits.storageGB} GB`)}
|
|
651
|
+
${this.getUsageBarHTML('AI Requests (Today)', this.getUsagePercentage('ai-requests'),
|
|
652
|
+
`${this.state.usage.aiRequestsToday} / ${this.tiers[this.state.tier].limits.aiRequestsPerDay}`)}
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div class="billing-actions">
|
|
656
|
+
<button onclick="window.cycleCAD.modules.billing.openCustomerPortal()" class="btn btn-secondary">
|
|
657
|
+
Manage Subscription
|
|
658
|
+
</button>
|
|
659
|
+
<button onclick="window.cycleCAD.modules.billing.exportUsageCSV()" class="btn btn-secondary">
|
|
660
|
+
Export Usage
|
|
661
|
+
</button>
|
|
662
|
+
${this.state.tier !== 'enterprise' ?
|
|
663
|
+
`<button onclick="window.cycleCAD.modules.billing.showUpgradePrompt('storage')" class="btn btn-primary">
|
|
664
|
+
Upgrade Plan
|
|
665
|
+
</button>` : ''}
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
`,
|
|
669
|
+
styles: `
|
|
670
|
+
.billing-panel {
|
|
671
|
+
padding: 16px;
|
|
672
|
+
display: flex;
|
|
673
|
+
flex-direction: column;
|
|
674
|
+
gap: 16px;
|
|
675
|
+
}
|
|
676
|
+
.current-plan h4, .usage-section h4 {
|
|
677
|
+
margin: 0 0 12px 0;
|
|
678
|
+
font-size: 14px;
|
|
679
|
+
font-weight: 600;
|
|
680
|
+
text-transform: uppercase;
|
|
681
|
+
color: #6B7280;
|
|
682
|
+
}
|
|
683
|
+
.plan-card {
|
|
684
|
+
border: 2px solid #E5E7EB;
|
|
685
|
+
border-radius: 8px;
|
|
686
|
+
padding: 16px;
|
|
687
|
+
background: #F9FAFB;
|
|
688
|
+
}
|
|
689
|
+
.plan-card.pro { border-color: #3B82F6; background: #EFF6FF; }
|
|
690
|
+
.plan-card.enterprise { border-color: #8B5CF6; background: #F5F3FF; }
|
|
691
|
+
.plan-name { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
|
692
|
+
.plan-price { font-size: 24px; font-weight: 700; color: #1F2937; margin-bottom: 8px; }
|
|
693
|
+
.plan-status { font-size: 12px; text-transform: uppercase; color: #6B7280; margin-bottom: 8px; }
|
|
694
|
+
.next-billing, .trial-countdown { font-size: 12px; color: #6B7280; }
|
|
695
|
+
.usage-section { display: flex; flex-direction: column; gap: 12px; }
|
|
696
|
+
.billing-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
697
|
+
`
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Helper: Get HTML for usage progress bar
|
|
703
|
+
*/
|
|
704
|
+
getUsageBarHTML(label, percentage, details) {
|
|
705
|
+
return `
|
|
706
|
+
<div class="usage-item">
|
|
707
|
+
<div class="usage-label">
|
|
708
|
+
<span>${label}</span>
|
|
709
|
+
<span class="usage-details">${details}</span>
|
|
710
|
+
</div>
|
|
711
|
+
<div class="usage-bar">
|
|
712
|
+
<div class="usage-fill" style="width: ${Math.min(percentage, 100)}%; background-color: ${
|
|
713
|
+
percentage > 90 ? '#EF4444' : percentage > 70 ? '#F59E0B' : '#10B981'
|
|
714
|
+
}"></div>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
`;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// Export for use in cycleCAD
|
|
722
|
+
if (typeof window !== 'undefined') {
|
|
723
|
+
window.BillingModule = BillingModule;
|
|
724
|
+
}
|