gymmonk-schema 0.63.0 → 0.64.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/index.d.ts CHANGED
@@ -40,6 +40,7 @@ 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';
43
44
  export * from './owner-profile.js';
44
45
  export * from './plan-abilities.js';
45
46
  export * from './plan-features-data.js';
@@ -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;AAClC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,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"}
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;AAElC,cAAc,cAAc,CAAC;AAC7B,cAAc,oBAAoB,CAAC;AAEnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,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,6 +54,8 @@ export * from './onboarding.js';
54
54
  export * from './onboarding-form.js';
55
55
  // ─── Gym structure & onboarding ──────────────────────────────────────────────
56
56
  export * from './organisation.js';
57
+ // ─── Razorpay checkout wire shapes ───────────────────────────────────────────
58
+ export * from './payment.js';
57
59
  export * from './owner-profile.js';
58
60
  // ─── Platform console (people + gyms directories, role keys) ─────────────────
59
61
  export * from './plan-abilities.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,cAAc,oBAAoB,CAAC;AACnC,gFAAgF;AAChF,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,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// ─── Platform console (people + gyms directories, role keys) ─────────────────\nexport * from './plan-abilities.js';\nexport * from './plan-features-data.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"]}
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,cAAc,oBAAoB,CAAC;AACnC,gFAAgF;AAChF,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,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';\n// ─── Razorpay checkout wire shapes ───────────────────────────────────────────\nexport * from './payment.js';\nexport * from './owner-profile.js';\n// ─── Platform console (people + gyms directories, role keys) ─────────────────\nexport * from './plan-abilities.js';\nexport * from './plan-features-data.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,165 @@
1
+ /**
2
+ * gymmonk-schema — Razorpay payment wire shapes
3
+ * =============================================
4
+ * The DTOs for buying a GymMonk SaaS subscription: create an order, then verify
5
+ * what checkout handed back. Ported from the reform platform's payment stack,
6
+ * which has been taking real money in production, rather than invented here.
7
+ *
8
+ * ── WHAT THE CLIENT MAY SAY, AND WHAT IT MAY NOT ────────────────────────────
9
+ * The order body names a PLAN and a TERM and, optionally, a coupon CODE. It
10
+ * never names an amount. The server prices the selection from `saas-catalog`
11
+ * and creates the Razorpay order for that figure, so a crafted request can ask
12
+ * to buy Pro but cannot ask what Pro costs. This is the same rule
13
+ * `subscribeBodySchema` already states for coupons, applied to the whole
14
+ * transaction.
15
+ *
16
+ * ── TWO FLOWS, AND WHY BOTH EXIST ───────────────────────────────────────────
17
+ * Razorpay bills either through the Orders API (a one-time charge) or the
18
+ * Subscriptions API (a recurring mandate against a pre-created plan id). The
19
+ * server decides which, and tells the client through `flow`, because the
20
+ * checkout options and the SIGNATURE FORMULA differ between them:
21
+ *
22
+ * order HMAC(order_id | payment_id)
23
+ * subscription HMAC(payment_id | subscription_id)
24
+ *
25
+ * Getting that pair the wrong way round produces a verification failure that
26
+ * looks exactly like tampering, which is why the flow is carried explicitly on
27
+ * the wire rather than inferred from which id happens to be present.
28
+ *
29
+ * Until real Razorpay plan ids are configured, every purchase falls back to the
30
+ * order flow — see `isPlaceholderRazorpayPlan`.
31
+ *
32
+ * @module gymmonk-schema/payment
33
+ */
34
+ import { z } from 'zod';
35
+ /**
36
+ * The prefix that marks a Razorpay plan id as NOT YET REAL.
37
+ *
38
+ * GymMonk needs four Razorpay plans to exist before the recurring flow can work
39
+ * (see `RAZORPAY_PLAN_KEYS`), and they cannot be created until there is a
40
+ * Razorpay account to create them in. Rather than block the whole feature on
41
+ * that, an unconfigured plan resolves to `plan_placeholder_<key>` and the
42
+ * server falls back to a one-time order for the same amount.
43
+ *
44
+ * This is the reform platform's approach and it matters more than it looks: the
45
+ * alternative is a half-built payment path that cannot be exercised until the
46
+ * account exists, so nobody finds out it is wrong until the day money is
47
+ * involved. With the fallback, the entire flow — order, checkout, signature,
48
+ * verification, activation — is live and testable now, and switching to
49
+ * recurring later changes one environment variable per plan.
50
+ */
51
+ export declare const RAZORPAY_PLACEHOLDER_PREFIX = "plan_placeholder";
52
+ /** Is this plan id a stand-in rather than a real Razorpay plan? */
53
+ export declare function isPlaceholderRazorpayPlan(planId: string | null | undefined): boolean;
54
+ export declare const paymentFlowSchema: z.ZodEnum<{
55
+ order: "order";
56
+ subscription: "subscription";
57
+ }>;
58
+ export type PaymentFlow = z.infer<typeof paymentFlowSchema>;
59
+ /**
60
+ * Where a payment order stands.
61
+ *
62
+ * Deliberately Razorpay's own lifecycle rather than a richer one of our own:
63
+ * `created → attempted → paid` for orders, plus `active` for a live
64
+ * subscription mandate. Inventing intermediate states is how a local status
65
+ * ends up disagreeing with the gateway's, and the gateway is the one holding
66
+ * the money.
67
+ */
68
+ export declare const paymentOrderStatusSchema: z.ZodEnum<{
69
+ active: "active";
70
+ expired: "expired";
71
+ paid: "paid";
72
+ failed: "failed";
73
+ created: "created";
74
+ attempted: "attempted";
75
+ authenticated: "authenticated";
76
+ refunded: "refunded";
77
+ }>;
78
+ export type PaymentOrderStatus = z.infer<typeof paymentOrderStatusSchema>;
79
+ export declare const createPaymentOrderBodySchema: z.ZodObject<{
80
+ planId: z.ZodEnum<{
81
+ free: "free";
82
+ pro: "pro";
83
+ }>;
84
+ tenureId: z.ZodString;
85
+ couponCode: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
86
+ }, z.core.$strip>;
87
+ export type CreatePaymentOrderBody = z.infer<typeof createPaymentOrderBodySchema>;
88
+ export declare const createPaymentOrderResultSchema: z.ZodObject<{
89
+ flow: z.ZodEnum<{
90
+ order: "order";
91
+ subscription: "subscription";
92
+ }>;
93
+ razorpayOrderId: z.ZodNullable<z.ZodString>;
94
+ razorpaySubscriptionId: z.ZodNullable<z.ZodString>;
95
+ amount: z.ZodNumber;
96
+ currency: z.ZodString;
97
+ keyId: z.ZodString;
98
+ orderId: z.ZodString;
99
+ }, z.core.$strip>;
100
+ export type CreatePaymentOrderResult = z.infer<typeof createPaymentOrderResultSchema>;
101
+ /**
102
+ * What checkout handed back, for signature verification.
103
+ *
104
+ * Both id fields are optional at the type level and exactly one is required per
105
+ * flow; the server enforces the pairing, because a body that names a flow it
106
+ * has no id for can only fail verification and should say so plainly.
107
+ */
108
+ export declare const verifyPaymentBodySchema: z.ZodObject<{
109
+ flow: z.ZodEnum<{
110
+ order: "order";
111
+ subscription: "subscription";
112
+ }>;
113
+ razorpayPaymentId: z.ZodString;
114
+ razorpaySignature: z.ZodString;
115
+ razorpayOrderId: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
116
+ razorpaySubscriptionId: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
117
+ }, z.core.$strip>;
118
+ export type VerifyPaymentBody = z.infer<typeof verifyPaymentBodySchema>;
119
+ /**
120
+ * One line of the owner's payment history.
121
+ *
122
+ * ── WHY THIS EXISTS AT ALL ──────────────────────────────────────────────────
123
+ * A gym that has just paid ₹14,999 had, until this, no record of it anywhere in
124
+ * the product: no receipt, no amount, no date, nothing to quote at support. The
125
+ * money left, the plan changed, and the app said nothing about the transaction.
126
+ *
127
+ * It is also a COMPLIANCE gap rather than only a courtesy one. GymMonk is
128
+ * Indian B2B SaaS, the price is all-in with 18% GST already inside it, and the
129
+ * buyer is a business that needs the tax component broken out to claim it. The
130
+ * catalog has computed `gstSplit` from the beginning for precisely this, and
131
+ * nothing was showing the result.
132
+ *
133
+ * ⚠️ AMOUNTS HERE ARE PAISE, matching Razorpay and the stored order. The client
134
+ * divides for display; nothing bills from these.
135
+ */
136
+ export declare const paymentReceiptSchema: z.ZodObject<{
137
+ id: z.ZodString;
138
+ planId: z.ZodEnum<{
139
+ free: "free";
140
+ pro: "pro";
141
+ }>;
142
+ tenureId: z.ZodString;
143
+ couponCode: z.ZodNullable<z.ZodString>;
144
+ baseAmount: z.ZodNumber;
145
+ gstAmount: z.ZodNumber;
146
+ totalAmount: z.ZodNumber;
147
+ currency: z.ZodString;
148
+ receipt: z.ZodString;
149
+ razorpayPaymentId: z.ZodNullable<z.ZodString>;
150
+ method: z.ZodNullable<z.ZodString>;
151
+ status: z.ZodEnum<{
152
+ active: "active";
153
+ expired: "expired";
154
+ paid: "paid";
155
+ failed: "failed";
156
+ created: "created";
157
+ attempted: "attempted";
158
+ authenticated: "authenticated";
159
+ refunded: "refunded";
160
+ }>;
161
+ paidAt: z.ZodNullable<z.ZodString>;
162
+ createdAt: z.ZodISODateTime;
163
+ }, z.core.$strip>;
164
+ export type PaymentReceipt = z.infer<typeof paymentReceiptSchema>;
165
+ //# sourceMappingURL=payment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment.d.ts","sourceRoot":"","sources":["../src/payment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,qBAAqB,CAAC;AAE9D,mEAAmE;AACnE,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEpF;AAID,eAAO,MAAM,iBAAiB;;;EAAoC,CAAC;AACnE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;;;;;;;;EASnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAI1E,eAAO,MAAM,4BAA4B;;;;;;;iBAYvC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAElF,eAAO,MAAM,8BAA8B;;;;;;;;;;;iBAazC,CAAC;AACH,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAItF;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;iBAMlC,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAIxE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAqB/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * gymmonk-schema — Razorpay payment wire shapes
3
+ * =============================================
4
+ * The DTOs for buying a GymMonk SaaS subscription: create an order, then verify
5
+ * what checkout handed back. Ported from the reform platform's payment stack,
6
+ * which has been taking real money in production, rather than invented here.
7
+ *
8
+ * ── WHAT THE CLIENT MAY SAY, AND WHAT IT MAY NOT ────────────────────────────
9
+ * The order body names a PLAN and a TERM and, optionally, a coupon CODE. It
10
+ * never names an amount. The server prices the selection from `saas-catalog`
11
+ * and creates the Razorpay order for that figure, so a crafted request can ask
12
+ * to buy Pro but cannot ask what Pro costs. This is the same rule
13
+ * `subscribeBodySchema` already states for coupons, applied to the whole
14
+ * transaction.
15
+ *
16
+ * ── TWO FLOWS, AND WHY BOTH EXIST ───────────────────────────────────────────
17
+ * Razorpay bills either through the Orders API (a one-time charge) or the
18
+ * Subscriptions API (a recurring mandate against a pre-created plan id). The
19
+ * server decides which, and tells the client through `flow`, because the
20
+ * checkout options and the SIGNATURE FORMULA differ between them:
21
+ *
22
+ * order HMAC(order_id | payment_id)
23
+ * subscription HMAC(payment_id | subscription_id)
24
+ *
25
+ * Getting that pair the wrong way round produces a verification failure that
26
+ * looks exactly like tampering, which is why the flow is carried explicitly on
27
+ * the wire rather than inferred from which id happens to be present.
28
+ *
29
+ * Until real Razorpay plan ids are configured, every purchase falls back to the
30
+ * order flow — see `isPlaceholderRazorpayPlan`.
31
+ *
32
+ * @module gymmonk-schema/payment
33
+ */
34
+ import { z } from 'zod';
35
+ import { isoDateTimeSchema, objectIdSchema, optionalTrimmedString } from './common.js';
36
+ import { saasPlanIdSchema } from './subscription.js';
37
+ // ─── Placeholder plan ids ────────────────────────────────────────────────────
38
+ /**
39
+ * The prefix that marks a Razorpay plan id as NOT YET REAL.
40
+ *
41
+ * GymMonk needs four Razorpay plans to exist before the recurring flow can work
42
+ * (see `RAZORPAY_PLAN_KEYS`), and they cannot be created until there is a
43
+ * Razorpay account to create them in. Rather than block the whole feature on
44
+ * that, an unconfigured plan resolves to `plan_placeholder_<key>` and the
45
+ * server falls back to a one-time order for the same amount.
46
+ *
47
+ * This is the reform platform's approach and it matters more than it looks: the
48
+ * alternative is a half-built payment path that cannot be exercised until the
49
+ * account exists, so nobody finds out it is wrong until the day money is
50
+ * involved. With the fallback, the entire flow — order, checkout, signature,
51
+ * verification, activation — is live and testable now, and switching to
52
+ * recurring later changes one environment variable per plan.
53
+ */
54
+ export const RAZORPAY_PLACEHOLDER_PREFIX = 'plan_placeholder';
55
+ /** Is this plan id a stand-in rather than a real Razorpay plan? */
56
+ export function isPlaceholderRazorpayPlan(planId) {
57
+ return !planId || planId.startsWith(RAZORPAY_PLACEHOLDER_PREFIX);
58
+ }
59
+ // ─── Flow ────────────────────────────────────────────────────────────────────
60
+ export const paymentFlowSchema = z.enum(['order', 'subscription']);
61
+ /**
62
+ * Where a payment order stands.
63
+ *
64
+ * Deliberately Razorpay's own lifecycle rather than a richer one of our own:
65
+ * `created → attempted → paid` for orders, plus `active` for a live
66
+ * subscription mandate. Inventing intermediate states is how a local status
67
+ * ends up disagreeing with the gateway's, and the gateway is the one holding
68
+ * the money.
69
+ */
70
+ export const paymentOrderStatusSchema = z.enum([
71
+ 'created',
72
+ 'attempted',
73
+ 'authenticated',
74
+ 'paid',
75
+ 'active',
76
+ 'expired',
77
+ 'failed',
78
+ 'refunded',
79
+ ]);
80
+ // ─── Create order ────────────────────────────────────────────────────────────
81
+ export const createPaymentOrderBodySchema = z.object({
82
+ planId: saasPlanIdSchema,
83
+ /** The term being bought. Required: there is no such thing as a paid plan with no term. */
84
+ tenureId: z.string().trim().min(1),
85
+ /**
86
+ * A discount code, if one was typed.
87
+ *
88
+ * ⚠️ SENT, NEVER TRUSTED — the same rule as `subscribeBodySchema`. The server
89
+ * re-runs `applySaasCoupon` and charges what IT computes. A code that does
90
+ * not apply is a 400 rather than a silent charge at full price.
91
+ */
92
+ couponCode: optionalTrimmedString(40),
93
+ });
94
+ export const createPaymentOrderResultSchema = z.object({
95
+ flow: paymentFlowSchema,
96
+ /** Present on the order flow. */
97
+ razorpayOrderId: z.string().nullable(),
98
+ /** Present on the subscription flow. */
99
+ razorpaySubscriptionId: z.string().nullable(),
100
+ /** Paise, all-in. What checkout will actually charge. */
101
+ amount: z.number().int().nonnegative(),
102
+ currency: z.string(),
103
+ /** The publishable key. Safe to send; the secret never leaves the server. */
104
+ keyId: z.string(),
105
+ /** Our own PaymentOrder row, for support and reconciliation. */
106
+ orderId: objectIdSchema,
107
+ });
108
+ // ─── Verify ──────────────────────────────────────────────────────────────────
109
+ /**
110
+ * What checkout handed back, for signature verification.
111
+ *
112
+ * Both id fields are optional at the type level and exactly one is required per
113
+ * flow; the server enforces the pairing, because a body that names a flow it
114
+ * has no id for can only fail verification and should say so plainly.
115
+ */
116
+ export const verifyPaymentBodySchema = z.object({
117
+ flow: paymentFlowSchema,
118
+ razorpayPaymentId: z.string().trim().min(1),
119
+ razorpaySignature: z.string().trim().min(1),
120
+ razorpayOrderId: optionalTrimmedString(120),
121
+ razorpaySubscriptionId: optionalTrimmedString(120),
122
+ });
123
+ // ─── Receipts ────────────────────────────────────────────────────────────────
124
+ /**
125
+ * One line of the owner's payment history.
126
+ *
127
+ * ── WHY THIS EXISTS AT ALL ──────────────────────────────────────────────────
128
+ * A gym that has just paid ₹14,999 had, until this, no record of it anywhere in
129
+ * the product: no receipt, no amount, no date, nothing to quote at support. The
130
+ * money left, the plan changed, and the app said nothing about the transaction.
131
+ *
132
+ * It is also a COMPLIANCE gap rather than only a courtesy one. GymMonk is
133
+ * Indian B2B SaaS, the price is all-in with 18% GST already inside it, and the
134
+ * buyer is a business that needs the tax component broken out to claim it. The
135
+ * catalog has computed `gstSplit` from the beginning for precisely this, and
136
+ * nothing was showing the result.
137
+ *
138
+ * ⚠️ AMOUNTS HERE ARE PAISE, matching Razorpay and the stored order. The client
139
+ * divides for display; nothing bills from these.
140
+ */
141
+ export const paymentReceiptSchema = z.object({
142
+ id: objectIdSchema,
143
+ /** What was bought, so a line reads "Pro, 12 months" rather than an amount. */
144
+ planId: saasPlanIdSchema,
145
+ tenureId: z.string(),
146
+ /** The code that explains a charge below list price, if any. */
147
+ couponCode: z.string().nullable(),
148
+ /** ⚠️ PAISE. `baseAmount + gstAmount === totalAmount`, always. */
149
+ baseAmount: z.number().int().nonnegative(),
150
+ gstAmount: z.number().int().nonnegative(),
151
+ totalAmount: z.number().int().nonnegative(),
152
+ currency: z.string(),
153
+ /** Our own reference. What support asks for. */
154
+ receipt: z.string(),
155
+ /** Razorpay's payment id — what the customer sees on their bank statement. */
156
+ razorpayPaymentId: z.string().nullable(),
157
+ /** card / upi / netbanking / wallet, as the gateway reported it. */
158
+ method: z.string().nullable(),
159
+ status: paymentOrderStatusSchema,
160
+ paidAt: z.string().nullable(),
161
+ createdAt: isoDateTimeSchema,
162
+ });
163
+ //# sourceMappingURL=payment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payment.js","sourceRoot":"","sources":["../src/payment.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Razorpay payment wire shapes\n * =============================================\n * The DTOs for buying a GymMonk SaaS subscription: create an order, then verify\n * what checkout handed back. Ported from the reform platform's payment stack,\n * which has been taking real money in production, rather than invented here.\n *\n * ── WHAT THE CLIENT MAY SAY, AND WHAT IT MAY NOT ────────────────────────────\n * The order body names a PLAN and a TERM and, optionally, a coupon CODE. It\n * never names an amount. The server prices the selection from `saas-catalog`\n * and creates the Razorpay order for that figure, so a crafted request can ask\n * to buy Pro but cannot ask what Pro costs. This is the same rule\n * `subscribeBodySchema` already states for coupons, applied to the whole\n * transaction.\n *\n * ── TWO FLOWS, AND WHY BOTH EXIST ───────────────────────────────────────────\n * Razorpay bills either through the Orders API (a one-time charge) or the\n * Subscriptions API (a recurring mandate against a pre-created plan id). The\n * server decides which, and tells the client through `flow`, because the\n * checkout options and the SIGNATURE FORMULA differ between them:\n *\n * order HMAC(order_id | payment_id)\n * subscription HMAC(payment_id | subscription_id)\n *\n * Getting that pair the wrong way round produces a verification failure that\n * looks exactly like tampering, which is why the flow is carried explicitly on\n * the wire rather than inferred from which id happens to be present.\n *\n * Until real Razorpay plan ids are configured, every purchase falls back to the\n * order flow — see `isPlaceholderRazorpayPlan`.\n *\n * @module gymmonk-schema/payment\n */\n\nimport { z } from 'zod';\nimport { isoDateTimeSchema, objectIdSchema, optionalTrimmedString } from './common.js';\nimport { saasPlanIdSchema } from './subscription.js';\n\n// ─── Placeholder plan ids ────────────────────────────────────────────────────\n\n/**\n * The prefix that marks a Razorpay plan id as NOT YET REAL.\n *\n * GymMonk needs four Razorpay plans to exist before the recurring flow can work\n * (see `RAZORPAY_PLAN_KEYS`), and they cannot be created until there is a\n * Razorpay account to create them in. Rather than block the whole feature on\n * that, an unconfigured plan resolves to `plan_placeholder_<key>` and the\n * server falls back to a one-time order for the same amount.\n *\n * This is the reform platform's approach and it matters more than it looks: the\n * alternative is a half-built payment path that cannot be exercised until the\n * account exists, so nobody finds out it is wrong until the day money is\n * involved. With the fallback, the entire flow — order, checkout, signature,\n * verification, activation — is live and testable now, and switching to\n * recurring later changes one environment variable per plan.\n */\nexport const RAZORPAY_PLACEHOLDER_PREFIX = 'plan_placeholder';\n\n/** Is this plan id a stand-in rather than a real Razorpay plan? */\nexport function isPlaceholderRazorpayPlan(planId: string | null | undefined): boolean {\n return !planId || planId.startsWith(RAZORPAY_PLACEHOLDER_PREFIX);\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────────────\n\nexport const paymentFlowSchema = z.enum(['order', 'subscription']);\nexport type PaymentFlow = z.infer<typeof paymentFlowSchema>;\n\n/**\n * Where a payment order stands.\n *\n * Deliberately Razorpay's own lifecycle rather than a richer one of our own:\n * `created → attempted → paid` for orders, plus `active` for a live\n * subscription mandate. Inventing intermediate states is how a local status\n * ends up disagreeing with the gateway's, and the gateway is the one holding\n * the money.\n */\nexport const paymentOrderStatusSchema = z.enum([\n 'created',\n 'attempted',\n 'authenticated',\n 'paid',\n 'active',\n 'expired',\n 'failed',\n 'refunded',\n]);\nexport type PaymentOrderStatus = z.infer<typeof paymentOrderStatusSchema>;\n\n// ─── Create order ────────────────────────────────────────────────────────────\n\nexport const createPaymentOrderBodySchema = z.object({\n planId: saasPlanIdSchema,\n /** The term being bought. Required: there is no such thing as a paid plan with no term. */\n tenureId: z.string().trim().min(1),\n /**\n * A discount code, if one was typed.\n *\n * ⚠️ SENT, NEVER TRUSTED — the same rule as `subscribeBodySchema`. The server\n * re-runs `applySaasCoupon` and charges what IT computes. A code that does\n * not apply is a 400 rather than a silent charge at full price.\n */\n couponCode: optionalTrimmedString(40),\n});\nexport type CreatePaymentOrderBody = z.infer<typeof createPaymentOrderBodySchema>;\n\nexport const createPaymentOrderResultSchema = z.object({\n flow: paymentFlowSchema,\n /** Present on the order flow. */\n razorpayOrderId: z.string().nullable(),\n /** Present on the subscription flow. */\n razorpaySubscriptionId: z.string().nullable(),\n /** Paise, all-in. What checkout will actually charge. */\n amount: z.number().int().nonnegative(),\n currency: z.string(),\n /** The publishable key. Safe to send; the secret never leaves the server. */\n keyId: z.string(),\n /** Our own PaymentOrder row, for support and reconciliation. */\n orderId: objectIdSchema,\n});\nexport type CreatePaymentOrderResult = z.infer<typeof createPaymentOrderResultSchema>;\n\n// ─── Verify ──────────────────────────────────────────────────────────────────\n\n/**\n * What checkout handed back, for signature verification.\n *\n * Both id fields are optional at the type level and exactly one is required per\n * flow; the server enforces the pairing, because a body that names a flow it\n * has no id for can only fail verification and should say so plainly.\n */\nexport const verifyPaymentBodySchema = z.object({\n flow: paymentFlowSchema,\n razorpayPaymentId: z.string().trim().min(1),\n razorpaySignature: z.string().trim().min(1),\n razorpayOrderId: optionalTrimmedString(120),\n razorpaySubscriptionId: optionalTrimmedString(120),\n});\nexport type VerifyPaymentBody = z.infer<typeof verifyPaymentBodySchema>;\n\n// ─── Receipts ────────────────────────────────────────────────────────────────\n\n/**\n * One line of the owner's payment history.\n *\n * ── WHY THIS EXISTS AT ALL ──────────────────────────────────────────────────\n * A gym that has just paid ₹14,999 had, until this, no record of it anywhere in\n * the product: no receipt, no amount, no date, nothing to quote at support. The\n * money left, the plan changed, and the app said nothing about the transaction.\n *\n * It is also a COMPLIANCE gap rather than only a courtesy one. GymMonk is\n * Indian B2B SaaS, the price is all-in with 18% GST already inside it, and the\n * buyer is a business that needs the tax component broken out to claim it. The\n * catalog has computed `gstSplit` from the beginning for precisely this, and\n * nothing was showing the result.\n *\n * ⚠️ AMOUNTS HERE ARE PAISE, matching Razorpay and the stored order. The client\n * divides for display; nothing bills from these.\n */\nexport const paymentReceiptSchema = z.object({\n id: objectIdSchema,\n /** What was bought, so a line reads \"Pro, 12 months\" rather than an amount. */\n planId: saasPlanIdSchema,\n tenureId: z.string(),\n /** The code that explains a charge below list price, if any. */\n couponCode: z.string().nullable(),\n /** ⚠️ PAISE. `baseAmount + gstAmount === totalAmount`, always. */\n baseAmount: z.number().int().nonnegative(),\n gstAmount: z.number().int().nonnegative(),\n totalAmount: z.number().int().nonnegative(),\n currency: z.string(),\n /** Our own reference. What support asks for. */\n receipt: z.string(),\n /** Razorpay's payment id — what the customer sees on their bank statement. */\n razorpayPaymentId: z.string().nullable(),\n /** card / upi / netbanking / wallet, as the gateway reported it. */\n method: z.string().nullable(),\n status: paymentOrderStatusSchema,\n paidAt: z.string().nullable(),\n createdAt: isoDateTimeSchema,\n});\nexport type PaymentReceipt = z.infer<typeof paymentReceiptSchema>;\n"]}
@@ -305,6 +305,21 @@ export interface SaasCoupon {
305
305
  export declare const SAAS_COUPONS: SaasCoupon[];
306
306
  /** Every Razorpay plan that must exist, published terms and coupons alike. */
307
307
  export declare const RAZORPAY_PLAN_KEYS: string[];
308
+ /**
309
+ * Which Razorpay plan bills this exact selection.
310
+ *
311
+ * ONE place decides, because the coupon does not modify a plan's amount — it
312
+ * selects a DIFFERENT plan that happens to charge less (see the note above).
313
+ * Working that out at the call site is how a discounted checkout ends up
314
+ * billing the full-price plan: the code is validated, the label is shown, and
315
+ * the customer is charged ₹14,999 anyway.
316
+ *
317
+ * Returns `null` for the free tier, which bills nothing and needs no plan.
318
+ * A coupon that does not apply to this plan and term is IGNORED here rather
319
+ * than silently honoured — the caller validates it separately through
320
+ * `applySaasCoupon`, which is the function allowed to reject.
321
+ */
322
+ export declare function razorpayPlanKeyFor(planId: SaasPlanId, tenureId: SaasTenureId, couponCode?: string | null): string | null;
308
323
  /** Look a code up. Case- and whitespace-insensitive, because people type it. */
309
324
  export declare function getSaasCoupon(code: string | null | undefined): SaasCoupon | undefined;
310
325
  /** Why a code was refused, so the screen can say something useful. */
@@ -1 +1 @@
1
- {"version":3,"file":"saas-catalog.d.ts","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"saas-catalog.d.ts","sourceRoot":"","sources":["../src/saas-catalog.ts"],"names":[],"mappings":"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"}
@@ -317,6 +317,30 @@ export const RAZORPAY_PLAN_KEYS = [
317
317
  ...SAAS_TENURES.map((t) => `${TARGET_SAAS_PLAN}_${t.id}`),
318
318
  ...SAAS_COUPONS.map((c) => c.razorpayPlanKey),
319
319
  ];
320
+ /**
321
+ * Which Razorpay plan bills this exact selection.
322
+ *
323
+ * ONE place decides, because the coupon does not modify a plan's amount — it
324
+ * selects a DIFFERENT plan that happens to charge less (see the note above).
325
+ * Working that out at the call site is how a discounted checkout ends up
326
+ * billing the full-price plan: the code is validated, the label is shown, and
327
+ * the customer is charged ₹14,999 anyway.
328
+ *
329
+ * Returns `null` for the free tier, which bills nothing and needs no plan.
330
+ * A coupon that does not apply to this plan and term is IGNORED here rather
331
+ * than silently honoured — the caller validates it separately through
332
+ * `applySaasCoupon`, which is the function allowed to reject.
333
+ */
334
+ export function razorpayPlanKeyFor(planId, tenureId, couponCode) {
335
+ if (planId === 'free')
336
+ return null;
337
+ if (couponCode) {
338
+ const applied = applySaasCoupon(couponCode, planId, tenureId);
339
+ if (!isCouponRejected(applied))
340
+ return applied.coupon.razorpayPlanKey;
341
+ }
342
+ return `${planId}_${tenureId}`;
343
+ }
320
344
  /** Look a code up. Case- and whitespace-insensitive, because people type it. */
321
345
  export function getSaasCoupon(code) {
322
346
  if (!code)
@@ -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/** 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 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"]}
@@ -51,6 +51,7 @@ export declare const ownerSubscriptionSchema: z.ZodObject<{
51
51
  past_due: "past_due";
52
52
  }>;
53
53
  currentPeriodEnd: z.ZodNullable<z.ZodString>;
54
+ paidStartsOn: z.ZodNullable<z.ZodString>;
54
55
  couponCode: z.ZodNullable<z.ZodString>;
55
56
  razorpaySubscriptionId: z.ZodNullable<z.ZodString>;
56
57
  createdAt: z.ZodISODateTime;
@@ -1 +1 @@
1
- {"version":3,"file":"subscription.d.ts","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;EAAyD,CAAC;AAEvF,eAAO,MAAM,6BAA6B;;;;;;EAMxC,CAAC;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAIpF,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;iBAalC,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAIxE,eAAO,MAAM,mBAAmB;;;;;;;;iBAqB9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
1
+ {"version":3,"file":"subscription.d.ts","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB;;;EAAyD,CAAC;AAEvF,eAAO,MAAM,6BAA6B;;;;;;EAMxC,CAAC;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAIpF,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;iBAkClC,CAAC;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAIxE,eAAO,MAAM,mBAAmB;;;;;;;;iBAqB9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
@@ -42,6 +42,27 @@ export const ownerSubscriptionSchema = z.object({
42
42
  tenureId: z.string(),
43
43
  status: ownerSubscriptionStatusSchema,
44
44
  currentPeriodEnd: isoDateSchema.nullable(),
45
+ /**
46
+ * The day the PAID term begins, when that is not today.
47
+ *
48
+ * ── WHY A SECOND DATE ───────────────────────────────────────────────────
49
+ * A purchase never overwrites time the gym already holds. Buying while a
50
+ * trial or a paid term is still running APPENDS the new term to the end of
51
+ * it, so `currentPeriodEnd` moves out and this records where the money
52
+ * actually starts paying for something.
53
+ *
54
+ * That is the whole of the "it must not start today while the old one is
55
+ * still running" requirement, and it needs no scheduled-subscription
56
+ * machinery: there is one paid tier, so an appended term is indistinguishable
57
+ * from a queued one except in what the screen can SAY about it. This field is
58
+ * what lets it say "your 12-month term starts 4 September" rather than
59
+ * silently showing a date twelve months out with no explanation.
60
+ *
61
+ * `null` when the term started immediately, which is every purchase made with
62
+ * nothing already running. A date in the PAST is normal and simply means the
63
+ * queued term has since begun.
64
+ */
65
+ paidStartsOn: isoDateSchema.nullable(),
45
66
  /** The discount code this subscription was bought with, if any. */
46
67
  couponCode: z.string().nullable(),
47
68
  razorpaySubscriptionId: z.string().nullable(),
@@ -1 +1 @@
1
- {"version":3,"file":"subscription.js","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAmB,MAAM,kBAAkB,CAAC;AAElE,gFAAgF;AAEhF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,IAAI,CAAC,aAA8C,CAAC,CAAC;AAEvF,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC,IAAI,CAAC;IAClD,QAAQ;IACR,UAAU;IACV,UAAU;IACV,WAAW;IACX,SAAS;CACV,CAAC,CAAC;AAGH,gFAAgF;AAEhF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,EAAE,EAAE,cAAc;IAClB,OAAO,EAAE,cAAc;IACvB,cAAc,EAAE,cAAc;IAC9B,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,MAAM,EAAE,6BAA6B;IACrC,gBAAgB,EAAE,aAAa,CAAC,QAAQ,EAAE;IAC1C,mEAAmE;IACnE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7C,SAAS,EAAE,iBAAiB;IAC5B,SAAS,EAAE,iBAAiB;CAC7B,CAAC,CAAC;AAGH,gFAAgF;AAEhF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1C,MAAM,EAAE,gBAAgB;IACxB,mCAAmC;IACnC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC7C,wEAAwE;IACxE,gBAAgB,EAAE,qBAAqB,CAAC,GAAG,CAAC;IAC5C;;;;;;;;;;;;;OAaG;IACH,UAAU,EAAE,qBAAqB,CAAC,EAAE,CAAC;CACtC,CAAC,CAAC","sourcesContent":["/**\n * gymmonk-schema — Owner SaaS subscription\n * ========================================\n * The GymMonk plan an owner buys to run their gym on the platform (distinct from\n * the membership plans they sell to members). The catalog reuses the generic\n * pricing primitives; a subscription tracks the owner's current plan + period.\n *\n * @module gymmonk-schema/subscription\n */\n\nimport { z } from 'zod';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n} from './common.js';\nimport { SAAS_PLAN_IDS, type SaasPlanId } from './plan-limits.js';\n\n// ─── Plan ids & status ───────────────────────────────────────────────────────\n\n/**\n * SaaS tier ids — the runtime validator for a stored `planId`.\n *\n * DERIVED from `SAAS_PLAN_IDS` rather than written out again, so the enum can\n * never drift from the tier list the limits and the pricing cards are keyed on.\n * `SaasPlanId` itself lives in `plan-limits.ts`; it is the type every gate is\n * written against, and having two declarations of it was how this file used to\n * accept `growth` and `elite` — two tiers no screen has offered since the\n * catalog went to three.\n *\n * ⚠️ Narrowing this enum is a BREAKING change for any stored subscription on a\n * dropped tier. Safe here only because both GymMonk databases are wiped before\n * launch; on a live database this needs a migration first.\n */\nexport const saasPlanIdSchema = z.enum(SAAS_PLAN_IDS as [SaasPlanId, ...SaasPlanId[]]);\n\nexport const ownerSubscriptionStatusSchema = z.enum([\n 'active',\n 'trialing',\n 'past_due',\n 'cancelled',\n 'expired',\n]);\nexport type OwnerSubscriptionStatus = z.infer<typeof ownerSubscriptionStatusSchema>;\n\n// ─── Entity ──────────────────────────────────────────────────────────────────\n\nexport const ownerSubscriptionSchema = z.object({\n id: objectIdSchema,\n ownerId: objectIdSchema,\n organisationId: objectIdSchema,\n planId: saasPlanIdSchema,\n tenureId: z.string(),\n status: ownerSubscriptionStatusSchema,\n currentPeriodEnd: isoDateSchema.nullable(),\n /** The discount code this subscription was bought with, if any. */\n couponCode: z.string().nullable(),\n razorpaySubscriptionId: z.string().nullable(),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type OwnerSubscription = z.infer<typeof ownerSubscriptionSchema>;\n\n// ─── Subscribe / change body ─────────────────────────────────────────────────\n\nexport const subscribeBodySchema = z.object({\n planId: saasPlanIdSchema,\n /** Free plan carries no tenure. */\n tenureId: z.string().trim().min(1).nullable(),\n /** Razorpay payment reference for paid plans (verified server-side). */\n paymentReference: optionalTrimmedString(200),\n /**\n * A discount code, if the owner redeemed one.\n *\n * ⚠️ SENT, NEVER TRUSTED. The client tells us WHICH code was typed; it does\n * not get to say what that code is worth. The server re-runs\n * `applySaasCoupon` against this catalog and bills the Razorpay plan the\n * result names — otherwise a crafted request buys a year of Pro for whatever\n * the caller felt like, and the amount would still reconcile against a plan\n * we created ourselves.\n *\n * Optional and nullable: absent on the free plan and on an undiscounted\n * purchase, and a code that does not apply is a 400 rather than a silent\n * fall-back to full price — see `applySaasCoupon`'s rejection reasons.\n */\n couponCode: optionalTrimmedString(40),\n});\nexport type SubscribeBody = z.infer<typeof subscribeBodySchema>;\n"]}
1
+ {"version":3,"file":"subscription.js","sourceRoot":"","sources":["../src/subscription.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAmB,MAAM,kBAAkB,CAAC;AAElE,gFAAgF;AAEhF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,IAAI,CAAC,aAA8C,CAAC,CAAC;AAEvF,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC,IAAI,CAAC;IAClD,QAAQ;IACR,UAAU;IACV,UAAU;IACV,WAAW;IACX,SAAS;CACV,CAAC,CAAC;AAGH,gFAAgF;AAEhF,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,EAAE,EAAE,cAAc;IAClB,OAAO,EAAE,cAAc;IACvB,cAAc,EAAE,cAAc;IAC9B,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;IACpB,MAAM,EAAE,6BAA6B;IACrC,gBAAgB,EAAE,aAAa,CAAC,QAAQ,EAAE;IAC1C;;;;;;;;;;;;;;;;;;;OAmBG;IACH,YAAY,EAAE,aAAa,CAAC,QAAQ,EAAE;IACtC,mEAAmE;IACnE,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACjC,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7C,SAAS,EAAE,iBAAiB;IAC5B,SAAS,EAAE,iBAAiB;CAC7B,CAAC,CAAC;AAGH,gFAAgF;AAEhF,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1C,MAAM,EAAE,gBAAgB;IACxB,mCAAmC;IACnC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC7C,wEAAwE;IACxE,gBAAgB,EAAE,qBAAqB,CAAC,GAAG,CAAC;IAC5C;;;;;;;;;;;;;OAaG;IACH,UAAU,EAAE,qBAAqB,CAAC,EAAE,CAAC;CACtC,CAAC,CAAC","sourcesContent":["/**\n * gymmonk-schema — Owner SaaS subscription\n * ========================================\n * The GymMonk plan an owner buys to run their gym on the platform (distinct from\n * the membership plans they sell to members). The catalog reuses the generic\n * pricing primitives; a subscription tracks the owner's current plan + period.\n *\n * @module gymmonk-schema/subscription\n */\n\nimport { z } from 'zod';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n} from './common.js';\nimport { SAAS_PLAN_IDS, type SaasPlanId } from './plan-limits.js';\n\n// ─── Plan ids & status ───────────────────────────────────────────────────────\n\n/**\n * SaaS tier ids — the runtime validator for a stored `planId`.\n *\n * DERIVED from `SAAS_PLAN_IDS` rather than written out again, so the enum can\n * never drift from the tier list the limits and the pricing cards are keyed on.\n * `SaasPlanId` itself lives in `plan-limits.ts`; it is the type every gate is\n * written against, and having two declarations of it was how this file used to\n * accept `growth` and `elite` — two tiers no screen has offered since the\n * catalog went to three.\n *\n * ⚠️ Narrowing this enum is a BREAKING change for any stored subscription on a\n * dropped tier. Safe here only because both GymMonk databases are wiped before\n * launch; on a live database this needs a migration first.\n */\nexport const saasPlanIdSchema = z.enum(SAAS_PLAN_IDS as [SaasPlanId, ...SaasPlanId[]]);\n\nexport const ownerSubscriptionStatusSchema = z.enum([\n 'active',\n 'trialing',\n 'past_due',\n 'cancelled',\n 'expired',\n]);\nexport type OwnerSubscriptionStatus = z.infer<typeof ownerSubscriptionStatusSchema>;\n\n// ─── Entity ──────────────────────────────────────────────────────────────────\n\nexport const ownerSubscriptionSchema = z.object({\n id: objectIdSchema,\n ownerId: objectIdSchema,\n organisationId: objectIdSchema,\n planId: saasPlanIdSchema,\n tenureId: z.string(),\n status: ownerSubscriptionStatusSchema,\n currentPeriodEnd: isoDateSchema.nullable(),\n /**\n * The day the PAID term begins, when that is not today.\n *\n * ── WHY A SECOND DATE ───────────────────────────────────────────────────\n * A purchase never overwrites time the gym already holds. Buying while a\n * trial or a paid term is still running APPENDS the new term to the end of\n * it, so `currentPeriodEnd` moves out and this records where the money\n * actually starts paying for something.\n *\n * That is the whole of the \"it must not start today while the old one is\n * still running\" requirement, and it needs no scheduled-subscription\n * machinery: there is one paid tier, so an appended term is indistinguishable\n * from a queued one except in what the screen can SAY about it. This field is\n * what lets it say \"your 12-month term starts 4 September\" rather than\n * silently showing a date twelve months out with no explanation.\n *\n * `null` when the term started immediately, which is every purchase made with\n * nothing already running. A date in the PAST is normal and simply means the\n * queued term has since begun.\n */\n paidStartsOn: isoDateSchema.nullable(),\n /** The discount code this subscription was bought with, if any. */\n couponCode: z.string().nullable(),\n razorpaySubscriptionId: z.string().nullable(),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type OwnerSubscription = z.infer<typeof ownerSubscriptionSchema>;\n\n// ─── Subscribe / change body ─────────────────────────────────────────────────\n\nexport const subscribeBodySchema = z.object({\n planId: saasPlanIdSchema,\n /** Free plan carries no tenure. */\n tenureId: z.string().trim().min(1).nullable(),\n /** Razorpay payment reference for paid plans (verified server-side). */\n paymentReference: optionalTrimmedString(200),\n /**\n * A discount code, if the owner redeemed one.\n *\n * ⚠️ SENT, NEVER TRUSTED. The client tells us WHICH code was typed; it does\n * not get to say what that code is worth. The server re-runs\n * `applySaasCoupon` against this catalog and bills the Razorpay plan the\n * result names — otherwise a crafted request buys a year of Pro for whatever\n * the caller felt like, and the amount would still reconcile against a plan\n * we created ourselves.\n *\n * Optional and nullable: absent on the free plan and on an undiscounted\n * purchase, and a code that does not apply is a 400 rather than a silent\n * fall-back to full price — see `applySaasCoupon`'s rejection reasons.\n */\n couponCode: optionalTrimmedString(40),\n});\nexport type SubscribeBody = z.infer<typeof subscribeBodySchema>;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gymmonk-schema",
3
- "version": "0.63.0",
3
+ "version": "0.64.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",