@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.
- package/components/ClientDetailForm.vue +35 -1
- package/components/ClientMain.vue +93 -0
- package/components/ClientSubscriptionForm.vue +527 -0
- package/composables/useSubscription.ts +40 -0
- package/package.json +1 -1
- package/utils/subscription-form.test.ts +284 -0
- package/utils/subscription-form.ts +265 -0
|
@@ -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
|
-
<
|
|
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 = ''">×</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.
|
|
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
|
+
}
|