@formata/limitr-ui 0.1.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.
@@ -0,0 +1,1079 @@
1
+ //
2
+ // Copyright 2026 Formata, Inc. All rights reserved.
3
+ //
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // you may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ //
8
+ // http://www.apache.org/licenses/LICENSE-2.0
9
+ //
10
+ // Unless required by applicable law or agreed to in writing, software
11
+ // distributed under the License is distributed on an "AS IS" BASIS,
12
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ // See the License for the specific language governing permissions and
14
+ // limitations under the License.
15
+ //
16
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
17
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
18
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
19
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
20
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
21
+ };
22
+ var __metadata = (this && this.__metadata) || function (k, v) {
23
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
24
+ };
25
+ import { customElement, property, state } from "lit/decorators.js";
26
+ import { LimitrElement } from './element.js';
27
+ import { css, html, nothing } from "lit";
28
+ import './table.ts';
29
+ /**
30
+ * Show a customer's current plan & usage information (if any).
31
+ *
32
+ * For Stripe integration via Limitr Cloud, the following metadata fields are expected
33
+ * in customer.metadata:
34
+ *
35
+ * - stripe_subscription_id: string - Stripe subscription ID
36
+ * - stripe_subscription_status: string - Status (active, canceled, past_due, etc.)
37
+ * - stripe_current_period_start: number - Unix timestamp (ms)
38
+ * - stripe_current_period_end: number - Unix timestamp (ms)
39
+ * - stripe_cancel_at_period_end: boolean - Will cancel at end of period
40
+ * - stripe_customer_id: string - Stripe customer ID
41
+ * - stripe_payment_method_type: string - Payment method type (card, etc.)
42
+ * - stripe_payment_method_last4: string - Last 4 digits of payment method
43
+ * - stripe_payment_method_brand: string - Card brand (visa, mastercard, etc.)
44
+ *
45
+ * Coupon metadata fields (populated by Limitr when coupon is applied):
46
+ * - stripe_coupon_code: string - The coupon/promo code
47
+ * - stripe_coupon_status: string - 'pending' | 'applied' | 'invalid' | 'expired'
48
+ * - stripe_coupon_name: string - Display name for the coupon
49
+ * - stripe_coupon_percent_off: string - Percentage off (if percent-based)
50
+ * - stripe_coupon_amount_off: string - Amount off in cents (if amount-based)
51
+ * - stripe_coupon_currency: string - Currency for amount_off coupons
52
+ * - stripe_coupon_duration: string - 'once' | 'repeating' | 'forever'
53
+ * - stripe_coupon_duration_in_months: string - Number of months (if repeating)
54
+ */
55
+ let LimitrCurrentPlan = class LimitrCurrentPlan extends LimitrElement {
56
+ constructor() {
57
+ super(...arguments);
58
+ this.showStripeInfo = false;
59
+ this.hideUsage = false;
60
+ this.hideCancel = false;
61
+ this.cancelImmediately = false;
62
+ this.theme = 'light';
63
+ this.denyPolicyChanges = false;
64
+ this.currentPlan = null;
65
+ this.currentCustomer = null;
66
+ this.loading = true;
67
+ this.showPricingTable = false;
68
+ this._internalHideCancel = false;
69
+ }
70
+ /**
71
+ * Styles.
72
+ */
73
+ static get styles() {
74
+ return css `
75
+ :host {
76
+ display: block;
77
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
78
+ --limitr-bg-primary: #ffffff;
79
+ --limitr-bg-secondary: #f8f9fa;
80
+ --limitr-text-primary: #000000;
81
+ --limitr-text-secondary: #6c757d;
82
+ --limitr-border: #dee2e6;
83
+ --limitr-accent: #000000;
84
+ --limitr-accent-text: #ffffff;
85
+ --limitr-radius: 12px;
86
+ --limitr-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
87
+ }
88
+
89
+ :host([theme="dark"]) {
90
+ --limitr-bg-primary: #1a1a1a;
91
+ --limitr-bg-secondary: #2d2d2d;
92
+ --limitr-text-primary: #ffffff;
93
+ --limitr-text-secondary: #a0a0a0;
94
+ --limitr-border: #404040;
95
+ --limitr-accent: #ffffff;
96
+ --limitr-accent-text: #000000;
97
+ }
98
+
99
+ .container {
100
+ margin: 0 auto;
101
+ padding: 24px;
102
+ }
103
+
104
+ .plan-card {
105
+ background: var(--limitr-bg-primary);
106
+ border: 2px solid var(--limitr-border);
107
+ border-radius: var(--limitr-radius);
108
+ padding: 32px;
109
+ box-shadow: var(--limitr-shadow);
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .plan-header {
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: flex-start;
117
+ margin-bottom: 24px;
118
+ padding-bottom: 24px;
119
+ border-bottom: 2px solid var(--limitr-border);
120
+ }
121
+
122
+ .plan-info {
123
+ flex: 1;
124
+ }
125
+
126
+ .plan-label {
127
+ font-size: 12px;
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.5px;
130
+ color: var(--limitr-text-secondary);
131
+ margin-bottom: 8px;
132
+ font-weight: 600;
133
+ }
134
+
135
+ .plan-name {
136
+ font-size: 28px;
137
+ font-weight: 700;
138
+ color: var(--limitr-text-primary);
139
+ margin: 0 0 8px 0;
140
+ }
141
+
142
+ .plan-price {
143
+ display: flex;
144
+ align-items: baseline;
145
+ gap: 4px;
146
+ color: var(--limitr-text-secondary);
147
+ font-size: 18px;
148
+ font-weight: 500;
149
+ }
150
+
151
+ .price-amount {
152
+ font-size: 24px;
153
+ font-weight: 700;
154
+ color: var(--limitr-text-primary);
155
+ }
156
+
157
+ .change-plan-btn {
158
+ padding: 12px 24px;
159
+ border: 2px solid var(--limitr-accent);
160
+ background: transparent;
161
+ color: var(--limitr-accent);
162
+ font-size: 14px;
163
+ font-weight: 600;
164
+ border-radius: 8px;
165
+ cursor: pointer;
166
+ transition: all 0.2s ease;
167
+ text-transform: uppercase;
168
+ letter-spacing: 0.5px;
169
+ white-space: nowrap;
170
+ }
171
+
172
+ .change-plan-btn:hover {
173
+ background: var(--limitr-accent);
174
+ color: var(--limitr-accent-text);
175
+ }
176
+
177
+ .plan-actions {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 12px;
181
+ align-items: flex-end;
182
+ }
183
+
184
+ .cancel-plan-btn {
185
+ padding: 8px 16px;
186
+ border: 1px solid #dc2626;
187
+ background: transparent;
188
+ color: #dc2626;
189
+ font-size: 12px;
190
+ font-weight: 600;
191
+ border-radius: 6px;
192
+ cursor: pointer;
193
+ transition: all 0.2s ease;
194
+ text-transform: uppercase;
195
+ letter-spacing: 0.5px;
196
+ white-space: nowrap;
197
+ }
198
+
199
+ .cancel-plan-btn:hover {
200
+ background: #dc2626;
201
+ color: #ffffff;
202
+ }
203
+
204
+ .resume-plan-btn {
205
+ padding: 8px 16px;
206
+ border: 1px solid #10b981;
207
+ background: transparent;
208
+ color: #10b981;
209
+ font-size: 12px;
210
+ font-weight: 600;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.2s ease;
214
+ text-transform: uppercase;
215
+ letter-spacing: 0.5px;
216
+ white-space: nowrap;
217
+ }
218
+
219
+ .resume-plan-btn:hover {
220
+ background: #10b981;
221
+ color: #ffffff;
222
+ }
223
+
224
+ .price-original {
225
+ font-size: 16px;
226
+ color: var(--limitr-text-secondary);
227
+ text-decoration: line-through;
228
+ margin-right: 8px;
229
+ }
230
+
231
+ .coupon-badge {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 6px;
235
+ margin-top: 6px;
236
+ padding: 4px 10px;
237
+ background: #eeffc9;
238
+ color: #4d7c0f;
239
+ border-radius: 20px;
240
+ font-size: 12px;
241
+ font-weight: 600;
242
+ }
243
+
244
+ :host([theme="dark"]) .coupon-badge {
245
+ background: #2d4a0a;
246
+ color: #a3e635;
247
+ }
248
+
249
+ .coupon-badge .coupon-tag-icon {
250
+ font-size: 13px;
251
+ }
252
+
253
+ .coupon-duration {
254
+ font-size: 11px;
255
+ color: var(--limitr-text-secondary);
256
+ margin-top: 2px;
257
+ }
258
+
259
+ .usage-section {
260
+ margin-bottom: 32px;
261
+ }
262
+
263
+ .section-title {
264
+ font-size: 16px;
265
+ font-weight: 600;
266
+ color: var(--limitr-text-primary);
267
+ margin: 0 0 16px 0;
268
+ text-transform: uppercase;
269
+ letter-spacing: 0.5px;
270
+ }
271
+
272
+ .usage-item {
273
+ margin-bottom: 20px;
274
+ }
275
+
276
+ .usage-item:last-child {
277
+ margin-bottom: 0;
278
+ }
279
+
280
+ .usage-header {
281
+ display: flex;
282
+ justify-content: space-between;
283
+ align-items: center;
284
+ margin-bottom: 8px;
285
+ }
286
+
287
+ .usage-name {
288
+ font-size: 14px;
289
+ font-weight: 500;
290
+ color: var(--limitr-text-primary);
291
+ }
292
+
293
+ .usage-stats {
294
+ font-size: 14px;
295
+ font-weight: 600;
296
+ color: var(--limitr-text-secondary);
297
+ }
298
+
299
+ .usage-bar {
300
+ width: 100%;
301
+ height: 8px;
302
+ background: var(--limitr-bg-secondary);
303
+ border-radius: 4px;
304
+ overflow: hidden;
305
+ }
306
+
307
+ .usage-bar-fill {
308
+ height: 100%;
309
+ background: var(--limitr-accent);
310
+ transition: width 0.3s ease;
311
+ border-radius: 4px;
312
+ }
313
+
314
+ .usage-bar-fill.warning {
315
+ background: #f59e0b;
316
+ }
317
+
318
+ .usage-bar-fill.danger {
319
+ background: #ef4444;
320
+ }
321
+
322
+ .stripe-section {
323
+ margin-bottom: 32px;
324
+ }
325
+
326
+ .stripe-info {
327
+ display: grid;
328
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
329
+ gap: 16px;
330
+ }
331
+
332
+ .info-item {
333
+ padding: 16px;
334
+ background: var(--limitr-bg-secondary);
335
+ border-radius: 8px;
336
+ }
337
+
338
+ .info-label {
339
+ font-size: 12px;
340
+ text-transform: uppercase;
341
+ letter-spacing: 0.5px;
342
+ color: var(--limitr-text-secondary);
343
+ margin-bottom: 4px;
344
+ font-weight: 600;
345
+ }
346
+
347
+ .info-value {
348
+ font-size: 16px;
349
+ font-weight: 600;
350
+ color: var(--limitr-text-primary);
351
+ }
352
+
353
+ .status-badge {
354
+ display: inline-block;
355
+ padding: 4px 12px;
356
+ border-radius: 12px;
357
+ font-size: 12px;
358
+ font-weight: 600;
359
+ text-transform: uppercase;
360
+ letter-spacing: 0.5px;
361
+ }
362
+
363
+ .status-badge.active {
364
+ background: #10b981;
365
+ color: #ffffff;
366
+ }
367
+
368
+ .status-badge.canceled {
369
+ background: #6b7280;
370
+ color: #ffffff;
371
+ }
372
+
373
+ .status-badge.past_due {
374
+ background: #ef4444;
375
+ color: #ffffff;
376
+ }
377
+
378
+ .invoices-section {
379
+ margin-top: 32px;
380
+ padding-top: 24px;
381
+ border-top: 2px solid var(--limitr-border);
382
+ }
383
+
384
+ .invoices-list {
385
+ display: flex;
386
+ flex-direction: column;
387
+ gap: 12px;
388
+ }
389
+
390
+ .invoice-item {
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: space-between;
394
+ padding: 16px;
395
+ background: var(--limitr-bg-secondary);
396
+ border-radius: 8px;
397
+ gap: 16px;
398
+ }
399
+
400
+ .invoice-info {
401
+ flex: 1;
402
+ min-width: 0;
403
+ }
404
+
405
+ .invoice-number {
406
+ font-weight: 600;
407
+ color: var(--limitr-text-primary);
408
+ font-size: 14px;
409
+ }
410
+
411
+ .invoice-date {
412
+ font-size: 12px;
413
+ color: var(--limitr-text-secondary);
414
+ margin-top: 4px;
415
+ }
416
+
417
+ .invoice-amount {
418
+ font-weight: 600;
419
+ color: var(--limitr-text-primary);
420
+ white-space: nowrap;
421
+ }
422
+
423
+ .invoice-download {
424
+ padding: 8px 16px;
425
+ border: 2px solid var(--limitr-accent);
426
+ background: transparent;
427
+ color: var(--limitr-accent);
428
+ font-size: 13px;
429
+ font-weight: 600;
430
+ border-radius: 6px;
431
+ cursor: pointer;
432
+ transition: all 0.2s ease;
433
+ text-decoration: none;
434
+ white-space: nowrap;
435
+ }
436
+
437
+ .invoice-download:hover {
438
+ background: var(--limitr-accent);
439
+ color: var(--limitr-accent-text);
440
+ }
441
+
442
+ .loading {
443
+ text-align: center;
444
+ padding: 48px;
445
+ color: var(--limitr-text-secondary);
446
+ font-size: 16px;
447
+ }
448
+
449
+ .empty {
450
+ text-align: center;
451
+ padding: 48px;
452
+ color: var(--limitr-text-secondary);
453
+ }
454
+
455
+ .empty-title {
456
+ font-size: 20px;
457
+ font-weight: 600;
458
+ margin-bottom: 8px;
459
+ }
460
+
461
+ .modal-overlay {
462
+ position: fixed;
463
+ top: 0;
464
+ left: 0;
465
+ right: 0;
466
+ bottom: 0;
467
+ background: rgba(0, 0, 0, 0.5);
468
+ display: flex;
469
+ align-items: center;
470
+ justify-content: center;
471
+ z-index: 1000;
472
+ padding: 24px;
473
+ }
474
+
475
+ .modal-content {
476
+ background: var(--limitr-bg-primary);
477
+ border-radius: var(--limitr-radius);
478
+ max-width: 1200px;
479
+ width: 100%;
480
+ max-height: 90vh;
481
+ overflow-y: auto;
482
+ position: relative;
483
+ }
484
+
485
+ .modal-header {
486
+ display: flex;
487
+ justify-content: space-between;
488
+ align-items: center;
489
+ padding: 24px 24px 16px;
490
+ border-bottom: 2px solid var(--limitr-border);
491
+ position: sticky;
492
+ top: 0;
493
+ background: var(--limitr-bg-primary);
494
+ z-index: 1;
495
+ }
496
+
497
+ .modal-title {
498
+ font-size: 24px;
499
+ font-weight: 700;
500
+ color: var(--limitr-text-primary);
501
+ margin: 0;
502
+ }
503
+
504
+ .close-btn {
505
+ background: transparent;
506
+ border: none;
507
+ font-size: 28px;
508
+ color: var(--limitr-text-secondary);
509
+ cursor: pointer;
510
+ padding: 4px 8px;
511
+ line-height: 1;
512
+ transition: color 0.2s ease;
513
+ }
514
+
515
+ .close-btn:hover {
516
+ color: var(--limitr-text-primary);
517
+ }
518
+
519
+ @media (max-width: 768px) {
520
+ .container {
521
+ padding: 16px;
522
+ }
523
+
524
+ .plan-card {
525
+ padding: 24px;
526
+ }
527
+
528
+ .plan-header {
529
+ flex-direction: column;
530
+ gap: 16px;
531
+ }
532
+
533
+ .plan-actions {
534
+ width: 100%;
535
+ align-items: stretch;
536
+ }
537
+
538
+ .change-plan-btn,
539
+ .cancel-plan-btn,
540
+ .resume-plan-btn {
541
+ width: 100%;
542
+ }
543
+
544
+ .stripe-info {
545
+ grid-template-columns: 1fr;
546
+ }
547
+ }
548
+ `;
549
+ }
550
+ connectedCallback() {
551
+ super.connectedCallback();
552
+ this.loadData();
553
+ }
554
+ async updated(changedProperties) {
555
+ await super.updated(changedProperties);
556
+ if (changedProperties.has('policy')) {
557
+ // Re-register handler if policy changed (including first time it's set)
558
+ //deno-lint-ignore no-explicit-any
559
+ const oldPolicy = changedProperties.get('policy');
560
+ if (oldPolicy)
561
+ oldPolicy.removeHandler(this.policyHandlerId);
562
+ if (this.policy) {
563
+ this.policy.addHandler(this.policyHandlerId, (key, _value) => {
564
+ if (key.includes('internal')) {
565
+ this.loadData();
566
+ }
567
+ });
568
+ }
569
+ }
570
+ if (changedProperties.has('policy') || changedProperties.has('customerId')) {
571
+ await this.loadData();
572
+ }
573
+ }
574
+ async loadData() {
575
+ this.loading = true;
576
+ this._internalHideCancel = false;
577
+ try {
578
+ this.currentPlan = await this.getCustomerPlan();
579
+ this.currentCustomer = await this.customer();
580
+ const defaultPlan = await this.policy.defaultPlan();
581
+ if (defaultPlan && this.currentPlan) {
582
+ this._internalHideCancel = defaultPlan.name === this.currentPlan.name;
583
+ }
584
+ }
585
+ catch (e) {
586
+ console.error('Error loading current plan data:', e);
587
+ this.currentPlan = null;
588
+ this.currentCustomer = null;
589
+ }
590
+ finally {
591
+ this.loading = false;
592
+ }
593
+ this.requestUpdate();
594
+ }
595
+ handleChangePlan() {
596
+ this.showPricingTable = true;
597
+ }
598
+ handleClosePricingTable() {
599
+ this.showPricingTable = false;
600
+ }
601
+ async handlePlanSelected(e) {
602
+ // Re-emit the event for parent components to handle
603
+ const event = new CustomEvent('plan-change', {
604
+ detail: e.detail,
605
+ bubbles: true,
606
+ composed: true,
607
+ });
608
+ this.dispatchEvent(event);
609
+ this.showPricingTable = false;
610
+ await this.loadData();
611
+ }
612
+ handleCancelPlan() {
613
+ if (confirm("Are you sure you'd like to cancel your subscription?")) {
614
+ // Emit event for parent to handle the cancellation
615
+ const event = new CustomEvent('plan-cancel', {
616
+ detail: {
617
+ planName: this.currentPlan?.name,
618
+ customerId: this.customerId
619
+ },
620
+ bubbles: true,
621
+ composed: true,
622
+ });
623
+ this.dispatchEvent(event);
624
+ if (!this.denyPolicyChanges && this.policy) {
625
+ this.policy.wsSend(JSON.stringify({
626
+ type: 'cancel-subscription',
627
+ at_period_end: !this.cancelImmediately,
628
+ customer: this.currentCustomer,
629
+ }));
630
+ }
631
+ }
632
+ }
633
+ handleResumeSubscription() {
634
+ if (confirm("Are you sure you'd like to resume your subscription?")) {
635
+ // Emit event for parent to handle resuming the subscription
636
+ const event = new CustomEvent('subscription-resume', {
637
+ detail: {
638
+ planName: this.currentPlan?.name,
639
+ customerId: this.customerId
640
+ },
641
+ bubbles: true,
642
+ composed: true,
643
+ });
644
+ this.dispatchEvent(event);
645
+ if (!this.denyPolicyChanges && this.policy && !this.cancelImmediately) {
646
+ this.policy.wsSend(JSON.stringify({
647
+ type: 'resume-stripe-subscription',
648
+ id: this.customerId,
649
+ }));
650
+ }
651
+ }
652
+ }
653
+ /**
654
+ * Pluralize a unit name if needed based on the value.
655
+ */
656
+ pluralizeUnit(unit, value) {
657
+ const numValue = typeof value === 'string' ? parseFloat(value) : value;
658
+ if (numValue === 1)
659
+ return unit;
660
+ const nonPluralUnits = [
661
+ 'GB', 'MB', 'KB', 'TB', 'MiB', 'GiB', 'KiB', 'TiB',
662
+ 'ms', 'seconds', 'minutes', 'hours', 'days',
663
+ 'storage', 'data', 'bandwidth'
664
+ ];
665
+ if (nonPluralUnits.includes(unit)) {
666
+ return unit;
667
+ }
668
+ const irregularPlurals = {
669
+ 'seat': 'seats',
670
+ 'token': 'tokens',
671
+ 'request': 'requests',
672
+ 'call': 'calls',
673
+ 'query': 'queries',
674
+ 'credit': 'credits',
675
+ 'user': 'users',
676
+ 'member': 'members',
677
+ 'item': 'items'
678
+ };
679
+ if (irregularPlurals[unit.toLowerCase()]) {
680
+ return irregularPlurals[unit.toLowerCase()];
681
+ }
682
+ if (!unit.endsWith('s')) {
683
+ return unit + 's';
684
+ }
685
+ return unit;
686
+ }
687
+ //deno-lint-ignore no-explicit-any
688
+ getCouponDetails(metadata) {
689
+ if (!metadata?.stripe_coupon_status || metadata.stripe_coupon_status !== 'applied') {
690
+ return null;
691
+ }
692
+ const price = this.currentPlan?.price;
693
+ if (!price || price.amount === undefined)
694
+ return null;
695
+ const originalAmount = typeof price.amount === 'string' ? parseFloat(price.amount) : price.amount;
696
+ let discountedAmount = originalAmount;
697
+ let label = '';
698
+ if (metadata.stripe_coupon_percent_off) {
699
+ const percentOff = parseFloat(metadata.stripe_coupon_percent_off);
700
+ discountedAmount = originalAmount * (1 - percentOff / 100);
701
+ label = `${percentOff}% off`;
702
+ }
703
+ else if (metadata.stripe_coupon_amount_off) {
704
+ const amountOff = parseFloat(metadata.stripe_coupon_amount_off) / 100; // cents -> dollars
705
+ discountedAmount = Math.max(0, originalAmount - amountOff);
706
+ label = `$${amountOff.toFixed(2)} off`;
707
+ }
708
+ // Duration label
709
+ let durationLabel = '';
710
+ const duration = metadata.stripe_coupon_duration;
711
+ if (duration === 'once') {
712
+ durationLabel = 'First period only';
713
+ }
714
+ else if (duration === 'repeating' && metadata.stripe_coupon_duration_in_months) {
715
+ durationLabel = `First ${metadata.stripe_coupon_duration_in_months} months`;
716
+ }
717
+ // 'forever' = no duration label, it's just the price now
718
+ return {
719
+ hasDiscount: discountedAmount < originalAmount,
720
+ discountedAmount,
721
+ originalAmount,
722
+ label,
723
+ durationLabel,
724
+ };
725
+ }
726
+ /**
727
+ * Check if subscription status is active/valid (not terminated or never activated)
728
+ */
729
+ //deno-lint-ignore no-explicit-any
730
+ hasActiveSubscription(metadata) {
731
+ if (!metadata?.stripe_subscription_id)
732
+ return false;
733
+ const status = metadata.stripe_subscription_status;
734
+ // Valid statuses: active, trialing, past_due (still active, just payment issue)
735
+ // Invalid: canceled, incomplete_expired, incomplete (never activated), unpaid
736
+ // TODO: remove incomplete - just here for weird testing state
737
+ return status && ['active', 'trialing', 'past_due', 'incomplete'].includes(status);
738
+ }
739
+ //deno-lint-ignore no-explicit-any
740
+ formatLimitValue(limit, creditName) {
741
+ if (!limit || limit.value === undefined)
742
+ return 'Unlimited';
743
+ const credit = this.getCredit(creditName);
744
+ const value = limit.value;
745
+ if (credit && credit.stof_units && credit.stof_units !== 'float' && credit.stof_units !== 'int') {
746
+ return `${value} ${credit.stof_units}`;
747
+ }
748
+ if (credit && credit.unit) {
749
+ const pluralizedUnit = this.pluralizeUnit(credit.unit, value);
750
+ return `${value} ${pluralizedUnit}`;
751
+ }
752
+ return `${value}`;
753
+ }
754
+ formatUsageValue(usage, creditName) {
755
+ const credit = this.getCredit(creditName);
756
+ if (credit && credit.stof_units && credit.stof_units !== 'float' && credit.stof_units !== 'int') {
757
+ return `${usage} ${credit.stof_units}`;
758
+ }
759
+ if (credit && credit.unit) {
760
+ const pluralizedUnit = this.pluralizeUnit(credit.unit, usage);
761
+ return `${usage} ${pluralizedUnit}`;
762
+ }
763
+ return `${usage}`;
764
+ }
765
+ //deno-lint-ignore no-explicit-any
766
+ getUsagePercentage(usage, limit) {
767
+ if (!limit || limit.value === undefined)
768
+ return 0;
769
+ const limitValue = typeof limit.value === 'string' ? parseFloat(limit.value) : limit.value;
770
+ return Math.min(100, (usage / limitValue) * 100);
771
+ }
772
+ getUsageClass(percentage) {
773
+ if (percentage >= 90)
774
+ return 'danger';
775
+ if (percentage >= 75)
776
+ return 'warning';
777
+ return '';
778
+ }
779
+ formatDate(timestamp) {
780
+ return new Date(timestamp).toLocaleDateString('en-US', {
781
+ year: 'numeric',
782
+ month: 'long',
783
+ day: 'numeric'
784
+ });
785
+ }
786
+ //deno-lint-ignore no-explicit-any
787
+ renderStripeInfo(metadata) {
788
+ if (!metadata || !metadata.stripe_subscription_id)
789
+ return null;
790
+ const status = metadata.stripe_subscription_status || 'unknown';
791
+ const periodStart = metadata.stripe_current_period_start;
792
+ const periodEnd = metadata.stripe_current_period_end;
793
+ const paymentType = metadata.stripe_payment_method_type;
794
+ const last4 = metadata.stripe_payment_method_last4;
795
+ const brand = metadata.stripe_payment_method_brand;
796
+ const cancelAtPeriodEnd = metadata.stripe_cancel_at_period_end;
797
+ return html `
798
+ <div class="stripe-section">
799
+ <h3 class="section-title">Subscription Details</h3>
800
+ <div class="stripe-info">
801
+ <div class="info-item">
802
+ <div class="info-label">Status</div>
803
+ <div class="info-value">
804
+ <span class="status-badge ${status}">${status}</span>
805
+ ${cancelAtPeriodEnd ? html `<div style="font-size: 12px; margin-top: 4px; color: var(--limitr-text-secondary);">Cancels at period end</div>` : ''}
806
+ </div>
807
+ </div>
808
+ ${periodStart ? html `
809
+ <div class="info-item">
810
+ <div class="info-label">Current Period</div>
811
+ <div class="info-value" style="font-size: 14px;">
812
+ ${this.formatDate(periodStart)}<br/>
813
+ to ${this.formatDate(periodEnd)}
814
+ </div>
815
+ </div>
816
+ ` : ''}
817
+ ${paymentType && last4 ? html `
818
+ <div class="info-item">
819
+ <div class="info-label">Payment Method</div>
820
+ <div class="info-value">
821
+ ${brand ? brand.charAt(0).toUpperCase() + brand.slice(1) : paymentType} •••• ${last4}
822
+ </div>
823
+ </div>
824
+ ` : ''}
825
+ </div>
826
+ </div>
827
+ `;
828
+ }
829
+ //deno-lint-ignore no-explicit-any
830
+ renderUsage(plan, customer) {
831
+ if (this.hideUsage || !plan || !customer)
832
+ return null;
833
+ const entitlements = plan.entitlements || {};
834
+ const meters = customer.meters || {};
835
+ // Filter to visible entitlements with limits, usage, and the correct scope.
836
+ const customerType = customer.type;
837
+ //deno-lint-ignore no-explicit-any
838
+ const usageItems = Object.entries(entitlements).filter(([entName, ent]) => {
839
+ return !ent.hidden && ent.limit && meters[entName] !== undefined && (!ent.scope || ent.scope === customerType);
840
+ });
841
+ if (usageItems.length === 0)
842
+ return null;
843
+ return html `
844
+ <div class="usage-section">
845
+ <h3 class="section-title">Usage</h3>
846
+ ${
847
+ //deno-lint-ignore no-explicit-any
848
+ usageItems.map(([entName, entitlement]) => {
849
+ const limit = entitlement.limit;
850
+ const usage = meters[entName]?.value || 0;
851
+ const percentage = this.getUsagePercentage(usage, limit);
852
+ const usageClass = this.getUsageClass(percentage);
853
+ return html `
854
+ <div class="usage-item">
855
+ <div class="usage-header">
856
+ <span class="usage-name">${entitlement.description || entName}</span>
857
+ <span class="usage-stats">
858
+ ${this.formatUsageValue(usage, limit.credit)} / ${this.formatLimitValue(limit, limit.credit)}
859
+ </span>
860
+ </div>
861
+ <div class="usage-bar">
862
+ <div class="usage-bar-fill ${usageClass}" style="width: ${percentage}%"></div>
863
+ </div>
864
+ </div>
865
+ `;
866
+ })}
867
+ </div>
868
+ `;
869
+ }
870
+ /**
871
+ * Render invoices section if invoices are available in policy.
872
+ */
873
+ renderInvoices() {
874
+ if (!this.policy || !this.customerId)
875
+ return null;
876
+ const policy = this.policyRecord;
877
+ if (!policy.invoices || !policy.invoices[this.customerId])
878
+ return null;
879
+ const responseObject = policy.invoices[this.customerId];
880
+ const invoices = responseObject?.data.invoices;
881
+ if (!invoices || !Array.isArray(invoices) || invoices.length === 0) {
882
+ return null;
883
+ }
884
+ return html `
885
+ <div class="invoices-section">
886
+ <h3 class="section-title">Recent Invoices</h3>
887
+ <div class="invoices-list">
888
+ ${invoices.map((invoice) => html `
889
+ <div class="invoice-item">
890
+ <div class="invoice-info">
891
+ <div class="invoice-number">${invoice.number || invoice.id}</div>
892
+ <div class="invoice-date">${this.formatDate(invoice.created)}</div>
893
+ </div>
894
+ <div class="invoice-amount">
895
+ ${this.formatCurrency(invoice.total, invoice.currency)}
896
+ </div>
897
+ <div class="invoice-status">
898
+ <span class="status-badge ${invoice.status}">${invoice.status}</span>
899
+ </div>
900
+ ${invoice.invoice_pdf || invoice.hosted_invoice_url ? html `
901
+ <a
902
+ href="${invoice.invoice_pdf || invoice.hosted_invoice_url}"
903
+ target="_blank"
904
+ class="invoice-download"
905
+ >
906
+ Download
907
+ </a>
908
+ ` : nothing}
909
+ </div>
910
+ `)}
911
+ </div>
912
+ </div>
913
+ `;
914
+ }
915
+ /**
916
+ * Format currency value
917
+ */
918
+ formatCurrency(amountInCents, currency = 'usd') {
919
+ const amount = amountInCents / 100;
920
+ return new Intl.NumberFormat('en-US', {
921
+ style: 'currency',
922
+ currency: currency.toUpperCase(),
923
+ }).format(amount);
924
+ }
925
+ /**
926
+ * Render.
927
+ */
928
+ render() {
929
+ if (this.loading) {
930
+ return html `<div class="loading">Loading plan information...</div>`;
931
+ }
932
+ if (!this.currentPlan) {
933
+ return html `
934
+ <div class="empty">
935
+ <div class="empty-title">No Plan Selected</div>
936
+ <p>You don't have an active plan yet.</p>
937
+ </div>
938
+ `;
939
+ }
940
+ const price = this.currentPlan.price;
941
+ const metadata = this.currentCustomer?.metadata || {};
942
+ const hasActiveSubscription = this.hasActiveSubscription(metadata);
943
+ const cancelAtPeriodEnd = metadata.stripe_cancel_at_period_end === true || metadata.stripe_cancel_at_period_end === 'true';
944
+ const coupon = this.getCouponDetails(metadata);
945
+ return html `
946
+ <div class="container">
947
+ <div class="plan-card">
948
+ <div class="plan-header">
949
+ <div class="plan-info">
950
+ <div class="plan-label">Current Plan</div>
951
+ <h2 class="plan-name">${this.currentPlan.label || this.currentPlan.name}</h2>
952
+ ${price ? html `
953
+ <div class="plan-price">
954
+ ${coupon?.hasDiscount ? html `
955
+ <span class="price-original">${price.prefix || ''}${typeof price.amount === 'number' ? price.amount.toFixed(2) : price.amount}</span>
956
+ ` : ''}
957
+ ${price.prefix || ''}
958
+ <span class="price-amount">${coupon?.hasDiscount ? coupon.discountedAmount.toFixed(2) : (typeof price.amount === 'number' ? price.amount.toFixed(2) : price.amount)}</span>
959
+ ${price.suffix || ''}
960
+ </div>
961
+ ${coupon?.hasDiscount ? html `
962
+ <div class="coupon-badge">
963
+ <span class="coupon-tag-icon">🏷️</span>
964
+ ${metadata.stripe_coupon_name || metadata.stripe_coupon_code} — ${coupon.label}
965
+ </div>
966
+ ${coupon.durationLabel ? html `
967
+ <div class="coupon-duration">${coupon.durationLabel}</div>
968
+ ` : ''}
969
+ ` : ''}
970
+ ` : ''}
971
+ </div>
972
+ <div class="plan-actions">
973
+ <button class="change-plan-btn" @click=${this.handleChangePlan}>
974
+ Change Plan
975
+ </button>
976
+ ${this.stripePortalUrl ? html `
977
+ <button class="change-plan-btn" @click=${() => globalThis.location.href = this.stripePortalUrl}>
978
+ Manage Billing
979
+ </button>
980
+ ` : nothing}
981
+ ${!this.hideCancel && !this._internalHideCancel && hasActiveSubscription ? html `
982
+ ${cancelAtPeriodEnd ? html `
983
+ <button class="resume-plan-btn" @click=${this.handleResumeSubscription}>
984
+ Resume Subscription
985
+ </button>
986
+ ` : html `
987
+ <button class="cancel-plan-btn" @click=${this.handleCancelPlan}>
988
+ Cancel Plan
989
+ </button>
990
+ `}
991
+ ` : nothing}
992
+ </div>
993
+ </div>
994
+
995
+ ${this.renderUsage(this.currentPlan, this.currentCustomer)}
996
+ ${this.showStripeInfo && hasActiveSubscription ? this.renderStripeInfo(metadata) : ''}
997
+ ${this.renderInvoices()}
998
+ </div>
999
+ </div>
1000
+
1001
+ ${this.showPricingTable ? html `
1002
+ <div class="modal-overlay" @click=${this.handleClosePricingTable}>
1003
+ <div class="modal-content" @click=${(e) => e.stopPropagation()}>
1004
+ <div class="modal-header">
1005
+ <h2 class="modal-title">Select a Plan</h2>
1006
+ <button class="close-btn" @click=${this.handleClosePricingTable}>&times;</button>
1007
+ </div>
1008
+ <limitr-pricing-table
1009
+ .policy=${this.policy}
1010
+ .customerId=${this.customerId}
1011
+ .stripePortalUrl=${this.stripePortalUrl}
1012
+ ?denyPolicyChanges=${this.denyPolicyChanges}
1013
+ theme=${this.theme}
1014
+ @plan-select=${this.handlePlanSelected}
1015
+ ?requestStripeInvoices=${false}
1016
+ ?requestStripePortalUrl=${false}
1017
+ ?interactive=${true}
1018
+ ></limitr-pricing-table>
1019
+ </div>
1020
+ </div>
1021
+ ` : ''}
1022
+ `;
1023
+ }
1024
+ };
1025
+ __decorate([
1026
+ property({ type: Boolean }),
1027
+ __metadata("design:type", Boolean)
1028
+ ], LimitrCurrentPlan.prototype, "showStripeInfo", void 0);
1029
+ __decorate([
1030
+ property({ type: Boolean }),
1031
+ __metadata("design:type", Boolean)
1032
+ ], LimitrCurrentPlan.prototype, "hideUsage", void 0);
1033
+ __decorate([
1034
+ property({ type: Boolean }),
1035
+ __metadata("design:type", Boolean)
1036
+ ], LimitrCurrentPlan.prototype, "hideCancel", void 0);
1037
+ __decorate([
1038
+ property({ type: Boolean }),
1039
+ __metadata("design:type", Boolean)
1040
+ ], LimitrCurrentPlan.prototype, "cancelImmediately", void 0);
1041
+ __decorate([
1042
+ property(),
1043
+ __metadata("design:type", String)
1044
+ ], LimitrCurrentPlan.prototype, "theme", void 0);
1045
+ __decorate([
1046
+ property({ type: Boolean })
1047
+ /** When true, emit plan select events only, without setting customer plan on policy. */
1048
+ ,
1049
+ __metadata("design:type", Boolean)
1050
+ ], LimitrCurrentPlan.prototype, "denyPolicyChanges", void 0);
1051
+ __decorate([
1052
+ state()
1053
+ //deno-lint-ignore no-explicit-any
1054
+ ,
1055
+ __metadata("design:type", Object)
1056
+ ], LimitrCurrentPlan.prototype, "currentPlan", void 0);
1057
+ __decorate([
1058
+ state()
1059
+ //deno-lint-ignore no-explicit-any
1060
+ ,
1061
+ __metadata("design:type", Object)
1062
+ ], LimitrCurrentPlan.prototype, "currentCustomer", void 0);
1063
+ __decorate([
1064
+ state(),
1065
+ __metadata("design:type", Boolean)
1066
+ ], LimitrCurrentPlan.prototype, "loading", void 0);
1067
+ __decorate([
1068
+ state(),
1069
+ __metadata("design:type", Boolean)
1070
+ ], LimitrCurrentPlan.prototype, "showPricingTable", void 0);
1071
+ __decorate([
1072
+ state(),
1073
+ __metadata("design:type", Boolean)
1074
+ ], LimitrCurrentPlan.prototype, "_internalHideCancel", void 0);
1075
+ LimitrCurrentPlan = __decorate([
1076
+ customElement('limitr-current-plan')
1077
+ ], LimitrCurrentPlan);
1078
+ export { LimitrCurrentPlan };
1079
+ //# sourceMappingURL=current.js.map