@odla-ai/chapter 0.54.1 → 0.54.2

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
@@ -27,6 +27,7 @@ __export(src_exports, {
27
27
  BUILDER_LIBRARY_MANIFEST_PATH: () => BUILDER_LIBRARY_MANIFEST_PATH,
28
28
  BUILDER_LIBRARY_PROMOTE_PATH: () => BUILDER_LIBRARY_PROMOTE_PATH,
29
29
  BUILDER_LIBRARY_SCHEMA_VERSION: () => BUILDER_LIBRARY_SCHEMA_VERSION,
30
+ CHAPTER_EMAIL_EVENTS: () => CHAPTER_EMAIL_EVENTS,
30
31
  CHAPTER_FLOW_FIELDS_SCHEMA_VERSION: () => CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
31
32
  CHAPTER_PROMOTION_ADAPTER: () => CHAPTER_PROMOTION_ADAPTER,
32
33
  DEFAULT_CHAPTER_COPY: () => DEFAULT_CHAPTER_COPY,
@@ -83,6 +84,7 @@ __export(src_exports, {
83
84
  canonicalSignupControl: () => canonicalSignupControl,
84
85
  chapterBrandFromTokens: () => chapterBrandFromTokens,
85
86
  chapterDb: () => chapterDb,
87
+ chapterEmailEvents: () => chapterEmailEvents,
86
88
  chapterFlowFieldContract: () => chapterFlowFieldContract,
87
89
  clampArray: () => clampArray,
88
90
  classifyMemberStripeEvent: () => classifyMemberStripeEvent,
@@ -262,6 +264,7 @@ __export(src_exports, {
262
264
  updateClerkUserMetadataByEmail: () => updateClerkUserMetadataByEmail,
263
265
  validateAdmissionGrantDocument: () => validateAdmissionGrantDocument,
264
266
  validateBuilderLibraryDocument: () => validateBuilderLibraryDocument,
267
+ validateEmailReadiness: () => validateEmailReadiness,
265
268
  validateGiftOfferCatalog: () => validateGiftOfferCatalog,
266
269
  validateMemberContentDocument: () => validateMemberContentDocument,
267
270
  validateMemberSystemAuthority: () => validateMemberSystemAuthority,
@@ -1380,6 +1383,7 @@ Warmly,
1380
1383
  ${name}`;
1381
1384
  return {
1382
1385
  adminNotification: {
1386
+ enabled: true,
1383
1387
  subject: `New application: {{firstName}} {{lastName}}`,
1384
1388
  text: `A new application came in for ${name}.
1385
1389
 
@@ -1390,18 +1394,21 @@ Tier: {{tier}}
1390
1394
  Review it: {{adminUrl}}`
1391
1395
  },
1392
1396
  submitConfirmation: {
1397
+ enabled: true,
1393
1398
  subject: `We received your application to ${name}`,
1394
1399
  text: `Hi {{firstName}},
1395
1400
 
1396
1401
  Thanks for applying to ${name}. {{submitNextStep}}${sign}`
1397
1402
  },
1398
1403
  paymentConfirmation: {
1404
+ enabled: true,
1399
1405
  subject: `Welcome to ${name}`,
1400
1406
  text: `Hi {{firstName}},
1401
1407
 
1402
1408
  Your membership payment{{paymentAmountSummary}} is confirmed. {{paymentNextStep}}${sign}`
1403
1409
  },
1404
1410
  prepEmail: {
1411
+ enabled: true,
1405
1412
  subject: `Your ${name} intro call`,
1406
1413
  text: `Hi {{firstName}},
1407
1414
 
@@ -1410,18 +1417,19 @@ You are all set{{paymentAmountSummary}}. Looking forward to our call on {{startA
1410
1417
  Join here: {{meetingLink}}${sign}`
1411
1418
  },
1412
1419
  onboardingInvite: {
1420
+ enabled: true,
1413
1421
  subject: `You're in, ${name}`,
1414
1422
  text: `Hi {{firstName}},
1415
1423
 
1416
1424
  Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
1417
1425
  },
1418
- seatAccepted: SEAT_ACCEPTED_TEMPLATE
1426
+ seatAccepted: { ...SEAT_ACCEPTED_TEMPLATE, enabled: true }
1419
1427
  };
1420
1428
  }
1421
1429
  function buildGroupSeed(config) {
1422
1430
  const prices = config.prices;
1423
1431
  const emails = config.emails;
1424
- const policy = config.policy ?? {};
1432
+ const policy2 = config.policy ?? {};
1425
1433
  const scheduling = {
1426
1434
  ...DEFAULT_SCHEDULING,
1427
1435
  ...config.scheduling ?? {},
@@ -1436,17 +1444,17 @@ function buildGroupSeed(config) {
1436
1444
  interval: prices?.interval ?? "year",
1437
1445
  notificationEmail: emails?.notificationEmail ?? "",
1438
1446
  replyTo: emails?.replyTo ?? emails?.notificationEmail ?? "",
1439
- disclaimerText: policy.disclaimerText ?? "",
1440
- refundPolicyText: policy.refundPolicyText ?? "",
1441
- merchantDisclosureText: policy.merchantDisclosureText ?? "",
1442
- trustCopy: policy.trustCopy ?? "",
1447
+ disclaimerText: policy2.disclaimerText ?? "",
1448
+ refundPolicyText: policy2.refundPolicyText ?? "",
1449
+ merchantDisclosureText: policy2.merchantDisclosureText ?? "",
1450
+ trustCopy: policy2.trustCopy ?? "",
1443
1451
  emailTemplates: emails?.templates ?? defaultEmailTemplates(config.name),
1444
1452
  schedulingJson: scheduling
1445
1453
  };
1446
1454
  if (emails?.debugEmail) row2.debugEmail = emails.debugEmail;
1447
1455
  if (emails?.fromName?.trim()) row2.fromName = emails.fromName.trim();
1448
- if (policy.commitmentText) row2.commitmentText = policy.commitmentText;
1449
- if (policy.normsText) row2.normsText = policy.normsText;
1456
+ if (policy2.commitmentText) row2.commitmentText = policy2.commitmentText;
1457
+ if (policy2.normsText) row2.normsText = policy2.normsText;
1450
1458
  return row2;
1451
1459
  }
1452
1460
 
@@ -2755,13 +2763,13 @@ function firstPrice(object2) {
2755
2763
  return { ...priceId ? { priceId } : {}, ...quantity !== void 0 ? { quantity } : {} };
2756
2764
  }
2757
2765
  function normalizeNetworkCommercialStripeEvent(input, digest) {
2758
- const event = raw(input);
2759
- const eventId = providerId(event.id, "evt_") ?? "";
2760
- const eventType = text(event.type) ?? "";
2761
- const object2 = raw(raw(event.data).object);
2766
+ const event2 = raw(input);
2767
+ const eventId = providerId(event2.id, "evt_") ?? "";
2768
+ const eventType = text(event2.type) ?? "";
2769
+ const object2 = raw(raw(event2.data).object);
2762
2770
  const objectId = providerId(object2.id) ?? "";
2763
2771
  const candidates = metadataCandidates(object2);
2764
- const identity = {};
2772
+ const identity2 = {};
2765
2773
  const identityKeys2 = {
2766
2774
  appId: "odlaAppId",
2767
2775
  chapterId: "chapterId",
@@ -2771,7 +2779,7 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
2771
2779
  };
2772
2780
  for (const [target, source] of Object.entries(identityKeys2)) {
2773
2781
  const value = metadataValue(candidates, source);
2774
- if (value) identity[target] = value;
2782
+ if (value) identity2[target] = value;
2775
2783
  }
2776
2784
  let objectKind = "ignored";
2777
2785
  if (eventType.startsWith("customer.subscription.")) objectKind = "subscription";
@@ -2793,11 +2801,11 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
2793
2801
  eventId,
2794
2802
  digest,
2795
2803
  eventType,
2796
- eventCreated: integer(event.created) ?? 0,
2797
- livemode: event.livemode === true,
2804
+ eventCreated: integer(event2.created) ?? 0,
2805
+ livemode: event2.livemode === true,
2798
2806
  objectKind,
2799
2807
  objectId,
2800
- identity,
2808
+ identity: identity2,
2801
2809
  ...metadataValue(candidates, "applicationId") ? { applicationId: metadataValue(candidates, "applicationId") } : {},
2802
2810
  ...metadataValue(candidates, "personId") ? { personId: metadataValue(candidates, "personId") } : {},
2803
2811
  ...metadataValue(candidates, "membershipId") ? { membershipId: metadataValue(candidates, "membershipId") } : {},
@@ -3644,15 +3652,15 @@ function membershipDiscountPolicyVersion(value) {
3644
3652
  // src/membership-offer.ts
3645
3653
  function createMembershipCommercialOffer(input) {
3646
3654
  const offering = membershipOfferingVersion(input.offering);
3647
- const policy = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
3655
+ const policy2 = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
3648
3656
  const now = input.now;
3649
3657
  if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(input.approvedFoundingCount) || input.approvedFoundingCount < 0) {
3650
3658
  throw new TypeError("invalid offer clock or founding count");
3651
3659
  }
3652
3660
  if (offering.status !== "published" || now < offering.effectiveFrom || offering.effectiveUntil !== void 0 && now >= offering.effectiveUntil) throw new TypeError("offering is unavailable");
3653
- if (policy && policy.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
3654
- const founding = Boolean(policy && policy.status === "published" && offering.basePriceCents > 0 && policy.cohortEligibleSkuIds.includes(offering.baseSkuId) && now >= policy.qualifyingStartsAt && now < policy.qualifyingEndsAt && input.approvedFoundingCount < policy.softTarget);
3655
- const earned = founding ? policy : null;
3661
+ if (policy2 && policy2.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
3662
+ const founding = Boolean(policy2 && policy2.status === "published" && offering.basePriceCents > 0 && policy2.cohortEligibleSkuIds.includes(offering.baseSkuId) && now >= policy2.qualifyingStartsAt && now < policy2.qualifyingEndsAt && input.approvedFoundingCount < policy2.softTarget);
3663
+ const earned = founding ? policy2 : null;
3656
3664
  const initialTermEndsAt = offering.term.kind === "fixed_end" ? offering.term.endsAt : addMembershipCalendarMonths(now, offering.term.months);
3657
3665
  if (initialTermEndsAt <= now) throw new TypeError("membership service term has ended");
3658
3666
  if (offering.term.kind === "rolling_months" && offering.term.months !== (offering.interval === "year" ? 12 : 1)) {
@@ -3744,15 +3752,15 @@ async function previewMembershipCommercialOffer(db, input) {
3744
3752
  }
3745
3753
  if (!Number.isSafeInteger(input.now) || input.now < 0) reject("invalid_offer_window");
3746
3754
  const state = await authoritativeState(db, input);
3747
- const { offering, policy } = await currentPolicy(db, input, false, state);
3748
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
3755
+ const { offering, policy: policy2 } = await currentPolicy(db, input, false, state);
3756
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
3749
3757
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
3750
3758
  const contract = createMembershipCommercialOffer({
3751
3759
  offering,
3752
- discountPolicy: policy,
3760
+ discountPolicy: policy2,
3753
3761
  approvedFoundingCount: Number(approvedFoundingCount),
3754
3762
  now: input.now,
3755
- couponId: await membershipDiscountCouponId(input, policy?.id)
3763
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
3756
3764
  });
3757
3765
  const discount = contract.billingTerms.discount;
3758
3766
  return {
@@ -3781,12 +3789,12 @@ async function currentPolicy(db, input, restartAware, state) {
3781
3789
  });
3782
3790
  if (!Array.isArray(rows3.membershipOfferingVersion) || !Array.isArray(rows3.discountPolicyVersion) || rows3.membershipOfferingVersion.length >= 200 || rows3.discountPolicyVersion.length >= 200) return reject("policy_history_incomplete");
3783
3791
  const offerings = rows3.membershipOfferingVersion.map((row2) => membershipOfferingVersion(row2.snapshot)).filter((offer) => offer.chapterId === input.chapterId && offer.baseSkuId === input.skuId && offer.status === "published" && input.now >= offer.effectiveFrom && (offer.effectiveUntil === void 0 || input.now < offer.effectiveUntil));
3784
- const policies = rows3.discountPolicyVersion.map((row2) => membershipDiscountPolicyVersion(row2.snapshot)).filter((policy2) => policy2.chapterId === input.chapterId && policy2.status === "published" && policy2.cohortEligibleSkuIds.includes(input.skuId) && input.now >= policy2.qualifyingStartsAt && input.now < policy2.qualifyingEndsAt);
3792
+ const policies = rows3.discountPolicyVersion.map((row2) => membershipDiscountPolicyVersion(row2.snapshot)).filter((policy3) => policy3.chapterId === input.chapterId && policy3.status === "published" && policy3.cohortEligibleSkuIds.includes(input.skuId) && input.now >= policy3.qualifyingStartsAt && input.now < policy3.qualifyingEndsAt);
3785
3793
  const offering = offerings.sort((a, b) => b.version - a.version)[0];
3786
3794
  const restart = restartAware && input.applicationId ? await load(db, "membershipRestart", (await membershipOfferIdForRequest({ ...input, applicationId: input.applicationId, requestId: "checkout_binding" })).replace(/^offer_/, "membership_")) : void 0;
3787
- const policy = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
3788
- if (!offering || offerings.filter((item) => item.version === offering.version).length !== 1 || policy && policies.filter((item) => item.version === policy.version).length !== 1) reject("ambiguous_membership_policy");
3789
- return { offering, policy };
3795
+ const policy2 = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
3796
+ if (!offering || offerings.filter((item) => item.version === offering.version).length !== 1 || policy2 && policies.filter((item) => item.version === policy2.version).length !== 1) reject("ambiguous_membership_policy");
3797
+ return { offering, policy: policy2 };
3790
3798
  }
3791
3799
  async function issueMembershipCommercialOffer(db, input) {
3792
3800
  validate(input);
@@ -3794,15 +3802,15 @@ async function issueMembershipCommercialOffer(db, input) {
3794
3802
  const state = await authoritativeState(db, input);
3795
3803
  const existing = await load(db, "membershipCheckoutOffer", input.offerId);
3796
3804
  if (existing) return restore(existing, input);
3797
- const { offering, policy } = await currentPolicy(db, input, true, state);
3798
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
3805
+ const { offering, policy: policy2 } = await currentPolicy(db, input, true, state);
3806
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
3799
3807
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
3800
3808
  const contract = createMembershipCommercialOffer({
3801
3809
  offering,
3802
- discountPolicy: policy,
3810
+ discountPolicy: policy2,
3803
3811
  approvedFoundingCount: Number(approvedFoundingCount),
3804
3812
  now: input.now,
3805
- couponId: await membershipDiscountCouponId(input, policy?.id)
3813
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
3806
3814
  });
3807
3815
  const value = {
3808
3816
  id: input.offerId,
@@ -5584,7 +5592,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5584
5592
  };
5585
5593
  if (action === "seat-accept") return acceptNamedSeatRecipient(db, recipientInput, now);
5586
5594
  if (action === "seat-approve") return approveNamedSeatRecipient(db, recipientInput, now);
5587
- const checkout = {
5595
+ const checkout2 = {
5588
5596
  ...target,
5589
5597
  priceId: peer.seatPriceId,
5590
5598
  recipientName: String(input.recipientName),
@@ -5592,7 +5600,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5592
5600
  purchaserEmail: String(input.purchaserEmail),
5593
5601
  purchaserUserId: String(input.purchaserUserId)
5594
5602
  };
5595
- if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout, now))) return grantIncludedNamedSeat(db, checkout, now);
5603
+ if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout2, now))) return grantIncludedNamedSeat(db, checkout2, now);
5596
5604
  const secret = await getVaultSecret(db, stripeSecretName);
5597
5605
  if (!secret) throw new Error("network Stripe provider is unavailable");
5598
5606
  if (action === "seat-decline") {
@@ -5608,7 +5616,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
5608
5616
  return { seatId: seat.id, status: "refunded", refundedCents: refunded.refundedCents, subscriptionCanceled: false };
5609
5617
  }
5610
5618
  if (action === "seat-operator-refund") return refundNamedSeatByOperator(db, { ...target, seatId: String(input.seatId) }, secret, now);
5611
- if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout, secret, now);
5619
+ if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout2, secret, now);
5612
5620
  const evidence = { ...target, seatId: String(input.seatId), eventId: String(input.eventId) };
5613
5621
  if (action === "seat-payments") {
5614
5622
  const receipt = await confirmNamedMembershipSeatPayment(db, evidence, now);
@@ -5679,9 +5687,9 @@ function createMembershipAuthorityHandler(options) {
5679
5687
  const identities = /* @__PURE__ */ new Set();
5680
5688
  for (const peer of peers) {
5681
5689
  if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(peer.appId) || !SAFE.test(peer.chapterId) || !/^[a-z0-9][a-z0-9-]{1,31}$/.test(peer.environment) || !/^[a-z0-9]{2,12}$/.test(peer.runtime) || !/^[A-Za-z][A-Za-z0-9_-]{1,127}$/.test(peer.secretName)) throw new TypeError("invalid authority peer");
5682
- const identity = `${peer.appId}:${peer.runtime}`;
5683
- if (identities.has(identity)) throw new TypeError("ambiguous authority peer");
5684
- identities.add(identity);
5690
+ const identity2 = `${peer.appId}:${peer.runtime}`;
5691
+ if (identities.has(identity2)) throw new TypeError("ambiguous authority peer");
5692
+ identities.add(identity2);
5685
5693
  if (peer.seatPriceId !== void 0 && !/^price_[A-Za-z0-9]+$/.test(peer.seatPriceId)) throw new TypeError("invalid seat Price");
5686
5694
  }
5687
5695
  return async (req) => {
@@ -6993,6 +7001,41 @@ async function createBuilderLibraryEnvelope(document) {
6993
7001
  return { document, digest: await builderLibraryDigest(document) };
6994
7002
  }
6995
7003
 
7004
+ // src/email-retirement.ts
7005
+ function isRetiredEmailCopy(subject, text6) {
7006
+ const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
7007
+ const bodyCopy = text6.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
7008
+ return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
7009
+ }
7010
+
7011
+ // src/email.ts
7012
+ var import_crm5 = require("@odla-ai/crm");
7013
+
7014
+ // src/email-events.ts
7015
+ var identity = ["firstName", "lastName", "email", "phone", "state"];
7016
+ var policy = ["refundPolicyText", "commitmentText", "normsText"];
7017
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
7018
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
7019
+ var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
7020
+ var CHAPTER_EMAIL_EVENTS = {
7021
+ adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
7022
+ submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
7023
+ paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
7024
+ prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
7025
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
7026
+ namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
7027
+ seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
7028
+ };
7029
+ function chapterEmailEvents(chapter) {
7030
+ const base = chapter.mode === "hub" ? Object.fromEntries(Object.entries(chapter.crm.config.templates ?? {}).map(([key3, spec]) => [key3, {
7031
+ label: key3,
7032
+ trigger: "Administrator sends a message",
7033
+ audience: "Selected contact",
7034
+ variables: spec.vars
7035
+ }])) : CHAPTER_EMAIL_EVENTS;
7036
+ return { ...base, ...chapter.config.emails?.events };
7037
+ }
7038
+
6996
7039
  // src/email.ts
6997
7040
  function render(template, vars) {
6998
7041
  return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
@@ -7016,19 +7059,158 @@ function isAlreadySent(priorRows) {
7016
7059
  function planDelivery(input) {
7017
7060
  const tpl = input.group.emailTemplates?.[input.template];
7018
7061
  if (!tpl) return { deliver: false, reason: "template-missing" };
7019
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
7062
+ if (typeof tpl.enabled !== "boolean") return { deliver: false, reason: "template-invalid", issues: ["Choose explicitly whether this email is enabled."] };
7063
+ if (tpl.enabled !== true && !input.force) return { deliver: false, reason: "disabled" };
7064
+ if (!/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(input.to)) return { deliver: false, reason: "recipient-invalid" };
7020
7065
  const vars = groupVars(input.group, input.vars);
7066
+ if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
7067
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
7068
+ const issues = (0, import_crm5.validateEmailCopy)(tpl, {
7069
+ allowedVariables: event2?.variables,
7070
+ optionalVariables: event2?.optionalVariables,
7071
+ requiredVariables: event2?.requiresTier ? ["tierName"] : [],
7072
+ values: vars,
7073
+ tierId: vars.tierId
7074
+ });
7075
+ if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
7021
7076
  const isProd = input.envName === "prod";
7022
7077
  const redirect = !isProd && !!input.group.debugEmail;
7023
7078
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
7024
7079
  const to = redirect ? input.group.debugEmail : input.to;
7025
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
7080
+ const renderedSubject = render(tpl.subject, vars);
7081
+ const renderedText = render(tpl.text, vars);
7082
+ if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
7083
+ if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
7084
+ const subject = (redirect ? "[dev] " : "") + renderedSubject;
7026
7085
  const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
7027
7086
 
7028
- ` + render(tpl.text, vars) : render(tpl.text, vars);
7087
+ ` + renderedText : renderedText;
7029
7088
  return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
7030
7089
  }
7031
7090
 
7091
+ // src/email-readiness.ts
7092
+ var import_crm6 = require("@odla-ai/crm");
7093
+ function validateEmailReadiness(input) {
7094
+ const issues = [];
7095
+ for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
7096
+ issues.push({ code: "event-undeclared", message: `Stored email ${key3} has no declared event. Review its trigger before using it.` });
7097
+ }
7098
+ if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
7099
+ for (const tier of input.tiers ?? []) {
7100
+ if (!(0, import_crm6.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
7101
+ }
7102
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm6.isEmailTierName)(tier.name, tier.id));
7103
+ if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
7104
+ const events2 = Object.entries(input.events).map(([key3, event2]) => {
7105
+ const template = input.templates[key3];
7106
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm6.validateEmailCopy)(template, { allowedVariables: event2.variables });
7107
+ if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
7108
+ if (template) {
7109
+ const used = [...`${template.subject ?? ""}
7110
+ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7111
+ for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
7112
+ problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
7113
+ }
7114
+ }
7115
+ if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
7116
+ else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
7117
+ return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
7118
+ });
7119
+ return { checkedAt: input.now ?? Date.now(), events: events2, issues };
7120
+ }
7121
+
7122
+ // src/notify.ts
7123
+ var import_crm8 = require("@odla-ai/crm");
7124
+
7125
+ // src/email-event-values.ts
7126
+ var import_crm7 = require("@odla-ai/crm");
7127
+
7128
+ // src/tiers-store.ts
7129
+ function rowToTier(row2, groupId) {
7130
+ if (!row2 || typeof row2 !== "object" || Array.isArray(row2) || typeof row2.id !== "string" || !row2.id || row2.groupId !== void 0 && row2.groupId !== groupId || !Number.isSafeInteger(row2.priceCents) || Number(row2.priceCents) < 0 || row2.currency !== void 0 && (typeof row2.currency !== "string" || !/^[a-z]{3}$/.test(row2.currency)) || row2.interval !== void 0 && row2.interval !== "month" && row2.interval !== "year") {
7131
+ throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
7132
+ }
7133
+ return {
7134
+ id: String(row2.id ?? ""),
7135
+ groupId: String(row2.groupId ?? groupId),
7136
+ name: String(row2.name ?? ""),
7137
+ priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
7138
+ ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
7139
+ ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
7140
+ ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
7141
+ ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
7142
+ sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
7143
+ // A row that never set `active` is offered; only an explicit false retires
7144
+ // a tier, so a half-seeded row cannot silently hide a membership.
7145
+ active: row2.active !== false
7146
+ };
7147
+ }
7148
+ async function loadTiers(db, group) {
7149
+ const groupId = String(group.id ?? "");
7150
+ let rows3;
7151
+ try {
7152
+ rows3 = (await db.query({
7153
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
7154
+ })).tiers;
7155
+ } catch {
7156
+ throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
7157
+ }
7158
+ if (!Array.isArray(rows3)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
7159
+ const list = rows3.map((row2) => rowToTier(row2, groupId));
7160
+ return resolveTiers(group, list);
7161
+ }
7162
+ async function loadOfferableTiers(db, group, hasSecretKey) {
7163
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
7164
+ }
7165
+
7166
+ // src/notification-variables.ts
7167
+ async function frozenCheckoutVariables(db, app) {
7168
+ const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
7169
+ const offer = intent?.membershipOffer, contract = offer?.contract;
7170
+ const offering = contract?.offering, quote = intent?.paymentQuote;
7171
+ const amount = contract?.dueTodayCents ?? intent?.priceCents;
7172
+ const currency = offering?.currency ?? intent?.currency;
7173
+ let formatted = "";
7174
+ if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
7175
+ formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
7176
+ }
7177
+ return {
7178
+ paymentAmount: formatted,
7179
+ paymentAmountSummary: formatted ? ` of ${formatted}` : "",
7180
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
7181
+ };
7182
+ }
7183
+ async function catalogTierName(db, group, app) {
7184
+ try {
7185
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
7186
+ } catch {
7187
+ return "";
7188
+ }
7189
+ }
7190
+ async function checkoutVariables(db, group, app) {
7191
+ const frozen = await frozenCheckoutVariables(db, app);
7192
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
7193
+ }
7194
+
7195
+ // src/email-event-values.ts
7196
+ async function emailTierVariables(db, input) {
7197
+ try {
7198
+ const [apps, groups2] = await Promise.all([
7199
+ db.query({ applications: { $: { where: { id: input.applicationId }, limit: 1 } } }),
7200
+ db.query({ groups: { $: { where: { id: input.groupId }, limit: 1 } } })
7201
+ ]);
7202
+ const app = apps.applications?.[0], group = groups2.groups?.[0];
7203
+ if (!app || !group || app.groupId !== input.groupId) return { tierName: "", tier: "" };
7204
+ const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
7205
+ if (!tierId) return { tierName: "", tier: "" };
7206
+ const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
7207
+ const tierName = (0, import_crm7.isEmailTierName)(name, tierId) ? name.trim() : "";
7208
+ return { tierName, tier: tierName, tierId };
7209
+ } catch {
7210
+ return { tierName: "", tier: "" };
7211
+ }
7212
+ }
7213
+
7032
7214
  // src/notify.ts
7033
7215
  function fromAddress(configured, name) {
7034
7216
  const display = name?.trim();
@@ -7046,14 +7228,17 @@ async function sendTemplated(deps, input) {
7046
7228
  const cloudflareReady = Boolean(deps.sender && deps.from);
7047
7229
  const override = deps.debugEmail?.trim();
7048
7230
  const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
7231
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
7232
+ const vars = event2?.requiresTier && input.applicationId ? { ...input.vars, ...await emailTierVariables(deps.db, { applicationId: input.applicationId, groupId: group.id, tierId: input.tierId }) } : input.vars;
7049
7233
  const decision = planDelivery({
7050
7234
  envName: deps.envName,
7051
7235
  group,
7052
7236
  template: input.template,
7053
7237
  to: input.to,
7054
- vars: input.vars,
7238
+ vars,
7055
7239
  cloudflareReady,
7056
- force: input.force
7240
+ force: input.force,
7241
+ event: event2
7057
7242
  });
7058
7243
  if (!decision.deliver) return { sent: false, reason: decision.reason };
7059
7244
  const previous = prior[0];
@@ -7078,6 +7263,8 @@ async function sendTemplated(deps, input) {
7078
7263
  error: decision.transport === "log-only" ? "" : "delivery-pending",
7079
7264
  ...input.applicationId ? { applicationId: input.applicationId } : {}
7080
7265
  };
7266
+ if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
7267
+ if ((0, import_crm8.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
7081
7268
  try {
7082
7269
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id10, attrs: row2 }], {
7083
7270
  mutationId: `email-claim:${id10}:${attempt}`,
@@ -7231,35 +7418,35 @@ function findGiftRef(obj) {
7231
7418
  ...chapterId ? { chapterId } : {}
7232
7419
  };
7233
7420
  }
7234
- function normalizeWebhookEvent(event, options = {}) {
7235
- const obj = event.data?.object ?? {};
7236
- if (event.type === "charge.refunded" && (!Number.isSafeInteger(obj.amount) || Number(obj.amount) <= 0 || obj.amount_refunded !== obj.amount || obj.refunded !== true)) return { kind: "ignored", type: event.type };
7421
+ function normalizeWebhookEvent(event2, options = {}) {
7422
+ const obj = event2.data?.object ?? {};
7423
+ if (event2.type === "charge.refunded" && (!Number.isSafeInteger(obj.amount) || Number(obj.amount) <= 0 || obj.amount_refunded !== obj.amount || obj.refunded !== true)) return { kind: "ignored", type: event2.type };
7237
7424
  const gift = findGiftRef(obj);
7238
7425
  if (gift.giftIntentId) {
7239
- const { giftIntentId, providerObjectId, ...identity } = gift;
7240
- if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
7241
- return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity };
7426
+ const { giftIntentId, providerObjectId, ...identity2 } = gift;
7427
+ if (event2.type === "payment_intent.succeeded" || event2.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
7428
+ return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity2 };
7242
7429
  }
7243
- if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "refunded" };
7244
- if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "canceled" };
7245
- return { kind: "ignored", type: event.type };
7430
+ if (event2.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "refunded" };
7431
+ if (event2.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "canceled" };
7432
+ return { kind: "ignored", type: event2.type };
7246
7433
  }
7247
7434
  const ref = findApplicationRef(obj);
7248
7435
  const seatId = obj.metadata?.seatId;
7249
7436
  if (typeof seatId === "string" && seatId) {
7250
- if (!options.requireProviderPayment) return { kind: "ignored", type: event.type };
7251
- if (event.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
7252
- if (event.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
7253
- return { kind: "ignored", type: event.type };
7437
+ if (!options.requireProviderPayment) return { kind: "ignored", type: event2.type };
7438
+ if (event2.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
7439
+ if (event2.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
7440
+ return { kind: "ignored", type: event2.type };
7254
7441
  }
7255
- if (event.type === "invoice.paid" && options.requireProviderPayment || event.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event.type };
7256
- switch (event.type) {
7442
+ if (event2.type === "invoice.paid" && options.requireProviderPayment || event2.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event2.type };
7443
+ switch (event2.type) {
7257
7444
  case "invoice.payment_failed":
7258
- return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event.type };
7445
+ return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event2.type };
7259
7446
  case "invoice.paid":
7260
7447
  case "invoice.payment_succeeded": {
7261
7448
  if (obj.billing_reason !== "subscription_create" && obj.billing_reason !== "subscription_cycle") {
7262
- return { kind: "ignored", type: event.type };
7449
+ return { kind: "ignored", type: event2.type };
7263
7450
  }
7264
7451
  const renewalAt = membershipInvoicePeriodEnd(obj);
7265
7452
  const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
@@ -7273,11 +7460,11 @@ function normalizeWebhookEvent(event, options = {}) {
7273
7460
  case "charge.refunded":
7274
7461
  return { kind: "refunded", ...ref };
7275
7462
  case "customer.subscription.updated":
7276
- return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event.type };
7463
+ return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event2.type };
7277
7464
  case "customer.subscription.deleted":
7278
7465
  return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "canceled", ...ref };
7279
7466
  default:
7280
- return { kind: "ignored", type: event.type };
7467
+ return { kind: "ignored", type: event2.type };
7281
7468
  }
7282
7469
  }
7283
7470
  function firstPaymentPatch(currentStatus, renewalAt) {
@@ -7625,10 +7812,10 @@ function findGiftOffer(catalog, offerId, now) {
7625
7812
  }
7626
7813
 
7627
7814
  // src/network.ts
7628
- var import_crm7 = require("@odla-ai/crm");
7815
+ var import_crm11 = require("@odla-ai/crm");
7629
7816
 
7630
7817
  // src/crm-person-projection.ts
7631
- var import_crm5 = require("@odla-ai/crm");
7818
+ var import_crm9 = require("@odla-ai/crm");
7632
7819
  async function ensureProjectedPerson(deps, input, options = {}) {
7633
7820
  const email = String(input.email ?? "").trim().toLowerCase();
7634
7821
  if (!email) throw new Error("crm_projection_email_required");
@@ -7646,7 +7833,7 @@ async function ensureProjectedPerson(deps, input, options = {}) {
7646
7833
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`chapter:person:v1:${email}`)));
7647
7834
  const id10 = `person-${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
7648
7835
  try {
7649
- const created = await (0, import_crm5.createRecord)({ ...deps, db: deps.db }, {
7836
+ const created = await (0, import_crm9.createRecord)({ ...deps, db: deps.db }, {
7650
7837
  id: id10,
7651
7838
  type: "person",
7652
7839
  input: { ...input, email },
@@ -7662,7 +7849,7 @@ async function ensureProjectedPerson(deps, input, options = {}) {
7662
7849
  }
7663
7850
 
7664
7851
  // src/applicant-projection.ts
7665
- var import_crm6 = require("@odla-ai/crm");
7852
+ var import_crm10 = require("@odla-ai/crm");
7666
7853
 
7667
7854
  // src/crm-application-fields.ts
7668
7855
  function applicationCrmFields(crm, app) {
@@ -7681,10 +7868,10 @@ async function upsertPerson(deps, opts) {
7681
7868
  const email = opts.email.toLowerCase();
7682
7869
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
7683
7870
  const { record: record4, created } = await ensureProjectedPerson(deps, { ...opts.input, email });
7684
- if (!created) await (0, import_crm6.updateRecord)(crmDeps, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
7871
+ if (!created) await (0, import_crm10.updateRecord)(crmDeps, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
7685
7872
  const initial = deps.crm.initialStage("person");
7686
7873
  if (!created && record4.stage == null && initial) {
7687
- await (0, import_crm6.setStage)(crmDeps, {
7874
+ await (0, import_crm10.setStage)(crmDeps, {
7688
7875
  id: String(record4.id),
7689
7876
  to: initial,
7690
7877
  authorId: "system",
@@ -7807,7 +7994,7 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
7807
7994
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
7808
7995
  }
7809
7996
  async function findSharedRecord(deps, record4, tag) {
7810
- const structured = await (0, import_crm7.getRecordByOrigin)(
7997
+ const structured = await (0, import_crm11.getRecordByOrigin)(
7811
7998
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
7812
7999
  record4.sourceId,
7813
8000
  record4.sourceRecordId
@@ -7859,15 +8046,15 @@ async function projectSharedRecord(deps, shared, options = {}) {
7859
8046
  const existing = await findSharedRecord(deps, record4, tag);
7860
8047
  let recordId;
7861
8048
  if (existing && typeof existing.id === "string") {
7862
- await (0, import_crm7.updateRecord)(crmDeps, { id: existing.id, input: record4.input });
8049
+ await (0, import_crm11.updateRecord)(crmDeps, { id: existing.id, input: record4.input });
7863
8050
  recordId = existing.id;
7864
8051
  } else if (record4.type === "person" && typeof record4.input.email === "string") {
7865
8052
  const projected = await ensureProjectedPerson(deps, record4.input);
7866
8053
  recordId = String(projected.record.id);
7867
- if (!projected.created) await (0, import_crm7.updateRecord)(crmDeps, { id: recordId, input: record4.input });
8054
+ if (!projected.created) await (0, import_crm11.updateRecord)(crmDeps, { id: recordId, input: record4.input });
7868
8055
  } else {
7869
8056
  recordId = `network_${shortHash(`${record4.sourceId}\0${record4.type}\0${record4.sourceRecordId}`)}`;
7870
- await (0, import_crm7.createRecord)({ ...crmDeps, newId: () => recordId }, {
8057
+ await (0, import_crm11.createRecord)({ ...crmDeps, newId: () => recordId }, {
7871
8058
  type: record4.type,
7872
8059
  input: record4.input,
7873
8060
  mutationId: `share-create:${tag}`
@@ -7877,7 +8064,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
7877
8064
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
7878
8065
  { mutationId: `share-map:${tag}:${recordId}` }
7879
8066
  );
7880
- await (0, import_crm7.upsertRecordOrigin)(crmDeps, {
8067
+ await (0, import_crm11.upsertRecordOrigin)(crmDeps, {
7881
8068
  recordId,
7882
8069
  sourceId: record4.sourceId,
7883
8070
  sourceRecordId: record4.sourceRecordId,
@@ -7888,7 +8075,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
7888
8075
  }
7889
8076
 
7890
8077
  // src/crm-sync.ts
7891
- var import_crm8 = require("@odla-ai/crm");
8078
+ var import_crm12 = require("@odla-ai/crm");
7892
8079
 
7893
8080
  // src/crm-account-projection.ts
7894
8081
  async function syncAccountToCrm(deps, userId) {
@@ -7973,9 +8160,9 @@ async function syncApplicationToCrm(deps, opts) {
7973
8160
  const stage = opts.stage || void 0;
7974
8161
  const { record: existing, created } = await ensureProjectedPerson(deps, input, { stage });
7975
8162
  const recordId = String(existing.id);
7976
- if (!created) await (0, import_crm8.updateRecord)(recordDeps, { id: recordId, input });
8163
+ if (!created) await (0, import_crm12.updateRecord)(recordDeps, { id: recordId, input });
7977
8164
  if (!created && stage && existing.stage !== stage) {
7978
- await (0, import_crm8.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
8165
+ await (0, import_crm12.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
7979
8166
  }
7980
8167
  const billing = billingColumns(opts.app);
7981
8168
  await deps.db.transact([
@@ -7983,7 +8170,7 @@ async function syncApplicationToCrm(deps, opts) {
7983
8170
  // Optional typed dates are removed with a retract, never an invalid null.
7984
8171
  ...!("renewalAt" in billing) ? [{ t: "retract", ns: "crm_record", id: recordId, attrs: ["renewalAt"] }] : []
7985
8172
  ]);
7986
- await (0, import_crm8.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
8173
+ await (0, import_crm12.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
7987
8174
  return recordId;
7988
8175
  }
7989
8176
  async function backfillCrm(deps) {
@@ -8650,44 +8837,6 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
8650
8837
  };
8651
8838
  }
8652
8839
 
8653
- // src/tiers-store.ts
8654
- function rowToTier(row2, groupId) {
8655
- if (!row2 || typeof row2 !== "object" || Array.isArray(row2) || typeof row2.id !== "string" || !row2.id || row2.groupId !== void 0 && row2.groupId !== groupId || !Number.isSafeInteger(row2.priceCents) || Number(row2.priceCents) < 0 || row2.currency !== void 0 && (typeof row2.currency !== "string" || !/^[a-z]{3}$/.test(row2.currency)) || row2.interval !== void 0 && row2.interval !== "month" && row2.interval !== "year") {
8656
- throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
8657
- }
8658
- return {
8659
- id: String(row2.id ?? ""),
8660
- groupId: String(row2.groupId ?? groupId),
8661
- name: String(row2.name ?? ""),
8662
- priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
8663
- ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
8664
- ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
8665
- ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
8666
- ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
8667
- sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
8668
- // A row that never set `active` is offered; only an explicit false retires
8669
- // a tier, so a half-seeded row cannot silently hide a membership.
8670
- active: row2.active !== false
8671
- };
8672
- }
8673
- async function loadTiers(db, group) {
8674
- const groupId = String(group.id ?? "");
8675
- let rows3;
8676
- try {
8677
- rows3 = (await db.query({
8678
- tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
8679
- })).tiers;
8680
- } catch {
8681
- throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
8682
- }
8683
- if (!Array.isArray(rows3)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
8684
- const list = rows3.map((row2) => rowToTier(row2, groupId));
8685
- return resolveTiers(group, list);
8686
- }
8687
- async function loadOfferableTiers(db, group, hasSecretKey) {
8688
- return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
8689
- }
8690
-
8691
8840
  // src/signup-control-contract.ts
8692
8841
  var SIGNUP_CONTROL_SCHEMA_VERSION = 1;
8693
8842
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
@@ -8995,15 +9144,15 @@ function policyOf(value, errors, tiers2) {
8995
9144
  if (!overrides || Object.keys(overrides).length > 20) errors.push("document.policy.tierPolicies must be a bounded tier map");
8996
9145
  else for (const [id10, raw2] of Object.entries(overrides)) {
8997
9146
  const path = `document.policy.tierPolicies.${id10}`;
8998
- const policy = recordOf(raw2);
8999
- if (!tiers2.some((tier) => tier.id === id10) || !policy) {
9147
+ const policy2 = recordOf(raw2);
9148
+ if (!tiers2.some((tier) => tier.id === id10) || !policy2) {
9000
9149
  errors.push(`${path} must name a declared tier and policy`);
9001
9150
  continue;
9002
9151
  }
9003
- unknownKeys3(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
9152
+ unknownKeys3(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
9004
9153
  tierPolicies[id10] = {
9005
- refundPolicyText: text5(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
9006
- ...policy.trustCopy !== void 0 ? { trustCopy: text5(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
9154
+ refundPolicyText: text5(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
9155
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text5(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
9007
9156
  };
9008
9157
  }
9009
9158
  }
@@ -9058,7 +9207,7 @@ function validateSignupControlDocument(input) {
9058
9207
  errors.push("document.membershipPage.tiers must follow the available tier display order");
9059
9208
  }
9060
9209
  const application = applicationOf(root.application, errors);
9061
- const policy = policyOf(root.policy, errors, tiers2);
9210
+ const policy2 = policyOf(root.policy, errors, tiers2);
9062
9211
  let scheduling;
9063
9212
  if (root.scheduling !== void 0) {
9064
9213
  const checked = validateScheduling(root.scheduling);
@@ -9078,7 +9227,7 @@ function validateSignupControlDocument(input) {
9078
9227
  tiers: tiers2,
9079
9228
  membershipPage: page.value,
9080
9229
  ...application ? { application } : {},
9081
- ...policy ? { policy } : {},
9230
+ ...policy2 ? { policy: policy2 } : {},
9082
9231
  ...scheduling ? { scheduling } : {},
9083
9232
  ...theme ? { theme } : {},
9084
9233
  ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
@@ -9791,23 +9940,23 @@ function memberSystemAvailability(input) {
9791
9940
  ...!input.bnfFrontendReachable ? { reason: "bnf_frontend_unavailable" } : {}
9792
9941
  };
9793
9942
  }
9794
- function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
9943
+ function classifyMemberStripeEvent(identity2, target, hasProvenLegacyLocalLink) {
9795
9944
  const keys2 = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
9796
- const supplied = keys2.filter((key3) => identity[key3] !== void 0);
9797
- if (supplied.some((key3) => identity[key3] !== target[key3])) return "ignore_sibling";
9945
+ const supplied = keys2.filter((key3) => identity2[key3] !== void 0);
9946
+ if (supplied.some((key3) => identity2[key3] !== target[key3])) return "ignore_sibling";
9798
9947
  if (supplied.length === keys2.length) return "apply";
9799
9948
  return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
9800
9949
  }
9801
9950
  function planMemberSystemEventReplay(events2) {
9802
9951
  const seen = /* @__PURE__ */ new Map();
9803
9952
  const plan = { accepted: [], replayed: [], collisions: [] };
9804
- for (const event of events2) {
9805
- const digest = seen.get(event.id);
9953
+ for (const event2 of events2) {
9954
+ const digest = seen.get(event2.id);
9806
9955
  if (digest === void 0) {
9807
- seen.set(event.id, event.digest);
9808
- plan.accepted.push(event.id);
9809
- } else if (digest === event.digest) plan.replayed.push(event.id);
9810
- else plan.collisions.push(event.id);
9956
+ seen.set(event2.id, event2.digest);
9957
+ plan.accepted.push(event2.id);
9958
+ } else if (digest === event2.digest) plan.replayed.push(event2.id);
9959
+ else plan.collisions.push(event2.id);
9811
9960
  }
9812
9961
  return plan;
9813
9962
  }