@7365admin1/layer-common 3.2.8-staging.203 → 3.2.8-staging.205
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 +138 -8
- package/components/ClientSubscriptionForm.vue +527 -0
- package/composables/useSubscription.ts +40 -0
- package/package.json +1 -1
- package/utils/client-nature.test.ts +77 -0
- package/utils/subscription-form.test.ts +284 -0
- package/utils/subscription-form.ts +265 -0
|
@@ -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
|
+
}
|