@odla-ai/chapter 0.30.0 → 0.31.1

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.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  brandTokens: () => brandTokens,
32
32
  bucketSeries: () => bucketSeries,
33
33
  buildGroupSeed: () => buildGroupSeed,
34
+ buildTierSeeds: () => buildTierSeeds,
34
35
  canApprove: () => canApprove,
35
36
  canBook: () => canBook,
36
37
  canChangeRole: () => canChangeRole,
@@ -55,6 +56,7 @@ __export(index_exports, {
55
56
  emailGroupFrom: () => emailGroupFrom,
56
57
  endForSlot: () => endForSlot,
57
58
  findApplicationRef: () => findApplicationRef,
59
+ findTier: () => findTier,
58
60
  firstPaymentPatch: () => firstPaymentPatch,
59
61
  formatChapterCopy: () => formatChapterCopy,
60
62
  formationFields: () => formationFields,
@@ -67,7 +69,10 @@ __export(index_exports, {
67
69
  isSlotAvailable: () => isSlotAvailable,
68
70
  isValidEmail: () => isValidEmail,
69
71
  joinConfig: () => joinConfig,
72
+ joinConfigTiers: () => joinConfigTiers,
70
73
  leaderCrmConfig: () => leaderCrmConfig,
74
+ loadOfferableTiers: () => loadOfferableTiers,
75
+ loadTiers: () => loadTiers,
71
76
  meetingCreateRow: () => meetingCreateRow,
72
77
  meetingRescheduleUpdate: () => meetingRescheduleUpdate,
73
78
  memberApplication: () => memberApplication,
@@ -75,6 +80,7 @@ __export(index_exports, {
75
80
  networkSourceTag: () => networkSourceTag,
76
81
  normalizeSharedRecord: () => normalizeSharedRecord,
77
82
  normalizeWebhookEvent: () => normalizeWebhookEvent,
83
+ offerableTiers: () => offerableTiers,
78
84
  paymentsReady: () => paymentsReady,
79
85
  personInputFromApp: () => personInputFromApp,
80
86
  planDelivery: () => planDelivery,
@@ -92,6 +98,7 @@ __export(index_exports, {
92
98
  resolveLeaderFormation: () => resolveLeaderFormation,
93
99
  resolvePipeline: () => resolvePipeline,
94
100
  resolveScheduling: () => resolveScheduling,
101
+ resolveTiers: () => resolveTiers,
95
102
  roleFromClaim: () => roleFromClaim,
96
103
  sendTemplated: () => sendTemplated,
97
104
  sharedPersonInput: () => sharedPersonInput,
@@ -104,6 +111,8 @@ __export(index_exports, {
104
111
  submitApplication: () => submitApplication,
105
112
  subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
106
113
  syncApplicationToCrm: () => syncApplicationToCrm,
114
+ tierIsFree: () => tierIsFree,
115
+ tierPayable: () => tierPayable,
107
116
  updateClerkUserMetadata: () => updateClerkUserMetadata,
108
117
  updateClerkUserMetadataByEmail: () => updateClerkUserMetadataByEmail,
109
118
  validateScheduling: () => validateScheduling,
@@ -113,7 +122,7 @@ __export(index_exports, {
113
122
  module.exports = __toCommonJS(index_exports);
114
123
 
115
124
  // src/config.ts
116
- var import_crm = require("@odla-ai/crm");
125
+ var import_crm2 = require("@odla-ai/crm");
117
126
 
118
127
  // src/schema.ts
119
128
  function attr(type, flags = {}) {
@@ -178,6 +187,8 @@ var applications = {
178
187
  phone: attr("string", { optional: true }),
179
188
  state: attr("string", { optional: true }),
180
189
  groupId: attr("string", { indexed: true, optional: true }),
190
+ // The tier this application joined on. Absent on rows that predate tiers.
191
+ tierId: attr("string", { indexed: true, optional: true }),
181
192
  stripeCustomerId: attr("string", { indexed: true, optional: true }),
182
193
  stripeSubscriptionId: attr("string", { indexed: true, optional: true }),
183
194
  renewalAt: attr("number", { optional: true }),
@@ -209,6 +220,19 @@ var groups = {
209
220
  createdAt: attr("number", { indexed: true })
210
221
  }
211
222
  };
223
+ var tiers = {
224
+ attrs: {
225
+ id: id(),
226
+ groupId: attr("string", { indexed: true }),
227
+ name: attr("string"),
228
+ priceCents: attr("number"),
229
+ stripePriceId: attr("string", { optional: true }),
230
+ blurb: attr("string", { optional: true }),
231
+ sortOrder: attr("number", { indexed: true }),
232
+ active: attr("boolean", { indexed: true }),
233
+ createdAt: attr("number", { indexed: true })
234
+ }
235
+ };
212
236
  var meetings = {
213
237
  attrs: {
214
238
  id: id(),
@@ -250,6 +274,7 @@ function chapterDb(mode, auth, includeNetworkNotes = false) {
250
274
  if (mode === "chapter") {
251
275
  entities.applications = applications;
252
276
  entities.groups = groups;
277
+ entities.tiers = tiers;
253
278
  entities.meetings = meetings;
254
279
  entities.emailLog = emailLog;
255
280
  }
@@ -510,6 +535,9 @@ function canApprove(status, p) {
510
535
  return p.approvableFrom.includes(status);
511
536
  }
512
537
 
538
+ // src/member.ts
539
+ var import_crm = require("@odla-ai/crm");
540
+
513
541
  // src/application-id.ts
514
542
  var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
515
543
  async function applicationIdForSubmission(submissionId) {
@@ -538,9 +566,21 @@ function resolveApplication(a) {
538
566
  if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
539
567
  throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
540
568
  }
569
+ const conditions = a?.conditions ?? {};
570
+ for (const [field, declared] of Object.entries(conditions)) {
571
+ for (const key of ["visibleWhen", "requiredWhen"]) {
572
+ const expression = declared?.[key];
573
+ if (expression === void 0) continue;
574
+ if (typeof expression !== "string" || !expression.trim()) {
575
+ throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
576
+ }
577
+ (0, import_crm.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key}`);
578
+ }
579
+ }
541
580
  return {
542
581
  required,
543
582
  optional,
583
+ conditions,
544
584
  maxLen: a?.maxLen ?? {},
545
585
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
546
586
  bodyCap: a?.bodyCap ?? 32768,
@@ -577,7 +617,9 @@ function applicantProfile(chapter, fields) {
577
617
  }
578
618
  async function submitApplication(db, chapter, fields, opts) {
579
619
  const app = chapter.application;
580
- for (const f of app.required) {
620
+ const fieldStates = (0, import_crm.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
621
+ for (const [f, state] of Object.entries(fieldStates)) {
622
+ if (!state.required) continue;
581
623
  const v = fields[f];
582
624
  if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
583
625
  }
@@ -596,10 +638,17 @@ async function submitApplication(db, chapter, fields, opts) {
596
638
  const id2 = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
597
639
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
598
640
  for (const f of [...app.required, ...app.optional]) {
641
+ if (fieldStates[f]?.visible === false) continue;
599
642
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
600
643
  }
601
644
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
602
645
  if (opts.groupId) row.groupId = opts.groupId;
646
+ if (typeof fields.tierId === "string" && fields.tierId) {
647
+ if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
648
+ return { ok: false, error: "tierId is not an offered tier" };
649
+ }
650
+ row.tierId = fields.tierId;
651
+ }
603
652
  if (acked) row.disclaimerAckAt = opts.now;
604
653
  const { duplicate } = await db.transact(
605
654
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
@@ -607,12 +656,15 @@ async function submitApplication(db, chapter, fields, opts) {
607
656
  );
608
657
  return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
609
658
  }
610
- function joinConfig(group, paymentsReady2) {
659
+ function joinConfig(group, paymentsReady2, tiers2 = [], conditions = {}) {
611
660
  return {
612
661
  id: group.id,
613
662
  name: group.name,
614
663
  standardPriceCents: group.standardPriceCents ?? 0,
615
664
  foundingDiscountCents: group.foundingDiscountCents ?? 0,
665
+ tiers: [...tiers2],
666
+ // The browser evaluates the same conditions the server enforces.
667
+ conditions,
616
668
  disclaimerText: group.disclaimerText ?? "",
617
669
  refundPolicyText: group.refundPolicyText ?? "",
618
670
  trustCopy: group.trustCopy ?? "",
@@ -1151,7 +1203,7 @@ function defineChapter(config) {
1151
1203
  if (typeof name !== "string" || name.trim() === "") throw new Error("defineChapter.name: a non-empty name is required");
1152
1204
  const mode = config.mode ?? "chapter";
1153
1205
  if (mode !== "chapter" && mode !== "hub") throw new Error(`defineChapter.mode: must be "chapter" or "hub" \u2014 got ${JSON.stringify(config.mode)}`);
1154
- const crm = isResolvedCrm(config.crm) ? config.crm : (0, import_crm.defineCrm)(config.crm ?? defaultCrm(mode));
1206
+ const crm = isResolvedCrm(config.crm) ? config.crm : (0, import_crm2.defineCrm)(config.crm ?? defaultCrm(mode));
1155
1207
  if (mode === "chapter") {
1156
1208
  if (!config.emails || typeof config.emails.notificationEmail !== "string" || config.emails.notificationEmail === "") {
1157
1209
  throw new Error("defineChapter.emails.notificationEmail: required in chapter mode");
@@ -1243,7 +1295,7 @@ function defineChapter(config) {
1243
1295
  }
1244
1296
 
1245
1297
  // src/leader.ts
1246
- var import_crm2 = require("@odla-ai/crm");
1298
+ var import_crm3 = require("@odla-ai/crm");
1247
1299
  function leaderCrmConfig(options = {}) {
1248
1300
  const base = options.base ?? defaultCrm("hub");
1249
1301
  const defaultChapter = {
@@ -1402,16 +1454,72 @@ function leaderCrmConfig(options = {}) {
1402
1454
  reverseLabel: "applicant"
1403
1455
  };
1404
1456
  }
1405
- return (0, import_crm2.composeCrmConfigs)(base, portfolio);
1457
+ return (0, import_crm3.composeCrmConfigs)(base, portfolio);
1458
+ }
1459
+
1460
+ // src/descriptor.ts
1461
+ var import_crm4 = require("@odla-ai/crm");
1462
+
1463
+ // src/tiers.ts
1464
+ function buildTierSeeds(groupId, configured = [], now = 0) {
1465
+ return configured.map((tier, index) => ({
1466
+ id: tier.id,
1467
+ groupId,
1468
+ name: tier.name,
1469
+ priceCents: tier.priceCents,
1470
+ ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
1471
+ ...tier.blurb ? { blurb: tier.blurb } : {},
1472
+ sortOrder: tier.sortOrder ?? index,
1473
+ active: tier.active !== false,
1474
+ createdAt: now
1475
+ }));
1476
+ }
1477
+ function tierIsFree(tier) {
1478
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
1479
+ }
1480
+ function tierPayable(tier, group, hasSecretKey) {
1481
+ if (tierIsFree(tier)) return true;
1482
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
1483
+ }
1484
+ function byOrder(a, b) {
1485
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
1486
+ }
1487
+ function resolveTiers(group, rows = []) {
1488
+ const stored = rows.filter((tier) => tier.active);
1489
+ if (stored.length) return [...stored].sort(byOrder);
1490
+ return [{
1491
+ id: "standard",
1492
+ groupId: String(group.id ?? ""),
1493
+ name: "Membership",
1494
+ priceCents: group.standardPriceCents ?? 0,
1495
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
1496
+ sortOrder: 0,
1497
+ active: true
1498
+ }];
1499
+ }
1500
+ function offerableTiers(tiers2, group, hasSecretKey) {
1501
+ return tiers2.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
1502
+ }
1503
+ function findTier(tiers2, tierId) {
1504
+ if (!tierId) return tiers2.length === 1 ? tiers2[0] : null;
1505
+ return tiers2.find((tier) => tier.id === tierId) ?? null;
1506
+ }
1507
+ function joinConfigTiers(tiers2) {
1508
+ return tiers2.map((tier) => ({
1509
+ id: tier.id,
1510
+ name: tier.name,
1511
+ priceCents: tier.priceCents,
1512
+ blurb: tier.blurb ?? "",
1513
+ free: tierIsFree(tier)
1514
+ }));
1406
1515
  }
1407
1516
 
1408
1517
  // src/descriptor.ts
1409
- var import_crm3 = require("@odla-ai/crm");
1410
1518
  function createChapterIntegration(chapter, options = {}) {
1411
1519
  const basePath = options.basePath ?? "/api/crm";
1412
1520
  const now = options.now ?? Date.now();
1413
1521
  const emails = chapter.config.emails;
1414
- const crmDesc = (0, import_crm3.createCrmIntegration)(chapter.crm, {
1522
+ const crmDesc = (0, import_crm4.createCrmIntegration)(chapter.crm, {
1415
1523
  basePath,
1416
1524
  now,
1417
1525
  ...emails?.notificationEmail ? { notificationEmail: emails.notificationEmail } : {},
@@ -1422,6 +1530,14 @@ function createChapterIntegration(chapter, options = {}) {
1422
1530
  const group = chapter.groupSeed();
1423
1531
  if (group) {
1424
1532
  seeds.push({ id: "group", ns: "groups", key: { attr: "id", value: chapter.id }, attrs: { ...group, createdAt: now } });
1533
+ for (const tier of buildTierSeeds(chapter.id, chapter.config.tiers, now)) {
1534
+ seeds.push({
1535
+ id: `tier:${String(tier.id)}`,
1536
+ ns: "tiers",
1537
+ key: { attr: "id", value: String(tier.id) },
1538
+ attrs: tier
1539
+ });
1540
+ }
1425
1541
  }
1426
1542
  const isLeader = (chapter.config.network?.targets?.length ?? 0) > 0;
1427
1543
  const runbooks = isLeader ? {
@@ -1660,7 +1776,7 @@ function canceledPatch() {
1660
1776
  }
1661
1777
 
1662
1778
  // src/network.ts
1663
- var import_crm4 = require("@odla-ai/crm");
1779
+ var import_crm5 = require("@odla-ai/crm");
1664
1780
 
1665
1781
  // src/network-contract.ts
1666
1782
  var DEFAULT_SHARE_FIELDS = {
@@ -1762,14 +1878,14 @@ async function upsertPerson(deps, opts) {
1762
1878
  const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1763
1879
  const existing = crm_record?.[0];
1764
1880
  if (existing && typeof existing.id === "string") {
1765
- await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1881
+ await (0, import_crm5.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
1766
1882
  return { recordId: existing.id };
1767
1883
  }
1768
- const created = await (0, import_crm4.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1884
+ const created = await (0, import_crm5.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
1769
1885
  return { recordId: created.id };
1770
1886
  }
1771
1887
  async function findSharedRecord(deps, record, tag) {
1772
- const structured = await (0, import_crm4.getRecordByOrigin)(
1888
+ const structured = await (0, import_crm5.getRecordByOrigin)(
1773
1889
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1774
1890
  record.sourceId,
1775
1891
  record.sourceRecordId
@@ -1821,11 +1937,11 @@ async function projectSharedRecord(deps, shared, options = {}) {
1821
1937
  const existing = await findSharedRecord(deps, record, tag);
1822
1938
  let recordId;
1823
1939
  if (existing && typeof existing.id === "string") {
1824
- await (0, import_crm4.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1940
+ await (0, import_crm5.updateRecord)(crmDeps, { id: existing.id, input: record.input });
1825
1941
  recordId = existing.id;
1826
1942
  } else {
1827
1943
  recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1828
- await (0, import_crm4.createRecord)({ ...crmDeps, newId: () => recordId }, {
1944
+ await (0, import_crm5.createRecord)({ ...crmDeps, newId: () => recordId }, {
1829
1945
  type: record.type,
1830
1946
  input: record.input,
1831
1947
  mutationId: `share-create:${tag}`
@@ -1835,7 +1951,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
1835
1951
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1836
1952
  { mutationId: `share-map:${tag}:${recordId}` }
1837
1953
  );
1838
- await (0, import_crm4.upsertRecordOrigin)(crmDeps, {
1954
+ await (0, import_crm5.upsertRecordOrigin)(crmDeps, {
1839
1955
  recordId,
1840
1956
  sourceId: record.sourceId,
1841
1957
  sourceRecordId: record.sourceRecordId,
@@ -1864,7 +1980,7 @@ async function projectApplicant(deps, applicant) {
1864
1980
  }
1865
1981
 
1866
1982
  // src/crm-sync.ts
1867
- var import_crm5 = require("@odla-ai/crm");
1983
+ var import_crm6 = require("@odla-ai/crm");
1868
1984
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1869
1985
  function personInputFromApp(chapter, app) {
1870
1986
  const input = sharedPersonInput({
@@ -1904,16 +2020,16 @@ async function syncApplicationToCrm(deps, opts) {
1904
2020
  let recordId;
1905
2021
  if (existing && typeof existing.id === "string") {
1906
2022
  recordId = existing.id;
1907
- await (0, import_crm5.updateRecord)(recordDeps, { id: recordId, input });
2023
+ await (0, import_crm6.updateRecord)(recordDeps, { id: recordId, input });
1908
2024
  } else {
1909
- const created = await (0, import_crm5.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
2025
+ const created = await (0, import_crm6.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1910
2026
  recordId = created.id;
1911
2027
  }
1912
2028
  if (existing && stage && existing.stage !== stage) {
1913
- await (0, import_crm5.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
2029
+ await (0, import_crm6.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1914
2030
  }
1915
2031
  await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1916
- await (0, import_crm5.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
2032
+ await (0, import_crm6.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1917
2033
  return recordId;
1918
2034
  }
1919
2035
  async function backfillCrm(deps) {
@@ -2493,4 +2609,36 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
2493
2609
  ...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
2494
2610
  };
2495
2611
  }
2612
+
2613
+ // src/tiers-store.ts
2614
+ function rowToTier(row, groupId) {
2615
+ return {
2616
+ id: String(row.id ?? ""),
2617
+ groupId: String(row.groupId ?? groupId),
2618
+ name: String(row.name ?? ""),
2619
+ priceCents: typeof row.priceCents === "number" ? row.priceCents : 0,
2620
+ ...typeof row.stripePriceId === "string" && row.stripePriceId ? { stripePriceId: row.stripePriceId } : {},
2621
+ ...typeof row.blurb === "string" ? { blurb: row.blurb } : {},
2622
+ sortOrder: typeof row.sortOrder === "number" ? row.sortOrder : 0,
2623
+ // A row that never set `active` is offered; only an explicit false retires
2624
+ // a tier, so a half-seeded row cannot silently hide a membership.
2625
+ active: row.active !== false
2626
+ };
2627
+ }
2628
+ async function loadTiers(db, group) {
2629
+ const groupId = String(group.id ?? "");
2630
+ let rows;
2631
+ try {
2632
+ rows = (await db.query({
2633
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
2634
+ })).tiers;
2635
+ } catch {
2636
+ rows = void 0;
2637
+ }
2638
+ const list = Array.isArray(rows) ? rows.map((row) => rowToTier(row, groupId)) : [];
2639
+ return resolveTiers(group, list);
2640
+ }
2641
+ async function loadOfferableTiers(db, group, hasSecretKey) {
2642
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2643
+ }
2496
2644
  //# sourceMappingURL=index.cjs.map