@odla-ai/chapter 0.30.0 → 0.31.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.
@@ -1,6 +1,24 @@
1
- import { CrmConfig, Crm } from '@odla-ai/crm';
1
+ import { CrmConfig, Crm, FieldConditions } from '@odla-ai/crm';
2
2
  import { initAdmin } from '@odla-ai/db';
3
3
 
4
+ /**
5
+ * A tier as declared in `defineChapter`, before it becomes a row.
6
+ *
7
+ * `groupId` is absent here because the chapter owns exactly one group at seed
8
+ * time; the seeder fills it in.
9
+ */
10
+ interface ChapterTierConfig {
11
+ id: string;
12
+ name: string;
13
+ priceCents: number;
14
+ stripePriceId?: string;
15
+ blurb?: string;
16
+ /** Defaults to declaration order when omitted. */
17
+ sortOrder?: number;
18
+ /** Defaults to true; set false to retire a tier without deleting it. */
19
+ active?: boolean;
20
+ }
21
+
4
22
  type TextFields$1<Key extends string> = {
5
23
  [Field in Key]: string;
6
24
  };
@@ -147,6 +165,30 @@ interface ResolvedNetwork {
147
165
  readers: readonly ResolvedNetworkReader[];
148
166
  }
149
167
 
168
+ /**
169
+ * Membership pricing for the group row (chapter mode).
170
+ *
171
+ * This is the single-price model that predates tiers. It still seeds the group
172
+ * row and remains the fallback a chapter reads when it declares no tiers, so
173
+ * existing chapters keep working; new work should prefer `tiers`.
174
+ */
175
+ interface ChapterPrices {
176
+ standardCents: number;
177
+ foundingDiscountCents?: number;
178
+ /** ISO currency, default "usd". */
179
+ currency?: string;
180
+ /** Billing interval, default "year". */
181
+ interval?: "year" | "month";
182
+ }
183
+ /** Membership policy + compliance copy stored on the group row. */
184
+ interface ChapterPolicy {
185
+ disclaimerText?: string;
186
+ refundPolicyText?: string;
187
+ trustCopy?: string;
188
+ commitmentText?: string;
189
+ normsText?: string;
190
+ }
191
+
150
192
  /** Which feature profile a site runs. `chapter` is the full public member site
151
193
  * (join, Stripe membership, booking, member area, admin, CRM); `hub` is
152
194
  * admin-only and CRM-focused (a directory/registry over the same CRM). */
@@ -252,23 +294,7 @@ interface ChapterBrand {
252
294
  }>>;
253
295
  logos?: string;
254
296
  }
255
- /** Membership pricing for the group row (chapter mode). */
256
- interface ChapterPrices {
257
- standardCents: number;
258
- foundingDiscountCents?: number;
259
- /** ISO currency, default "usd". */
260
- currency?: string;
261
- /** Billing interval, default "year". */
262
- interval?: "year" | "month";
263
- }
264
- /** Membership policy + compliance copy stored on the group row. */
265
- interface ChapterPolicy {
266
- disclaimerText?: string;
267
- refundPolicyText?: string;
268
- trustCopy?: string;
269
- commitmentText?: string;
270
- normsText?: string;
271
- }
297
+
272
298
  /** One owner-editable transactional email template. */
