@7365admin1/layer-common 3.2.8-staging.202 → 3.2.8-staging.203

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.
@@ -70,177 +70,54 @@
70
70
  </section>
71
71
 
72
72
  <!-- ===== SUBSCRIPTION INFORMATION ===== -->
73
+ <!--
74
+ READ-ONLY on purpose. This section used to offer six dropdowns and two
75
+ date boxes that looked editable and saved nothing: the plan list was two
76
+ invented names, and Start / End were always blank because nothing ever
77
+ loaded them. It now shows what the client's record actually says, using
78
+ the same wording as the Client List. Setting or changing a subscription is
79
+ a separate piece of work with a real endpoint behind it.
80
+ -->
73
81
  <section class="section">
74
82
  <h3>Subscription Information</h3>
75
83
 
76
- <div class="row">
77
- <div class="field">
78
- <label>Subscription plan type</label>
79
- <div class="select-wrap">
80
- <select v-model="form.planType">
81
- <option v-for="opt in PLAN_TYPE_OPTIONS" :key="opt" :value="opt">
82
- {{ opt }}
83
- </option>
84
- </select>
85
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
86
- <path
87
- d="M1 1l4 4 4-4"
88
- stroke="currentColor"
89
- stroke-width="1.4"
90
- stroke-linecap="round"
91
- />
92
- </svg>
93
- </div>
94
- </div>
95
- <div class="field">
96
- <label>Auto Renewal</label>
97
- <div class="select-wrap">
98
- <select v-model="form.autoRenewal">
99
- <option
100
- v-for="opt in AUTO_RENEWAL_OPTIONS"
101
- :key="opt"
102
- :value="opt"
103
- >
104
- {{ opt }}
105
- </option>
106
- </select>
107
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
108
- <path
109
- d="M1 1l4 4 4-4"
110
- stroke="currentColor"
111
- stroke-width="1.4"
112
- stroke-linecap="round"
113
- />
114
- </svg>
115
- </div>
116
- </div>
117
- </div>
84
+ <p v-if="subscription.state === 'none'" class="sub-none">
85
+ No subscription set up for this client yet.
86
+ </p>
118
87
 
119
- <div class="row">
120
- <div class="field">
121
- <label>Billing Cycle</label>
122
- <div class="select-wrap">
123
- <select v-model="form.billingCycle" @change="onBillingCycleChange">
124
- <option
125
- v-for="opt in BILLING_CYCLE_OPTIONS"
126
- :key="opt"
127
- :value="opt"
128
- >
129
- {{ opt }}
130
- </option>
131
- </select>
132
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
133
- <path
134
- d="M1 1l4 4 4-4"
135
- stroke="currentColor"
136
- stroke-width="1.4"
137
- stroke-linecap="round"
138
- />
139
- </svg>
88
+ <template v-else>
89
+ <div class="row">
90
+ <div class="field">
91
+ <label>Subscription</label>
92
+ <p
93
+ class="read-value"
94
+ :class="{ 'needs-attention': subscription.needsAttention }"
95
+ >
96
+ {{ subscription.label }}
97
+ </p>
140
98
  </div>
141
- </div>
142
- <div class="field">
143
- <label>Status</label>
144
- <div class="select-wrap">
145
- <select v-model="form.status">
146
- <option v-for="opt in STATUS_OPTIONS" :key="opt" :value="opt">
147
- {{ opt }}
148
- </option>
149
- </select>
150
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
151
- <path
152
- d="M1 1l4 4 4-4"
153
- stroke="currentColor"
154
- stroke-width="1.4"
155
- stroke-linecap="round"
156
- />
157
- </svg>
99
+ <div class="field">
100
+ <label>Billing Cycle</label>
101
+ <p class="read-value">{{ subscription.billingCycle }}</p>
158
102
  </div>
159
103
  </div>
160
- </div>
161
104
 
162
- <div class="row row-single">
163
- <div class="field">
164
- <label>Subscription plan</label>
165
- <div class="select-wrap">
166
- <select v-model="form.subscriptionPlan">
167
- <option
168
- v-for="opt in subscriptionPlanOptions"
169
- :key="opt"
170
- :value="opt"
171
- >
172
- {{ opt }}
173
- </option>
174
- </select>
175
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
176
- <path
177
- d="M1 1l4 4 4-4"
178
- stroke="currentColor"
179
- stroke-width="1.4"
180
- stroke-linecap="round"
181
- />
182
- </svg>
105
+ <div class="row">
106
+ <div class="field">
107
+ <label>Start Date</label>
108
+ <p class="read-value">{{ subscription.start }}</p>
183
109
  </div>
