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

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.
@@ -79,7 +79,17 @@
79
79
  a separate piece of work with a real endpoint behind it.
80
80
  -->
81
81
  <section class="section">
82
- <h3>Subscription Information</h3>
82
+ <div class="section-head">
83
+ <h3>Subscription Information</h3>
84
+ <!--
85
+ The values stay read-only - they are what the client's record says.
86
+ Changing them is a real form with real rules behind it, so this opens
87
+ that rather than pretending a dropdown here would save anything.
88
+ -->
89
+ <button class="btn-manage-sub" @click="$emit('manage-subscription')">
90
+ {{ subscription.state === 'none' ? 'Set up subscription' : 'Edit subscription' }}
91
+ </button>
92
+ </div>
83
93
 
84
94
  <p v-if="subscription.state === 'none'" class="sub-none">
85
95
  No subscription set up for this client yet.
@@ -234,6 +244,7 @@ const props = defineProps<{
234
244
  const emit = defineEmits<{
235
245
  (e: 'cancel'): void
236
246
  (e: 'submit', payload: Record<string, any>): void
247
+ (e: 'manage-subscription'): void
237
248
  }>()
238
249
 
239
250
  /* ── CONSTANTS ── */
@@ -490,6 +501,29 @@ watch(
490
501
  .section {
491
502
  margin-bottom: 26px;
492
503
  }
504
+ .section-head {
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: space-between;
508
+ gap: 12px;
509
+ flex-wrap: wrap;
510
+ }
511
+ .section-head h3 {
512
+ margin-bottom: 0;
513
+ }
514
+ .btn-manage-sub {
515
+ padding: 6px 14px;
516
+ font-size: 12px;
517
+ font-weight: 600;
518
+ border: 1px solid var(--border);
519
+ border-radius: var(--r-pill);
520
+ background: var(--card);
521
+ color: var(--accent-text);
522
+ cursor: pointer;
523
+ white-space: nowrap;
524
+ margin-bottom: 14px;
525
+ }
526
+ .btn-manage-sub:hover { background: var(--hover); }
493
527
  .section h3 {
494
528
  font-size: 15px;
495
529
  font-weight: 700;
@@ -56,6 +56,11 @@
56
56
  <div class="table-card">
57
57
 
58
58
  <!-- Toolbar row -->
59
+ <div v-if="savedNote" class="saved-note" role="status">
60
+ {{ savedNote }}
61
+ <button class="saved-note-close" @click="savedNote = ''">&times;</button>
62
+ </div>
63
+
59
64
  <div class="toolbar">
60
65
  <div class="toolbar-left">
61
66
  <button class="refresh-btn" :class="{ spinning: loading }" @click="handleRefresh">
@@ -123,6 +128,9 @@
123
128
  -->
124
129
  <td v-if="item.sub.state === 'none'" colspan="4" class="sub-none">
125
130
  No subscription set up
131
+ <button class="sub-setup-btn" @click="openSubscription(item)">
132
+ Set up subscription
133
+ </button>
126
134
  </td>
127
135
 
128
136
  <template v-else>
@@ -212,9 +220,24 @@
212
220
  :client="selectedClient"
213
221
  @cancel="cancelView"
214
222
  @submit="submitClient"
223
+ @manage-subscription="openSubscription(selectedClient)"
215
224
  />
216
225
  </div>
217
226
 
227
+ <!--
228
+ SET UP OR CHANGE A CLIENT'S SUBSCRIPTION. Opened from the row that has
229
+ none, from the row menu, or from the client's own drawer - all three land
230
+ here, so there is one form and one set of rules behind them.
231
+ -->
232
+ <ClientSubscriptionForm
233
+ v-if="subscriptionClient"
234
+ :org-id="subscriptionClient._id"
235
+ :client-name="subscriptionClient.name"
236
+ :site-count="subscriptionClient.sites"
237
+ @cancel="subscriptionClient = null"
238
+ @saved="onSubscriptionSaved"
239
+ />
240
+
218
241
  <div v-if="dialog" class="overlay" @click.self="dialog = false">
219
242
  <div class="dialog-box">
220
243
  <InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
@@ -247,6 +270,9 @@
247
270
  <button class="dd-item" @click="viewClient(activeMenuItem)">
248
271
  View Organization
249
272
  </button>
273
+ <button class="dd-item" @click="openSubscription(activeMenuItem)">
274
+ {{ activeMenuItem?.sub?.state === 'none' ? 'Set up subscription' : 'Edit subscription' }}
275
+ </button>
250
276
  <button
251
277
  v-if="activeMenuItem?.status === 'suspended'"
252
278
  class="dd-item"
@@ -273,6 +299,7 @@ import useVerification from '../composables/useVerification'
273
299
  import useRole from '../composables/useRole'
274
300
  import InvitationClientForm from './InvitationClientForm.vue'
275
301
  import ClientDetailForm from './ClientDetailForm.vue'
302
+ import ClientSubscriptionForm from './ClientSubscriptionForm.vue'
276
303
  import useCustomerSite from '../composables/useCustomerSite'
277
304
  import { describeClientSubscription } from '../utils/client-subscription'
278
305
 
@@ -321,6 +348,10 @@ const roleCache = ref<Record<string, string>>({})
321
348
  const viewMode = ref<'table' | 'detail'>('table')
322
349
  const selectedClient = ref<any>(null)
323
350
 
351
+ /* subscription set-up / edit */
352
+ const subscriptionClient = ref<any>(null)
353
+ const savedNote = ref('')
354
+
324
355
  /* suspend confirmation */
325
356
  const confirmSuspendItem = ref<any>(null)
326
357
  const confirmSuspendLoading = ref(false)
@@ -372,6 +403,30 @@ async function activateClient(item: any) {
372
403
  }
373
404
  }
374
405
 
406
+ /* ── SUBSCRIPTION ── */
407
+ function openSubscription(item: any) {
408
+ if (!item?._id) return
409
+ savedNote.value = ''
410
+ subscriptionClient.value = item
411
+ closeMenu()
412
+ }
413
+
414
+ async function onSubscriptionSaved(message: string) {
415
+ subscriptionClient.value = null
416
+ savedNote.value = message
417
+ // The list derives every subscription state from the record itself, so
418
+ // re-reading it is all that is needed for the row to tell the truth again.
419
+ await fetchData()
420
+
421
+ // The drawer reads the same row, so if it is open it has to be handed the
422
+ // refreshed one - otherwise it would still be showing what was true before
423
+ // the save.
424
+ if (selectedClient.value?._id) {
425
+ selectedClient.value =
426
+ items.value.find(i => i._id === selectedClient.value._id) ?? selectedClient.value
427
+ }
428
+ }
429
+
375
430
  /* ── SUSPEND CONFIRMATION ── */
376
431
  function askSuspend(item: any) {
377
432
  confirmSuspendItem.value = item
@@ -850,6 +905,44 @@ tbody td { vertical-align: middle; }
850
905
 
851
906
  /* A client nobody has set up yet: plainly stated, not shouted, not a dash. */
852
907
  .sub-none { color: var(--text2); font-size: 13px; }
908
+ /* The way in, said next to the state it fixes. It sits in a cell that already
909
+ spans four columns, so it adds no width to the table. */
910
+ .sub-setup-btn {
911
+ margin-left: 10px;
912
+ padding: 3px 10px;
913
+ font-size: 12px;
914
+ font-weight: 600;
915
+ border: 1px solid var(--border);
916
+ border-radius: 12px;
917
+ background: var(--card);
918
+ color: var(--accent-text);
919
+ cursor: pointer;
920
+ white-space: nowrap;
921
+ }
922
+ .sub-setup-btn:hover { background: var(--hover); }
923
+
924
+ .saved-note {
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: space-between;
928
+ gap: 10px;
929
+ margin: 12px 16px 0;
930
+ padding: 9px 12px;
931
+ border: 1px solid var(--border);
932
+ border-radius: 8px;
933
+ background: var(--ok-bg);
934
+ color: var(--text);
935
+ font-size: 13px;
936
+ font-weight: 600;
937
+ }
938
+ .saved-note-close {
939
+ border: none;
940
+ background: none;
941
+ color: var(--text2);
942
+ font-size: 16px;
943
+ line-height: 1;
944
+ cursor: pointer;
945
+ }
853
946
  .sub-label { font-size: 13px; }
854
947
  /* Owner decision 8: an end date has passed and somebody has to look at it. */
855
948
  .needs-attention { color: var(--warn); font-weight: 500; }
@@ -0,0 +1,527 @@
1
+ <template>
2
+ <div class="overlay" @click.self="close">
3
+ <div class="sub-dialog" role="dialog" aria-modal="true" aria-labelledby="sub-dialog-title">
4
+
5
+ <!-- ===== HEADER ===== -->
6
+ <div class="sub-head">
7
+ <h3 id="sub-dialog-title">
8
+ {{ isEdit ? 'Edit subscription' : 'Set up subscription' }}
9
+ </h3>
10
+ <p class="sub-sub">{{ clientName }}</p>
11
+ </div>
12
+
13
+ <!-- ===== BODY ===== -->
14
+ <div class="sub-body">
15
+
16
+ <p v-if="loading" class="sub-loading">Loading this client's subscription…</p>
17
+
18
+ <template v-else>
19
+ <!--
20
+ One message at a time, and always a sentence a person can act on.
21
+ The API's own refusals are written for a reader, so when the server
22
+ says no this is exactly what the server said.
23
+ -->
24
+ <p v-if="problem" class="sub-error" role="alert">{{ problem }}</p>
25
+
26
+ <!-- Plan -->
27
+ <div class="sub-field">
28
+ <label for="sub-plan">Plan</label>
29
+ <select id="sub-plan" v-model="values.plan" :disabled="saving">
30
+ <option value="">Choose a plan</option>
31
+ <option v-for="p in plans" :key="p._id" :value="p._id">
32
+ {{ planOptionLabel(p) }}
33
+ </option>
34
+ </select>
35
+ <span v-if="!plansLoading && plans.length === 0" class="sub-hint">
36
+ No active plans exist yet, so there is nothing to put this client on.
37
+ </span>
38
+ </div>
39
+
40
+ <!-- Charging -->
41
+ <fieldset class="sub-field">
42
+ <legend>Charging</legend>
43
+ <label class="sub-radio">
44
+ <input
45
+ type="radio"
46
+ name="billingMode"
47
+ :value="BILLING_COMPLIMENTARY"
48
+ v-model="values.billingMode"
49
+ :disabled="saving"
50
+ />
51
+ <span>
52
+ <strong>Complimentary (not charged)</strong>
53
+ <em>Free use, with a real start and end date Seven365 monitors.</em>
54
+ </span>
55
+ </label>
56
+ <label class="sub-radio">
57
+ <input
58
+ type="radio"
59
+ name="billingMode"
60
+ :value="BILLING_PAID"
61
+ v-model="values.billingMode"
62
+ :disabled="saving"
63
+ />
64
+ <span>
65
+ <strong>Paying</strong>
66
+ <em>Charged the monthly value below. Payment is taken by Red Dot, not here.</em>
67
+ </span>
68
+ </label>
69
+ </fieldset>
70
+
71
+ <!-- Dates: native pickers, because the browser already has one -->
72
+ <div class="sub-row">
73
+ <div class="sub-field">
74
+ <label for="sub-start">Start date</label>
75
+ <input id="sub-start" v-model="values.startDate" type="date" :disabled="saving" />
76
+ </div>
77
+ <div class="sub-field">
78
+ <label for="sub-end">End date</label>
79
+ <input id="sub-end" v-model="values.endDate" type="date" :disabled="saving" />
80
+ <span class="sub-hint">Required. Every client has one, free or not.</span>
81
+ </div>
82
+ </div>
83
+
84
+ <!--
85
+ WHAT THIS MEANS FOR THIS CLIENT. A plan is priced per site, per
86
+ month, so the person is shown the arithmetic already done: the
87
+ per-site price, this client's live site count, and the monthly
88
+ value that comes out of the two.
89
+ -->
90
+ <div class="sub-value">
91
+ <div class="sub-value-line">
92
+ <span class="sub-value-label">Monthly value</span>
93
+ <span class="sub-value-figure" :class="{ 'is-free': isComplimentary }">
94
+ {{ formatMoney(monthly) }}
95
+ <span v-if="isComplimentary" class="sub-free-badge">not charged — complimentary</span>
96
+ </span>
97
+ </div>
98
+ <p class="sub-value-working">
99
+ {{ workingLine }}
100
+ </p>
101
+ <p v-if="siteCount === 0" class="sub-hint">
102
+ This client has no sites yet, so the monthly value is
103
+ {{ formatMoney(0) }} until sites are added.
104
+ </p>
105
+ <!-- Owner decision 5: switching a client from free to paying is an
106
+ ordinary change of state, so it is worth naming when it is
107
+ about to happen rather than letting it pass silently. -->
108
+ <p v-if="wasComplimentary && !isComplimentary" class="sub-hint sub-hint-strong">
109
+ This changes {{ clientName }} from complimentary to paying.
110
+ </p>
111
+ <p v-if="isEdit && changedValue" class="sub-hint sub-hint-strong">
112
+ Monthly value changes from {{ formatMoney(originalMonthly) }} to
113
+ {{ formatMoney(monthly) }}.
114
+ </p>
115
+ </div>
116
+ </template>
117
+ </div>
118
+
119
+ <!-- ===== FOOTER ===== -->
120
+ <div class="sub-actions">
121
+ <button class="btn-cancel" :disabled="saving" @click="close">Cancel</button>
122
+ <button class="btn-save" :disabled="saving || loading" @click="save">
123
+ <span v-if="saving" class="spinner spinner-light" />
124
+ <span v-else>{{ isEdit ? 'Save changes' : 'Set up subscription' }}</span>
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </template>
130
+
131
+ <script setup lang="ts">
132
+ /**
133
+ * SET UP A CLIENT'S SUBSCRIPTION, OR CHANGE THE ONE THEY HAVE.
134
+ *
135
+ * The same four questions either way, so there is one form and not two: which
136
+ * plan, complimentary or paying, from when, until when. Editing simply arrives
137
+ * with the answers already filled in.
138
+ *
139
+ * It talks to the three staff-only console endpoints
140
+ * (`/api/subscriptions/console/:id`, where `:id` is the CLIENT'S organisation).
141
+ * It cannot suspend or reactivate anybody - that is Phase 3, and the edit
142
+ * endpoint does not accept a status, so there is deliberately no control here
143
+ * that could set one. It takes no card details and calls no payment gateway:
144
+ * Red Dot is the payment provider and none of that belongs on a staff screen.
145
+ */
146
+ import { computed, onMounted, reactive, ref } from 'vue'
147
+ import useSubscription from '../composables/useSubscription'
148
+ import useSubscriptionPlan from '../composables/useSubscriptionPlan'
149
+ import {
150
+ BILLING_COMPLIMENTARY,
151
+ BILLING_PAID,
152
+ buildConsolePayload,
153
+ formatMoney,
154
+ formValuesFrom,
155
+ monthlyValue,
156
+ planOptionLabel,
157
+ readApiError,
158
+ validateConsoleSubscription,
159
+ type TPlanOption,
160
+ } from '../utils/subscription-form'
161
+
162
+ const props = defineProps<{
163
+ /** The client's organisation id - what `:id` on the console routes means. */
164
+ orgId: string
165
+ clientName?: string
166
+ /** The live site count the Client List already fetched. Never typed in. */
167
+ siteCount?: number
168
+ }>()
169
+
170
+ const emit = defineEmits<{
171
+ (e: 'cancel'): void
172
+ (e: 'saved', message: string): void
173
+ }>()
174
+
175
+ const {
176
+ getConsoleSubscriptionByOrgId,
177
+ setUpConsoleSubscription,
178
+ updateConsoleSubscription,
179
+ } = useSubscription()
180
+ const { getAll: getPlans } = useSubscriptionPlan()
181
+
182
+ const plans = ref<TPlanOption[]>([])
183
+ const plansLoading = ref(true)
184
+ const loading = ref(true)
185
+ const saving = ref(false)
186
+ const problem = ref('')
187
+
188
+ const existing = ref<Record<string, any> | null>(null)
189
+ const values = reactive(formValuesFrom(null))
190
+ const originalValues = reactive(formValuesFrom(null))
191
+
192
+ const clientName = computed(() => props.clientName || 'this client')
193
+ const siteCount = computed(() => Number(props.siteCount ?? 0))
194
+ const isEdit = computed(() => Boolean(existing.value?._id))
195
+ const isComplimentary = computed(() => values.billingMode === BILLING_COMPLIMENTARY)
196
+ const wasComplimentary = computed(
197
+ () => isEdit.value && originalValues.billingMode === BILLING_COMPLIMENTARY,
198
+ )
199
+
200
+ const selectedPlan = computed(
201
+ () => plans.value.find(p => String(p._id) === String(values.plan)) ?? null,
202
+ )
203
+ const monthly = computed(() =>
204
+ isComplimentary.value ? 0 : monthlyValue(selectedPlan.value, siteCount.value),
205
+ )
206
+ const originalMonthly = computed(() => {
207
+ if (originalValues.billingMode === BILLING_COMPLIMENTARY) return 0
208
+ const plan = plans.value.find(p => String(p._id) === String(originalValues.plan))
209
+ return monthlyValue(plan, siteCount.value)
210
+ })
211
+ const changedValue = computed(() => originalMonthly.value !== monthly.value)
212
+
213
+ /** The arithmetic, spelled out, so nobody has to work it out or trust it blind. */
214
+ const workingLine = computed(() => {
215
+ const sites = siteCount.value
216
+ const plural = sites === 1 ? 'site' : 'sites'
217
+ if (!selectedPlan.value) return `${sites} ${plural}. Choose a plan to see the monthly value.`
218
+
219
+ const price = formatMoney(Number(selectedPlan.value.price ?? 0))
220
+ const gross = formatMoney(monthlyValue(selectedPlan.value, sites))
221
+
222
+ return isComplimentary.value
223
+ ? `${price} per site, per month x ${sites} ${plural} = ${gross} - waived while this client is complimentary.`
224
+ : `${price} per site, per month x ${sites} ${plural} = ${gross}.`
225
+ })
226
+
227
+ async function load() {
228
+ // The plan list and the client's current record are independent reads, so
229
+ // they go together rather than one after the other.
230
+ const [planResult, subResult] = await Promise.allSettled([
231
+ getPlans({ limit: 100, status: 'active' }),
232
+ getConsoleSubscriptionByOrgId(props.orgId),
233
+ ])
234
+
235
+ if (planResult.status === 'fulfilled') {
236
+ const res: any = planResult.value
237
+ plans.value = res?.items ?? res?.data?.items ?? []
238
+ } else {
239
+ problem.value = readApiError(
240
+ planResult.reason,
241
+ 'The plan list could not be loaded, so there is nothing to choose from yet. Try again in a moment.',
242
+ )
243
+ }
244
+ plansLoading.value = false
245
+
246
+ if (subResult.status === 'fulfilled') {
247
+ // A client with no subscription is answered as 200 with null. That is the
248
+ // state most clients are in, not an error - it just means "set one up".
249
+ existing.value = (subResult.value as any) ?? null
250
+ } else {
251
+ problem.value = readApiError(
252
+ subResult.reason,
253
+ 'This client\'s subscription could not be read. Try again in a moment.',
254
+ )
255
+ }
256
+
257
+ Object.assign(values, formValuesFrom(existing.value))
258
+ Object.assign(originalValues, formValuesFrom(existing.value))
259
+ loading.value = false
260
+ }
261
+
262
+ async function save() {
263
+ problem.value = ''
264
+
265
+ const payload = buildConsolePayload(values, plans.value, siteCount.value)
266
+ const refusal = validateConsoleSubscription(payload, plans.value)
267
+ if (refusal) {
268
+ problem.value = refusal
269
+ return
270
+ }
271
+
272
+ saving.value = true
273
+ try {
274
+ if (isEdit.value) {
275
+ await updateConsoleSubscription(props.orgId, payload)
276
+ emit('saved', 'Subscription updated.')
277
+ } else {
278
+ await setUpConsoleSubscription(props.orgId, payload)
279
+ emit('saved', 'Subscription set up.')
280
+ }
281
+ } catch (error: any) {
282
+ problem.value = readApiError(
283
+ error,
284
+ 'That could not be saved. Check the details and try again.',
285
+ )
286
+ } finally {
287
+ saving.value = false
288
+ }
289
+ }
290
+
291
+ function close() {
292
+ if (saving.value) return
293
+ emit('cancel')
294
+ }
295
+
296
+ onMounted(load)
297
+ </script>
298
+
299
+ <style scoped>
300
+ .overlay {
301
+ position: fixed;
302
+ inset: 0;
303
+ background: var(--scrim);
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ z-index: 1000;
308
+ padding: 16px;
309
+ }
310
+
311
+ .sub-dialog {
312
+ background: var(--card);
313
+ border-radius: 12px;
314
+ width: 100%;
315
+ max-width: 520px;
316
+ max-height: 90vh;
317
+ overflow-y: auto;
318
+ box-shadow: var(--shadow-modal);
319
+ }
320
+
321
+ .sub-head {
322
+ padding: 20px 24px 12px;
323
+ border-bottom: 1px solid var(--border);
324
+ }
325
+ .sub-head h3 {
326
+ font-size: 16px;
327
+ font-weight: 700;
328
+ color: var(--text);
329
+ margin: 0;
330
+ }
331
+ .sub-sub {
332
+ font-size: 13px;
333
+ color: var(--text2);
334
+ margin: 4px 0 0;
335
+ }
336
+
337
+ .sub-body {
338
+ padding: 18px 24px 4px;
339
+ }
340
+ .sub-loading {
341
+ font-size: 13px;
342
+ color: var(--text2);
343
+ margin: 8px 0 18px;
344
+ }
345
+
346
+ .sub-error {
347
+ font-size: 13px;
348
+ line-height: 1.45;
349
+ color: var(--err);
350
+ background: var(--err-bg);
351
+ border: 1px solid var(--err-border);
352
+ border-radius: 8px;
353
+ padding: 10px 12px;
354
+ margin: 0 0 16px;
355
+ }
356
+
357
+ .sub-field {
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 6px;
361
+ margin-bottom: 16px;
362
+ border: none;
363
+ padding: 0;
364
+ min-width: 0;
365
+ }
366
+ .sub-field label,
367
+ .sub-field legend {
368
+ font-size: 12px;
369
+ font-weight: 600;
370
+ color: var(--text2);
371
+ padding: 0;
372
+ }
373
+ .sub-field select,
374
+ .sub-field input[type="date"] {
375
+ height: 38px;
376
+ width: 100%;
377
+ border: 1px solid var(--border);
378
+ border-radius: 8px;
379
+ background: var(--card);
380
+ color: var(--text);
381
+ font-size: 13px;
382
+ padding: 0 10px;
383
+ }
384
+ .sub-field select:disabled,
385
+ .sub-field input:disabled {
386
+ opacity: 0.6;
387
+ }
388
+
389
+ .sub-hint {
390
+ font-size: 12px;
391
+ line-height: 1.4;
392
+ color: var(--text2);
393
+ }
394
+ .sub-hint-strong {
395
+ color: var(--text);
396
+ font-weight: 600;
397
+ }
398
+
399
+ .sub-radio {
400
+ display: flex;
401
+ align-items: flex-start;
402
+ gap: 9px;
403
+ border: 1px solid var(--border);
404
+ border-radius: 8px;
405
+ padding: 10px 12px;
406
+ cursor: pointer;
407
+ }
408
+ .sub-radio input {
409
+ margin-top: 2px;
410
+ flex-shrink: 0;
411
+ }
412
+ .sub-radio strong {
413
+ display: block;
414
+ font-size: 13px;
415
+ font-weight: 600;
416
+ color: var(--text);
417
+ }
418
+ .sub-radio em {
419
+ display: block;
420
+ font-style: normal;
421
+ font-size: 12px;
422
+ line-height: 1.4;
423
+ color: var(--text2);
424
+ margin-top: 2px;
425
+ }
426
+
427
+ .sub-row {
428
+ display: flex;
429
+ gap: 14px;
430
+ }
431
+ .sub-row .sub-field {
432
+ flex: 1 1 0;
433
+ }
434
+
435
+ .sub-value {
436
+ border: 1px solid var(--border);
437
+ border-radius: 8px;
438
+ padding: 12px 14px;
439
+ margin-bottom: 18px;
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: 6px;
443
+ }
444
+ .sub-value-line {
445
+ display: flex;
446
+ align-items: baseline;
447
+ justify-content: space-between;
448
+ gap: 12px;
449
+ }
450
+ .sub-value-label {
451
+ font-size: 12px;
452
+ font-weight: 600;
453
+ color: var(--text2);
454
+ }
455
+ .sub-value-figure {
456
+ font-size: 18px;
457
+ font-weight: 800;
458
+ color: var(--text);
459
+ text-align: right;
460
+ }
461
+ .sub-value-figure.is-free {
462
+ color: var(--text2);
463
+ }
464
+ .sub-free-badge {
465
+ display: block;
466
+ font-size: 11px;
467
+ font-weight: 700;
468
+ color: var(--text2);
469
+ margin-top: 2px;
470
+ }
471
+ .sub-value-working {
472
+ font-size: 12px;
473
+ line-height: 1.4;
474
+ color: var(--text2);
475
+ margin: 0;
476
+ }
477
+
478
+ .sub-actions {
479
+ display: flex;
480
+ justify-content: flex-end;
481
+ gap: 10px;
482
+ padding: 14px 24px 20px;
483
+ }
484
+ .btn-cancel {
485
+ padding: 9px 18px;
486
+ font-size: 13px;
487
+ font-weight: 600;
488
+ border: 1px solid var(--border);
489
+ border-radius: 8px;
490
+ background: var(--card);
491
+ color: var(--text2);
492
+ cursor: pointer;
493
+ }
494
+ .btn-cancel:hover:not(:disabled) { background: var(--hover); }
495
+ .btn-save {
496
+ display: inline-flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ min-width: 150px;
500
+ padding: 9px 18px;
501
+ font-size: 13px;
502
+ font-weight: 600;
503
+ border: none;
504
+ border-radius: 8px;
505
+ background: var(--accent-strong);
506
+ color: var(--on-accent-strong);
507
+ cursor: pointer;
508
+ }
509
+ .btn-save:hover:not(:disabled) { opacity: 0.88; }
510
+ .btn-cancel:disabled,
511
+ .btn-save:disabled { opacity: 0.7; cursor: not-allowed; }
512
+
513
+ .spinner {
514
+ display: inline-block;
515
+ width: 12px;
516
+ height: 12px;
517
+ border: 1.5px solid rgba(255,255,255,0.5);
518
+ border-top-color: var(--card);
519
+ border-radius: 50%;
520
+ animation: spin 0.7s linear infinite;
521
+ }
522
+ @keyframes spin { to { transform: rotate(360deg); } }
523
+
524
+ @media (max-width: 520px) {
525
+ .sub-row { flex-direction: column; gap: 0; }
526
+ }
527
+ </style>
@@ -112,6 +112,43 @@ export default function useSubscription() {
112
112
  );
113
113
  }
114
114
 
115
+ /**
116
+ * THE SEVEN365 STAFF CONSOLE. `:id` is the CLIENT'S ORGANISATION id, never a
117
+ * subscription id - the API reads the organisation from the URL and never
118
+ * from the body, so these cannot be pointed at another client by accident.
119
+ *
120
+ * A separate path from `/api/subscriptions/org/:id` on purpose: that one is
121
+ * what a client's own subscription screen calls, and locking it to Seven365
122
+ * staff would have broken them. These three are staff-only on the server.
123
+ *
124
+ * No card field, no gateway, no money - Red Dot handles payment.
125
+ */
126
+ function getConsoleSubscriptionByOrgId(orgId: string) {
127
+ return useNuxtApp().$api<Record<string, any> | null>(
128
+ `/api/subscriptions/console/${orgId}`
129
+ );
130
+ }
131
+
132
+ function setUpConsoleSubscription(orgId: string, value: Record<string, any>) {
133
+ return useNuxtApp().$api<Record<string, any>>(
134
+ `/api/subscriptions/console/${orgId}`,
135
+ {
136
+ method: "POST",
137
+ body: value,
138
+ }
139
+ );
140
+ }
141
+
142
+ function updateConsoleSubscription(orgId: string, value: Record<string, any>) {
143
+ return useNuxtApp().$api<Record<string, any>>(
144
+ `/api/subscriptions/console/${orgId}`,
145
+ {
146
+ method: "PUT",
147
+ body: value,
148
+ }
149
+ );
150
+ }
151
+
115
152
  function updateSeatsById({
116
153
  subscriptionId = "",
117
154
  seats = 0,
@@ -146,5 +183,8 @@ export default function useSubscription() {
146
183
  initOrgSubscription,
147
184
  getByAffiliateId,
148
185
  updateSeatsById,
186
+ getConsoleSubscriptionByOrgId,
187
+ setUpConsoleSubscription,
188
+ updateConsoleSubscription,
149
189
  };
150
190
  }
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.203",
5
+ "version": "3.2.8-staging.204",
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,284 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ BILLING_COMPLIMENTARY,
6
+ BILLING_PAID,
7
+ buildConsolePayload,
8
+ formValuesFrom,
9
+ formatMoney,
10
+ monthlyValue,
11
+ planOptionLabel,
12
+ readApiError,
13
+ todayISO,
14
+ validateConsoleSubscription,
15
+ } from "./subscription-form.ts";
16
+
17
+ const PLANS = [
18
+ { _id: "aaaaaaaaaaaaaaaaaaaaaaaa", name: "Standard Plan", price: 25, status: "active" },
19
+ { _id: "bbbbbbbbbbbbbbbbbbbbbbbb", name: "Retired Plan", price: 40, status: "deactive" },
20
+ { _id: "cccccccccccccccccccccccc", name: "Free Bundle", price: null, status: "active" },
21
+ ];
22
+
23
+ const STANDARD = PLANS[0]._id;
24
+
25
+ function form(overrides: Record<string, any> = {}) {
26
+ return {
27
+ plan: STANDARD,
28
+ billingMode: BILLING_COMPLIMENTARY,
29
+ startDate: "2026-09-01",
30
+ endDate: "2027-08-31",
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ /* ── WHAT A CLIENT IS WORTH PER MONTH ── */
36
+
37
+ test("a plan is priced per site, per month", () => {
38
+ assert.equal(monthlyValue(PLANS[0], 4), 100);
39
+ assert.equal(monthlyValue(PLANS[0], 1), 25);
40
+ });
41
+
42
+ test("no sites is worth nothing, and is not an error", () => {
43
+ assert.equal(monthlyValue(PLANS[0], 0), 0);
44
+ });
45
+
46
+ test("a plan with no price is worth nothing rather than NaN", () => {
47
+ assert.equal(monthlyValue(PLANS[2], 7), 0);
48
+ assert.equal(monthlyValue(undefined, 7), 0);
49
+ assert.equal(monthlyValue({ price: "not a number" } as any, 7), 0);
50
+ });
51
+
52
+ test("the monthly figure reads as Singapore money", () => {
53
+ assert.equal(formatMoney(100), "S$100.00");
54
+ assert.equal(formatMoney(0), "S$0.00");
55
+ assert.equal(formatMoney(NaN), "S$0.00");
56
+ });
57
+
58
+ test("a plan is offered with its per-site price beside its name", () => {
59
+ assert.equal(
60
+ planOptionLabel(PLANS[0]),
61
+ "Standard Plan - S$25.00 per site, per month",
62
+ );
63
+ assert.equal(planOptionLabel(PLANS[2]), "Free Bundle - no per-site price set");
64
+ });
65
+
66
+ /* ── WHAT GETS SENT ── */
67
+
68
+ test("a complimentary subscription is sent with no price at all", () => {
69
+ const payload = buildConsolePayload(form(), PLANS, 4);
70
+ assert.equal(payload.amount, 0);
71
+ assert.equal(payload.billingMode, BILLING_COMPLIMENTARY);
72
+ });
73
+
74
+ test("a paying subscription is sent the plan price times the site count", () => {
75
+ const payload = buildConsolePayload(form({ billingMode: BILLING_PAID }), PLANS, 4);
76
+ assert.equal(payload.amount, 100);
77
+ });
78
+
79
+ test("an empty start date is sent as today, the same default the API uses", () => {
80
+ const payload = buildConsolePayload(form({ startDate: "" }), PLANS, 1);
81
+ assert.equal(payload.startDate, todayISO());
82
+ });
83
+
84
+ test("the payload carries nothing but the five fields the API accepts", () => {
85
+ const payload = buildConsolePayload(form({ billingMode: BILLING_PAID }), PLANS, 2);
86
+ assert.deepEqual(Object.keys(payload).sort(), [
87
+ "amount",
88
+ "billingMode",
89
+ "endDate",
90
+ "plan",
91
+ "startDate",
92
+ ]);
93
+ });
94
+
95
+ test("no status is ever sent - suspend and reactivate are Phase 3", () => {
96
+ const payload: Record<string, any> = buildConsolePayload(form(), PLANS, 2);
97
+ assert.equal(payload.status, undefined);
98
+ });
99
+
100
+ test("nothing about a card, an invoice or a gateway is ever sent", () => {
101
+ const sent = JSON.stringify(buildConsolePayload(form({ billingMode: BILLING_PAID }), PLANS, 3));
102
+ for (const forbidden of ["card", "cvv", "invoice", "reddot", "payment_method"]) {
103
+ assert.equal(sent.toLowerCase().includes(forbidden), false, forbidden);
104
+ }
105
+ });
106
+
107
+ /* ── WHAT IS REFUSED, BEFORE THE REQUEST GOES OUT ── */
108
+
109
+ test("a form with everything answered is not refused", () => {
110
+ assert.equal(
111
+ validateConsoleSubscription(buildConsolePayload(form(), PLANS, 4), PLANS),
112
+ null,
113
+ );
114
+ assert.equal(
115
+ validateConsoleSubscription(
116
+ buildConsolePayload(form({ billingMode: BILLING_PAID }), PLANS, 4),
117
+ PLANS,
118
+ ),
119
+ null,
120
+ );
121
+ });
122
+
123
+ test("no plan chosen is refused", () => {
124
+ assert.equal(
125
+ validateConsoleSubscription(buildConsolePayload(form({ plan: "" }), PLANS, 1), PLANS),
126
+ "Choose a plan.",
127
+ );
128
+ });
129
+
130
+ test("a plan that does not exist is refused", () => {
131
+ assert.equal(
132
+ validateConsoleSubscription(
133
+ buildConsolePayload(form({ plan: "dddddddddddddddddddddddd" }), PLANS, 1),
134
+ PLANS,
135
+ ),
136
+ "That plan could not be found. Pick one from the list.",
137
+ );
138
+ });
139
+
140
+ test("a plan that is no longer active is refused, and named", () => {
141
+ assert.equal(
142
+ validateConsoleSubscription(
143
+ buildConsolePayload(form({ plan: PLANS[1]._id }), PLANS, 1),
144
+ PLANS,
145
+ ),
146
+ "The plan Retired Plan is no longer active, so it cannot be given to a client. Pick an active plan.",
147
+ );
148
+ });
149
+
150
+ test("an end date before the start date is refused", () => {
151
+ assert.equal(
152
+ validateConsoleSubscription(
153
+ buildConsolePayload(form({ startDate: "2026-09-01", endDate: "2026-08-31" }), PLANS, 1),
154
+ PLANS,
155
+ ),
156
+ "The end date must be after the start date.",
157
+ );
158
+ });
159
+
160
+ test("an end date equal to the start date is refused too - the API refuses it", () => {
161
+ assert.equal(
162
+ validateConsoleSubscription(
163
+ buildConsolePayload(form({ startDate: "2026-09-01", endDate: "2026-09-01" }), PLANS, 1),
164
+ PLANS,
165
+ ),
166
+ "The end date must be after the start date.",
167
+ );
168
+ });
169
+
170
+ test("a missing end date is refused - every client has one, free or not", () => {
171
+ assert.equal(
172
+ validateConsoleSubscription(buildConsolePayload(form({ endDate: "" }), PLANS, 1), PLANS),
173
+ "Enter an end date. Every client has one, free or not.",
174
+ );
175
+ });
176
+
177
+ test("an unreadable date is refused as a date, not as something else", () => {
178
+ assert.equal(
179
+ validateConsoleSubscription(
180
+ buildConsolePayload(form({ endDate: "31/08/2027" }), PLANS, 1),
181
+ PLANS,
182
+ ),
183
+ "The end date is not a valid date.",
184
+ );
185
+ assert.equal(
186
+ validateConsoleSubscription(
187
+ buildConsolePayload(form({ startDate: "not-a-date" }), PLANS, 1),
188
+ PLANS,
189
+ ),
190
+ "The start date is not a valid date.",
191
+ );
192
+ });
193
+
194
+ test("a complimentary subscription carrying a price is refused, not silently zeroed", () => {
195
+ // The form can only produce this by disagreeing with itself - which is
196
+ // exactly what this rule is here to catch before it reaches the API.
197
+ const payload = {
198
+ ...buildConsolePayload(form({ billingMode: BILLING_PAID }), PLANS, 4),
199
+ billingMode: BILLING_COMPLIMENTARY,
200
+ };
201
+ assert.equal(payload.amount, 100);
202
+ assert.equal(
203
+ validateConsoleSubscription(payload, PLANS),
204
+ "A complimentary subscription is not charged, so it cannot carry a price. Set the price to 0, or change this client to paying.",
205
+ );
206
+ });
207
+
208
+ test("a charging arrangement that is neither is refused", () => {
209
+ assert.equal(
210
+ validateConsoleSubscription(buildConsolePayload(form({ billingMode: "" }), PLANS, 1), PLANS),
211
+ "Choose whether this client is complimentary or paying.",
212
+ );
213
+ });
214
+
215
+ test("a complimentary client with no sites is allowed - most clients are exactly this", () => {
216
+ assert.equal(
217
+ validateConsoleSubscription(buildConsolePayload(form(), PLANS, 0), PLANS),
218
+ null,
219
+ );
220
+ });
221
+
222
+ /* ── PRE-FILLING THE FORM ── */
223
+
224
+ test("a client with no subscription opens complimentary, starting today, with no end date", () => {
225
+ const values = formValuesFrom(null, new Date("2026-08-20T10:00:00"));
226
+ assert.equal(values.plan, "");
227
+ assert.equal(values.billingMode, BILLING_COMPLIMENTARY);
228
+ assert.equal(values.startDate, todayISO(new Date("2026-08-20T10:00:00")));
229
+ assert.equal(values.endDate, "");
230
+ });
231
+
232
+ test("an existing subscription opens with its own plan, mode and dates", () => {
233
+ const values = formValuesFrom({
234
+ _id: "eeeeeeeeeeeeeeeeeeeeeeee",
235
+ plan: STANDARD,
236
+ billingMode: "complimentary",
237
+ startDate: "2026-01-15T00:00:00.000Z",
238
+ endDate: "2026-12-31T00:00:00.000Z",
239
+ });
240
+ assert.equal(values.plan, STANDARD);
241
+ assert.equal(values.billingMode, BILLING_COMPLIMENTARY);
242
+ assert.equal(values.startDate, "2026-01-15");
243
+ assert.equal(values.endDate, "2026-12-31");
244
+ });
245
+
246
+ test("a record with no billingMode opens as paying - every one today came from the paid checkout", () => {
247
+ const values = formValuesFrom({
248
+ _id: "eeeeeeeeeeeeeeeeeeeeeeee",
249
+ createdAt: "2025-03-04T00:00:00.000Z",
250
+ nextBillingDate: "2026-04-05T00:00:00.000Z",
251
+ });
252
+ assert.equal(values.billingMode, BILLING_PAID);
253
+ // The dates fall back to what the record does carry, exactly as the list does.
254
+ assert.equal(values.startDate, "2025-03-04");
255
+ assert.equal(values.endDate, "2026-04-05");
256
+ });
257
+
258
+ test("a complimentary record has no nextBillingDate, so the end date comes from endDate alone", () => {
259
+ const values = formValuesFrom({
260
+ _id: "eeeeeeeeeeeeeeeeeeeeeeee",
261
+ billingMode: "complimentary",
262
+ createdAt: "2026-02-01T00:00:00.000Z",
263
+ endDate: "2027-02-01T00:00:00.000Z",
264
+ });
265
+ assert.equal(values.endDate, "2027-02-01");
266
+ });
267
+
268
+ /* ── WHAT A REFUSAL FROM THE SERVER SAYS ── */
269
+
270
+ test("the API's own wording is what the person is shown", () => {
271
+ assert.equal(
272
+ readApiError(
273
+ { data: { status: "error", message: "This client already has a subscription. Edit the existing one instead of creating another." } },
274
+ "fallback",
275
+ ),
276
+ "This client already has a subscription. Edit the existing one instead of creating another.",
277
+ );
278
+ });
279
+
280
+ test("a message that says nothing to a person falls back to one that does", () => {
281
+ assert.equal(readApiError(new Error("fetch failed"), "Try again."), "Try again.");
282
+ assert.equal(readApiError(undefined, "Try again."), "Try again.");
283
+ assert.equal(readApiError({ data: { message: " " } }, "Try again."), "Try again.");
284
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * THE STAFF CONSOLE'S SUBSCRIPTION FORM, DECIDED IN ONE PLACE.
3
+ *
4
+ * A Seven365 staff member sets a client up, or changes what the client is on.
5
+ * They pick four things - a plan, whether the client is complimentary or
6
+ * paying, a start date and an end date - and nothing else. Everything else on
7
+ * that screen is worked out here so nobody has to do arithmetic in their head:
8
+ *
9
+ * - a plan is priced PER SITE, PER MONTH (owner decision 3), so what the
10
+ * client is worth per month is the plan's price times how many sites they
11
+ * have - a number the Client List already knows;
12
+ * - a complimentary client is worth nothing per month, because nobody is
13
+ * charging them - but the arrangement is still real, still dated, and
14
+ * still monitored.
15
+ *
16
+ * WHY THE REFUSALS LIVE HERE AND NOT ONLY IN THE COMPONENT. The API refuses
17
+ * the same four things (core `subscription.controller.ts`, `readConsoleForm`)
18
+ * and answers with a sentence written to be shown to a person. Repeating those
19
+ * checks in front of the request is not a second opinion - it is the same
20
+ * answer, delivered before a person has waited for a round trip, in the same
21
+ * words, so the two can never disagree about what is wrong. When the server
22
+ * still refuses, its message is what the screen shows.
23
+ *
24
+ * Nothing here takes a card, quotes a total to charge, or moves any money.
25
+ * Red Dot is the payment provider (owner decision 1). The monthly figure is an
26
+ * indication of what a client is worth, shown to a member of staff.
27
+ */
28
+
29
+ /** The two charging arrangements, spelled the way the API stores them. */
30
+ export const BILLING_COMPLIMENTARY = "complimentary";
31
+ export const BILLING_PAID = "paid";
32
+
33
+ export interface TPlanOption {
34
+ _id?: string;
35
+ name?: string;
36
+ price?: number | null;
37
+ status?: string;
38
+ [key: string]: any;
39
+ }
40
+
41
+ /** What the person has filled in. Dates are what `<input type="date">` gives. */
42
+ export interface TSubscriptionFormValues {
43
+ plan: string;
44
+ billingMode: string;
45
+ startDate: string;
46
+ endDate: string;
47
+ }
48
+
49
+ /** What `POST`/`PUT /api/subscriptions/console/:id` accepts. Nothing else. */
50
+ export interface TConsolePayload {
51
+ plan: string;
52
+ billingMode: string;
53
+ startDate: string;
54
+ endDate: string;
55
+ amount: number;
56
+ }
57
+
58
+ /** Today, as `<input type="date">` wants it. Local, not UTC - a person in
59
+ * Singapore choosing "today" means their today. */
60
+ export function todayISO(now: Date = new Date()): string {
61
+ const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
62
+ return local.toISOString().slice(0, 10);
63
+ }
64
+
65
+ const money = new Intl.NumberFormat("en-US", {
66
+ minimumFractionDigits: 2,
67
+ maximumFractionDigits: 2,
68
+ });
69
+
70
+ /**
71
+ * "S$250.00". One currency, because the console only ever writes SGD.
72
+ *
73
+ * The symbol is written rather than asked for: `Intl`'s currency style answers
74
+ * a bare "$" under `en-SG` and "SGD 250.00" under `en-US`, and which one a
75
+ * person sees would then depend on where their browser thinks it is. On a
76
+ * screen about money that is not a difference worth leaving to chance.
77
+ */
78
+ export function formatMoney(value: number): string {
79
+ return `S$${money.format(Number.isFinite(value) ? value : 0)}`;
80
+ }
81
+
82
+ /**
83
+ * What this client is worth per month on this plan: the per-site monthly price
84
+ * times their live site count. A plan with no price (a free bundle) is 0, and
85
+ * so is a client with no sites yet - neither is an error.
86
+ */
87
+ export function monthlyValue(
88
+ plan: TPlanOption | null | undefined,
89
+ siteCount: number,
90
+ ): number {
91
+ const price = Number(plan?.price ?? 0);
92
+ const sites = Number(siteCount ?? 0);
93
+ if (!Number.isFinite(price) || !Number.isFinite(sites)) return 0;
94
+ return Math.max(0, price) * Math.max(0, sites);
95
+ }
96
+
97
+ /** "Standard Plan - S$25.00 per site, per month". The price belongs beside the
98
+ * name; without it the person is picking a plan blind. */
99
+ export function planOptionLabel(plan: TPlanOption): string {
100
+ const price = Number(plan?.price ?? 0);
101
+ const name = plan?.name ?? "Unnamed plan";
102
+ return price > 0
103
+ ? `${name} - ${formatMoney(price)} per site, per month`
104
+ : `${name} - no per-site price set`;
105
+ }
106
+
107
+ function isValidISODate(value: string): boolean {
108
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? "")) return false;
109
+ const d = new Date(`${value}T00:00:00`);
110
+ return !isNaN(d.getTime());
111
+ }
112
+
113
+ /**
114
+ * Everything that must be true before the request goes out, in the order the
115
+ * API checks it, worded the way the API words it.
116
+ *
117
+ * It reads the PAYLOAD rather than the four fields, so the price it judges is
118
+ * the price that would actually be sent - a rule that read the form instead
119
+ * could only ever agree with itself.
120
+ *
121
+ * Returns the ONE thing to tell the person, or `null` when there is nothing to
122
+ * tell them. One message at a time on purpose: a list of five complaints about
123
+ * a four-field form is harder to act on than the first thing to fix.
124
+ */
125
+ export function validateConsoleSubscription(
126
+ payload: TConsolePayload,
127
+ plans: TPlanOption[],
128
+ ): string | null {
129
+ if (!payload?.plan) return "Choose a plan.";
130
+
131
+ if (
132
+ payload.billingMode !== BILLING_COMPLIMENTARY &&
133
+ payload.billingMode !== BILLING_PAID
134
+ ) {
135
+ return "Choose whether this client is complimentary or paying.";
136
+ }
137
+
138
+ if (payload.startDate && !isValidISODate(payload.startDate)) {
139
+ return "The start date is not a valid date.";
140
+ }
141
+
142
+ if (!payload.endDate) {
143
+ return "Enter an end date. Every client has one, free or not.";
144
+ }
145
+
146
+ if (!isValidISODate(payload.endDate)) {
147
+ return "The end date is not a valid date.";
148
+ }
149
+
150
+ // A start date is optional and defaults to today, exactly as the API does -
151
+ // so the comparison has to use the same date the API would have used.
152
+ // Both are `YYYY-MM-DD`, which sorts as text in date order, so this is the
153
+ // same comparison the API makes without dragging a timezone into it.
154
+ if (payload.endDate <= (payload.startDate || todayISO())) {
155
+ return "The end date must be after the start date.";
156
+ }
157
+
158
+ const plan = plans?.find((p) => String(p?._id) === String(payload.plan));
159
+ if (!plan) return "That plan could not be found. Pick one from the list.";
160
+
161
+ if (plan.status && plan.status !== "active") {
162
+ return (
163
+ "The plan " +
164
+ (plan.name ?? "you picked") +
165
+ " is no longer active, so it cannot be given to a client. Pick an active plan."
166
+ );
167
+ }
168
+
169
+ // A complimentary client is not charged, so a price against one is a
170
+ // contradiction worth saying out loud rather than quietly storing as zero -
171
+ // otherwise the person walks away believing they set a price that is not
172
+ // there. This is the check that the charging mode and the figure agree.
173
+ if (payload.billingMode === BILLING_COMPLIMENTARY && Number(payload.amount) > 0) {
174
+ return "A complimentary subscription is not charged, so it cannot carry a price. Set the price to 0, or change this client to paying.";
175
+ }
176
+
177
+ if (Number(payload.amount) < 0) return "The price cannot be negative.";
178
+
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * The body the console endpoints take, and nothing more.
184
+ *
185
+ * `status` is deliberately absent: suspending and reactivating a client is
186
+ * Phase 3 and the edit endpoint does not accept a status, so this form cannot
187
+ * change one by accident. No card field, no invoice, no gateway.
188
+ */
189
+ export function buildConsolePayload(
190
+ values: TSubscriptionFormValues,
191
+ plans: TPlanOption[],
192
+ siteCount = 0,
193
+ ): TConsolePayload {
194
+ const plan = plans?.find((p) => String(p?._id) === String(values.plan));
195
+ const complimentary = values.billingMode === BILLING_COMPLIMENTARY;
196
+
197
+ return {
198
+ plan: values.plan,
199
+ billingMode: values.billingMode,
200
+ startDate: values.startDate || todayISO(),
201
+ endDate: values.endDate,
202
+ amount: complimentary ? 0 : monthlyValue(plan, siteCount),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Turn a failed request into something a person can act on.
208
+ *
209
+ * The API answers `{ status: "error", message: "..." }` and its console
210
+ * messages are already written for a person to read, so the message is what
211
+ * the screen shows. Only when there is nothing readable does this fall back to
212
+ * wording of its own - a raw dump of a fetch error tells the person nothing
213
+ * they can do.
214
+ */
215
+ export function readApiError(error: any, fallback: string): string {
216
+ const message =
217
+ error?.data?.message ??
218
+ error?.response?._data?.message ??
219
+ error?.data?.error ??
220
+ (typeof error?.message === "string" && !/fetch failed|^\[/i.test(error.message)
221
+ ? error.message
222
+ : "");
223
+
224
+ return typeof message === "string" && message.trim() ? message : fallback;
225
+ }
226
+
227
+ /**
228
+ * Pre-fill the form from what the client already has.
229
+ *
230
+ * The subscription the API sends back carries dates as ISO timestamps; the
231
+ * date inputs want `YYYY-MM-DD`, which is the front of one. An absent
232
+ * `billingMode` reads as paid - every subscription that exists today came from
233
+ * the paid checkout, which is the same rule the model states.
234
+ */
235
+ export function formValuesFrom(
236
+ subscription: Record<string, any> | null | undefined,
237
+ now: Date = new Date(),
238
+ ): TSubscriptionFormValues {
239
+ const sub = subscription ?? {};
240
+
241
+ const dateOnly = (value: any) => {
242
+ if (!value) return "";
243
+ const d = value instanceof Date ? value : new Date(String(value));
244
+ return isNaN(d.getTime()) ? "" : todayISO(d);
245
+ };
246
+
247
+ if (!sub._id) {
248
+ return {
249
+ plan: "",
250
+ billingMode: BILLING_COMPLIMENTARY,
251
+ startDate: todayISO(now),
252
+ endDate: "",
253
+ };
254
+ }
255
+
256
+ return {
257
+ plan: sub.plan ? String(sub.plan) : "",
258
+ billingMode:
259
+ String(sub.billingMode ?? "").trim().toLowerCase() === BILLING_COMPLIMENTARY
260
+ ? BILLING_COMPLIMENTARY
261
+ : BILLING_PAID,
262
+ startDate: dateOnly(sub.startDate ?? sub.createdAt) || todayISO(now),
263
+ endDate: dateOnly(sub.endDate ?? sub.nextBillingDate),
264
+ };
265
+ }