273
299
  interface EmailTemplate {
274
300
  subject: string;
@@ -350,6 +376,11 @@ interface ResolvedPipeline {
350
376
  interface ChapterApplication {
351
377
  required?: readonly string[];
352
378
  optional?: readonly string[];
379
+ /** Per-field CEL conditions — show a field, or demand it, based on the
380
+ * answers so far. Enforced server-side as well as rendered, so a hidden
381
+ * field is never demanded and a conditionally-required one cannot be
382
+ * skipped by posting directly. Validated when the chapter is defined. */
383
+ conditions?: Record<string, FieldConditions>;
353
384
  /** Per-field character cap. Fields not listed use `defaultMaxLen`. */
354
385
  maxLen?: Record<string, number>;
355
386
  defaultMaxLen?: number;
@@ -390,6 +421,8 @@ interface ResolvedApplication {
390
421
  crmFields: readonly string[];
391
422
  maxArrayLen: number;
392
423
  validateEmail: boolean;
424
+ /** Resolved per-field conditions. Empty means every field is unconditional. */
425
+ conditions: Record<string, FieldConditions>;
393
426
  }
394
427
  /** The `defineChapter()` config a site fills in. */
395
428
  interface ChapterConfig {
@@ -411,8 +444,10 @@ interface ChapterConfig {
411
444
  /** Enable a public, host-rendered formation application workflow. */
412
445
  formation?: LeaderFormationConfig;
413
446
  thesis?: unknown;
414
- /** Required in `chapter` mode. */
447
+ /** Required in `chapter` mode, and the fallback when `tiers` is empty. */
415
448
  prices?: ChapterPrices;
449
+ /** Membership tiers. A tier priced at 0 is free and never touches Stripe. */
450
+ tiers?: ChapterTierConfig[];
416
451
  policy?: ChapterPolicy;
417
452
  /** `notificationEmail` required in `chapter` mode. */
418
453
  emails?: ChapterEmails;
@@ -1,6 +1,24 @@
1
- import { CrmConfig, Crm } from '@odla-ai/crm';
1
+ import { CrmConfig, Crm, FieldConditions } from '@odla-ai/crm';
2
2
  import { initAdmin } from '@odla-ai/db';
3
3
 
4
+ /**
5
+ * A tier as declared in `defineChapter`, before it becomes a row.
6
+ *
7
+ * `groupId` is absent here because the chapter owns exactly one group at seed
8
+ * time; the seeder fills it in.
9
+ */
10
+ interface ChapterTierConfig {
11
+ id: string;
12
+ name: string;
13
+ priceCents: number;
14
+ stripePriceId?: string;
15
+ blurb?: string;
16
+ /** Defaults to declaration order when omitted. */
17
+ sortOrder?: number;
18
+ /** Defaults to true; set false to retire a tier without deleting it. */
19
+ active?: boolean;
20
+ }
21
+
4
22
  type TextFields$1<Key extends string> = {
5
23
  [Field in Key]: string;
6
24
  };
@@ -147,6 +165,30 @@ interface ResolvedNetwork {
147
165
  readers: readonly ResolvedNetworkReader[];
148
166
  }
149
167
 
168
+ /**
169
+ * Membership pricing for the group row (chapter mode).
170
+ *
171
+ * This is the single-price model that predates tiers. It still seeds the group
172
+ * row and remains the fallback a chapter reads when it declares no tiers, so
173
+ * existing chapters keep working; new work should prefer `tiers`.
174
+ */
175
+ interface ChapterPrices {
176
+ standardCents: number;
177
+ foundingDiscountCents?: number;
178
+ /** ISO currency, default "usd". */
179
+ currency?: string;
180
+ /** Billing interval, default "year". */
181
+ interval?: "year" | "month";
182
+ }
183
+ /** Membership policy + compliance copy stored on the group row. */
184
+ interface ChapterPolicy {
185
+ disclaimerText?: string;
186
+ refundPolicyText?: string;
187
+ trustCopy?: string;
188
+ commitmentText?: string;
189
+ normsText?: string;
190
+ }
191
+
150
192
  /** Which feature profile a site runs. `chapter` is the full public member site
151
193
  * (join, Stripe membership, booking, member area, admin, CRM); `hub` is
152
194
  * admin-only and CRM-focused (a directory/registry over the same CRM). */
@@ -252,23 +294,7 @@ interface ChapterBrand {
252
294
  }>>;
253
295
  logos?: string;
254
296
  }
255
- /** Membership pricing for the group row (chapter mode). */
256
- interface ChapterPrices {
257
- standardCents: number;
258
- foundingDiscountCents?: number;
259
- /** ISO currency, default "usd". */
260
- currency?: string;
261
- /** Billing interval, default "year". */
262
- interval?: "year" | "month";
263
- }
264
- /** Membership policy + compliance copy stored on the group row. */
265
- interface ChapterPolicy {
266
- disclaimerText?: string;
267
- refundPolicyText?: string;
268
- trustCopy?: string;
269
- commitmentText?: string;
270
- normsText?: string;
271
- }
297
+
272
298
  /** One owner-editable transactional email template. */
273
299
  interface EmailTemplate {
274
300
  subject: string;
@@ -350,6 +376,11 @@ interface ResolvedPipeline {
350
376
  interface ChapterApplication {
351
377
  required?: readonly string[];
352
378
  optional?: readonly string[];
379
+ /** Per-field CEL conditions — show a field, or demand it, based on the
380
+ * answers so far. Enforced server-side as well as rendered, so a hidden
381
+ * field is never demanded and a conditionally-required one cannot be
382
+ * skipped by posting directly. Validated when the chapter is defined. */
383
+ conditions?: Record<string, FieldConditions>;
353
384
  /** Per-field character cap. Fields not listed use `defaultMaxLen`. */
354
385
  maxLen?: Record<string, number>;
355
386
  defaultMaxLen?: number;
@@ -390,6 +421,8 @@ interface ResolvedApplication {
390
421
  crmFields: readonly string[];
391
422
  maxArrayLen: number;
392
423
  validateEmail: boolean;
424
+ /** Resolved per-field conditions. Empty means every field is unconditional. */
425
+ conditions: Record<string, FieldConditions>;
393
426
  }
394
427
  /** The `defineChapter()` config a site fills in. */
395
428
  interface ChapterConfig {
@@ -411,8 +444,10 @@ interface ChapterConfig {
411
444
  /** Enable a public, host-rendered formation application workflow. */
412
445
  formation?: LeaderFormationConfig;
413
446
  thesis?: unknown;
414
- /** Required in `chapter` mode. */
447
+ /** Required in `chapter` mode, and the fallback when `tiers` is empty. */
415
448
  prices?: ChapterPrices;
449
+ /** Membership tiers. A tier priced at 0 is free and never touches Stripe. */
450
+ tiers?: ChapterTierConfig[];
416
451
  policy?: ChapterPolicy;
417
452
  /** `notificationEmail` required in `chapter` mode. */
418
453
  emails?: ChapterEmails;
@@ -153,6 +153,9 @@ async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
153
153
  }
154
154
  }
155
155
 
156
+ // src/member.ts
157
+ import { assertFieldCondition, resolveFieldStates } from "@odla-ai/crm";
158
+
156
159
  // src/application-id.ts
157
160
  var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
158
161
  async function applicationIdForSubmission(submissionId) {
@@ -191,7 +194,9 @@ function applicantProfile(chapter, fields) {
191
194
  }
192
195
  async function submitApplication(db, chapter, fields, opts) {
193
196
  const app = chapter.application;
194
- for (const f of app.required) {
197
+ const fieldStates = resolveFieldStates(app.conditions ?? {}, fields, app.required);
198
+ for (const [f, state] of Object.entries(fieldStates)) {
199
+ if (!state.required) continue;
195
200
  const v = fields[f];
196
201
  if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
197
202
  }
@@ -210,10 +215,17 @@ async function submitApplication(db, chapter, fields, opts) {
210
215
  const id = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
211
216
  const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
212
217
  for (const f of [...app.required, ...app.optional]) {
218
+ if (fieldStates[f]?.visible === false) continue;
213
219
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
214
220
  }
215
221
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
216
222
  if (opts.groupId) row.groupId = opts.groupId;
223
+ if (typeof fields.tierId === "string" && fields.tierId) {
224
+ if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
225
+ return { ok: false, error: "tierId is not an offered tier" };
226
+ }
227
+ row.tierId = fields.tierId;
228
+ }
217
229
  if (acked) row.disclaimerAckAt = opts.now;
218
230
  const { duplicate } = await db.transact(
219
231
  [{ t: "update", ns: "applications", id, attrs: row }],
@@ -221,12 +233,15 @@ async function submitApplication(db, chapter, fields, opts) {
221
233
  );
222
234
  return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
223
235
  }
224
- function joinConfig(group, paymentsReady) {
236
+ function joinConfig(group, paymentsReady, tiers = [], conditions = {}) {
225
237
  return {
226
238
  id: group.id,
227
239
  name: group.name,
228
240
  standardPriceCents: group.standardPriceCents ?? 0,
229
241
  foundingDiscountCents: group.foundingDiscountCents ?? 0,
242
+ tiers: [...tiers],
243
+ // The browser evaluates the same conditions the server enforces.
244
+ conditions,
230
245
  disclaimerText: group.disclaimerText ?? "",
231
246
  refundPolicyText: group.refundPolicyText ?? "",
232
247
  trustCopy: group.trustCopy ?? "",
@@ -236,6 +251,79 @@ function joinConfig(group, paymentsReady) {
236
251
  };
237
252
  }
238
253
 
254
+ // src/tiers.ts
255
+ function tierIsFree(tier) {
256
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
257
+ }
258
+ function tierPayable(tier, group, hasSecretKey) {
259
+ if (tierIsFree(tier)) return true;
260
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
261
+ }
262
+ function byOrder(a, b) {
263
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
264
+ }
265
+ function resolveTiers(group, rows2 = []) {
266
+ const stored = rows2.filter((tier) => tier.active);
267
+ if (stored.length) return [...stored].sort(byOrder);
268
+ return [{
269
+ id: "standard",
270
+ groupId: String(group.id ?? ""),
271
+ name: "Membership",
272
+ priceCents: group.standardPriceCents ?? 0,
273
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
274
+ sortOrder: 0,
275
+ active: true
276
+ }];
277
+ }
278
+ function offerableTiers(tiers, group, hasSecretKey) {
279
+ return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
280
+ }
281
+ function findTier(tiers, tierId) {
282
+ if (!tierId) return tiers.length === 1 ? tiers[0] : null;
283
+ return tiers.find((tier) => tier.id === tierId) ?? null;
284
+ }
285
+ function joinConfigTiers(tiers) {
286
+ return tiers.map((tier) => ({
287
+ id: tier.id,
288
+ name: tier.name,
289
+ priceCents: tier.priceCents,
290
+ blurb: tier.blurb ?? "",
291
+ free: tierIsFree(tier)
292
+ }));
293
+ }
294
+
295
+ // src/tiers-store.ts
296
+ function rowToTier(row, groupId) {
297
+ return {
298
+ id: String(row.id ?? ""),
299
+ groupId: String(row.groupId ?? groupId),
300
+ name: String(row.name ?? ""),
301
+ priceCents: typeof row.priceCents === "number" ? row.priceCents : 0,
302
+ ...typeof row.stripePriceId === "string" && row.stripePriceId ? { stripePriceId: row.stripePriceId } : {},
303
+ ...typeof row.blurb === "string" ? { blurb: row.blurb } : {},
304
+ sortOrder: typeof row.sortOrder === "number" ? row.sortOrder : 0,
305
+ // A row that never set `active` is offered; only an explicit false retires
306
+ // a tier, so a half-seeded row cannot silently hide a membership.
307
+ active: row.active !== false
308
+ };
309
+ }
310
+ async function loadTiers(db, group) {
311
+ const groupId = String(group.id ?? "");
312
+ let rows2;
313
+ try {
314
+ rows2 = (await db.query({
315
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
316
+ })).tiers;
317
+ } catch {
318
+ rows2 = void 0;
319
+ }
320
+ const list = Array.isArray(rows2) ? rows2.map((row) => rowToTier(row, groupId)) : [];
321
+ return resolveTiers(group, list);
322
+ }
323
+ async function loadOfferableTiers(db, group, hasSecretKey) {
324
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
325
+ }
326
+
239
327
  // src/scheduling.ts
240
328
  var SCHEDULING_DEFAULTS = {
241
329
  slotMinutes: 45,
@@ -990,7 +1078,15 @@ var handleMember = async (req, url, env, ctx) => {
990
1078
  if (!group) return json({ error: "not found" }, 404);
991
1079
  const stripeKey = await getVaultSecret(db, "stripe_secret_key");
992
1080
  const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
993
- return json({ ...joinConfig(group, paymentsReady), copy: chapter.copy.join });
1081
+ const offered = await loadOfferableTiers(
1082
+ db,
1083
+ group,
1084
+ Boolean(stripeKey)
1085
+ );
1086
+ return json({
1087
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
1088
+ copy: chapter.copy.join
1089
+ });
994
1090
  }
995
1091
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
996
1092
  const applicationId = url.searchParams.get("application") ?? "";
@@ -1029,9 +1125,11 @@ var handleMember = async (req, url, env, ctx) => {
1029
1125
  const group = Array.isArray(groups) ? groups[0] : void 0;
1030
1126
  if (!group) return json({ error: "not found" }, 404);
1031
1127
  const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1032
- const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
1033
1128
  const status = String(app.status ?? "");
1034
- if (!paymentsReady) return json({ state: "booking", applicationId });
1129
+ const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1130
+ const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1131
+ const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1132
+ if (!payable) return json({ state: "booking", applicationId });
1035
1133
  if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
1036
1134
  return json({ state: "paymentPending", applicationId });
1037
1135
  }
@@ -1050,11 +1148,13 @@ var handleMember = async (req, url, env, ctx) => {
1050
1148
  }
1051
1149
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
1052
1150
  const db = ctx.makeDb(env);
1151
+ const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1053
1152
  const result = await submitApplication(db, chapter, parsed, {
1054
1153
  submissionId,
1055
1154
  groupId: chapter.id,
1056
1155
  now: Date.now(),
1057
- newId: () => crypto.randomUUID()
1156
+ newId: () => crypto.randomUUID(),
1157
+ ...offeredTierIds ? { tierIds: offeredTierIds } : {}
1058
1158
  });
1059
1159
  if (!result.ok) return json({ error: result.error }, 400);
1060
1160
  if (!result.duplicate) {
@@ -1638,9 +1738,16 @@ async function startSubscription(req, env, ctx) {
1638
1738
  if (!app) return json({ error: "not found" }, 404);
1639
1739
  if (app.status !== "submitted") return json({ error: "already processed" }, 409);
1640
1740
  const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
1641
- const priceId = group?.stripePriceId;
1642
1741
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
1643
- if (!group || !priceId || !secretKey) return json({ error: "payments not configured" }, 503);
1742
+ if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
1743
+ const tier = findTier(
1744
+ await loadTiers(db, group),
1745
+ typeof app.tierId === "string" ? app.tierId : null
1746
+ );
1747
+ if (!tier) return json({ error: "tier not found", code: "tier_unresolved" }, 400);
1748
+ if (tierIsFree(tier)) return json({ error: "tier is free", code: "tier_not_payable" }, 409);
1749
+ const priceId = tier.stripePriceId;
1750
+ if (!priceId) return json({ error: "payments not configured" }, 503);
1644
1751
  const provider = createStripeProvider({ secretKey });
1645
1752
  let result;
1646
1753
  try {