184
- <p class="hint">
185
- Values of dropdown are determined by the list under selected billing
186
- cycle.
187
- </p>
188
- </div>
189
- </div>
190
-
191
- <div class="row">
192
- <div class="field">
193
- <label>Start Date</label>
194
- <div class="date-input">
195
- <input type="text" :value="form.startDate" disabled />
196
- <svg viewBox="0 0 16 16" fill="none" width="14" height="14">
197
- <rect
198
- x="1"
199
- y="3"
200
- width="14"
201
- height="12"
202
- rx="2"
203
- stroke="currentColor"
204
- stroke-width="1.3"
205
- />
206
- <path d="M1 7h14" stroke="currentColor" stroke-width="1.3" />
207
- <path
208
- d="M5 1v3M11 1v3"
209
- stroke="currentColor"
210
- stroke-width="1.3"
211
- stroke-linecap="round"
212
- />
213
- </svg>
214
- </div>
215
- </div>
216
- <div class="field">
217
- <label>End Date</label>
218
- <div class="date-input">
219
- <input type="text" :value="form.endDate" disabled />
220
- <svg viewBox="0 0 16 16" fill="none" width="14" height="14">
221
- <rect
222
- x="1"
223
- y="3"
224
- width="14"
225
- height="12"
226
- rx="2"
227
- stroke="currentColor"
228
- stroke-width="1.3"
229
- />
230
- <path d="M1 7h14" stroke="currentColor" stroke-width="1.3" />
231
- <path
232
- d="M5 1v3M11 1v3"
233
- stroke="currentColor"
234
- stroke-width="1.3"
235
- stroke-linecap="round"
236
- />
237
- </svg>
110
+ <div class="field">
111
+ <label>End Date</label>
112
+ <p
113
+ class="read-value"
114
+ :class="{ 'needs-attention': subscription.needsAttention }"
115
+ >
116
+ {{ subscription.end }}
117
+ </p>
238
118
  </div>
239
119
  </div>
240
- </div>
241
- <p class="hint hint-below">
242
- Start and end date depend on the billing cycle, and cannot be edited.
243
- </p>
120
+ </template>
244
121
  </section>
245
122
 
246
123
  <!-- ===== INCLUDED APPLICATION ===== -->
@@ -281,6 +158,7 @@
281
158
  import { reactive, computed, watch } from 'vue'
282
159
  import useOrg from '../composables/useOrg'
283
160
  import useCustomerSite from '../composables/useCustomerSite'
161
+ import { describeClientSubscription } from '../utils/client-subscription'
284
162
  import useUser from '../composables/useUser'
285
163
 
