gymmonk-schema 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/common.d.ts +22 -0
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +25 -0
- package/dist/common.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/late-fee-reminder.d.ts +96 -0
- package/dist/late-fee-reminder.d.ts.map +1 -0
- package/dist/late-fee-reminder.js +61 -0
- package/dist/late-fee-reminder.js.map +1 -0
- package/dist/saas-catalog.d.ts.map +1 -1
- package/dist/saas-catalog.js +9 -2
- package/dist/saas-catalog.js.map +1 -1
- package/dist/user.d.ts +14 -1
- package/dist/user.d.ts.map +1 -1
- package/dist/user.js +38 -1
- package/dist/user.js.map +1 -1
- package/package.json +1 -1
package/dist/common.d.ts
CHANGED
|
@@ -201,4 +201,26 @@ export type BillingDayOfMonth = z.infer<typeof billingDayOfMonthSchema>;
|
|
|
201
201
|
* resolves to the 30th rather than rolling into the next month.
|
|
202
202
|
*/
|
|
203
203
|
export declare function billingAnchorOnOrBefore(day: number, onOrBefore: string): string;
|
|
204
|
+
/**
|
|
205
|
+
* The timezone every GymMonk DAY is read in.
|
|
206
|
+
*
|
|
207
|
+
* Matches gymmonk-backend's `GYM_TIMEZONE` and gymmonk-web-client's `IST`,
|
|
208
|
+
* which is the point: three definitions that agree by coincidence are one
|
|
209
|
+
* refactor away from disagreeing, and the shared package is where the one they
|
|
210
|
+
* should all delegate to belongs.
|
|
211
|
+
*/
|
|
212
|
+
export declare const GYM_TIMEZONE = "Asia/Kolkata";
|
|
213
|
+
/**
|
|
214
|
+
* A moment's calendar day in the GYM'S zone, as `YYYY-MM-DD`.
|
|
215
|
+
*
|
|
216
|
+
* ⚠️ NOT `toISOString().slice(0, 10)`, which is the UTC day. Between midnight
|
|
217
|
+
* and 05:30 IST those are different dates, and everything user-facing here is
|
|
218
|
+
* about a day a customer experiences: the day a trial ends, the day a term runs
|
|
219
|
+
* to, how many days are left. Reading the UTC day made a countdown OVERSTATE
|
|
220
|
+
* how long a trial had left for the first five and a half hours of every day,
|
|
221
|
+
* and disagreed with the server's own entitlement gate while doing it.
|
|
222
|
+
*
|
|
223
|
+
* `en-CA` because its short date format is exactly ISO `YYYY-MM-DD`.
|
|
224
|
+
*/
|
|
225
|
+
export declare function gymDay(moment?: Date, timeZone?: string): string;
|
|
204
226
|
//# sourceMappingURL=common.d.ts.map
|
package/dist/common.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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"}
|
package/dist/common.js
CHANGED
|
@@ -259,4 +259,29 @@ export function billingAnchorOnOrBefore(day, onOrBefore) {
|
|
|
259
259
|
const prev = clampTo(py, pm);
|
|
260
260
|
return `${String(py).padStart(4, '0')}-${String(pm).padStart(2, '0')}-${String(prev).padStart(2, '0')}`;
|
|
261
261
|
}
|
|
262
|
+
// ─── The gym's calendar ──────────────────────────────────────────────────────
|
|
263
|
+
/**
|
|
264
|
+
* The timezone every GymMonk DAY is read in.
|
|
265
|
+
*
|
|
266
|
+
* Matches gymmonk-backend's `GYM_TIMEZONE` and gymmonk-web-client's `IST`,
|
|
267
|
+
* which is the point: three definitions that agree by coincidence are one
|
|
268
|
+
* refactor away from disagreeing, and the shared package is where the one they
|
|
269
|
+
* should all delegate to belongs.
|
|
270
|
+
*/
|
|
271
|
+
export const GYM_TIMEZONE = 'Asia/Kolkata';
|
|
272
|
+
/**
|
|
273
|
+
* A moment's calendar day in the GYM'S zone, as `YYYY-MM-DD`.
|
|
274
|
+
*
|
|
275
|
+
* ⚠️ NOT `toISOString().slice(0, 10)`, which is the UTC day. Between midnight
|
|
276
|
+
* and 05:30 IST those are different dates, and everything user-facing here is
|
|
277
|
+
* about a day a customer experiences: the day a trial ends, the day a term runs
|
|
278
|
+
* to, how many days are left. Reading the UTC day made a countdown OVERSTATE
|
|
279
|
+
* how long a trial had left for the first five and a half hours of every day,
|
|
280
|
+
* and disagreed with the server's own entitlement gate while doing it.
|
|
281
|
+
*
|
|
282
|
+
* `en-CA` because its short date format is exactly ISO `YYYY-MM-DD`.
|
|
283
|
+
*/
|
|
284
|
+
export function gymDay(moment = new Date(), timeZone = GYM_TIMEZONE) {
|
|
285
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone }).format(moment);
|
|
286
|
+
}
|
|
262
287
|
//# sourceMappingURL=common.js.map
|
package/dist/common.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Shared helpers and primitives\n * ==============================================\n * The stable building blocks reused across every GymMonk domain schema.\n * Mirrors the `dating-schema` / `near-me-schema` idiom: pure Zod, const-object\n * enums fed to `z.enum(...)`, NodeNext `.js` import specifiers.\n *\n * @module gymmonk-schema/common\n */\n\nimport { z } from 'zod';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined`\n * so Zod `.optional()` treats them as \"not provided\".\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n/**\n * A multi-select filter carried in a query string.\n *\n * A URL has no array type, so a multi-select arrives either as\n * `?muscle=chest,back` or, if the client used `URLSearchParams.append`, as\n * repeated keys that Express hands over as a real array. Both are accepted and\n * normalised to one array, because which one a caller produces is an accident\n * of how it built the URL, not a different intent.\n *\n * Unknown values are DROPPED rather than rejected: a filter chip that is\n * renamed or removed should stop matching, not 422 the whole screen for\n * somebody who still has the old URL open.\n */\nexport const csvOf = <T extends z.ZodTypeAny>(member: T) =>\n z.preprocess((value) => {\n if (value === undefined || value === null || value === '') return undefined;\n const raw = Array.isArray(value) ? value : String(value).split(',');\n const cleaned = raw.map((v) => String(v).trim()).filter(Boolean);\n return cleaned.length > 0 ? cleaned.filter((v) => member.safeParse(v).success) : undefined;\n }, z.array(member).optional());\n\n// ─── Identifiers ─────────────────────────────────────────────────────────────\n\n/** A 24-character hex MongoDB ObjectId string. */\nexport const objectIdSchema = z\n .string()\n .trim()\n .regex(/^[0-9a-fA-F]{24}$/, 'Invalid id');\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/** Pagination query params shared across list endpoints. */\nexport const paginationSchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(100).default(20),\n});\nexport type Pagination = z.infer<typeof paginationSchema>;\n\n// ─── Soft-delete visibility ──────────────────────────────────────────────────\n\n/**\n * How a list query treats soft-deleted rows.\n *\n * THREE states, not a boolean. A boolean can only say \"live\" or \"live +\n * deleted\", which means a console offering a \"Deleted\" filter has to fetch both\n * and drop the live ones client-side — and that breaks the moment the list is\n * paginated: page 1 of \"everything\" may contain no deleted rows at all, so the\n * screen shows an empty list under a filter that matches dozens of records\n * further down. Only the server can answer \"just the deleted ones\".\n *\n * `exclude` the default, and what every ordinary caller wants\n * `include` live and deleted together, for a search that should find both\n * `only` the restore queue\n *\n * Lives here rather than beside one directory because both the people and the\n * gym listings take it, and they must behave identically.\n */\nexport const deletedFilterSchema = z.enum(['exclude', 'include', 'only']);\nexport type DeletedFilter = z.infer<typeof deletedFilterSchema>;\n\n// ─── Contact primitives (India) ──────────────────────────────────────────────\n\n/**\n * The raw patterns, exported so the onboarding FORM schemas validate a typed\n * value exactly the way the API schemas below do — one rule, both sides.\n */\nexport const PHONE_REGEX = /^[6-9]\\d{9}$/;\nexport const PINCODE_REGEX = /^\\d{6}$/;\n\n/**\n * A bare 10-digit Indian mobile number. Stored RAW (no `+91`) app-wide — the\n * country code is display-only. Mirrors the jansathi phone-number standard.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number');\nexport type Phone = z.infer<typeof phoneSchema>;\n\n/** Optional phone — empty/whitespace becomes `undefined`. */\nexport const optionalPhoneSchema = z.preprocess(emptyStringToUndefined, phoneSchema.optional());\n\n/**\n * A phone number with everything but its last four digits hidden: `••••••3210`.\n *\n * For screens that must let somebody RECOGNISE a number without the screen\n * PUBLISHING it. There are two, and both matter:\n *\n * - the invite preview, which is public because the code is the bearer\n * secret. Printing the member's number there would turn any forwarded link\n * into a contact-details leak.\n * - the claim mismatch prompt, where the member is choosing between the\n * number their gym has and the one on their account. Four digits is enough\n * to tell your own two numbers apart, and not enough to learn a stranger's.\n *\n * Returns `''` for nothing at all, so a caller can render \"no number on file\"\n * rather than a row of dots that looks like a real hidden number.\n */\nexport function maskPhone(phone: string | null | undefined): string {\n const digits = (phone ?? '').trim();\n if (!digits) return '';\n if (digits.length <= 4) return digits;\n return `${'•'.repeat(digits.length - 4)}${digits.slice(-4)}`;\n}\n\n/** A 6-digit Indian postal PIN code. */\nexport const pincodeSchema = z\n .string()\n .trim()\n .regex(PINCODE_REGEX, 'Enter a valid 6-digit pincode');\nexport type Pincode = z.infer<typeof pincodeSchema>;\n\n/** Optional email — empty/whitespace becomes `undefined`, else validated. */\nexport const optionalEmailSchema = z.preprocess(\n emptyStringToUndefined,\n z.email('Enter a valid email').optional(),\n);\n\n// ─── Date & time primitives ──────────────────────────────────────────────────\n\n/** A calendar date with no time component, `YYYY-MM-DD`. */\nexport const isoDateSchema = z\n .string()\n .trim()\n .regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Expected an YYYY-MM-DD date');\nexport type IsoDate = z.infer<typeof isoDateSchema>;\n\n/** A full ISO-8601 timestamp, e.g. `2026-08-06T09:30:00.000Z`. */\nexport const isoDateTimeSchema = z.iso.datetime({ offset: true });\nexport type IsoDateTime = z.infer<typeof isoDateTimeSchema>;\n\n/** Time of day in 24-hour `HH:MM`. */\nexport const timeOfDaySchema = z\n .string()\n .trim()\n .regex(/^([01]\\d|2[0-3]):[0-5]\\d$/, 'Expected an HH:MM time');\nexport type TimeOfDay = z.infer<typeof timeOfDaySchema>;\n\n/**\n * `Date` rolls overflow forward (Feb 31 → Mar 3), so a real date is one whose\n * parts survive the round-trip.\n *\n * Lives here rather than beside one form's rules because every date-of-birth\n * field in the product needs it — the onboarding wizards and the owner's desk\n * entry forms all check the same thing.\n */\nexport function isRealDate(year: number, month: number, day: number): boolean {\n const date = new Date(Date.UTC(year, month - 1, day));\n return (\n date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n );\n}\n\n/** Whole years elapsed since the given Y/M/D, in UTC. Negative when future. */\nexport function ageInYears(year: number, month: number, day: number): number {\n const date = Date.UTC(year, month - 1, day);\n return (Date.now() - date) / (365.25 * 24 * 60 * 60 * 1000);\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\n/**\n * A whole-rupee (INR) amount. GymMonk prices are whole rupees; format with a\n * `₹` and grouping at the edge, never store the formatted string.\n */\nexport const priceInrSchema = z.number().int().min(0);\nexport type PriceInr = z.infer<typeof priceInrSchema>;\n\n// ─── Names ───────────────────────────────────────────────────────────────────\n\n/**\n * Split one typed name into the two fields a `User` actually stores.\n *\n * Every screen that asks for a name asks for it in ONE box — onboarding step 1\n * and the profile's Personal Details editor — because \"first name / last name\"\n * is a storage detail, not a question a person can answer about their own name.\n * The split therefore has to happen somewhere, and it has to happen the SAME\n * way in both places: if onboarding split on the last space and the editor on\n * the first, a member who opened the editor and pressed Save without touching\n * anything would have their name silently re-cut.\n *\n * Splits on the FIRST space, so \"Ram Kumar Singh\" is `Ram` + `Kumar Singh`.\n * Collapses runs of whitespace, so a double space or a trailing one cannot\n * produce an empty surname or a leading blank.\n *\n * The inverse is the backend's `fullName(first, last)` and the web client's\n * `fullName(user)`, which join the halves back with a single space — so a name\n * that goes through this and back out again is unchanged.\n */\nexport function splitFullName(value: string): { firstName: string; lastName: string } {\n const parts = value.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: '', lastName: '' };\n const [firstName, ...rest] = parts;\n return { firstName: firstName as string, lastName: rest.join(' ') };\n}\n\n/**\n * The two stored name halves joined back into one string.\n *\n * The exact inverse of `splitFullName`, and deliberately sitting beside it:\n * a name that goes through one and back through the other must come out\n * unchanged, which is only guaranteed while both rules live in one file.\n *\n * Trims each half and drops an empty one, so a member with no surname reads as\n * `\"Rahul\"` rather than `\"Rahul \"`. Returns `''` when there is nothing to\n * join; callers pick their own fallback (the email, \"Member\", a dash), because\n * the right one differs per screen.\n *\n * Both products call it through a thin local module — gymmonk-backend\n * `shared/utils/user-name` and gymmonk-web-client `shared/lib/user-name` — so\n * the many existing call sites keep their convenient signatures while there is\n * only ONE rule about what a full name is.\n */\nexport function joinFullName(firstName?: string | null, lastName?: string | null): string {\n return [firstName, lastName]\n .map((part) => (part ?? '').trim())\n .filter(Boolean)\n .join(' ');\n}\n\n// ─── Billing cycle ───────────────────────────────────────────────────────────\n\n/**\n * The day of the month a member's fee falls due, e.g. `16` for \"the 16th of\n * every month\".\n *\n * A DAY NUMBER, not a date, and the distinction is the whole point. A member\n * joining through the app on 5 June with a billing day of 26 is billed\n * 26 May → 26 June: the day is an anchor for a repeating cycle, so it may name\n * a date earlier than the one they joined on. Storing a date instead would\n * make \"the 26th of every month\" unanswerable the moment the first term ended.\n *\n * 1–31 rather than 1–28. A gym that bills on the 31st is making a perfectly\n * ordinary statement, and the months that have no 31st are handled where the\n * term is dated — by clamping to the last day the month actually has, which is\n * what `addMonths` in the backend already does for every other term. Refusing\n * 29, 30 and 31 here would reject a real answer to avoid arithmetic that is\n * already written.\n */\nexport const billingDayOfMonthSchema = z\n .number()\n .int('Pick a day of the month')\n .min(1, 'Pick a day between 1 and 31')\n .max(31, 'Pick a day between 1 and 31');\nexport type BillingDayOfMonth = z.infer<typeof billingDayOfMonthSchema>;\n\n/**\n * The most recent occurrence of `day` on or before `onOrBefore`, as `YYYY-MM-DD`.\n *\n * This is the rule that decides which term a joining member's first payment\n * covers: never a period starting after they joined, so the month they are\n * actually training in is always the month they are paying for. Joining on\n * 5 June with day 26 gives 26 May; with day 10 it gives 10 May, because 10 June\n * has not happened yet.\n *\n * Lives in the schema package because BOTH sides need it — the app previews\n * \"your plan will run 26 May to 26 June\" as the member picks the day, and the\n * server dates the term for real. Two copies of this arithmetic is how a member\n * is shown one period and billed for another.\n *\n * Clamped to the last day of the target month, so day 31 in a 30-day month\n * resolves to the 30th rather than rolling into the next month.\n */\nexport function billingAnchorOnOrBefore(day: number, onOrBefore: string): string {\n const [y, m, d] = onOrBefore.split('-').map(Number) as [number, number, number];\n const clampTo = (year: number, month: number): number =>\n Math.min(day, new Date(Date.UTC(year, month, 0)).getUTCDate());\n\n // This month's occurrence, if it has already happened.\n const thisMonth = clampTo(y, m);\n if (thisMonth <= d) {\n return `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(thisMonth).padStart(2, '0')}`;\n }\n\n // Otherwise the previous month's.\n const py = m === 1 ? y - 1 : y;\n const pm = m === 1 ? 12 : m - 1;\n const prev = clampTo(py, pm);\n return `${String(py).padStart(4, '0')}-${String(pm).padStart(2, '0')}-${String(prev).padStart(2, '0')}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Shared helpers and primitives\n * ==============================================\n * The stable building blocks reused across every GymMonk domain schema.\n * Mirrors the `dating-schema` / `near-me-schema` idiom: pure Zod, const-object\n * enums fed to `z.enum(...)`, NodeNext `.js` import specifiers.\n *\n * @module gymmonk-schema/common\n */\n\nimport { z } from 'zod';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined`\n * so Zod `.optional()` treats them as \"not provided\".\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n/**\n * A multi-select filter carried in a query string.\n *\n * A URL has no array type, so a multi-select arrives either as\n * `?muscle=chest,back` or, if the client used `URLSearchParams.append`, as\n * repeated keys that Express hands over as a real array. Both are accepted and\n * normalised to one array, because which one a caller produces is an accident\n * of how it built the URL, not a different intent.\n *\n * Unknown values are DROPPED rather than rejected: a filter chip that is\n * renamed or removed should stop matching, not 422 the whole screen for\n * somebody who still has the old URL open.\n */\nexport const csvOf = <T extends z.ZodTypeAny>(member: T) =>\n z.preprocess((value) => {\n if (value === undefined || value === null || value === '') return undefined;\n const raw = Array.isArray(value) ? value : String(value).split(',');\n const cleaned = raw.map((v) => String(v).trim()).filter(Boolean);\n return cleaned.length > 0 ? cleaned.filter((v) => member.safeParse(v).success) : undefined;\n }, z.array(member).optional());\n\n// ─── Identifiers ─────────────────────────────────────────────────────────────\n\n/** A 24-character hex MongoDB ObjectId string. */\nexport const objectIdSchema = z\n .string()\n .trim()\n .regex(/^[0-9a-fA-F]{24}$/, 'Invalid id');\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/** Pagination query params shared across list endpoints. */\nexport const paginationSchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n limit: z.coerce.number().int().min(1).max(100).default(20),\n});\nexport type Pagination = z.infer<typeof paginationSchema>;\n\n// ─── Soft-delete visibility ──────────────────────────────────────────────────\n\n/**\n * How a list query treats soft-deleted rows.\n *\n * THREE states, not a boolean. A boolean can only say \"live\" or \"live +\n * deleted\", which means a console offering a \"Deleted\" filter has to fetch both\n * and drop the live ones client-side — and that breaks the moment the list is\n * paginated: page 1 of \"everything\" may contain no deleted rows at all, so the\n * screen shows an empty list under a filter that matches dozens of records\n * further down. Only the server can answer \"just the deleted ones\".\n *\n * `exclude` the default, and what every ordinary caller wants\n * `include` live and deleted together, for a search that should find both\n * `only` the restore queue\n *\n * Lives here rather than beside one directory because both the people and the\n * gym listings take it, and they must behave identically.\n */\nexport const deletedFilterSchema = z.enum(['exclude', 'include', 'only']);\nexport type DeletedFilter = z.infer<typeof deletedFilterSchema>;\n\n// ─── Contact primitives (India) ──────────────────────────────────────────────\n\n/**\n * The raw patterns, exported so the onboarding FORM schemas validate a typed\n * value exactly the way the API schemas below do — one rule, both sides.\n */\nexport const PHONE_REGEX = /^[6-9]\\d{9}$/;\nexport const PINCODE_REGEX = /^\\d{6}$/;\n\n/**\n * A bare 10-digit Indian mobile number. Stored RAW (no `+91`) app-wide — the\n * country code is display-only. Mirrors the jansathi phone-number standard.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(PHONE_REGEX, 'Enter a valid 10-digit mobile number');\nexport type Phone = z.infer<typeof phoneSchema>;\n\n/** Optional phone — empty/whitespace becomes `undefined`. */\nexport const optionalPhoneSchema = z.preprocess(emptyStringToUndefined, phoneSchema.optional());\n\n/**\n * A phone number with everything but its last four digits hidden: `••••••3210`.\n *\n * For screens that must let somebody RECOGNISE a number without the screen\n * PUBLISHING it. There are two, and both matter:\n *\n * - the invite preview, which is public because the code is the bearer\n * secret. Printing the member's number there would turn any forwarded link\n * into a contact-details leak.\n * - the claim mismatch prompt, where the member is choosing between the\n * number their gym has and the one on their account. Four digits is enough\n * to tell your own two numbers apart, and not enough to learn a stranger's.\n *\n * Returns `''` for nothing at all, so a caller can render \"no number on file\"\n * rather than a row of dots that looks like a real hidden number.\n */\nexport function maskPhone(phone: string | null | undefined): string {\n const digits = (phone ?? '').trim();\n if (!digits) return '';\n if (digits.length <= 4) return digits;\n return `${'•'.repeat(digits.length - 4)}${digits.slice(-4)}`;\n}\n\n/** A 6-digit Indian postal PIN code. */\nexport const pincodeSchema = z\n .string()\n .trim()\n .regex(PINCODE_REGEX, 'Enter a valid 6-digit pincode');\nexport type Pincode = z.infer<typeof pincodeSchema>;\n\n/** Optional email — empty/whitespace becomes `undefined`, else validated. */\nexport const optionalEmailSchema = z.preprocess(\n emptyStringToUndefined,\n z.email('Enter a valid email').optional(),\n);\n\n// ─── Date & time primitives ──────────────────────────────────────────────────\n\n/** A calendar date with no time component, `YYYY-MM-DD`. */\nexport const isoDateSchema = z\n .string()\n .trim()\n .regex(/^\\d{4}-\\d{2}-\\d{2}$/, 'Expected an YYYY-MM-DD date');\nexport type IsoDate = z.infer<typeof isoDateSchema>;\n\n/** A full ISO-8601 timestamp, e.g. `2026-08-06T09:30:00.000Z`. */\nexport const isoDateTimeSchema = z.iso.datetime({ offset: true });\nexport type IsoDateTime = z.infer<typeof isoDateTimeSchema>;\n\n/** Time of day in 24-hour `HH:MM`. */\nexport const timeOfDaySchema = z\n .string()\n .trim()\n .regex(/^([01]\\d|2[0-3]):[0-5]\\d$/, 'Expected an HH:MM time');\nexport type TimeOfDay = z.infer<typeof timeOfDaySchema>;\n\n/**\n * `Date` rolls overflow forward (Feb 31 → Mar 3), so a real date is one whose\n * parts survive the round-trip.\n *\n * Lives here rather than beside one form's rules because every date-of-birth\n * field in the product needs it — the onboarding wizards and the owner's desk\n * entry forms all check the same thing.\n */\nexport function isRealDate(year: number, month: number, day: number): boolean {\n const date = new Date(Date.UTC(year, month - 1, day));\n return (\n date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n );\n}\n\n/** Whole years elapsed since the given Y/M/D, in UTC. Negative when future. */\nexport function ageInYears(year: number, month: number, day: number): number {\n const date = Date.UTC(year, month - 1, day);\n return (Date.now() - date) / (365.25 * 24 * 60 * 60 * 1000);\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\n/**\n * A whole-rupee (INR) amount. GymMonk prices are whole rupees; format with a\n * `₹` and grouping at the edge, never store the formatted string.\n */\nexport const priceInrSchema = z.number().int().min(0);\nexport type PriceInr = z.infer<typeof priceInrSchema>;\n\n// ─── Names ───────────────────────────────────────────────────────────────────\n\n/**\n * Split one typed name into the two fields a `User` actually stores.\n *\n * Every screen that asks for a name asks for it in ONE box — onboarding step 1\n * and the profile's Personal Details editor — because \"first name / last name\"\n * is a storage detail, not a question a person can answer about their own name.\n * The split therefore has to happen somewhere, and it has to happen the SAME\n * way in both places: if onboarding split on the last space and the editor on\n * the first, a member who opened the editor and pressed Save without touching\n * anything would have their name silently re-cut.\n *\n * Splits on the FIRST space, so \"Ram Kumar Singh\" is `Ram` + `Kumar Singh`.\n * Collapses runs of whitespace, so a double space or a trailing one cannot\n * produce an empty surname or a leading blank.\n *\n * The inverse is the backend's `fullName(first, last)` and the web client's\n * `fullName(user)`, which join the halves back with a single space — so a name\n * that goes through this and back out again is unchanged.\n */\nexport function splitFullName(value: string): { firstName: string; lastName: string } {\n const parts = value.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return { firstName: '', lastName: '' };\n const [firstName, ...rest] = parts;\n return { firstName: firstName as string, lastName: rest.join(' ') };\n}\n\n/**\n * The two stored name halves joined back into one string.\n *\n * The exact inverse of `splitFullName`, and deliberately sitting beside it:\n * a name that goes through one and back through the other must come out\n * unchanged, which is only guaranteed while both rules live in one file.\n *\n * Trims each half and drops an empty one, so a member with no surname reads as\n * `\"Rahul\"` rather than `\"Rahul \"`. Returns `''` when there is nothing to\n * join; callers pick their own fallback (the email, \"Member\", a dash), because\n * the right one differs per screen.\n *\n * Both products call it through a thin local module — gymmonk-backend\n * `shared/utils/user-name` and gymmonk-web-client `shared/lib/user-name` — so\n * the many existing call sites keep their convenient signatures while there is\n * only ONE rule about what a full name is.\n */\nexport function joinFullName(firstName?: string | null, lastName?: string | null): string {\n return [firstName, lastName]\n .map((part) => (part ?? '').trim())\n .filter(Boolean)\n .join(' ');\n}\n\n// ─── Billing cycle ───────────────────────────────────────────────────────────\n\n/**\n * The day of the month a member's fee falls due, e.g. `16` for \"the 16th of\n * every month\".\n *\n * A DAY NUMBER, not a date, and the distinction is the whole point. A member\n * joining through the app on 5 June with a billing day of 26 is billed\n * 26 May → 26 June: the day is an anchor for a repeating cycle, so it may name\n * a date earlier than the one they joined on. Storing a date instead would\n * make \"the 26th of every month\" unanswerable the moment the first term ended.\n *\n * 1–31 rather than 1–28. A gym that bills on the 31st is making a perfectly\n * ordinary statement, and the months that have no 31st are handled where the\n * term is dated — by clamping to the last day the month actually has, which is\n * what `addMonths` in the backend already does for every other term. Refusing\n * 29, 30 and 31 here would reject a real answer to avoid arithmetic that is\n * already written.\n */\nexport const billingDayOfMonthSchema = z\n .number()\n .int('Pick a day of the month')\n .min(1, 'Pick a day between 1 and 31')\n .max(31, 'Pick a day between 1 and 31');\nexport type BillingDayOfMonth = z.infer<typeof billingDayOfMonthSchema>;\n\n/**\n * The most recent occurrence of `day` on or before `onOrBefore`, as `YYYY-MM-DD`.\n *\n * This is the rule that decides which term a joining member's first payment\n * covers: never a period starting after they joined, so the month they are\n * actually training in is always the month they are paying for. Joining on\n * 5 June with day 26 gives 26 May; with day 10 it gives 10 May, because 10 June\n * has not happened yet.\n *\n * Lives in the schema package because BOTH sides need it — the app previews\n * \"your plan will run 26 May to 26 June\" as the member picks the day, and the\n * server dates the term for real. Two copies of this arithmetic is how a member\n * is shown one period and billed for another.\n *\n * Clamped to the last day of the target month, so day 31 in a 30-day month\n * resolves to the 30th rather than rolling into the next month.\n */\nexport function billingAnchorOnOrBefore(day: number, onOrBefore: string): string {\n const [y, m, d] = onOrBefore.split('-').map(Number) as [number, number, number];\n const clampTo = (year: number, month: number): number =>\n Math.min(day, new Date(Date.UTC(year, month, 0)).getUTCDate());\n\n // This month's occurrence, if it has already happened.\n const thisMonth = clampTo(y, m);\n if (thisMonth <= d) {\n return `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(thisMonth).padStart(2, '0')}`;\n }\n\n // Otherwise the previous month's.\n const py = m === 1 ? y - 1 : y;\n const pm = m === 1 ? 12 : m - 1;\n const prev = clampTo(py, pm);\n return `${String(py).padStart(4, '0')}-${String(pm).padStart(2, '0')}-${String(prev).padStart(2, '0')}`;\n}\n\n// ─── The gym's calendar ──────────────────────────────────────────────────────\n\n/**\n * The timezone every GymMonk DAY is read in.\n *\n * Matches gymmonk-backend's `GYM_TIMEZONE` and gymmonk-web-client's `IST`,\n * which is the point: three definitions that agree by coincidence are one\n * refactor away from disagreeing, and the shared package is where the one they\n * should all delegate to belongs.\n */\nexport const GYM_TIMEZONE = 'Asia/Kolkata';\n\n/**\n * A moment's calendar day in the GYM'S zone, as `YYYY-MM-DD`.\n *\n * ⚠️ NOT `toISOString().slice(0, 10)`, which is the UTC day. Between midnight\n * and 05:30 IST those are different dates, and everything user-facing here is\n * about a day a customer experiences: the day a trial ends, the day a term runs\n * to, how many days are left. Reading the UTC day made a countdown OVERSTATE\n * how long a trial had left for the first five and a half hours of every day,\n * and disagreed with the server's own entitlement gate while doing it.\n *\n * `en-CA` because its short date format is exactly ISO `YYYY-MM-DD`.\n */\nexport function gymDay(moment: Date = new Date(), timeZone: string = GYM_TIMEZONE): string {\n return new Intl.DateTimeFormat('en-CA', { timeZone }).format(moment);\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -40,10 +40,11 @@ export * from './nutrition.js';
|
|
|
40
40
|
export * from './onboarding.js';
|
|
41
41
|
export * from './onboarding-form.js';
|
|
42
42
|
export * from './organisation.js';
|
|
43
|
-
export * from './payment.js';
|
|
44
43
|
export * from './owner-profile.js';
|
|
44
|
+
export * from './payment.js';
|
|
45
45
|
export * from './plan-abilities.js';
|
|
46
46
|
export * from './plan-features-data.js';
|
|
47
|
+
export * from './late-fee-reminder.js';
|
|
47
48
|
export * from './plan-limits.js';
|
|
48
49
|
export * from './platform.js';
|
|
49
50
|
export * from './pricing.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAElC,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAE5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAE/B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,cAAc,WAAW,CAAC;AAE1B,cAAc,WAAW,CAAC;AAC1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AAEvC,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AAErC,cAAc,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAElC,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAE5B,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAE/B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,cAAc,WAAW,CAAC;AAE1B,cAAc,WAAW,CAAC;AAC1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AAEvC,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAElC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAEhC,cAAc,sBAAsB,CAAC;AAErC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,cAAc,CAAC;AAE7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAE7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAElC,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -54,12 +54,13 @@ export * from './onboarding.js';
|
|
|
54
54
|
export * from './onboarding-form.js';
|
|
55
55
|
// ─── Gym structure & onboarding ──────────────────────────────────────────────
|
|
56
56
|
export * from './organisation.js';
|
|
57
|
+
export * from './owner-profile.js';
|
|
57
58
|
// ─── Razorpay checkout wire shapes ───────────────────────────────────────────
|
|
58
59
|
export * from './payment.js';
|
|
59
|
-
export * from './owner-profile.js';
|
|
60
60
|
// ─── Platform console (people + gyms directories, role keys) ─────────────────
|
|
61
61
|
export * from './plan-abilities.js';
|
|
62
62
|
export * from './plan-features-data.js';
|
|
63
|
+
export * from './late-fee-reminder.js';
|
|
63
64
|
export * from './plan-limits.js';
|
|
64
65
|
export * from './platform.js';
|
|
65
66
|
export * from './pricing.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,6EAA6E;AAC7E,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,gFAAgF;AAChF,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,gFAAgF;AAChF,cAAc,mBAAmB,CAAC;AAClC,gFAAgF;AAChF,cAAc,cAAc,CAAC;AAC7B,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,6EAA6E;AAC7E,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,gFAAgF;AAChF,cAAc,WAAW,CAAC;AAC1B,cAAc,uBAAuB,CAAC;AACtC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,gFAAgF;AAChF,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,gFAAgF;AAChF,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,gFAAgF;AAChF,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,gFAAgF;AAChF,cAAc,sBAAsB,CAAC;AACrC,gFAAgF;AAChF,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,gFAAgF;AAChF,cAAc,cAAc,CAAC;AAC7B,gFAAgF;AAChF,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,gFAAgF;AAChF,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,mBAAmB,CAAC;AAClC,iFAAiF;AACjF,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC","sourcesContent":["/**\n * gymmonk-schema — Public API\n * ===========================\n * Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS).\n * Single source of truth (SSOT) for data shapes, TypeScript types and\n * validation, consumed by both gymmonk-backend and gymmonk-web-client.\n *\n * Every domain module is re-exported through this barrel. Import names are\n * globally unique across modules, so `export *` composes without collisions.\n */\n\n// ─── Owner desk-entry FORM validation (client-side working shapes) ───────────\nexport * from './add-person-form.js';\nexport * from './announcement.js';\n// ─── Location master data (the address picker's lookup API) ───────────────\nexport * from './area.js';\nexport * from './body-metrics.js';\nexport * from './center.js';\n// ─── Check-in QR (poster payload shared by owner, member and backend) ────────\nexport * from './chat.js';\nexport * from './check-in.js';\n// ─── Foundation ──────────────────────────────────────────────────────────────\nexport * from './codes.js';\nexport * from './common.js';\nexport * from './diet-chart.js';\nexport * from './enrolment.js';\nexport * from './equipment.js';\n// ─── Training ────────────────────────────────────────────────────────────────\nexport * from './exercise.js';\nexport * from './feedback.js';\n// ─── Uploads ─────────────────────────────────────────────────────────────────\nexport * from './file.js';\n// ─── \"List your gym\" — a member registers the business they run ──────────────\nexport * from './food.js';\nexport * from './gym-registration.js';\nexport * from './home.js';\nexport * from './insights.js';\n// ─── Where a place physically is (Maps link, pin, check-in position) ─────────\nexport * from './location.js';\n// ─── People ──────────────────────────────────────────────────────────────────\nexport * from './member.js';\nexport * from './member-profile.js';\nexport * from './membership.js';\n// ─── Membership ──────────────────────────────────────────────────────────────\nexport * from './membership-plan.js';\nexport * from './membership-request.js';\nexport * from './membership-status.js';\n// ─── API result messages + error codes ───────────────────────────────────────\nexport * from './messages.js';\nexport * from './notification.js';\n// ─── Engagement & analytics ──────────────────────────────────────────────────\nexport * from './nutrition.js';\nexport * from './onboarding.js';\n// ─── Onboarding wizard FORM validation (client-side working shapes) ──────────\nexport * from './onboarding-form.js';\n// ─── Gym structure & onboarding ──────────────────────────────────────────────\nexport * from './organisation.js';\nexport * from './owner-profile.js';\n// ─── Razorpay checkout wire shapes ───────────────────────────────────────────\nexport * from './payment.js';\n// ─── Platform console (people + gyms directories, role keys) ─────────────────\nexport * from './plan-abilities.js';\nexport * from './plan-features-data.js';\nexport * from './late-fee-reminder.js';\nexport * from './plan-limits.js';\nexport * from './platform.js';\nexport * from './pricing.js';\n// ─── Member \"edit my profile\" FORM validation ────────────────────────────────\nexport * from './profile-edit-form.js';\nexport * from './saas-catalog.js';\nexport * from './session.js';\nexport * from './shared.js';\nexport * from './social.js';\nexport * from './staff.js';\nexport * from './subscription.js';\n// ─── Member fee payment by UPI QR (the gym's own VPA, no gateway) ─────────────\nexport * from './upi.js';\nexport * from './user.js';\nexport * from './workout-plan.js';\n"]}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Late-fee reminder contracts
|
|
3
|
+
* ===========================
|
|
4
|
+
* What the owner's WhatsApp reminder screen reads. One shape, defined once,
|
|
5
|
+
* because the backend builds it and the web client renders it and a drift
|
|
6
|
+
* between the two shows up as an empty card rather than as an error.
|
|
7
|
+
*
|
|
8
|
+
* ── ⚠️ THREE ZEROES THAT MEAN DIFFERENT THINGS ──────────────────────────────
|
|
9
|
+
* `configured: false` is the PLATFORM having no WhatsApp credentials — nobody
|
|
10
|
+
* on GymMonk is being messaged, and no gym can fix it.
|
|
11
|
+
* `cap: 0` is THIS GYM on a plan without the allowance. An upgrade fixes it.
|
|
12
|
+
* `used: 0` is a gym with the allowance and nobody overdue, which is healthy.
|
|
13
|
+
*
|
|
14
|
+
* They are separate fields rather than one number because a screen that renders
|
|
15
|
+
* "0 reminders" for all three tells an owner nothing about which one to act on,
|
|
16
|
+
* and two of the three are not problems at all.
|
|
17
|
+
*
|
|
18
|
+
* @module late-fee-reminder
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
/**
|
|
22
|
+
* Our lifecycle for one reminder, which is NOT Meta's.
|
|
23
|
+
*
|
|
24
|
+
* `sending` is a reservation written before the gateway is called, so a row can
|
|
25
|
+
* sit here briefly during a sweep. `undeliverable` is terminal and, importantly,
|
|
26
|
+
* does not consume the gym's allowance.
|
|
27
|
+
*/
|
|
28
|
+
export declare const lateFeeNoticeStatusSchema: z.ZodEnum<{
|
|
29
|
+
sent: "sent";
|
|
30
|
+
sending: "sending";
|
|
31
|
+
undeliverable: "undeliverable";
|
|
32
|
+
}>;
|
|
33
|
+
export type LateFeeNoticeStatus = z.infer<typeof lateFeeNoticeStatusSchema>;
|
|
34
|
+
/**
|
|
35
|
+
* META's outcome, which arrives on a webhook minutes after the send.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ `sent` here is not the status above. Ours means "Meta accepted it"; this
|
|
38
|
+
* one means Meta says it left. Null means no webhook has arrived yet, which is
|
|
39
|
+
* ordinary for a reminder sent seconds ago and suspicious for one sent a day
|
|
40
|
+
* ago — the difference is worth showing rather than flattening to "sent".
|
|
41
|
+
*/
|
|
42
|
+
export declare const lateFeeDeliveryStatusSchema: z.ZodEnum<{
|
|
43
|
+
failed: "failed";
|
|
44
|
+
sent: "sent";
|
|
45
|
+
read: "read";
|
|
46
|
+
delivered: "delivered";
|
|
47
|
+
}>;
|
|
48
|
+
export type LateFeeDeliveryStatus = z.infer<typeof lateFeeDeliveryStatusSchema>;
|
|
49
|
+
/**
|
|
50
|
+
* One row of the owner's history.
|
|
51
|
+
*
|
|
52
|
+
* `memberName` is nullable because the stored notice deliberately holds no
|
|
53
|
+
* personal data — the name is resolved at read time from the enrolment, and a
|
|
54
|
+
* member who has since been erased correctly resolves to nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare const lateFeeReminderEntrySchema: z.ZodObject<{
|
|
57
|
+
id: z.ZodString;
|
|
58
|
+
memberName: z.ZodNullable<z.ZodString>;
|
|
59
|
+
status: z.ZodEnum<{
|
|
60
|
+
sent: "sent";
|
|
61
|
+
sending: "sending";
|
|
62
|
+
undeliverable: "undeliverable";
|
|
63
|
+
}>;
|
|
64
|
+
deliveryStatus: z.ZodNullable<z.ZodEnum<{
|
|
65
|
+
failed: "failed";
|
|
66
|
+
sent: "sent";
|
|
67
|
+
read: "read";
|
|
68
|
+
delivered: "delivered";
|
|
69
|
+
}>>;
|
|
70
|
+
sentAt: z.ZodNullable<z.ZodString>;
|
|
71
|
+
}, z.core.$strip>;
|
|
72
|
+
export type LateFeeReminderEntry = z.infer<typeof lateFeeReminderEntrySchema>;
|
|
73
|
+
export declare const lateFeeReminderUsageSchema: z.ZodObject<{
|
|
74
|
+
configured: z.ZodBoolean;
|
|
75
|
+
cap: z.ZodNumber;
|
|
76
|
+
used: z.ZodNumber;
|
|
77
|
+
remaining: z.ZodNumber;
|
|
78
|
+
recent: z.ZodArray<z.ZodObject<{
|
|
79
|
+
id: z.ZodString;
|
|
80
|
+
memberName: z.ZodNullable<z.ZodString>;
|
|
81
|
+
status: z.ZodEnum<{
|
|
82
|
+
sent: "sent";
|
|
83
|
+
sending: "sending";
|
|
84
|
+
undeliverable: "undeliverable";
|
|
85
|
+
}>;
|
|
86
|
+
deliveryStatus: z.ZodNullable<z.ZodEnum<{
|
|
87
|
+
failed: "failed";
|
|
88
|
+
sent: "sent";
|
|
89
|
+
read: "read";
|
|
90
|
+
delivered: "delivered";
|
|
91
|
+
}>>;
|
|
92
|
+
sentAt: z.ZodNullable<z.ZodString>;
|
|
93
|
+
}, z.core.$strip>>;
|
|
94
|
+
}, z.core.$strip>;
|
|
95
|
+
export type LateFeeReminderUsage = z.infer<typeof lateFeeReminderUsageSchema>;
|
|
96
|
+
//# sourceMappingURL=late-fee-reminder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"late-fee-reminder.d.ts","sourceRoot":"","sources":["../src/late-fee-reminder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;EAA+C,CAAC;AACtF,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B;;;;;EAAkD,CAAC;AAC3F,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEhF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;iBAMrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;iBAQrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Late-fee reminder contracts
|
|
3
|
+
* ===========================
|
|
4
|
+
* What the owner's WhatsApp reminder screen reads. One shape, defined once,
|
|
5
|
+
* because the backend builds it and the web client renders it and a drift
|
|
6
|
+
* between the two shows up as an empty card rather than as an error.
|
|
7
|
+
*
|
|
8
|
+
* ── ⚠️ THREE ZEROES THAT MEAN DIFFERENT THINGS ──────────────────────────────
|
|
9
|
+
* `configured: false` is the PLATFORM having no WhatsApp credentials — nobody
|
|
10
|
+
* on GymMonk is being messaged, and no gym can fix it.
|
|
11
|
+
* `cap: 0` is THIS GYM on a plan without the allowance. An upgrade fixes it.
|
|
12
|
+
* `used: 0` is a gym with the allowance and nobody overdue, which is healthy.
|
|
13
|
+
*
|
|
14
|
+
* They are separate fields rather than one number because a screen that renders
|
|
15
|
+
* "0 reminders" for all three tells an owner nothing about which one to act on,
|
|
16
|
+
* and two of the three are not problems at all.
|
|
17
|
+
*
|
|
18
|
+
* @module late-fee-reminder
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
/**
|
|
22
|
+
* Our lifecycle for one reminder, which is NOT Meta's.
|
|
23
|
+
*
|
|
24
|
+
* `sending` is a reservation written before the gateway is called, so a row can
|
|
25
|
+
* sit here briefly during a sweep. `undeliverable` is terminal and, importantly,
|
|
26
|
+
* does not consume the gym's allowance.
|
|
27
|
+
*/
|
|
28
|
+
export const lateFeeNoticeStatusSchema = z.enum(['sending', 'sent', 'undeliverable']);
|
|
29
|
+
/**
|
|
30
|
+
* META's outcome, which arrives on a webhook minutes after the send.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `sent` here is not the status above. Ours means "Meta accepted it"; this
|
|
33
|
+
* one means Meta says it left. Null means no webhook has arrived yet, which is
|
|
34
|
+
* ordinary for a reminder sent seconds ago and suspicious for one sent a day
|
|
35
|
+
* ago — the difference is worth showing rather than flattening to "sent".
|
|
36
|
+
*/
|
|
37
|
+
export const lateFeeDeliveryStatusSchema = z.enum(['sent', 'delivered', 'read', 'failed']);
|
|
38
|
+
/**
|
|
39
|
+
* One row of the owner's history.
|
|
40
|
+
*
|
|
41
|
+
* `memberName` is nullable because the stored notice deliberately holds no
|
|
42
|
+
* personal data — the name is resolved at read time from the enrolment, and a
|
|
43
|
+
* member who has since been erased correctly resolves to nothing.
|
|
44
|
+
*/
|
|
45
|
+
export const lateFeeReminderEntrySchema = z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
memberName: z.string().nullable(),
|
|
48
|
+
status: lateFeeNoticeStatusSchema,
|
|
49
|
+
deliveryStatus: lateFeeDeliveryStatusSchema.nullable(),
|
|
50
|
+
sentAt: z.string().nullable(),
|
|
51
|
+
});
|
|
52
|
+
export const lateFeeReminderUsageSchema = z.object({
|
|
53
|
+
/** Platform-wide: are there WhatsApp credentials at all. */
|
|
54
|
+
configured: z.boolean(),
|
|
55
|
+
/** This gym's monthly allowance. 0 means the plan does not include it. */
|
|
56
|
+
cap: z.number(),
|
|
57
|
+
used: z.number(),
|
|
58
|
+
remaining: z.number(),
|
|
59
|
+
recent: z.array(lateFeeReminderEntrySchema),
|
|
60
|
+
});
|
|
61
|
+
//# sourceMappingURL=late-fee-reminder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"late-fee-reminder.js","sourceRoot":"","sources":["../src/late-fee-reminder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC;AAGtF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;AAG3F;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC,MAAM,CAAC;IACjD,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,MAAM,EAAE,yBAAyB;IACjC,cAAc,EAAE,2BAA2B,CAAC,QAAQ,EAAE;IACtD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC,MAAM,CAAC;IACjD,4DAA4D;IAC5D,UAAU,EAAE,CAAC,CAAC,OAAO,EAAE;IACvB,0EAA0E;IAC1E,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;IACf,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,0BAA0B,CAAC;CAC5C,CAAC,CAAC","sourcesContent":["/**\n * Late-fee reminder contracts\n * ===========================\n * What the owner's WhatsApp reminder screen reads. One shape, defined once,\n * because the backend builds it and the web client renders it and a drift\n * between the two shows up as an empty card rather than as an error.\n *\n * ── ⚠️ THREE ZEROES THAT MEAN DIFFERENT THINGS ──────────────────────────────\n * `configured: false` is the PLATFORM having no WhatsApp credentials — nobody\n * on GymMonk is being messaged, and no gym can fix it.\n * `cap: 0` is THIS GYM on a plan without the allowance. An upgrade fixes it.\n * `used: 0` is a gym with the allowance and nobody overdue, which is healthy.\n *\n * They are separate fields rather than one number because a screen that renders\n * \"0 reminders\" for all three tells an owner nothing about which one to act on,\n * and two of the three are not problems at all.\n *\n * @module late-fee-reminder\n */\n\nimport { z } from 'zod';\n\n/**\n * Our lifecycle for one reminder, which is NOT Meta's.\n *\n * `sending` is a reservation written before the gateway is called, so a row can\n * sit here briefly during a sweep. `undeliverable` is terminal and, importantly,\n * does not consume the gym's allowance.\n */\nexport const lateFeeNoticeStatusSchema = z.enum(['sending', 'sent', 'undeliverable']);\nexport type LateFeeNoticeStatus = z.infer<typeof lateFeeNoticeStatusSchema>;\n\n/**\n * META's outcome, which arrives on a webhook minutes after the send.\n *\n * ⚠️ `sent` here is not the status above. Ours means \"Meta accepted it\"; this\n * one means Meta says it left. Null means no webhook has arrived yet, which is\n * ordinary for a reminder sent seconds ago and suspicious for one sent a day\n * ago — the difference is worth showing rather than flattening to \"sent\".\n */\nexport const lateFeeDeliveryStatusSchema = z.enum(['sent', 'delivered', 'read', 'failed']);\nexport type LateFeeDeliveryStatus = z.infer<typeof lateFeeDeliveryStatusSchema>;\n\n/**\n * One row of the owner's history.\n *\n * `memberName` is nullable because the stored notice deliberately holds no\n * personal data — the name is resolved at read time from the enrolment, and a\n * member who has since been erased correctly resolves to nothing.\n */\nexport const lateFeeReminderEntrySchema = z.object({\n id: z.string(),\n memberName: z.string().nullable(),\n status: lateFeeNoticeStatusSchema,\n deliveryStatus: lateFeeDeliveryStatusSchema.nullable(),\n sentAt: z.string().nullable(),\n});\nexport type LateFeeReminderEntry = z.infer<typeof lateFeeReminderEntrySchema>;\n\nexport const lateFeeReminderUsageSchema = z.object({\n /** Platform-wide: are there WhatsApp credentials at all. */\n configured: z.boolean(),\n /** This gym's monthly allowance. 0 means the plan does not include it. */\n cap: z.number(),\n used: z.number(),\n remaining: z.number(),\n recent: z.array(lateFeeReminderEntrySchema),\n});\nexport type LateFeeReminderUsage = z.infer<typeof lateFeeReminderUsageSchema>;\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saas-catalog.d.ts","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;
|
|
1
|
+
{"version":3,"file":"saas-catalog.d.ts","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"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"}
|
package/dist/saas-catalog.js
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
*
|
|
57
57
|
* @module gymmonk-schema/saas-catalog
|
|
58
58
|
*/
|
|
59
|
+
import { gymDay } from './common.js';
|
|
59
60
|
export const SAAS_TENURES = [
|
|
60
61
|
{ id: '6m', label: '6 months', months: 6 },
|
|
61
62
|
{ id: '12m', label: '12 months', months: 12 },
|
|
@@ -114,7 +115,9 @@ export const TRIAL_DAYS = 7;
|
|
|
114
115
|
export function trialEndsOn(from, days = TRIAL_DAYS) {
|
|
115
116
|
const end = new Date(from);
|
|
116
117
|
end.setDate(end.getDate() + days);
|
|
117
|
-
|
|
118
|
+
// The GYM's day, not the UTC one. A trial started at 01:00 IST used to be
|
|
119
|
+
// stamped with yesterday's UTC date and so ran a day short.
|
|
120
|
+
return gymDay(end);
|
|
118
121
|
}
|
|
119
122
|
/**
|
|
120
123
|
* Whole days left before `endsOn`, floored at zero.
|
|
@@ -127,7 +130,11 @@ export function daysLeftUntil(endsOn, today = new Date()) {
|
|
|
127
130
|
if (!endsOn)
|
|
128
131
|
return 0;
|
|
129
132
|
const end = Date.parse(`${endsOn}T00:00:00Z`);
|
|
130
|
-
|
|
133
|
+
// Both sides parsed as UTC midnight so the subtraction is whole days, but the
|
|
134
|
+
// DAY itself is read in the gym's zone — `entitlementOf` decides expiry on
|
|
135
|
+
// that same calendar, and a countdown that disagreed with the gate told a
|
|
136
|
+
// customer they had a day they did not.
|
|
137
|
+
const now = Date.parse(`${gymDay(today)}T00:00:00Z`);
|
|
131
138
|
if (Number.isNaN(end))
|
|
132
139
|
return 0;
|
|
133
140
|
return Math.max(0, Math.round((end - now) / 86_400_000));
|
package/dist/saas-catalog.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saas-catalog.js","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — The GymMonk price list\n * =======================================\n * ONE catalog. Every surface that quotes a GymMonk price reads this file: the\n * marketing site, the gym-registration wizard, the owner's manage-subscription\n * screen, and the legal Pricing document.\n *\n * It exists because there were FOUR, and they disagreed:\n *\n * shared/site/plans.ts free / starter 399 / pro 999 / elite 2499\n * features/owner/subscription/data the same four, same prices\n * features/gym/lib/gym-plans.ts free / starter 999 / growth 2499 / pro 4999\n * app/dummy/pricing/catalog.ts an unwired three-tier proposal\n *\n * So `pro` meant ₹999 on one screen and ₹4,999 on another, and the tenure an\n * owner picked during registration (`12m`) was an id the manage screen did not\n * recognise (`1y`). A second hand-written copy of a PRICE is the one kind of\n * duplication guaranteed to be noticed — by a customer, or by the payment\n * gateway comparing two of our own pages.\n *\n * ── PRICES ARE ALL-IN. GST IS ALREADY INSIDE ────────────────────────────────\n * SaaS attracts 18% GST in India. Every number here is what the gateway charges.\n *\n * Quoting tax-exclusive and adding 18% at the last step is the standard B2B\n * pattern and the wrong one for this audience: an owner who approves ₹999 and is\n * charged ₹1,179 does not read it as \"plus GST\", they read it as a bait. The\n * headline keeps its shape and the invoice split is shown underneath — see\n * `gstSplit`.\n *\n * ── LIST PRICE vs CHARGED PRICE, AND WHY BOTH ARE REAL ──────────────────────\n * Each plan carries a `listMonthly` (the MRP) and an explicit charge for each\n * term. Both terms are discounted against the MRP, so the cards read \"35% off\"\n * rather than \"0% off\" — the structure find-partner uses, where\n * `baseMonthlyPrice` anchors and `finalPrice` is what is taken.\n *\n * THE LIST PRICE IS THE INTENDED PRICE, not a decoration. GymMonk is launching\n * under it to get gyms on the product, and will close the gap by SHRINKING THE\n * DISCOUNT rather than by moving the MRP. That matters commercially and legally:\n *\n * commercially — a customer sees the same anchor and a smaller \"save %\", which\n * reads as an offer ending rather than a price rise. Far less\n * churn than editing the headline number.\n * legally — India's CCPA rules on misleading advertisement are aimed at a\n * struck-through price that was never real. The MRP here is the\n * published rate every discount is genuinely taken off, and\n * `assertAdvertisedDiscounts` fails the build if a price edit\n * ever makes the advertised percentage untrue. Present it as\n * MRP, never as \"was\".\n *\n * ── THE CHARGE IS STORED, NOT COMPUTED ──────────────────────────────────────\n * `prices` holds the actual rupee figure per term and the percentage is DERIVED\n * for display. The other way round cannot land on a price anyone would print:\n * 35% off ₹23,076 is ₹14,999.40. The MRP is what absorbs the awkward decimal\n * here, precisely so the CHARGE stays a number a customer recognises.\n * find-partner stores `finalPrice` for the same reason.\n *\n * @module gymmonk-schema/saas-catalog\n */\n\nimport type { SaasPlanId } from './plan-limits.js';\n\n// ─── Tenures ─────────────────────────────────────────────────────────────────\n\n/**\n * The billing terms.\n *\n * TWO, and both are long. The monthly and quarterly terms were removed on a\n * product call, and the reasoning is worth keeping because it is not obvious:\n *\n * - Gym software is only worth anything once a roster is IN it. A month is\n * not long enough to finish onboarding a gym's members, so a monthly term\n * sells a trial at full price and churns.\n * - A gym's own memberships are sold in half-years and years. Matching the\n * terms an owner already prices their own product in makes the decision\n * familiar rather than novel.\n *\n * The yearly id is `12m`, NOT `1y`. The shared pricing kit keys `TENURE_MONTHS`\n * on `12m`, and an older catalog emitted `1y`, so\n * `TierCard.pickDefaultHeroTenure` silently dropped the annual option —\n * `TENURE_MONTHS['1y'] ?? 0` is `0` and it requires `>= 1`. The best-value term\n * was missing from every card, and its \"when billed yearly\" footnote with it.\n *\n * ⚠️ `1m` IS GONE. It used to be the headline rate every card anchored on, so\n * anything reading `prices['1m']` reads `undefined` now — see `monthlyPrice`,\n * which is why that helper exists rather than being inlined.\n */\nexport type SaasTenureId = '6m' | '12m';\n\nexport interface SaasTenure {\n id: SaasTenureId;\n label: string;\n months: number;\n}\n\nexport const SAAS_TENURES: SaasTenure[] = [\n { id: '6m', label: '6 months', months: 6 },\n { id: '12m', label: '12 months', months: 12 },\n];\n\n/** The term a card leads with, and the one the coupons apply to. */\nexport const DEFAULT_SAAS_TENURE: SaasTenureId = '12m';\n\nexport function getSaasTenure(id: SaasTenureId): SaasTenure | undefined {\n return SAAS_TENURES.find((t) => t.id === id);\n}\n\n// ─── GST ─────────────────────────────────────────────────────────────────────\n\n/** India's SaaS rate. Already inside every price in this file. */\nexport const GST_PCT = 18;\n\n/**\n * Split an all-in amount into the base and the tax already inside it, for the\n * invoice line. Rounded to whole rupees, which is how it is charged.\n */\nexport function gstSplit(inclusiveAmount: number): { base: number; gst: number } {\n const base = Math.round(inclusiveAmount / (1 + GST_PCT / 100));\n return { base, gst: inclusiveAmount - base };\n}\n\n// ─── Trial ───────────────────────────────────────────────────────────────────\n\n/**\n * Days of Pro that every new gym gets, free and without a card.\n *\n * ── SEVEN, AND WHY IT IS SHORT ON PURPOSE ───────────────────────────────────\n * The trial is not a sales window, it is a CALIBRATION. Its job is to let an\n * owner use the whole product for long enough to know the shape of it, and then\n * to take it away while the memory is fresh. A gym that drops to Free on day\n * eight knows exactly what the thirty-member cutoff and the missing filters\n * cost them, because they were using the alternative yesterday.\n *\n * A fortnight blunts that. Long trials get treated as the product, the last\n * week is spent not thinking about it, and the downgrade reads as a new\n * restriction rather than as a loss.\n *\n * ── WHO GETS IT ─────────────────────────────────────────────────────────────\n * Only gyms arriving on FREE. A gym that pays gets no trial and no free days —\n * their term starts the day they pay, which is the only honest thing to do with\n * money already taken. `startProTrial` is the single place this is granted, and\n * `$setOnInsert` means it is granted exactly once per owner, ever.\n *\n * Requiring a card up front is the largest drop-off in Indian SMB software, and\n * it filters out precisely the cautious owner the trial exists to convince — so\n * there is no card, no gateway and nothing to cancel.\n */\nexport const TRIAL_DAYS = 7;\n\n/**\n * The day a trial started today would end, as `YYYY-MM-DD`.\n *\n * ── THE END DATE IS EXCLUSIVE ───────────────────────────────────────────────\n * Access runs while `today < currentPeriodEnd`, so a 7-day trial started on the\n * 1st ends on the 8th and the last usable day is the 7th — seven days, not\n * eight. Treating the end date as inclusive is the classic off-by-one here, and\n * it is the kind that only shows up as \"why did my week last eight days\".\n */\nexport function trialEndsOn(from: Date, days: number = TRIAL_DAYS): string {\n const end = new Date(from);\n end.setDate(end.getDate() + days);\n return end.toISOString().slice(0, 10);\n}\n\n/**\n * Whole days left before `endsOn`, floored at zero.\n *\n * Shared so the banner counting down and the server deciding entitlement cannot\n * disagree about whether the last day counts. `0` means it is over — the caller\n * shows \"your trial has ended\", never \"0 days left\".\n */\nexport function daysLeftUntil(endsOn: string | null | undefined, today = new Date()): number {\n if (!endsOn) return 0;\n const end = Date.parse(`${endsOn}T00:00:00Z`);\n const now = Date.parse(`${today.toISOString().slice(0, 10)}T00:00:00Z`);\n if (Number.isNaN(end)) return 0;\n return Math.max(0, Math.round((end - now) / 86_400_000));\n}\n\n// ─── Plans ───────────────────────────────────────────────────────────────────\n\nexport interface SaasPlan {\n id: SaasPlanId;\n name: string;\n /** The one-line pitch. */\n tagline: string;\n /** Who it is for. The tagline sells; this qualifies, so the wrong buyer self-selects out. */\n audience: string;\n /**\n * MRP per month — the published rate every term is discounted from, and the\n * price GymMonk intends to charge once the launch discounts close. Never\n * present it as a former price; see the module note.\n */\n listMonthly: number;\n /** What each term actually charges, all-in for the WHOLE term. GST inside. */\n prices: Record<SaasTenureId, number>;\n /** Exactly one plan carries this — the tier we want gyms on. */\n target?: boolean;\n accent: string;\n gradient: string;\n}\n\n/**\n * ── HOW THESE NUMBERS WERE CHOSEN ───────────────────────────────────────────\n * The MRP is ₹1,923 a month — ₹23,076 for a year. It is not a round number\n * because it is DERIVED rather than picked: the annual charge is ₹14,999 and\n * the annual discount is 35%, so the MRP is whatever makes both of those true\n * at once (14999 / 0.65). Getting this backwards is the trap the module note\n * warns about — quoting a struck-through figure that does not actually produce\n * the advertised percentage is precisely what India's CCPA rules on misleading\n * advertisement are aimed at, and `assertAdvertisedDiscounts` fails the build\n * rather than letting it ship.\n *\n * 12 months MRP ₹23,076 → ₹14,999 35% off ₹1,250/mo\n * 6 months MRP ₹11,538 → ₹9,230 20% off ₹1,538/mo\n *\n * The half-year is deliberately the WORSE per-month rate. Two terms only work\n * as a choice if one of them is clearly better value, and the annual term is\n * the one worth defending: a gym that has paid for a year finishes onboarding\n * its roster, and a gym that has finished onboarding its roster renews.\n *\n * ── THE PRICE IS ~₹1,250 A MONTH, WHICH IS THE POINT ────────────────────────\n * A gym turning over one to three lakh a month spends well under 1% of revenue\n * here. That is the band where software stops being a decision. Owners who need\n * it cheaper have the coupons below rather than a weaker tier — a discount is\n * reversible and targeted; a third plan is neither.\n */\nexport const SAAS_PLANS: SaasPlan[] = [\n {\n id: 'free',\n name: 'Free',\n tagline: 'Your whole gym, on the app',\n audience: 'Any gym getting its members onto GymMonk',\n listMonthly: 0,\n prices: { '6m': 0, '12m': 0 },\n accent: '#6b8f8c',\n gradient: 'linear-gradient(140deg,#4a6a66,#2b3f3d)',\n },\n {\n id: 'pro',\n name: 'Pro',\n tagline: 'Everything, run on numbers',\n audience: 'Any gym past its first thirty members',\n // ₹14,999 / 0.65 = ₹23,076.9 → ₹23,076 for the year, ₹1,923 a month.\n listMonthly: 1923,\n // 20% off the half-year MRP, 35% off the annual one. Both are asserted.\n prices: { '6m': 9230, '12m': 14999 },\n target: true,\n accent: '#00b3a9',\n gradient: 'linear-gradient(140deg,#0f766e,#052e2b)',\n },\n];\n\nexport function getSaasPlan(id: SaasPlanId): SaasPlan | undefined {\n return SAAS_PLANS.find((p) => p.id === id);\n}\n\n/** The tier we want gyms on. */\nexport const TARGET_SAAS_PLAN: SaasPlanId = 'pro';\n\n/**\n * The headline monthly price — the EFFECTIVE rate on the best term.\n *\n * ⚠️ This used to be `prices['1m']`, an actual month-to-month charge. There is\n * no monthly term any more, so it is now derived from the annual one: ₹14,999\n * over twelve months is ₹1,250 a month. Every \"from ₹X/mo\" on the marketing\n * site and every `baseMonthlyPrice` the pricing kit sorts on comes through\n * here, which is why it stayed a function — inlining `prices['1m']` at those\n * call sites would now read `undefined` and render \"₹NaN\".\n *\n * Distinct from `listMonthly`, which is the MRP. Conflating them is the other\n * mistake this helper prevents: quoting the MRP as the price would overstate\n * every figure on the site by half.\n */\nexport function monthlyPrice(plan: SaasPlan): number {\n // Delegates to `quoteSaas` rather than dividing again. Two helpers computing\n // the same figure is how ₹1,249 and ₹1,250 ended up on one card; there is now\n // exactly one place that turns a term price into a per-month figure, and it\n // floors. See the note on `SaasQuote.perMonth`.\n return quoteSaas(plan.id, DEFAULT_SAAS_TENURE)?.perMonth ?? 0;\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\nexport interface SaasQuote {\n planId: SaasPlanId;\n tenureId: SaasTenureId;\n /** MRP for the whole term — `listMonthly × months`. The struck-through figure. */\n listTotal: number;\n /** What is actually charged for the whole term, GST inside. */\n total: number;\n /** `total` spread over the term, for the \"₹699/mo\" line. */\n perMonth: number;\n /** Rupees off the MRP for this term. */\n saving: number;\n /** Derived for display, rounded. Never used to compute `total`. */\n discountPct: number;\n}\n\n/**\n * What a plan on a term actually costs.\n *\n * `perMonth` is rounded for display; `total` is the charge and is never derived\n * from it. Rounding the per-month figure and multiplying back would make twelve\n * displayed months add up to something other than the invoice.\n */\nexport function quoteSaas(planId: SaasPlanId, tenureId: SaasTenureId): SaasQuote | undefined {\n const plan = getSaasPlan(planId);\n const tenure = getSaasTenure(tenureId);\n if (!plan || !tenure) return undefined;\n\n const listTotal = plan.listMonthly * tenure.months;\n const total = plan.prices[tenureId];\n\n return {\n planId,\n tenureId,\n listTotal,\n total,\n // ── FLOORED, and it must stay floored. ──\n //\n // Two reasons, and the first is the hard one:\n //\n // 1. The shared pricing kit computes its OWN per-month figure as\n // `Math.floor(finalPrice / months)` (`TierCard`). That component is kept\n // deliberately identical to jansathi's and is not ours to change, so\n // rounding here put ₹1,250 in the card's footnote beside the card's own\n // ₹1,249 — the same number, twice, differently, on one screen.\n // 2. It is the safe direction for a public claim. ₹1,249 × 12 is ₹14,988,\n // slightly UNDER the ₹14,999 actually charged, so \"₹1,249/mo when billed\n // yearly\" can never overstate what a customer gets.\n //\n // The CHARGE is always `total`; this figure is presentational and nothing\n // bills from it.\n perMonth: Math.floor(total / tenure.months),\n saving: listTotal - total,\n // A free plan has no MRP to discount from, and `0/0` is NaN — which would\n // render as \"NaN% off\" on the free card.\n discountPct: listTotal > 0 ? Math.round((1 - total / listTotal) * 100) : 0,\n };\n}\n\n/**\n * THE PERCENTAGES ON THE CARDS HAVE TO BE TRUE, asserted rather than hoped for.\n *\n * Every discount GymMonk advertises is derived from the MRP at render time, so\n * a price edit cannot make a card lie about the arithmetic — but it CAN make it\n * lie about the intent. The two figures below are the ones quoted in writing to\n * customers and in the published pricing document, so they are pinned:\n *\n * 12 months exactly 35% off the annual MRP\n * 6 months exactly 20% off the half-year MRP\n *\n * \"Exactly\" means after the same rounding the card does, so this asserts what a\n * customer can actually read on the screen rather than an unrounded ideal.\n *\n * The legal weight is real: a struck-through price that does not produce the\n * advertised saving is what India's CCPA rules on misleading advertisement are\n * written for, and the refund policy is already published. Called by the test\n * suite.\n */\nexport const ADVERTISED_DISCOUNT_PCT: Record<SaasTenureId, number> = {\n '6m': 20,\n '12m': 35,\n};\n\nexport function advertisedDiscountsHold(): boolean {\n return SAAS_TENURES.every((t) => {\n const q = quoteSaas(TARGET_SAAS_PLAN, t.id);\n return !!q && q.discountPct === ADVERTISED_DISCOUNT_PCT[t.id];\n });\n}\n\n/** Throws with the actual figures when an advertised discount stops being true. */\nexport function assertAdvertisedDiscounts(): void {\n for (const t of SAAS_TENURES) {\n const q = quoteSaas(TARGET_SAAS_PLAN, t.id);\n const want = ADVERTISED_DISCOUNT_PCT[t.id];\n if (q && q.discountPct === want) continue;\n throw new Error(\n `Advertised discount broken on ${t.id}: the card will read ${q?.discountPct ?? '?'}% off ` +\n `(${inr(q?.listTotal ?? 0)} → ${inr(q?.total ?? 0)}) but ${want}% is what we publish. ` +\n `Fix the price or the MRP — see ADVERTISED_DISCOUNT_PCT.`,\n );\n }\n}\n\n/** ₹ with Indian digit grouping: 8388 → \"₹8,388\". */\nexport function inr(amount: number): string {\n return `₹${amount.toLocaleString('en-IN')}`;\n}\n\n// ─── Coupons ─────────────────────────────────────────────────────────────────\n\n/**\n * The discount codes, and why they exist instead of a cheaper tier.\n *\n * A small gym that cannot reach ₹14,999 is not a different PRODUCT — it wants\n * everything Pro does, at a price it can carry. The old answer to that was a\n * weaker tier, which permanently published a lesser version of GymMonk and let\n * anyone self-select into it. A coupon does the same commercial job with none of\n * that: it is targeted (we decide who gets the code), reversible (stop issuing\n * it and the price is back), and invisible to everyone who does not have one, so\n * the published price stays ₹14,999.\n *\n * ── ANNUAL ONLY, DELIBERATELY ───────────────────────────────────────────────\n * Both codes are valid on the twelve-month term and nothing else. A discounted\n * half-year would undercut the annual term it is meant to sell, and the whole\n * point of a concession is that it buys us the longer commitment.\n *\n * ── ONE RAZORPAY PLAN PER PRICE POINT ───────────────────────────────────────\n * Razorpay subscriptions bill against a PLAN, and a plan carries its amount, so\n * a discount is not a modifier on an existing plan — it is a different plan id.\n * FOUR exist in total: the two published terms, plus one per coupon.\n *\n * The ids themselves are NOT here. They differ between the test and live\n * Razorpay accounts, so putting them in a package published to npm would ship a\n * live billing identifier into every consumer and make the test account\n * unusable. Each entry carries a stable KEY instead, and the backend resolves it\n * to an id from its own environment — see `razorpayPlanKey`.\n */\nexport interface SaasCoupon {\n /** What the owner types. Compared case-insensitively; stored uppercase. */\n code: string;\n /** Shown on the card once it applies, e.g. \"Small gym price\". */\n label: string;\n /** The tier it discounts. Only the paid one can be discounted. */\n planId: SaasPlanId;\n /** The single term it is valid on. */\n tenureId: SaasTenureId;\n /** The whole-term charge after the code, all-in with GST inside. */\n price: number;\n /**\n * Stable handle for the Razorpay plan that bills this amount.\n *\n * The backend maps it to a real plan id through its environment. A key with\n * no mapping must fail LOUDLY at checkout rather than falling back to the\n * undiscounted plan — a customer who was promised ₹9,999 and is charged\n * ₹14,999 is a chargeback and a refund request, not a rounding error.\n */\n razorpayPlanKey: string;\n}\n\nexport const SAAS_COUPONS: SaasCoupon[] = [\n {\n code: 'SMALLGYM',\n label: 'Small gym price',\n planId: 'pro',\n tenureId: '12m',\n price: 9999,\n razorpayPlanKey: 'pro_12m_smallgym',\n },\n {\n code: 'NEWGYM',\n label: 'New gym price',\n planId: 'pro',\n tenureId: '12m',\n price: 12999,\n razorpayPlanKey: 'pro_12m_newgym',\n },\n];\n\n/** Every Razorpay plan that must exist, published terms and coupons alike. */\nexport const RAZORPAY_PLAN_KEYS: string[] = [\n ...SAAS_TENURES.map((t) => `${TARGET_SAAS_PLAN}_${t.id}`),\n ...SAAS_COUPONS.map((c) => c.razorpayPlanKey),\n];\n\n/**\n * Which Razorpay plan bills this exact selection.\n *\n * ONE place decides, because the coupon does not modify a plan's amount — it\n * selects a DIFFERENT plan that happens to charge less (see the note above).\n * Working that out at the call site is how a discounted checkout ends up\n * billing the full-price plan: the code is validated, the label is shown, and\n * the customer is charged ₹14,999 anyway.\n *\n * Returns `null` for the free tier, which bills nothing and needs no plan.\n * A coupon that does not apply to this plan and term is IGNORED here rather\n * than silently honoured — the caller validates it separately through\n * `applySaasCoupon`, which is the function allowed to reject.\n */\nexport function razorpayPlanKeyFor(\n planId: SaasPlanId,\n tenureId: SaasTenureId,\n couponCode?: string | null,\n): string | null {\n if (planId === 'free') return null;\n if (couponCode) {\n const applied = applySaasCoupon(couponCode, planId, tenureId);\n if (!isCouponRejected(applied)) return applied.coupon.razorpayPlanKey;\n }\n return `${planId}_${tenureId}`;\n}\n\n/** Look a code up. Case- and whitespace-insensitive, because people type it. */\nexport function getSaasCoupon(code: string | null | undefined): SaasCoupon | undefined {\n if (!code) return undefined;\n const wanted = code.trim().toUpperCase();\n return SAAS_COUPONS.find((c) => c.code === wanted);\n}\n\n/** Why a code was refused, so the screen can say something useful. */\nexport type CouponRejection = 'UNKNOWN_CODE' | 'WRONG_PLAN' | 'WRONG_TENURE';\n\nexport interface CouponApplication {\n coupon: SaasCoupon;\n /** The quote as it stands WITHOUT the code, for the struck-through figure. */\n base: SaasQuote;\n /** The quote as it stands WITH it. */\n discounted: SaasQuote;\n /** Rupees the code takes off the normal charge — not off the MRP. */\n couponSaving: number;\n}\n\n/**\n * Apply a code to a plan and term.\n *\n * Returns either the application or the REASON it was refused, because \"invalid\n * code\" is the wrong message for a code that is real but annual-only — the\n * owner would retype a correct code until they gave up.\n *\n * The discounted quote keeps the SAME MRP, so `discountPct` on it is the saving\n * against the list price (57% on ₹9,999) rather than against ₹14,999. That is\n * the honest reading and the one the card should lead with; `couponSaving`\n * carries the other number for \"you save a further ₹5,000\".\n */\nexport function applySaasCoupon(\n code: string,\n planId: SaasPlanId,\n tenureId: SaasTenureId,\n): CouponApplication | { rejected: CouponRejection } {\n const coupon = getSaasCoupon(code);\n if (!coupon) return { rejected: 'UNKNOWN_CODE' };\n if (coupon.planId !== planId) return { rejected: 'WRONG_PLAN' };\n if (coupon.tenureId !== tenureId) return { rejected: 'WRONG_TENURE' };\n\n const base = quoteSaas(planId, tenureId);\n if (!base) return { rejected: 'WRONG_PLAN' };\n\n const discounted: SaasQuote = {\n ...base,\n total: coupon.price,\n perMonth: Math.round(coupon.price / (getSaasTenure(tenureId)?.months ?? 12)),\n saving: base.listTotal - coupon.price,\n discountPct: base.listTotal > 0 ? Math.round((1 - coupon.price / base.listTotal) * 100) : 0,\n };\n\n return { coupon, base, discounted, couponSaving: base.total - coupon.price };\n}\n\n/** Narrowing helper — `applySaasCoupon` returns a union. */\nexport function isCouponRejected(\n result: CouponApplication | { rejected: CouponRejection },\n): result is { rejected: CouponRejection } {\n return 'rejected' in result;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"saas-catalog.js","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — The GymMonk price list\n * =======================================\n * ONE catalog. Every surface that quotes a GymMonk price reads this file: the\n * marketing site, the gym-registration wizard, the owner's manage-subscription\n * screen, and the legal Pricing document.\n *\n * It exists because there were FOUR, and they disagreed:\n *\n * shared/site/plans.ts free / starter 399 / pro 999 / elite 2499\n * features/owner/subscription/data the same four, same prices\n * features/gym/lib/gym-plans.ts free / starter 999 / growth 2499 / pro 4999\n * app/dummy/pricing/catalog.ts an unwired three-tier proposal\n *\n * So `pro` meant ₹999 on one screen and ₹4,999 on another, and the tenure an\n * owner picked during registration (`12m`) was an id the manage screen did not\n * recognise (`1y`). A second hand-written copy of a PRICE is the one kind of\n * duplication guaranteed to be noticed — by a customer, or by the payment\n * gateway comparing two of our own pages.\n *\n * ── PRICES ARE ALL-IN. GST IS ALREADY INSIDE ────────────────────────────────\n * SaaS attracts 18% GST in India. Every number here is what the gateway charges.\n *\n * Quoting tax-exclusive and adding 18% at the last step is the standard B2B\n * pattern and the wrong one for this audience: an owner who approves ₹999 and is\n * charged ₹1,179 does not read it as \"plus GST\", they read it as a bait. The\n * headline keeps its shape and the invoice split is shown underneath — see\n * `gstSplit`.\n *\n * ── LIST PRICE vs CHARGED PRICE, AND WHY BOTH ARE REAL ──────────────────────\n * Each plan carries a `listMonthly` (the MRP) and an explicit charge for each\n * term. Both terms are discounted against the MRP, so the cards read \"35% off\"\n * rather than \"0% off\" — the structure find-partner uses, where\n * `baseMonthlyPrice` anchors and `finalPrice` is what is taken.\n *\n * THE LIST PRICE IS THE INTENDED PRICE, not a decoration. GymMonk is launching\n * under it to get gyms on the product, and will close the gap by SHRINKING THE\n * DISCOUNT rather than by moving the MRP. That matters commercially and legally:\n *\n * commercially — a customer sees the same anchor and a smaller \"save %\", which\n * reads as an offer ending rather than a price rise. Far less\n * churn than editing the headline number.\n * legally — India's CCPA rules on misleading advertisement are aimed at a\n * struck-through price that was never real. The MRP here is the\n * published rate every discount is genuinely taken off, and\n * `assertAdvertisedDiscounts` fails the build if a price edit\n * ever makes the advertised percentage untrue. Present it as\n * MRP, never as \"was\".\n *\n * ── THE CHARGE IS STORED, NOT COMPUTED ──────────────────────────────────────\n * `prices` holds the actual rupee figure per term and the percentage is DERIVED\n * for display. The other way round cannot land on a price anyone would print:\n * 35% off ₹23,076 is ₹14,999.40. The MRP is what absorbs the awkward decimal\n * here, precisely so the CHARGE stays a number a customer recognises.\n * find-partner stores `finalPrice` for the same reason.\n *\n * @module gymmonk-schema/saas-catalog\n */\n\nimport { gymDay } from './common.js';\nimport type { SaasPlanId } from './plan-limits.js';\n\n// ─── Tenures ─────────────────────────────────────────────────────────────────\n\n/**\n * The billing terms.\n *\n * TWO, and both are long. The monthly and quarterly terms were removed on a\n * product call, and the reasoning is worth keeping because it is not obvious:\n *\n * - Gym software is only worth anything once a roster is IN it. A month is\n * not long enough to finish onboarding a gym's members, so a monthly term\n * sells a trial at full price and churns.\n * - A gym's own memberships are sold in half-years and years. Matching the\n * terms an owner already prices their own product in makes the decision\n * familiar rather than novel.\n *\n * The yearly id is `12m`, NOT `1y`. The shared pricing kit keys `TENURE_MONTHS`\n * on `12m`, and an older catalog emitted `1y`, so\n * `TierCard.pickDefaultHeroTenure` silently dropped the annual option —\n * `TENURE_MONTHS['1y'] ?? 0` is `0` and it requires `>= 1`. The best-value term\n * was missing from every card, and its \"when billed yearly\" footnote with it.\n *\n * ⚠️ `1m` IS GONE. It used to be the headline rate every card anchored on, so\n * anything reading `prices['1m']` reads `undefined` now — see `monthlyPrice`,\n * which is why that helper exists rather than being inlined.\n */\nexport type SaasTenureId = '6m' | '12m';\n\nexport interface SaasTenure {\n id: SaasTenureId;\n label: string;\n months: number;\n}\n\nexport const SAAS_TENURES: SaasTenure[] = [\n { id: '6m', label: '6 months', months: 6 },\n { id: '12m', label: '12 months', months: 12 },\n];\n\n/** The term a card leads with, and the one the coupons apply to. */\nexport const DEFAULT_SAAS_TENURE: SaasTenureId = '12m';\n\nexport function getSaasTenure(id: SaasTenureId): SaasTenure | undefined {\n return SAAS_TENURES.find((t) => t.id === id);\n}\n\n// ─── GST ─────────────────────────────────────────────────────────────────────\n\n/** India's SaaS rate. Already inside every price in this file. */\nexport const GST_PCT = 18;\n\n/**\n * Split an all-in amount into the base and the tax already inside it, for the\n * invoice line. Rounded to whole rupees, which is how it is charged.\n */\nexport function gstSplit(inclusiveAmount: number): { base: number; gst: number } {\n const base = Math.round(inclusiveAmount / (1 + GST_PCT / 100));\n return { base, gst: inclusiveAmount - base };\n}\n\n// ─── Trial ───────────────────────────────────────────────────────────────────\n\n/**\n * Days of Pro that every new gym gets, free and without a card.\n *\n * ── SEVEN, AND WHY IT IS SHORT ON PURPOSE ───────────────────────────────────\n * The trial is not a sales window, it is a CALIBRATION. Its job is to let an\n * owner use the whole product for long enough to know the shape of it, and then\n * to take it away while the memory is fresh. A gym that drops to Free on day\n * eight knows exactly what the thirty-member cutoff and the missing filters\n * cost them, because they were using the alternative yesterday.\n *\n * A fortnight blunts that. Long trials get treated as the product, the last\n * week is spent not thinking about it, and the downgrade reads as a new\n * restriction rather than as a loss.\n *\n * ── WHO GETS IT ─────────────────────────────────────────────────────────────\n * Only gyms arriving on FREE. A gym that pays gets no trial and no free days —\n * their term starts the day they pay, which is the only honest thing to do with\n * money already taken. `startProTrial` is the single place this is granted, and\n * `$setOnInsert` means it is granted exactly once per owner, ever.\n *\n * Requiring a card up front is the largest drop-off in Indian SMB software, and\n * it filters out precisely the cautious owner the trial exists to convince — so\n * there is no card, no gateway and nothing to cancel.\n */\nexport const TRIAL_DAYS = 7;\n\n/**\n * The day a trial started today would end, as `YYYY-MM-DD`.\n *\n * ── THE END DATE IS EXCLUSIVE ───────────────────────────────────────────────\n * Access runs while `today < currentPeriodEnd`, so a 7-day trial started on the\n * 1st ends on the 8th and the last usable day is the 7th — seven days, not\n * eight. Treating the end date as inclusive is the classic off-by-one here, and\n * it is the kind that only shows up as \"why did my week last eight days\".\n */\nexport function trialEndsOn(from: Date, days: number = TRIAL_DAYS): string {\n const end = new Date(from);\n end.setDate(end.getDate() + days);\n // The GYM's day, not the UTC one. A trial started at 01:00 IST used to be\n // stamped with yesterday's UTC date and so ran a day short.\n return gymDay(end);\n}\n\n/**\n * Whole days left before `endsOn`, floored at zero.\n *\n * Shared so the banner counting down and the server deciding entitlement cannot\n * disagree about whether the last day counts. `0` means it is over — the caller\n * shows \"your trial has ended\", never \"0 days left\".\n */\nexport function daysLeftUntil(endsOn: string | null | undefined, today = new Date()): number {\n if (!endsOn) return 0;\n const end = Date.parse(`${endsOn}T00:00:00Z`);\n // Both sides parsed as UTC midnight so the subtraction is whole days, but the\n // DAY itself is read in the gym's zone — `entitlementOf` decides expiry on\n // that same calendar, and a countdown that disagreed with the gate told a\n // customer they had a day they did not.\n const now = Date.parse(`${gymDay(today)}T00:00:00Z`);\n if (Number.isNaN(end)) return 0;\n return Math.max(0, Math.round((end - now) / 86_400_000));\n}\n\n// ─── Plans ───────────────────────────────────────────────────────────────────\n\nexport interface SaasPlan {\n id: SaasPlanId;\n name: string;\n /** The one-line pitch. */\n tagline: string;\n /** Who it is for. The tagline sells; this qualifies, so the wrong buyer self-selects out. */\n audience: string;\n /**\n * MRP per month — the published rate every term is discounted from, and the\n * price GymMonk intends to charge once the launch discounts close. Never\n * present it as a former price; see the module note.\n */\n listMonthly: number;\n /** What each term actually charges, all-in for the WHOLE term. GST inside. */\n prices: Record<SaasTenureId, number>;\n /** Exactly one plan carries this — the tier we want gyms on. */\n target?: boolean;\n accent: string;\n gradient: string;\n}\n\n/**\n * ── HOW THESE NUMBERS WERE CHOSEN ───────────────────────────────────────────\n * The MRP is ₹1,923 a month — ₹23,076 for a year. It is not a round number\n * because it is DERIVED rather than picked: the annual charge is ₹14,999 and\n * the annual discount is 35%, so the MRP is whatever makes both of those true\n * at once (14999 / 0.65). Getting this backwards is the trap the module note\n * warns about — quoting a struck-through figure that does not actually produce\n * the advertised percentage is precisely what India's CCPA rules on misleading\n * advertisement are aimed at, and `assertAdvertisedDiscounts` fails the build\n * rather than letting it ship.\n *\n * 12 months MRP ₹23,076 → ₹14,999 35% off ₹1,250/mo\n * 6 months MRP ₹11,538 → ₹9,230 20% off ₹1,538/mo\n *\n * The half-year is deliberately the WORSE per-month rate. Two terms only work\n * as a choice if one of them is clearly better value, and the annual term is\n * the one worth defending: a gym that has paid for a year finishes onboarding\n * its roster, and a gym that has finished onboarding its roster renews.\n *\n * ── THE PRICE IS ~₹1,250 A MONTH, WHICH IS THE POINT ────────────────────────\n * A gym turning over one to three lakh a month spends well under 1% of revenue\n * here. That is the band where software stops being a decision. Owners who need\n * it cheaper have the coupons below rather than a weaker tier — a discount is\n * reversible and targeted; a third plan is neither.\n */\nexport const SAAS_PLANS: SaasPlan[] = [\n {\n id: 'free',\n name: 'Free',\n tagline: 'Your whole gym, on the app',\n audience: 'Any gym getting its members onto GymMonk',\n listMonthly: 0,\n prices: { '6m': 0, '12m': 0 },\n accent: '#6b8f8c',\n gradient: 'linear-gradient(140deg,#4a6a66,#2b3f3d)',\n },\n {\n id: 'pro',\n name: 'Pro',\n tagline: 'Everything, run on numbers',\n audience: 'Any gym past its first thirty members',\n // ₹14,999 / 0.65 = ₹23,076.9 → ₹23,076 for the year, ₹1,923 a month.\n listMonthly: 1923,\n // 20% off the half-year MRP, 35% off the annual one. Both are asserted.\n prices: { '6m': 9230, '12m': 14999 },\n target: true,\n accent: '#00b3a9',\n gradient: 'linear-gradient(140deg,#0f766e,#052e2b)',\n },\n];\n\nexport function getSaasPlan(id: SaasPlanId): SaasPlan | undefined {\n return SAAS_PLANS.find((p) => p.id === id);\n}\n\n/** The tier we want gyms on. */\nexport const TARGET_SAAS_PLAN: SaasPlanId = 'pro';\n\n/**\n * The headline monthly price — the EFFECTIVE rate on the best term.\n *\n * ⚠️ This used to be `prices['1m']`, an actual month-to-month charge. There is\n * no monthly term any more, so it is now derived from the annual one: ₹14,999\n * over twelve months is ₹1,250 a month. Every \"from ₹X/mo\" on the marketing\n * site and every `baseMonthlyPrice` the pricing kit sorts on comes through\n * here, which is why it stayed a function — inlining `prices['1m']` at those\n * call sites would now read `undefined` and render \"₹NaN\".\n *\n * Distinct from `listMonthly`, which is the MRP. Conflating them is the other\n * mistake this helper prevents: quoting the MRP as the price would overstate\n * every figure on the site by half.\n */\nexport function monthlyPrice(plan: SaasPlan): number {\n // Delegates to `quoteSaas` rather than dividing again. Two helpers computing\n // the same figure is how ₹1,249 and ₹1,250 ended up on one card; there is now\n // exactly one place that turns a term price into a per-month figure, and it\n // floors. See the note on `SaasQuote.perMonth`.\n return quoteSaas(plan.id, DEFAULT_SAAS_TENURE)?.perMonth ?? 0;\n}\n\n// ─── Money ───────────────────────────────────────────────────────────────────\n\nexport interface SaasQuote {\n planId: SaasPlanId;\n tenureId: SaasTenureId;\n /** MRP for the whole term — `listMonthly × months`. The struck-through figure. */\n listTotal: number;\n /** What is actually charged for the whole term, GST inside. */\n total: number;\n /** `total` spread over the term, for the \"₹699/mo\" line. */\n perMonth: number;\n /** Rupees off the MRP for this term. */\n saving: number;\n /** Derived for display, rounded. Never used to compute `total`. */\n discountPct: number;\n}\n\n/**\n * What a plan on a term actually costs.\n *\n * `perMonth` is rounded for display; `total` is the charge and is never derived\n * from it. Rounding the per-month figure and multiplying back would make twelve\n * displayed months add up to something other than the invoice.\n */\nexport function quoteSaas(planId: SaasPlanId, tenureId: SaasTenureId): SaasQuote | undefined {\n const plan = getSaasPlan(planId);\n const tenure = getSaasTenure(tenureId);\n if (!plan || !tenure) return undefined;\n\n const listTotal = plan.listMonthly * tenure.months;\n const total = plan.prices[tenureId];\n\n return {\n planId,\n tenureId,\n listTotal,\n total,\n // ── FLOORED, and it must stay floored. ──\n //\n // Two reasons, and the first is the hard one:\n //\n // 1. The shared pricing kit computes its OWN per-month figure as\n // `Math.floor(finalPrice / months)` (`TierCard`). That component is kept\n // deliberately identical to jansathi's and is not ours to change, so\n // rounding here put ₹1,250 in the card's footnote beside the card's own\n // ₹1,249 — the same number, twice, differently, on one screen.\n // 2. It is the safe direction for a public claim. ₹1,249 × 12 is ₹14,988,\n // slightly UNDER the ₹14,999 actually charged, so \"₹1,249/mo when billed\n // yearly\" can never overstate what a customer gets.\n //\n // The CHARGE is always `total`; this figure is presentational and nothing\n // bills from it.\n perMonth: Math.floor(total / tenure.months),\n saving: listTotal - total,\n // A free plan has no MRP to discount from, and `0/0` is NaN — which would\n // render as \"NaN% off\" on the free card.\n discountPct: listTotal > 0 ? Math.round((1 - total / listTotal) * 100) : 0,\n };\n}\n\n/**\n * THE PERCENTAGES ON THE CARDS HAVE TO BE TRUE, asserted rather than hoped for.\n *\n * Every discount GymMonk advertises is derived from the MRP at render time, so\n * a price edit cannot make a card lie about the arithmetic — but it CAN make it\n * lie about the intent. The two figures below are the ones quoted in writing to\n * customers and in the published pricing document, so they are pinned:\n *\n * 12 months exactly 35% off the annual MRP\n * 6 months exactly 20% off the half-year MRP\n *\n * \"Exactly\" means after the same rounding the card does, so this asserts what a\n * customer can actually read on the screen rather than an unrounded ideal.\n *\n * The legal weight is real: a struck-through price that does not produce the\n * advertised saving is what India's CCPA rules on misleading advertisement are\n * written for, and the refund policy is already published. Called by the test\n * suite.\n */\nexport const ADVERTISED_DISCOUNT_PCT: Record<SaasTenureId, number> = {\n '6m': 20,\n '12m': 35,\n};\n\nexport function advertisedDiscountsHold(): boolean {\n return SAAS_TENURES.every((t) => {\n const q = quoteSaas(TARGET_SAAS_PLAN, t.id);\n return !!q && q.discountPct === ADVERTISED_DISCOUNT_PCT[t.id];\n });\n}\n\n/** Throws with the actual figures when an advertised discount stops being true. */\nexport function assertAdvertisedDiscounts(): void {\n for (const t of SAAS_TENURES) {\n const q = quoteSaas(TARGET_SAAS_PLAN, t.id);\n const want = ADVERTISED_DISCOUNT_PCT[t.id];\n if (q && q.discountPct === want) continue;\n throw new Error(\n `Advertised discount broken on ${t.id}: the card will read ${q?.discountPct ?? '?'}% off ` +\n `(${inr(q?.listTotal ?? 0)} → ${inr(q?.total ?? 0)}) but ${want}% is what we publish. ` +\n `Fix the price or the MRP — see ADVERTISED_DISCOUNT_PCT.`,\n );\n }\n}\n\n/** ₹ with Indian digit grouping: 8388 → \"₹8,388\". */\nexport function inr(amount: number): string {\n return `₹${amount.toLocaleString('en-IN')}`;\n}\n\n// ─── Coupons ─────────────────────────────────────────────────────────────────\n\n/**\n * The discount codes, and why they exist instead of a cheaper tier.\n *\n * A small gym that cannot reach ₹14,999 is not a different PRODUCT — it wants\n * everything Pro does, at a price it can carry. The old answer to that was a\n * weaker tier, which permanently published a lesser version of GymMonk and let\n * anyone self-select into it. A coupon does the same commercial job with none of\n * that: it is targeted (we decide who gets the code), reversible (stop issuing\n * it and the price is back), and invisible to everyone who does not have one, so\n * the published price stays ₹14,999.\n *\n * ── ANNUAL ONLY, DELIBERATELY ───────────────────────────────────────────────\n * Both codes are valid on the twelve-month term and nothing else. A discounted\n * half-year would undercut the annual term it is meant to sell, and the whole\n * point of a concession is that it buys us the longer commitment.\n *\n * ── ONE RAZORPAY PLAN PER PRICE POINT ───────────────────────────────────────\n * Razorpay subscriptions bill against a PLAN, and a plan carries its amount, so\n * a discount is not a modifier on an existing plan — it is a different plan id.\n * FOUR exist in total: the two published terms, plus one per coupon.\n *\n * The ids themselves are NOT here. They differ between the test and live\n * Razorpay accounts, so putting them in a package published to npm would ship a\n * live billing identifier into every consumer and make the test account\n * unusable. Each entry carries a stable KEY instead, and the backend resolves it\n * to an id from its own environment — see `razorpayPlanKey`.\n */\nexport interface SaasCoupon {\n /** What the owner types. Compared case-insensitively; stored uppercase. */\n code: string;\n /** Shown on the card once it applies, e.g. \"Small gym price\". */\n label: string;\n /** The tier it discounts. Only the paid one can be discounted. */\n planId: SaasPlanId;\n /** The single term it is valid on. */\n tenureId: SaasTenureId;\n /** The whole-term charge after the code, all-in with GST inside. */\n price: number;\n /**\n * Stable handle for the Razorpay plan that bills this amount.\n *\n * The backend maps it to a real plan id through its environment. A key with\n * no mapping must fail LOUDLY at checkout rather than falling back to the\n * undiscounted plan — a customer who was promised ₹9,999 and is charged\n * ₹14,999 is a chargeback and a refund request, not a rounding error.\n */\n razorpayPlanKey: string;\n}\n\nexport const SAAS_COUPONS: SaasCoupon[] = [\n {\n code: 'SMALLGYM',\n label: 'Small gym price',\n planId: 'pro',\n tenureId: '12m',\n price: 9999,\n razorpayPlanKey: 'pro_12m_smallgym',\n },\n {\n code: 'NEWGYM',\n label: 'New gym price',\n planId: 'pro',\n tenureId: '12m',\n price: 12999,\n razorpayPlanKey: 'pro_12m_newgym',\n },\n];\n\n/** Every Razorpay plan that must exist, published terms and coupons alike. */\nexport const RAZORPAY_PLAN_KEYS: string[] = [\n ...SAAS_TENURES.map((t) => `${TARGET_SAAS_PLAN}_${t.id}`),\n ...SAAS_COUPONS.map((c) => c.razorpayPlanKey),\n];\n\n/**\n * Which Razorpay plan bills this exact selection.\n *\n * ONE place decides, because the coupon does not modify a plan's amount — it\n * selects a DIFFERENT plan that happens to charge less (see the note above).\n * Working that out at the call site is how a discounted checkout ends up\n * billing the full-price plan: the code is validated, the label is shown, and\n * the customer is charged ₹14,999 anyway.\n *\n * Returns `null` for the free tier, which bills nothing and needs no plan.\n * A coupon that does not apply to this plan and term is IGNORED here rather\n * than silently honoured — the caller validates it separately through\n * `applySaasCoupon`, which is the function allowed to reject.\n */\nexport function razorpayPlanKeyFor(\n planId: SaasPlanId,\n tenureId: SaasTenureId,\n couponCode?: string | null,\n): string | null {\n if (planId === 'free') return null;\n if (couponCode) {\n const applied = applySaasCoupon(couponCode, planId, tenureId);\n if (!isCouponRejected(applied)) return applied.coupon.razorpayPlanKey;\n }\n return `${planId}_${tenureId}`;\n}\n\n/** Look a code up. Case- and whitespace-insensitive, because people type it. */\nexport function getSaasCoupon(code: string | null | undefined): SaasCoupon | undefined {\n if (!code) return undefined;\n const wanted = code.trim().toUpperCase();\n return SAAS_COUPONS.find((c) => c.code === wanted);\n}\n\n/** Why a code was refused, so the screen can say something useful. */\nexport type CouponRejection = 'UNKNOWN_CODE' | 'WRONG_PLAN' | 'WRONG_TENURE';\n\nexport interface CouponApplication {\n coupon: SaasCoupon;\n /** The quote as it stands WITHOUT the code, for the struck-through figure. */\n base: SaasQuote;\n /** The quote as it stands WITH it. */\n discounted: SaasQuote;\n /** Rupees the code takes off the normal charge — not off the MRP. */\n couponSaving: number;\n}\n\n/**\n * Apply a code to a plan and term.\n *\n * Returns either the application or the REASON it was refused, because \"invalid\n * code\" is the wrong message for a code that is real but annual-only — the\n * owner would retype a correct code until they gave up.\n *\n * The discounted quote keeps the SAME MRP, so `discountPct` on it is the saving\n * against the list price (57% on ₹9,999) rather than against ₹14,999. That is\n * the honest reading and the one the card should lead with; `couponSaving`\n * carries the other number for \"you save a further ₹5,000\".\n */\nexport function applySaasCoupon(\n code: string,\n planId: SaasPlanId,\n tenureId: SaasTenureId,\n): CouponApplication | { rejected: CouponRejection } {\n const coupon = getSaasCoupon(code);\n if (!coupon) return { rejected: 'UNKNOWN_CODE' };\n if (coupon.planId !== planId) return { rejected: 'WRONG_PLAN' };\n if (coupon.tenureId !== tenureId) return { rejected: 'WRONG_TENURE' };\n\n const base = quoteSaas(planId, tenureId);\n if (!base) return { rejected: 'WRONG_PLAN' };\n\n const discounted: SaasQuote = {\n ...base,\n total: coupon.price,\n perMonth: Math.round(coupon.price / (getSaasTenure(tenureId)?.months ?? 12)),\n saving: base.listTotal - coupon.price,\n discountPct: base.listTotal > 0 ? Math.round((1 - coupon.price / base.listTotal) * 100) : 0,\n };\n\n return { coupon, base, discounted, couponSaving: base.total - coupon.price };\n}\n\n/** Narrowing helper — `applySaasCoupon` returns a union. */\nexport function isCouponRejected(\n result: CouponApplication | { rejected: CouponRejection },\n): result is { rejected: CouponRejection } {\n return 'rejected' in result;\n}\n"]}
|
package/dist/user.d.ts
CHANGED
|
@@ -85,9 +85,21 @@ export declare const notificationPreferencesSchema: z.ZodObject<{
|
|
|
85
85
|
dailyWorkout: z.ZodBoolean;
|
|
86
86
|
calorieBurn: z.ZodBoolean;
|
|
87
87
|
membershipRenewal: z.ZodBoolean;
|
|
88
|
+
whatsappLateFee: z.ZodBoolean;
|
|
88
89
|
}, z.core.$strip>;
|
|
89
90
|
export type NotificationPreferences = z.infer<typeof notificationPreferencesSchema>;
|
|
90
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Everything on, nothing paused — what a new account gets, and what an account
|
|
93
|
+
* with no stored block resolves to.
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ `whatsappLateFee: true` is doing real work here, not padding the object.
|
|
96
|
+
* Every account created before this field existed has NO stored preferences at
|
|
97
|
+
* all, so they all read through to this default — which is the intended
|
|
98
|
+
* outcome under the opt-out ruling, but it means the value below is the actual
|
|
99
|
+
* consent position of the entire existing member base. Changing it to `false`
|
|
100
|
+
* silently switches the feature off for everyone who has never opened the
|
|
101
|
+
* preference screen.
|
|
102
|
+
*/
|
|
91
103
|
export declare const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences;
|
|
92
104
|
export declare const updateNotificationPreferencesBodySchema: z.ZodObject<{
|
|
93
105
|
pauseAll: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -95,6 +107,7 @@ export declare const updateNotificationPreferencesBodySchema: z.ZodObject<{
|
|
|
95
107
|
dailyWorkout: z.ZodOptional<z.ZodBoolean>;
|
|
96
108
|
calorieBurn: z.ZodOptional<z.ZodBoolean>;
|
|
97
109
|
membershipRenewal: z.ZodOptional<z.ZodBoolean>;
|
|
110
|
+
whatsappLateFee: z.ZodOptional<z.ZodBoolean>;
|
|
98
111
|
}, z.core.$strip>;
|
|
99
112
|
export type UpdateNotificationPreferencesBody = z.infer<typeof updateNotificationPreferencesBodySchema>;
|
|
100
113
|
/**
|
package/dist/user.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../src/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,kFAAkF;AAClF,eAAO,MAAM,aAAa;;;;;EAAkE,CAAC;AAC7F,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,eAAO,MAAM,gBAAgB;;;;;EAAyD,CAAC;AACvF,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;iBAiCnB,CAAC;AACH,MAAM,MAAM,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAI1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB;;;;;iBAQ/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B
|
|
1
|
+
{"version":3,"file":"user.d.ts","sourceRoot":"","sources":["../src/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,kFAAkF;AAClF,eAAO,MAAM,aAAa;;;;;EAAkE,CAAC;AAC7F,MAAM,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAEpD,eAAO,MAAM,gBAAgB;;;;;EAAyD,CAAC;AACvF,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;iBAiCnB,CAAC;AACH,MAAM,MAAM,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAI1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB;;;;;iBAQ/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B;;;;;;;iBAmCxC,CAAC;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAEpF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gCAAgC,EAAE,uBAO9C,CAAC;AAEF,eAAO,MAAM,uCAAuC;;;;;;;iBAA0C,CAAC;AAC/F,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CACrD,OAAO,uCAAuC,CAC/C,CAAC;AAIF;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B;;iBAEvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC"}
|
package/dist/user.js
CHANGED
|
@@ -91,14 +91,51 @@ export const notificationPreferencesSchema = z.object({
|
|
|
91
91
|
calorieBurn: z.boolean(),
|
|
92
92
|
/** "Your membership expires in N days". */
|
|
93
93
|
membershipRenewal: z.boolean(),
|
|
94
|
+
/**
|
|
95
|
+
* The WhatsApp late-fee reminder, and the ONLY switch a member has for it.
|
|
96
|
+
*
|
|
97
|
+
* ── WHY IT IS ON BY DEFAULT, WHICH NONE OF THE OTHERS HAD TO JUSTIFY ──────
|
|
98
|
+
* Product ruling: enrolling at a gym is consent to be told over WhatsApp that
|
|
99
|
+
* a fee is overdue. So this is opt-OUT, unlike anything else here, and that
|
|
100
|
+
* asymmetry is deliberate rather than an oversight — a reminder nobody
|
|
101
|
+
* receives is a debt nobody is told about.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ IT IS ALSO THE ONLY PREFERENCE THAT COSTS MONEY TO IGNORE. Meta bills
|
|
104
|
+
* per conversation, so a send to somebody who has said no is a charge against
|
|
105
|
+
* the gym's allowance AND a mark against the quality rating of the one
|
|
106
|
+
* business number every gym on the platform shares.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ `pauseAll` SILENCES THIS TOO. That follows this object's own stated
|
|
109
|
+
* contract — a paused account must not be woken by a kind added later — and
|
|
110
|
+
* it is the safe direction: under-sending is recoverable, an unwanted paid
|
|
111
|
+
* message is not.
|
|
112
|
+
*
|
|
113
|
+
* ⚠️ This flag is NOT the whole answer, because it can only speak for people
|
|
114
|
+
* who have an account. A member the gym wrote down but who never claimed
|
|
115
|
+
* theirs still receives reminders and can still reply STOP, which is recorded
|
|
116
|
+
* against their number instead. Both are checked before a send.
|
|
117
|
+
*/
|
|
118
|
+
whatsappLateFee: z.boolean(),
|
|
94
119
|
});
|
|
95
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Everything on, nothing paused — what a new account gets, and what an account
|
|
122
|
+
* with no stored block resolves to.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ `whatsappLateFee: true` is doing real work here, not padding the object.
|
|
125
|
+
* Every account created before this field existed has NO stored preferences at
|
|
126
|
+
* all, so they all read through to this default — which is the intended
|
|
127
|
+
* outcome under the opt-out ruling, but it means the value below is the actual
|
|
128
|
+
* consent position of the entire existing member base. Changing it to `false`
|
|
129
|
+
* silently switches the feature off for everyone who has never opened the
|
|
130
|
+
* preference screen.
|
|
131
|
+
*/
|
|
96
132
|
export const DEFAULT_NOTIFICATION_PREFERENCES = {
|
|
97
133
|
pauseAll: false,
|
|
98
134
|
gymTime: true,
|
|
99
135
|
dailyWorkout: true,
|
|
100
136
|
calorieBurn: true,
|
|
101
137
|
membershipRenewal: true,
|
|
138
|
+
whatsappLateFee: true,
|
|
102
139
|
};
|
|
103
140
|
export const updateNotificationPreferencesBodySchema = notificationPreferencesSchema.partial();
|
|
104
141
|
// ─── Account deletion ────────────────────────────────────────────────────────
|
package/dist/user.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.js","sourceRoot":"","sources":["../src/user.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEpG,gFAAgF;AAEhF,kFAAkF;AAClF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,eAAe,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAG7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,UAAU,EAAE,WAAW,EAAE,SAAS,CAAC,CAAC,CAAC;AAGvF,gFAAgF;AAEhF,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/B,EAAE,EAAE,cAAc;IAClB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;IACjB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC/B,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC5B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,aAAa,EAAE,CAAC,CAAC,OAAO,EAAE;IAC1B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,QAAQ,EAAE,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC;IAChC,MAAM,EAAE,gBAAgB;IACxB,SAAS,EAAE,iBAAiB;IAC5B,SAAS,EAAE,iBAAiB;CAC7B,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,SAAS,EAAE,qBAAqB,CAAC,EAAE,CAAC;IACpC,QAAQ,EAAE,qBAAqB,CAAC,EAAE,CAAC;IACnC,KAAK,EAAE,CAAC,CAAC,UAAU,CACjB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EACjE,WAAW,CAAC,QAAQ,EAAE,CACvB;IACD,MAAM,EAAE,qBAAqB,CAAC,IAAI,CAAC;CACpC,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC,MAAM,CAAC;IACpD,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE;IACrB,yDAAyD;IACzD,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,uCAAuC;IACvC,YAAY,EAAE,CAAC,CAAC,OAAO,EAAE;IACzB,sDAAsD;IACtD,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE;IACxB,2CAA2C;IAC3C,iBAAiB,EAAE,CAAC,CAAC,OAAO,EAAE;CAC/B,CAAC,CAAC;AAGH,+DAA+D;AAC/D,MAAM,CAAC,MAAM,gCAAgC,GAA4B;IACvE,QAAQ,EAAE,KAAK;IACf,OAAO,EAAE,IAAI;IACb,YAAY,EAAE,IAAI;IAClB,WAAW,EAAE,IAAI;IACjB,iBAAiB,EAAE,IAAI;CACxB,CAAC;AAEF,MAAM,CAAC,MAAM,uCAAuC,GAAG,6BAA6B,CAAC,OAAO,EAAE,CAAC;AAK/F,gFAAgF;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC,MAAM,CAAC;IACnD,WAAW,EAAE,iBAAiB;CAC/B,CAAC,CAAC","sourcesContent":["/**\n * gymmonk-schema — Authenticated user identity (\"me\")\n * ===================================================\n * The identity the whole app boots from — the shape of `GET /users/me`, mirrored\n * from the auth-synced `User`. This is the SSOT for the signed-in user's name,\n * avatar, email, phone, platform roles and status; the member/owner profile\n * modules layer product-specific data on top of it.\n *\n * @module gymmonk-schema/user\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema, optionalTrimmedString, phoneSchema } from './common.js';\n\n// ─── Platform roles & status ─────────────────────────────────────────────────\n\n/** Platform-wide roles (empty for most users). Distinct from gym owner/member. */\nexport const appRoleSchema = z.enum(['support_agent', 'moderator', 'manager', 'superadmin']);\nexport type AppRole = z.infer<typeof appRoleSchema>;\n\nexport const userStatusSchema = z.enum(['active', 'inactive', 'suspended', 'deleted']);\nexport type UserStatus = z.infer<typeof userStatusSchema>;\n\n// ─── The \"me\" entity ─────────────────────────────────────────────────────────\n\nexport const meSchema = z.object({\n id: objectIdSchema,\n email: z.string(),\n firstName: z.string(),\n lastName: z.string().nullable(),\n phone: z.string().nullable(),\n /**\n * Whether we have evidence this number reaches this person.\n *\n * True only when they opened a gym's WhatsApp invite link: the gym sent it to\n * that number, so whoever opened it holds that WhatsApp. A number the person\n * typed themselves is false, however true it happens to be — this records\n * what we have SEEN, not what we believe.\n *\n * It matters because the phone is the join key between an app account and a\n * gym's member record, and when two numbers disagree we have to prefer one.\n * The verified one wins.\n *\n * ⚠️ NOT a proof of ownership, and nothing security-bearing may treat it as\n * one. A WhatsApp message forwards, so this says the opener had the message,\n * not that they hold the SIM. It is an OTP's poor relation and is only ever\n * used to break a tie.\n *\n * ⚠️ Distinct from the roster's `verified`, which means \"this person has\n * claimed their record\". Two different facts, and a screen that shows both\n * must not let them read as one.\n */\n phoneVerified: z.boolean(),\n avatar: z.string().nullable(),\n appRoles: z.array(appRoleSchema),\n status: userStatusSchema,\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Me = z.infer<typeof meSchema>;\n\n// ─── Update the caller's own identity fields ─────────────────────────────────\n\n/**\n * Patch the product-owned identity fields (name/phone/avatar).\n * `email` is auth-owned and never updated here.\n *\n * A user has exactly TWO name fields and no denormalised third one. There used\n * to be a `displayName`, seeded once from the central-auth (Google) name and\n * never recomputed — and because every screen read it FIRST, the name a member\n * typed during onboarding was stored and never shown.\n *\n * The name is submitted as a UNIT: the profile screen edits it in one box and\n * splits on the first space, so a body carrying `firstName` is the complete\n * name and an absent `lastName` means the surname was deleted. The API applies\n * that rule (`shared/routes/user.route`), because `optionalTrimmedString` maps\n * an empty string to `undefined` and the wire cannot say \"clear it\" otherwise.\n */\nexport const updateUserBodySchema = z.object({\n firstName: optionalTrimmedString(80),\n lastName: optionalTrimmedString(80),\n phone: z.preprocess(\n (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v),\n phoneSchema.optional(),\n ),\n avatar: optionalTrimmedString(2000),\n});\nexport type UpdateUserBody = z.infer<typeof updateUserBodySchema>;\n\n// ─── Notification preferences ────────────────────────────────────────────────\n\n/**\n * What a person wants to be told about.\n *\n * `pauseAll` is a MASTER switch, not a sixth category: it silences everything\n * without the user having to remember which four they had turned on, and\n * un-pausing restores exactly what they had. The dispatcher checks it first\n * (see `notification/gym.dispatch`), so a paused account cannot be woken by a\n * new notification kind added later that nobody thought to gate.\n */\nexport const notificationPreferencesSchema = z.object({\n pauseAll: z.boolean(),\n /** Reminders before a booked or habitual gym session. */\n gymTime: z.boolean(),\n /** The day's assigned workout plan. */\n dailyWorkout: z.boolean(),\n /** Estimated calorie-burn updates through the day. */\n calorieBurn: z.boolean(),\n /** \"Your membership expires in N days\". */\n membershipRenewal: z.boolean(),\n});\nexport type NotificationPreferences = z.infer<typeof notificationPreferencesSchema>;\n\n/** Everything on, nothing paused — what a new account gets. */\nexport const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {\n pauseAll: false,\n gymTime: true,\n dailyWorkout: true,\n calorieBurn: true,\n membershipRenewal: true,\n};\n\nexport const updateNotificationPreferencesBodySchema = notificationPreferencesSchema.partial();\nexport type UpdateNotificationPreferencesBody = z.infer<\n typeof updateNotificationPreferencesBodySchema\n>;\n\n// ─── Account deletion ────────────────────────────────────────────────────────\n\n/**\n * A request to erase the account.\n *\n * Recorded, not executed. Deleting a gym owner cascades into the organisation,\n * its centres, every member and staff record, the membership ledger and the\n * payment history — some of which the business is legally required to keep for\n * a period. So the account is marked, signed out and queued for an operator,\n * which is also what \"right to erasure with a verification step\" looks like.\n */\nexport const accountDeletionRequestSchema = z.object({\n requestedAt: isoDateTimeSchema,\n});\nexport type AccountDeletionRequest = z.infer<typeof accountDeletionRequestSchema>;\n"]}
|
|
1
|
+
{"version":3,"file":"user.js","sourceRoot":"","sources":["../src/user.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Authenticated user identity (\"me\")\n * ===================================================\n * The identity the whole app boots from — the shape of `GET /users/me`, mirrored\n * from the auth-synced `User`. This is the SSOT for the signed-in user's name,\n * avatar, email, phone, platform roles and status; the member/owner profile\n * modules layer product-specific data on top of it.\n *\n * @module gymmonk-schema/user\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema, optionalTrimmedString, phoneSchema } from './common.js';\n\n// ─── Platform roles & status ─────────────────────────────────────────────────\n\n/** Platform-wide roles (empty for most users). Distinct from gym owner/member. */\nexport const appRoleSchema = z.enum(['support_agent', 'moderator', 'manager', 'superadmin']);\nexport type AppRole = z.infer<typeof appRoleSchema>;\n\nexport const userStatusSchema = z.enum(['active', 'inactive', 'suspended', 'deleted']);\nexport type UserStatus = z.infer<typeof userStatusSchema>;\n\n// ─── The \"me\" entity ─────────────────────────────────────────────────────────\n\nexport const meSchema = z.object({\n id: objectIdSchema,\n email: z.string(),\n firstName: z.string(),\n lastName: z.string().nullable(),\n phone: z.string().nullable(),\n /**\n * Whether we have evidence this number reaches this person.\n *\n * True only when they opened a gym's WhatsApp invite link: the gym sent it to\n * that number, so whoever opened it holds that WhatsApp. A number the person\n * typed themselves is false, however true it happens to be — this records\n * what we have SEEN, not what we believe.\n *\n * It matters because the phone is the join key between an app account and a\n * gym's member record, and when two numbers disagree we have to prefer one.\n * The verified one wins.\n *\n * ⚠️ NOT a proof of ownership, and nothing security-bearing may treat it as\n * one. A WhatsApp message forwards, so this says the opener had the message,\n * not that they hold the SIM. It is an OTP's poor relation and is only ever\n * used to break a tie.\n *\n * ⚠️ Distinct from the roster's `verified`, which means \"this person has\n * claimed their record\". Two different facts, and a screen that shows both\n * must not let them read as one.\n */\n phoneVerified: z.boolean(),\n avatar: z.string().nullable(),\n appRoles: z.array(appRoleSchema),\n status: userStatusSchema,\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Me = z.infer<typeof meSchema>;\n\n// ─── Update the caller's own identity fields ─────────────────────────────────\n\n/**\n * Patch the product-owned identity fields (name/phone/avatar).\n * `email` is auth-owned and never updated here.\n *\n * A user has exactly TWO name fields and no denormalised third one. There used\n * to be a `displayName`, seeded once from the central-auth (Google) name and\n * never recomputed — and because every screen read it FIRST, the name a member\n * typed during onboarding was stored and never shown.\n *\n * The name is submitted as a UNIT: the profile screen edits it in one box and\n * splits on the first space, so a body carrying `firstName` is the complete\n * name and an absent `lastName` means the surname was deleted. The API applies\n * that rule (`shared/routes/user.route`), because `optionalTrimmedString` maps\n * an empty string to `undefined` and the wire cannot say \"clear it\" otherwise.\n */\nexport const updateUserBodySchema = z.object({\n firstName: optionalTrimmedString(80),\n lastName: optionalTrimmedString(80),\n phone: z.preprocess(\n (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v),\n phoneSchema.optional(),\n ),\n avatar: optionalTrimmedString(2000),\n});\nexport type UpdateUserBody = z.infer<typeof updateUserBodySchema>;\n\n// ─── Notification preferences ────────────────────────────────────────────────\n\n/**\n * What a person wants to be told about.\n *\n * `pauseAll` is a MASTER switch, not a sixth category: it silences everything\n * without the user having to remember which four they had turned on, and\n * un-pausing restores exactly what they had. The dispatcher checks it first\n * (see `notification/gym.dispatch`), so a paused account cannot be woken by a\n * new notification kind added later that nobody thought to gate.\n */\nexport const notificationPreferencesSchema = z.object({\n pauseAll: z.boolean(),\n /** Reminders before a booked or habitual gym session. */\n gymTime: z.boolean(),\n /** The day's assigned workout plan. */\n dailyWorkout: z.boolean(),\n /** Estimated calorie-burn updates through the day. */\n calorieBurn: z.boolean(),\n /** \"Your membership expires in N days\". */\n membershipRenewal: z.boolean(),\n /**\n * The WhatsApp late-fee reminder, and the ONLY switch a member has for it.\n *\n * ── WHY IT IS ON BY DEFAULT, WHICH NONE OF THE OTHERS HAD TO JUSTIFY ──────\n * Product ruling: enrolling at a gym is consent to be told over WhatsApp that\n * a fee is overdue. So this is opt-OUT, unlike anything else here, and that\n * asymmetry is deliberate rather than an oversight — a reminder nobody\n * receives is a debt nobody is told about.\n *\n * ⚠️ IT IS ALSO THE ONLY PREFERENCE THAT COSTS MONEY TO IGNORE. Meta bills\n * per conversation, so a send to somebody who has said no is a charge against\n * the gym's allowance AND a mark against the quality rating of the one\n * business number every gym on the platform shares.\n *\n * ⚠️ `pauseAll` SILENCES THIS TOO. That follows this object's own stated\n * contract — a paused account must not be woken by a kind added later — and\n * it is the safe direction: under-sending is recoverable, an unwanted paid\n * message is not.\n *\n * ⚠️ This flag is NOT the whole answer, because it can only speak for people\n * who have an account. A member the gym wrote down but who never claimed\n * theirs still receives reminders and can still reply STOP, which is recorded\n * against their number instead. Both are checked before a send.\n */\n whatsappLateFee: z.boolean(),\n});\nexport type NotificationPreferences = z.infer<typeof notificationPreferencesSchema>;\n\n/**\n * Everything on, nothing paused — what a new account gets, and what an account\n * with no stored block resolves to.\n *\n * ⚠️ `whatsappLateFee: true` is doing real work here, not padding the object.\n * Every account created before this field existed has NO stored preferences at\n * all, so they all read through to this default — which is the intended\n * outcome under the opt-out ruling, but it means the value below is the actual\n * consent position of the entire existing member base. Changing it to `false`\n * silently switches the feature off for everyone who has never opened the\n * preference screen.\n */\nexport const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {\n pauseAll: false,\n gymTime: true,\n dailyWorkout: true,\n calorieBurn: true,\n membershipRenewal: true,\n whatsappLateFee: true,\n};\n\nexport const updateNotificationPreferencesBodySchema = notificationPreferencesSchema.partial();\nexport type UpdateNotificationPreferencesBody = z.infer<\n typeof updateNotificationPreferencesBodySchema\n>;\n\n// ─── Account deletion ────────────────────────────────────────────────────────\n\n/**\n * A request to erase the account.\n *\n * Recorded, not executed. Deleting a gym owner cascades into the organisation,\n * its centres, every member and staff record, the membership ledger and the\n * payment history — some of which the business is legally required to keep for\n * a period. So the account is marked, signed out and queued for an operator,\n * which is also what \"right to erasure with a verification step\" looks like.\n */\nexport const accountDeletionRequestSchema = z.object({\n requestedAt: isoDateTimeSchema,\n});\nexport type AccountDeletionRequest = z.infer<typeof accountDeletionRequestSchema>;\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gymmonk-schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS) — single source of truth (SSOT) consumed by gymmonk-backend and gymmonk-web-client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|