286
164
  const {
@@ -359,21 +237,6 @@ const emit = defineEmits<{
359
237
  }>()
360
238
 
361
239
  /* ── CONSTANTS ── */
362
- const PLAN_TYPE_OPTIONS = [
363
- 'Free (Bundle)',
364
- 'Paid (Bundle)',
365
- 'Paid (Custom App)'
366
- ]
367
- const AUTO_RENEWAL_OPTIONS = ['No', 'Yes']
368
- const BILLING_CYCLE_OPTIONS = ['Monthly', 'Annually']
369
- const STATUS_OPTIONS = ['Active', 'Suspend']
370
-
371
- // Subscription plan options keyed by billing cycle (annotation from mockup)
372
- const SUBSCRIPTION_PLAN_BY_CYCLE: Record<string, string[]> = {
373
- Monthly: ['Standard Plan', 'Premium Plan'],
374
- Annually: ['Standard Plan', 'Premium Plan']
375
- }
376
-
377
240
  const PHONE_RULES: Record<string, number> = {
378
241
  '+60': 9,
379
242
  '+61': 9,
@@ -399,31 +262,15 @@ const form = reactive({
399
262
  organizationName: '',
400
263
  phone: '',
401
264
 
402
- planType: 'Paid (Bundle)',
403
- autoRenewal: 'Yes',
404
- billingCycle: 'Monthly',
405
- status: 'Active',
406
- subscriptionPlan: 'Standard Plan',
407
-
408
- startDate: '',
409
- endDate: '',
410
-
411
265
  includedApps: [] as string[],
412
266
 
413
267
  sites: [] as SiteRow[]
414
268
  })
415
269
 
416
270
  /* ── COMPUTED ── */
417
- const subscriptionPlanOptions = computed(
418
- () => SUBSCRIPTION_PLAN_BY_CYCLE[form.billingCycle] ?? []
419
- )
420
-
421
- /* ── WATCH: keep subscription plan valid when billing cycle changes ── */
422
- watch(subscriptionPlanOptions, options => {
423
- if (!options.includes(form.subscriptionPlan)) {
424
- form.subscriptionPlan = options[0] ?? ''
425
- }
426
- })
271
+ // The row the Client List already fetched carries the subscription sub-document,
272
+ // so the drawer says exactly what the list says - no second, disagreeing source.
273
+ const subscription = computed(() => describeClientSubscription(props.client))
427
274
 
428
275
  watch(
429
276
  () => form.name,
@@ -454,10 +301,6 @@ watch([phonePrefix, phoneNumber], ([prefix, value]) => {
454
301
  })
455
302
 
456
303
  /* ── EVENTS ── */
457
- function onBillingCycleChange() {
458
- // Start / End date are derived from billing cycle and are not editable.
459
- // Recompute or refetch here as needed once wired to a real API.
460
- }
461
304
  function validateForm() {
462
305
  errors.name = ''
463
306
  errors.organizationName = ''
@@ -660,10 +503,6 @@ watch(
660
503
  gap: 20px;
661
504
  margin-bottom: 14px;
662
505
  }
663
- .row-single {
664
- grid-template-columns: 1fr;
665
- }
666
-
667
506
  .field {
668
507
  display: flex;
669
508
  flex-direction: column;
@@ -697,68 +536,17 @@ watch(
697
536
  color: var(--text2);
698
537
  }
699
538
 
700
- /* ===== SELECT ===== */
701
- .select-wrap {
702
- position: relative;
703
- }
704
- .select-wrap select {
705
- width: 100%;
706
- height: 38px;
707
- border: 1px solid var(--border);
708
- border-radius: var(--r-inner);
709
- padding: 0 30px 0 12px;
710
- font-size: 13px;
539
+ /* ===== READ-ONLY VALUES ===== */
540
+ /* A value the staff member reads rather than edits. */
541
+ .read-value {
542
+ font-size: 14px;
711
543
  color: var(--text);
712
- background: var(--card);
713
- appearance: none;
714
- cursor: pointer;
715
- outline: none;
716
- }
717
- .select-wrap select:focus {
718
- border-color: var(--muted);
719
- }
720
- .select-wrap .chevron {
721
- position: absolute;
722
- right: 11px;
723
- top: 50%;
724
- transform: translateY(-50%);
725
- width: 10px;
726
- height: 6px;
727
- pointer-events: none;
728
- }
729
-
730
- /* ===== DATE ===== */
731
- .date-input {
732
- position: relative;
733
- display: flex;
734
- align-items: center;
735
- }
736
- .date-input input {
737
- width: 100%;
738
- height: 38px;
739
- border: 1px solid var(--border);
740
- border-radius: var(--r-inner);
741
- padding: 0 34px 0 12px;
742
- font-size: 13px;
743
- color: var(--text2);
744
- background: var(--bg);
745
- outline: none;
746
- }
747
- .date-input svg {
748
- position: absolute;
749
- right: 12px;
750
- pointer-events: none;
751
- }
752
-
753
- /* ===== HINT ===== */
754
- .hint {
755
- font-size: 11px;
756
- color: var(--text2);
757
- line-height: 1.4;
758
- }
759
- .hint-below {
760
- margin-top: 4px;
544
+ padding: 10px 0;
545
+ min-height: 20px;
761
546
  }
547
+ .sub-none { font-size: 14px; color: var(--text2); }
548
+ /* Owner decision 8: an end date has passed and somebody has to look at it. */
549
+ .needs-attention { color: var(--warn); font-weight: 500; }
762
550
 
763
551
  /* ===== INCLUDED APPS ===== */
764
552
  .app-list {
@@ -37,19 +37,6 @@
37
37
  </svg>
38
38
  </div>
39
39
 
40
- <div class="filter-field">
41
- <label>Filter by plan type</label>
42
- <select v-model="filterPlan" @change="handleFilterChange">
43
- <option value="">All</option>
44
- <option v-for="app in APP_LIST" :key="app.value" :value="app.value">
45
- {{ app.title }}
46
- </option>
47
- </select>
48
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
49
- <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
50
- </svg>
51
- </div>
52
-
53
40
  <div class="filter-field">
54
41
  <label>Filter by billing cycle</label>
55
42
  <select v-model="filterBilling" @change="handleFilterChange">
@@ -111,10 +98,10 @@
111
98
  <th>Organization Name</th>
112
99
  <th>Email</th>
113
100
  <th>Sites</th>
114
- <th>Plan Type</th>
101
+ <th>Subscription</th>
115
102
  <th>Billing Cycle</th>
116
- <th>Subscription Start</th>
117
- <th>Subscription End</th>
103
+ <th>Start Date</th>
104
+ <th>End Date</th>
118
105
  <th>Status</th>
119
106
  <th>Actions</th>
120
107
  </tr>
@@ -129,28 +116,43 @@
129
116
  <td>
130
117
  <span class="sites-badge">{{ item.sites }}</span>
131
118
  </td>
132
- <td>{{ item.planType }}</td>
133
- <td class="td-muted">{{ item.billingCycle }}</td>
134
- <td>
135
- <span class="date-cell">
136
- <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
137
- <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
138
- <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
139
- <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
140
- </svg>
141
- {{ item.subscriptionStart }}
142
- </span>
143
- </td>
144
- <td>
145
- <span class="date-cell">
146
- <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
147
- <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
148
- <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
149
- <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
150
- </svg>
151
- {{ item.subscriptionEnd }}
152
- </span>
119
+ <!--
120
+ A client nobody has set up yet used to print four dashes here,
121
+ which reads as a broken screen. It is one ordinary state, so it
122
+ is said once, in words.
123
+ -->
124
+ <td v-if="item.sub.state === 'none'" colspan="4" class="sub-none">
125
+ No subscription set up
153
126
  </td>
127
+
128
+ <template v-else>
129
+ <td>
130
+ <span class="sub-label" :class="{ 'needs-attention': item.sub.needsAttention }">
131
+ {{ item.sub.label }}
132
+ </span>
133
+ </td>
134
+ <td class="td-muted">{{ item.sub.billingCycle }}</td>
135
+ <td>
136
+ <span class="date-cell">
137
+ <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
138
+ <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
139
+ <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
140
+ <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
141
+ </svg>
142
+ {{ item.sub.start }}
143
+ </span>
144
+ </td>
145
+ <td>
146
+ <span class="date-cell" :class="{ 'needs-attention': item.sub.needsAttention }">
147
+ <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
148
+ <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
149
+ <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
150
+ <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
151
+ </svg>
152
+ {{ item.sub.end }}
153
+ </span>
154
+ </td>
155
+ </template>
154
156
  <td>
155
157
  <span :class="['status-chip', item.status]">{{ item.status }}</span>
156
158
  </td>
@@ -272,7 +274,7 @@ import useRole from '../composables/useRole'
272
274
  import InvitationClientForm from './InvitationClientForm.vue'
273
275
  import ClientDetailForm from './ClientDetailForm.vue'
274
276
  import useCustomerSite from '../composables/useCustomerSite'
275
- import useSubscription from '../composables/useSubscription'
277
+ import { describeClientSubscription } from '../utils/client-subscription'
276
278
 
277
279
 
278
280
  /* ── TYPES ── */
@@ -282,7 +284,6 @@ type TabStatus = 'active' | 'suspended' | 'pending'
282
284
  const { getOrganizationsWithSubscription, updateStatus } = useOrg()
283
285
  const { getVerifications, cancelUserInvitation } = useVerification()
284
286
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
285
- const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
286
287
  const { getRoleById } = useRole()
287
288
 
288
289
  /* ── CONSTANTS ── */
@@ -306,7 +307,6 @@ const tableScrollEl = ref<HTMLElement | null>(null)
306
307
 
307
308
  // filterStatus
308
309
  const filterStatus = ref<'active' | 'suspended'>('active')
309
- const filterPlan = ref('') // '' = All | any APP_LIST value
310
310
  const filterBilling = ref('') // '' = All | 'monthly' | 'yearly'
311
311
 
312
312
  const items = ref<any[]>([])
@@ -338,21 +338,6 @@ const activeMenuItem = computed(() =>
338
338
  )
339
339
 
340
340
  /* ── HELPERS ── */
341
- function formatDate(date?: string | Date) {
342
- if (date == null || date === '') return '-'
343
- const d = date instanceof Date ? date : new Date(date as string)
344
- return isNaN(d.getTime()) ? '-' : d.toLocaleDateString('en-US')
345
- }
346
-
347
- function planTypeLabel(sub: Record<string, any>) {
348
- if (!sub) return '-'
349
- if (sub.paidType) return String(sub.paidType)
350
- if (sub.type === 'organization') return 'Organization'
351
- if (sub.type === 'affiliate') return 'Affiliate'
352
- const desc = String(sub.description || '').trim()
353
- return desc ? (desc.length > 48 ? `${desc.slice(0, 45)}…` : desc) : '-'
354
- }
355
-
356
341
  async function resolveRoleName(id: string) {
357
342
  if (!id) return '-'
358
343
  if (roleCache.value[id]) return roleCache.value[id]
@@ -418,7 +403,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
418
403
  search: search.value,
419
404
  limit: limit.value,
420
405
  status,
421
- type: filterPlan.value,
422
406
  billingCycle: filterBilling.value,
423
407
  })
424
408
 
@@ -428,8 +412,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
428
412
 
429
413
  items.value = await Promise.all(
430
414
  orgs.map(async (org: any) => {
431
- const sub = org?.subscription ?? {}
432
-
433
415
  let siteCount = siteCache[org._id]
434
416
 
435
417
  if (siteCount === undefined) {
@@ -444,18 +426,14 @@ async function fetchOrgs(status: 'active' | 'suspended') {
444
426
  }
445
427
 
446
428
  return {
447
- ...org,
448
-
449
- planType: planTypeLabel(sub),
429
+ ...org,
450
430
 
451
- billingCycle: sub?.billingCycle ?? "-",
431
+ sub: describeClientSubscription(org),
452
432
 
453
- subscriptionStart: formatDate(sub?.createdAt),
454
-
455
- subscriptionEnd: formatDate(sub?.nextBillingDate),
456
-
457
- sites: `${siteCount} Sites`,
458
- }
433
+ // The column header already says Sites, so the badge is the number.
434
+ // (It also used to read "1 Sites".)
435
+ sites: siteCount,
436
+ }
459
437
  })
460
438
  )
461
439
 
@@ -869,6 +847,12 @@ tbody tr:hover { background: var(--thead); }
869
847
  tbody td { vertical-align: middle; }
870
848
 
871
849
  .empty { text-align: center; color: var(--muted); padding: 48px 0 !important; }
850
+
851
+ /* A client nobody has set up yet: plainly stated, not shouted, not a dash. */
852
+ .sub-none { color: var(--text2); font-size: 13px; }
853
+ .sub-label { font-size: 13px; }
854
+ /* Owner decision 8: an end date has passed and somebody has to look at it. */
855
+ .needs-attention { color: var(--warn); font-weight: 500; }
872
856
  .td-name { font-weight: 500; color: var(--text); }
873
857
  .td-muted { color: var(--muted); font-size: 13px; }
874
858
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.8-staging.202",
5
+ "version": "3.2.8-staging.203",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ NOT_SET,
6
+ describeClientSubscription,
7
+ formatDate,
8
+ } from "./client-subscription.ts";
9
+
10
+ /** What the list endpoint really returns for an org that HAS a subscription. */
11
+ function orgWithSub(sub: Record<string, any>) {
12
+ return {
13
+ _id: "org1",
14
+ name: "A Client",
15
+ status: "active",
16
+ subscription: {
17
+ _id: "sub1",
18
+ org: "org1",
19
+ type: "organization",
20
+ billingCycle: "monthly",
21
+ createdAt: "2026-01-01T00:00:00.000Z",
22
+ nextBillingDate: "2027-01-01T00:00:00.000Z",
23
+ status: "active",
24
+ ...sub,
25
+ },
26
+ };
27
+ }
28
+
29
+ const NOW = new Date("2026-08-20T00:00:00.000Z");
30
+
31
+ test("no subscription document is a state, not a broken row", () => {
32
+ // The left join collapses a missing subscription to `{}` - this is the shape
33
+ // 11 production clients are in, and it used to render as four dashes.
34
+ for (const org of [{ subscription: {} }, { subscription: null }, {}, null, undefined]) {
35
+ const v = describeClientSubscription(org as any, NOW);
36
+ assert.equal(v.state, "none", JSON.stringify(org));
37
+ assert.equal(v.label, "No subscription set up");
38
+ assert.equal(v.billingCycle, NOT_SET);
39
+ assert.equal(v.start, NOT_SET);
40
+ assert.equal(v.end, NOT_SET);
41
+ assert.equal(v.needsAttention, false);
42
+ }
43
+ });
44
+
45
+ test("a running record with nothing else proven is plain Active, never 'paying'", () => {
46
+ // Nothing the endpoint sends proves anybody is paying, so it must not say so.
47
+ const v = describeClientSubscription(orgWithSub({}), NOW);
48
+ assert.equal(v.state, "active");
49
+ assert.equal(v.label, "Active");
50
+ assert.equal(v.billingCycle, "Monthly");
51
+ assert.equal(v.needsAttention, false);
52
+ });
53
+
54
+ test("complimentary is only claimed when the record says so", () => {
55
+ const v = describeClientSubscription(
56
+ orgWithSub({ billingMode: "complimentary" }),
57
+ NOW,
58
+ );
59
+ assert.equal(v.state, "complimentary");
60
+ assert.equal(v.label, "Complimentary (not charged)");
61
+
62
+ // and the opposite claim needs the same proof
63
+ assert.equal(
64
+ describeClientSubscription(orgWithSub({ billingMode: "paid" }), NOW).label,
65
+ "Active (paying)",
66
+ );
67
+ });
68
+
69
+ test("suspended and ended come from the record's own status", () => {
70
+ assert.equal(
71
+ describeClientSubscription(orgWithSub({ status: "suspended" }), NOW).state,
72
+ "suspended",
73
+ );
74
+
75
+ for (const s of ["ended", "canceled", "cancelled", "expired", "TERMINATED", " Ended "]) {
76
+ const v = describeClientSubscription(orgWithSub({ status: s }), NOW);
77
+ assert.equal(v.state, "ended", s);
78
+ assert.equal(v.label, "Ended");
79
+ assert.equal(v.needsAttention, false);
80
+ }
81
+ });
82
+
83
+ test("owner decision 8: a passed end date is flagged, not silently left as Active", () => {
84
+ const v = describeClientSubscription(
85
+ orgWithSub({ nextBillingDate: "2026-08-01T00:00:00.000Z" }),
86
+ NOW,
87
+ );
88
+ assert.equal(v.state, "ended");
89
+ assert.equal(v.label, "Ended (needs attention)");
90
+ assert.equal(v.needsAttention, true);
91
+ });
92
+
93
+ test("a suspended record is not re-labelled by its dates", () => {
94
+ // Status wins: a client already suspended does not need flagging again.
95
+ const v = describeClientSubscription(
96
+ orgWithSub({ status: "suspended", nextBillingDate: "2026-08-01T00:00:00.000Z" }),
97
+ NOW,
98
+ );
99
+ assert.equal(v.state, "suspended");
100
+ assert.equal(v.needsAttention, false);
101
+ });
102
+
103
+ test("Phase 2's startDate / endDate win over the fallbacks when they arrive", () => {
104
+ const v = describeClientSubscription(
105
+ orgWithSub({
106
+ startDate: "2026-03-05T00:00:00.000Z",
107
+ endDate: "2027-03-05T00:00:00.000Z",
108
+ createdAt: "2020-01-01T00:00:00.000Z",
109
+ nextBillingDate: "2020-02-01T00:00:00.000Z",
110
+ }),
111
+ NOW,
112
+ );
113
+ // The stale fallbacks are both in the past; if they had won, this row would
114
+ // have been flagged. It is not, so the real dates were used.
115
+ assert.equal(v.state, "active");
116
+ assert.equal(v.start, formatDate("2026-03-05T00:00:00.000Z"));
117
+ assert.equal(v.end, formatDate("2027-03-05T00:00:00.000Z"));
118
+ });
119
+
120
+ test("a missing or unreadable value says 'Not set', not a dash", () => {
121
+ assert.equal(formatDate(null), NOT_SET);
122
+ assert.equal(formatDate(""), NOT_SET);
123
+ assert.equal(formatDate("not a date"), NOT_SET);
124
+ assert.notEqual(formatDate("2026-08-20T00:00:00.000Z"), NOT_SET);
125
+
126
+ const v = describeClientSubscription(
127
+ orgWithSub({ billingCycle: "", createdAt: null, nextBillingDate: null }),
128
+ NOW,
129
+ );
130
+ assert.equal(v.billingCycle, NOT_SET);
131
+ assert.equal(v.start, NOT_SET);
132
+ assert.equal(v.end, NOT_SET);
133
+ assert.equal(v.state, "active");
134
+ });
135
+
136
+ test("a billing cycle the screen does not know is shown, not swallowed", () => {
137
+ assert.equal(
138
+ describeClientSubscription(orgWithSub({ billingCycle: "quarterly" }), NOW).billingCycle,
139
+ "quarterly",
140
+ );
141
+ assert.equal(
142
+ describeClientSubscription(orgWithSub({ billingCycle: "YEARLY" }), NOW).billingCycle,
143
+ "Yearly",
144
+ );
145
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * WHAT A CLIENT'S SUBSCRIPTION ROW HONESTLY SAYS, IN ONE PLACE.
3
+ *
4
+ * The super-admin Client List used to print "-" in Plan Type, Billing Cycle,
5
+ * Subscription Start and Subscription End for every client that has no
6
+ * subscription document. Four dashes read as a broken screen. They are not:
7
+ * they are a real, ordinary state - nobody has set that client up yet.
8
+ *
9
+ * This file turns the row the API actually returns into words a Seven365 staff
10
+ * member can act on without being told how the data is stored underneath.
11
+ *
12
+ * THE RULE THIS FILE OBEYS: never claim more than the record proves.
13
+ * The projection behind `GET /api/organizations/v2/orgs/subscriptions`
14
+ * (core `organization.repo.ts`, `$project`) sends exactly:
15
+ *
16
+ * _id, org, type, paidSeats, currentSeats, maxSeats,
17
+ * billingCycle, nextBillingDate, createdAt, status
18
+ *
19
+ * It does NOT send a plan, a price, or - today - a charging arrangement. So a
20
+ * client whose record exists and is running is called "Active", never
21
+ * "Active - paying", because nothing in the data proves anybody is paying.
22
+ * Where two states cannot yet be told apart, the lesser claim wins.
23
+ */
24
+
25
+ /** The five states a client's subscription can be in, as a person would say them. */
26
+ export type TClientSubscriptionState =
27
+ | "none"
28
+ | "complimentary"
29
+ | "active"
30
+ | "suspended"
31
+ | "ended";
32
+
33
+ export interface TClientSubscriptionView {
34
+ state: TClientSubscriptionState;
35
+ /** The one line the "Subscription" column shows. */
36
+ label: string;
37
+ /** True when somebody has to do something about this client. */
38
+ needsAttention: boolean;
39
+ /** "Monthly" / "Yearly" / "Not set". */
40
+ billingCycle: string;
41
+ /** A date, or "Not set". */
42
+ start: string;
43
+ /** A date, or "Not set". */
44
+ end: string;
45
+ }
46
+
47
+ export const NOT_SET = "Not set";
48
+
49
+ /**
50
+ * A status the record can carry that means the arrangement is over. `canceled`
51
+ * is the spelling the hourly billing job writes, so it has to be here; the
52
+ * other spellings cost nothing and stop a one-letter difference showing a
53
+ * finished client as running.
54
+ */
55
+ const ENDED_STATUSES = new Set([
56
+ "ended",
57
+ "canceled",
58
+ "cancelled",
59
+ "expired",
60
+ "terminated",
61
+ ]);
62
+
63
+ const BILLING_CYCLE_LABELS: Record<string, string> = {
64
+ monthly: "Monthly",
65
+ yearly: "Yearly",
66
+ annually: "Yearly",
67
+ };
68
+
69
+ /** Same behaviour the screen already had, with wording instead of a dash. */
70
+ export function formatDate(value?: string | Date | null): string {
71
+ if (value == null || value === "") return NOT_SET;
72
+ const d = value instanceof Date ? value : new Date(value);
73
+ return isNaN(d.getTime()) ? NOT_SET : d.toLocaleDateString("en-US");
74
+ }
75
+
76
+ function isPast(value: unknown, now: Date): boolean {
77
+ if (value == null || value === "") return false;
78
+ const d = value instanceof Date ? value : new Date(String(value));
79
+ return !isNaN(d.getTime()) && d.getTime() < now.getTime();
80
+ }
81
+
82
+ /**
83
+ * Read one organisation row from the list endpoint and say what it means.
84
+ *
85
+ * `org.subscription` is a LEFT join: an organisation with no subscription
86
+ * document still comes back as a row, with the sub-document collapsed to `{}`.
87
+ * That empty object - specifically, the absence of `_id` - is what "nobody has
88
+ * set this client up" looks like on the wire, and it is the state most clients
89
+ * are in right now.
90
+ */
91
+ export function describeClientSubscription(
92
+ org: Record<string, any> | null | undefined,
93
+ now: Date = new Date(),
94
+ ): TClientSubscriptionView {
95
+ const sub = org?.subscription ?? {};
96
+
97
+ if (!sub._id) {
98
+ return {
99
+ state: "none",
100
+ label: "No subscription set up",
101
+ needsAttention: false,
102
+ billingCycle: NOT_SET,
103
+ start: NOT_SET,
104
+ end: NOT_SET,
105
+ };
106
+ }
107
+
108
+ const cycleKey = String(sub.billingCycle ?? "").trim().toLowerCase();
109
+
110
+ // `startDate` / `endDate` are the fields the build spec adds in Phase 2. Until
111
+ // then the endpoint sends neither, so both fall through to what it does send -
112
+ // which is what this screen has always displayed.
113
+ const view = {
114
+ billingCycle: BILLING_CYCLE_LABELS[cycleKey] ?? (cycleKey ? String(sub.billingCycle) : NOT_SET),
115
+ start: formatDate(sub.startDate ?? sub.createdAt),
116
+ end: formatDate(sub.endDate ?? sub.nextBillingDate),
117
+ };
118
+
119
+ const status = String(sub.status ?? "").trim().toLowerCase();
120
+
121
+ if (status === "suspended") {
122
+ return { ...view, state: "suspended", label: "Suspended", needsAttention: false };
123
+ }
124
+
125
+ if (ENDED_STATUSES.has(status)) {
126
+ return { ...view, state: "ended", label: "Ended", needsAttention: false };
127
+ }
128
+
129
+ // Owner decision 8: when an end date passes, the client is flagged here FIRST.
130
+ // The record still says it is running, so the honest line is that the dates
131
+ // and the status disagree and a person has to settle it. Nothing on this
132
+ // screen suspends anybody - that is the job Phase 3 builds.
133
+ if (isPast(sub.endDate ?? sub.nextBillingDate, now)) {
134
+ return { ...view, state: "ended", label: "Ended (needs attention)", needsAttention: true };
135
+ }
136
+
137
+ // ponytail: dormant until Phase 2 writes `billingMode`. Nothing sends it
138
+ // today, so today every live record falls through to plain "Active" - which
139
+ // is the honest answer, because nothing in the data proves anybody is paying.
140
+ const billingMode = String(sub.billingMode ?? "").trim().toLowerCase();
141
+
142
+ if (billingMode === "complimentary") {
143
+ return {
144
+ ...view,
145
+ state: "complimentary",
146
+ label: "Complimentary (not charged)",
147
+ needsAttention: false,
148
+ };
149
+ }
150
+
151
+ return {
152
+ ...view,
153
+ state: "active",
154
+ label: billingMode === "paid" ? "Active (paying)" : "Active",
155
+ needsAttention: false,
156
+ };
157
+ }