@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.
@@ -437,12 +437,12 @@ function canonical(value) {
437
437
  return value;
438
438
  }
439
439
  async function stripeEventDigest(rawBody) {
440
- const event = JSON.parse(rawBody);
441
- if (!event || typeof event !== "object" || Array.isArray(event)) throw new TypeError("invalid Stripe event");
440
+ const event2 = JSON.parse(rawBody);
441
+ if (!event2 || typeof event2 !== "object" || Array.isArray(event2)) throw new TypeError("invalid Stripe event");
442
442
  const keys3 = ["id", "type", "created", "livemode", "account", "context", "api_version", "data"];
443
443
  return stripePayloadDigest(JSON.stringify(canonical({
444
444
  digestVersion: 1,
445
- ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event, key3)).map((key3) => [key3, event[key3]]))
445
+ ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event2, key3)).map((key3) => [key3, event2[key3]]))
446
446
  })));
447
447
  }
448
448
 
@@ -709,13 +709,13 @@ function firstPrice(object2) {
709
709
  return { ...priceId ? { priceId } : {}, ...quantity !== void 0 ? { quantity } : {} };
710
710
  }
711
711
  function normalizeNetworkCommercialStripeEvent(input, digest) {
712
- const event = raw(input);
713
- const eventId = providerId(event.id, "evt_") ?? "";
714
- const eventType = text(event.type) ?? "";
715
- const object2 = raw(raw(event.data).object);
712
+ const event2 = raw(input);
713
+ const eventId = providerId(event2.id, "evt_") ?? "";
714
+ const eventType = text(event2.type) ?? "";
715
+ const object2 = raw(raw(event2.data).object);
716
716
  const objectId = providerId(object2.id) ?? "";
717
717
  const candidates = metadataCandidates(object2);
718
- const identity = {};
718
+ const identity2 = {};
719
719
  const identityKeys2 = {
720
720
  appId: "odlaAppId",
721
721
  chapterId: "chapterId",
@@ -725,7 +725,7 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
725
725
  };
726
726
  for (const [target, source] of Object.entries(identityKeys2)) {
727
727
  const value = metadataValue(candidates, source);
728
- if (value) identity[target] = value;
728
+ if (value) identity2[target] = value;
729
729
  }
730
730
  let objectKind = "ignored";
731
731
  if (eventType.startsWith("customer.subscription.")) objectKind = "subscription";
@@ -747,11 +747,11 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
747
747
  eventId,
748
748
  digest,
749
749
  eventType,
750
- eventCreated: integer(event.created) ?? 0,
751
- livemode: event.livemode === true,
750
+ eventCreated: integer(event2.created) ?? 0,
751
+ livemode: event2.livemode === true,
752
752
  objectKind,
753
753
  objectId,
754
- identity,
754
+ identity: identity2,
755
755
  ...metadataValue(candidates, "applicationId") ? { applicationId: metadataValue(candidates, "applicationId") } : {},
756
756
  ...metadataValue(candidates, "personId") ? { personId: metadataValue(candidates, "personId") } : {},
757
757
  ...metadataValue(candidates, "membershipId") ? { membershipId: metadataValue(candidates, "membershipId") } : {},
@@ -1228,35 +1228,35 @@ function findGiftRef(obj) {
1228
1228
  ...chapterId ? { chapterId } : {}
1229
1229
  };
1230
1230
  }
1231
- function normalizeWebhookEvent(event, options = {}) {
1232
- const obj = event.data?.object ?? {};
1233
- 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 };
1231
+ function normalizeWebhookEvent(event2, options = {}) {
1232
+ const obj = event2.data?.object ?? {};
1233
+ 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 };
1234
1234
  const gift = findGiftRef(obj);
1235
1235
  if (gift.giftIntentId) {
1236
- const { giftIntentId, providerObjectId, ...identity } = gift;
1237
- if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1238
- return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity };
1236
+ const { giftIntentId, providerObjectId, ...identity2 } = gift;
1237
+ if (event2.type === "payment_intent.succeeded" || event2.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1238
+ return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity2 };
1239
1239
  }
1240
- if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "refunded" };
1241
- if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "canceled" };
1242
- return { kind: "ignored", type: event.type };
1240
+ if (event2.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "refunded" };
1241
+ if (event2.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "canceled" };
1242
+ return { kind: "ignored", type: event2.type };
1243
1243
  }
1244
1244
  const ref = findApplicationRef(obj);
1245
1245
  const seatId = obj.metadata?.seatId;
1246
1246
  if (typeof seatId === "string" && seatId) {
1247
- if (!options.requireProviderPayment) return { kind: "ignored", type: event.type };
1248
- if (event.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1249
- if (event.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1250
- return { kind: "ignored", type: event.type };
1247
+ if (!options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1248
+ if (event2.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1249
+ if (event2.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1250
+ return { kind: "ignored", type: event2.type };
1251
1251
  }
1252
- if (event.type === "invoice.paid" && options.requireProviderPayment || event.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event.type };
1253
- switch (event.type) {
1252
+ if (event2.type === "invoice.paid" && options.requireProviderPayment || event2.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1253
+ switch (event2.type) {
1254
1254
  case "invoice.payment_failed":
1255
- return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event.type };
1255
+ return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event2.type };
1256
1256
  case "invoice.paid":
1257
1257
  case "invoice.payment_succeeded": {
1258
1258
  if (obj.billing_reason !== "subscription_create" && obj.billing_reason !== "subscription_cycle") {
1259
- return { kind: "ignored", type: event.type };
1259
+ return { kind: "ignored", type: event2.type };
1260
1260
  }
1261
1261
  const renewalAt = membershipInvoicePeriodEnd(obj);
1262
1262
  const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
@@ -1270,11 +1270,11 @@ function normalizeWebhookEvent(event, options = {}) {
1270
1270
  case "charge.refunded":
1271
1271
  return { kind: "refunded", ...ref };
1272
1272
  case "customer.subscription.updated":
1273
- return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event.type };
1273
+ return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event2.type };
1274
1274
  case "customer.subscription.deleted":
1275
1275
  return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "canceled", ...ref };
1276
1276
  default:
1277
- return { kind: "ignored", type: event.type };
1277
+ return { kind: "ignored", type: event2.type };
1278
1278
  }
1279
1279
  }
1280
1280
  function firstPaymentPatch(currentStatus, renewalAt) {
@@ -1633,6 +1633,41 @@ async function backfillCrm(deps) {
1633
1633
  return { synced, errors };
1634
1634
  }
1635
1635
 
1636
+ // src/email-retirement.ts
1637
+ function isRetiredEmailCopy(subject, text8) {
1638
+ const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
1639
+ const bodyCopy = text8.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1640
+ return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
1641
+ }
1642
+
1643
+ // src/email.ts
1644
+ var import_crm5 = require("@odla-ai/crm");
1645
+
1646
+ // src/email-events.ts
1647
+ var identity = ["firstName", "lastName", "email", "phone", "state"];
1648
+ var policy = ["refundPolicyText", "commitmentText", "normsText"];
1649
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
1650
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
1651
+ var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
1652
+ var CHAPTER_EMAIL_EVENTS = {
1653
+ adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
1654
+ submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
1655
+ paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
1656
+ prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
1657
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
1658
+ namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
1659
+ seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
1660
+ };
1661
+ function chapterEmailEvents(chapter) {
1662
+ const base = chapter.mode === "hub" ? Object.fromEntries(Object.entries(chapter.crm.config.templates ?? {}).map(([key3, spec]) => [key3, {
1663
+ label: key3,
1664
+ trigger: "Administrator sends a message",
1665
+ audience: "Selected contact",
1666
+ variables: spec.vars
1667
+ }])) : CHAPTER_EMAIL_EVENTS;
1668
+ return { ...base, ...chapter.config.emails?.events };
1669
+ }
1670
+
1636
1671
  // src/email.ts
1637
1672
  function render(template, vars) {
1638
1673
  return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
@@ -1656,21 +1691,233 @@ function isAlreadySent(priorRows) {
1656
1691
  function planDelivery(input) {
1657
1692
  const tpl = input.group.emailTemplates?.[input.template];
1658
1693
  if (!tpl) return { deliver: false, reason: "template-missing" };
1659
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
1694
+ if (typeof tpl.enabled !== "boolean") return { deliver: false, reason: "template-invalid", issues: ["Choose explicitly whether this email is enabled."] };
1695
+ if (tpl.enabled !== true && !input.force) return { deliver: false, reason: "disabled" };
1696
+ if (!/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(input.to)) return { deliver: false, reason: "recipient-invalid" };
1660
1697
  const vars = groupVars(input.group, input.vars);
1698
+ if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
1699
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1700
+ const issues = (0, import_crm5.validateEmailCopy)(tpl, {
1701
+ allowedVariables: event2?.variables,
1702
+ optionalVariables: event2?.optionalVariables,
1703
+ requiredVariables: event2?.requiresTier ? ["tierName"] : [],
1704
+ values: vars,
1705
+ tierId: vars.tierId
1706
+ });
1707
+ if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
1661
1708
  const isProd = input.envName === "prod";
1662
1709
  const redirect = !isProd && !!input.group.debugEmail;
1663
1710
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1664
1711
  const to = redirect ? input.group.debugEmail : input.to;
1665
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1712
+ const renderedSubject = render(tpl.subject, vars);
1713
+ const renderedText = render(tpl.text, vars);
1714
+ if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1715
+ if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1716
+ const subject = (redirect ? "[dev] " : "") + renderedSubject;
1666
1717
  const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
1667
1718
 
1668
- ` + render(tpl.text, vars) : render(tpl.text, vars);
1719
+ ` + renderedText : renderedText;
1669
1720
  return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
1670
1721
  }
1671
1722
 
1672
1723
  // src/notify.ts
1673
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1724
+ var import_crm7 = require("@odla-ai/crm");
1725
+
1726
+ // src/email-event-values.ts
1727
+ var import_crm6 = require("@odla-ai/crm");
1728
+
1729
+ // src/tiers.ts
1730
+ function tierIsFree(tier) {
1731
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
1732
+ }
1733
+ function tierPayable(tier, group, hasSecretKey) {
1734
+ if (tierIsFree(tier)) return true;
1735
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
1736
+ }
1737
+ function byOrder(a, b) {
1738
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
1739
+ }
1740
+ function resolveTiers(group, rows5 = []) {
1741
+ const stored = rows5.filter((tier) => tier.active);
1742
+ if (rows5.length) return [...stored].sort(byOrder);
1743
+ return [{
1744
+ id: "standard",
1745
+ groupId: String(group.id ?? ""),
1746
+ name: "Membership",
1747
+ priceCents: group.standardPriceCents ?? 0,
1748
+ currency: group.currency ?? "usd",
1749
+ interval: group.interval ?? "year",
1750
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
1751
+ sortOrder: 0,
1752
+ active: true
1753
+ }];
1754
+ }
1755
+ function offerableTiers(tiers, group, hasSecretKey) {
1756
+ return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
1757
+ }
1758
+ function findTier(tiers, tierId) {
1759
+ if (!tierId) return tiers.length === 1 ? tiers[0] : null;
1760
+ return tiers.find((tier) => tier.id === tierId) ?? null;
1761
+ }
1762
+ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
1763
+ return tiers.map((tier) => ({
1764
+ id: tier.id,
1765
+ name: tier.name,
1766
+ priceCents: tier.priceCents,
1767
+ currency: tier.currency ?? defaults.currency ?? "usd",
1768
+ interval: tier.interval ?? defaults.interval ?? "year",
1769
+ blurb: tier.blurb ?? "",
1770
+ free: tierIsFree(tier),
1771
+ ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
1772
+ }));
1773
+ }
1774
+
1775
+ // src/tiers-store.ts
1776
+ function rowToTier(row2, groupId) {
1777
+ 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") {
1778
+ throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
1779
+ }
1780
+ return {
1781
+ id: String(row2.id ?? ""),
1782
+ groupId: String(row2.groupId ?? groupId),
1783
+ name: String(row2.name ?? ""),
1784
+ priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
1785
+ ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
1786
+ ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
1787
+ ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
1788
+ ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
1789
+ sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
1790
+ // A row that never set `active` is offered; only an explicit false retires
1791
+ // a tier, so a half-seeded row cannot silently hide a membership.
1792
+ active: row2.active !== false
1793
+ };
1794
+ }
1795
+ async function loadTiers(db, group) {
1796
+ const groupId = String(group.id ?? "");
1797
+ let rows5;
1798
+ try {
1799
+ rows5 = (await db.query({
1800
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
1801
+ })).tiers;
1802
+ } catch {
1803
+ throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
1804
+ }
1805
+ if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
1806
+ const list = rows5.map((row2) => rowToTier(row2, groupId));
1807
+ return resolveTiers(group, list);
1808
+ }
1809
+ async function loadOfferableTiers(db, group, hasSecretKey) {
1810
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
1811
+ }
1812
+
1813
+ // src/notification-variables.ts
1814
+ function lifecycleNotifyDeps(db, env) {
1815
+ return {
1816
+ db,
1817
+ envName: env.ODLA_ENV,
1818
+ sender: env.SEND_EMAIL,
1819
+ from: env.EMAIL_FROM,
1820
+ debugEmail: env.ODLA_DEBUG_EMAIL,
1821
+ now: () => Date.now(),
1822
+ newId: () => crypto.randomUUID()
1823
+ };
1824
+ }
1825
+ async function frozenCheckoutVariables(db, app) {
1826
+ const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
1827
+ const offer = intent?.membershipOffer, contract = offer?.contract;
1828
+ const offering = contract?.offering, quote = intent?.paymentQuote;
1829
+ const amount = contract?.dueTodayCents ?? intent?.priceCents;
1830
+ const currency = offering?.currency ?? intent?.currency;
1831
+ let formatted = "";
1832
+ if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1833
+ formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1834
+ }
1835
+ return {
1836
+ paymentAmount: formatted,
1837
+ paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1838
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
1839
+ };
1840
+ }
1841
+ async function catalogTierName(db, group, app) {
1842
+ try {
1843
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
1844
+ } catch {
1845
+ return "";
1846
+ }
1847
+ }
1848
+ async function checkoutVariables(db, group, app) {
1849
+ const frozen = await frozenCheckoutVariables(db, app);
1850
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
1851
+ }
1852
+ function formatInstant(at2, timeZone) {
1853
+ return new Intl.DateTimeFormat("en-US", {
1854
+ weekday: "long",
1855
+ month: "long",
1856
+ day: "numeric",
1857
+ year: "numeric",
1858
+ hour: "numeric",
1859
+ minute: "2-digit",
1860
+ timeZoneName: "short",
1861
+ timeZone
1862
+ }).format(new Date(at2));
1863
+ }
1864
+ var adminUrlFor = (origin) => `${origin}/admin/`;
1865
+ var membersUrlFor = (origin) => `${origin}/members/`;
1866
+ function applicantVariables(app) {
1867
+ const text8 = (v) => typeof v === "string" ? v : "";
1868
+ return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
1869
+ }
1870
+ function sampleVariables(origin, timeZone, now = Date.now()) {
1871
+ const startAt = formatInstant(now + 864e5, timeZone);
1872
+ return {
1873
+ firstName: "Sample",
1874
+ lastName: "Person",
1875
+ email: "sample@example.com",
1876
+ phone: "(555) 010-0100",
1877
+ state: "CA",
1878
+ tier: "Membership",
1879
+ tierName: "Membership",
1880
+ paymentAmount: "$100.00",
1881
+ paymentAmountSummary: " of $100.00",
1882
+ paymentNextStep: "Next, schedule your introduction call.",
1883
+ submitNextStep: "Next, schedule your introduction call.",
1884
+ startAt,
1885
+ endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
1886
+ timezone: timeZone,
1887
+ meetUrl: "https://meet.google.com/sample",
1888
+ htmlLink: "https://calendar.google.com/event?eid=sample",
1889
+ meetingTime: startAt,
1890
+ meetingLink: "https://meet.google.com/sample",
1891
+ adminUrl: adminUrlFor(origin),
1892
+ membersUrl: membersUrlFor(origin),
1893
+ purchaserName: "Sample Purchaser",
1894
+ recipientName: "Sample Recipient",
1895
+ chapterName: "Sample Chapter",
1896
+ acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1897
+ joinUrl: `${origin}/join?seat=sample`
1898
+ };
1899
+ }
1900
+
1901
+ // src/email-event-values.ts
1902
+ async function emailTierVariables(db, input) {
1903
+ try {
1904
+ const [apps, groups] = await Promise.all([
1905
+ db.query({ applications: { $: { where: { id: input.applicationId }, limit: 1 } } }),
1906
+ db.query({ groups: { $: { where: { id: input.groupId }, limit: 1 } } })
1907
+ ]);
1908
+ const app = apps.applications?.[0], group = groups.groups?.[0];
1909
+ if (!app || !group || app.groupId !== input.groupId) return { tierName: "", tier: "" };
1910
+ const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
1911
+ if (!tierId) return { tierName: "", tier: "" };
1912
+ const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
1913
+ const tierName2 = (0, import_crm6.isEmailTierName)(name, tierId) ? name.trim() : "";
1914
+ return { tierName: tierName2, tier: tierName2, tierId };
1915
+ } catch {
1916
+ return { tierName: "", tier: "" };
1917
+ }
1918
+ }
1919
+
1920
+ // src/notify.ts
1674
1921
  function fromAddress(configured, name) {
1675
1922
  const display = name?.trim();
1676
1923
  if (!display) return configured;
@@ -1687,14 +1934,17 @@ async function sendTemplated(deps, input) {
1687
1934
  const cloudflareReady = Boolean(deps.sender && deps.from);
1688
1935
  const override = deps.debugEmail?.trim();
1689
1936
  const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1937
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1938
+ const vars = event2?.requiresTier && input.applicationId ? { ...input.vars, ...await emailTierVariables(deps.db, { applicationId: input.applicationId, groupId: group.id, tierId: input.tierId }) } : input.vars;
1690
1939
  const decision = planDelivery({
1691
1940
  envName: deps.envName,
1692
1941
  group,
1693
1942
  template: input.template,
1694
1943
  to: input.to,
1695
- vars: input.vars,
1944
+ vars,
1696
1945
  cloudflareReady,
1697
- force: input.force
1946
+ force: input.force,
1947
+ event: event2
1698
1948
  });
1699
1949
  if (!decision.deliver) return { sent: false, reason: decision.reason };
1700
1950
  const previous = prior[0];
@@ -1719,6 +1969,8 @@ async function sendTemplated(deps, input) {
1719
1969
  error: decision.transport === "log-only" ? "" : "delivery-pending",
1720
1970
  ...input.applicationId ? { applicationId: input.applicationId } : {}
1721
1971
  };
1972
+ if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
1973
+ if ((0, import_crm7.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
1722
1974
  try {
1723
1975
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1724
1976
  mutationId: `email-claim:${id6}:${attempt}`,
@@ -1765,17 +2017,17 @@ async function sendTemplated(deps, input) {
1765
2017
  return { sent: true };
1766
2018
  }
1767
2019
  function emailGroupFrom(row2) {
1768
- const str3 = (v) => typeof v === "string" ? v : void 0;
2020
+ const str4 = (v) => typeof v === "string" ? v : void 0;
1769
2021
  const templates = row2.emailTemplates && typeof row2.emailTemplates === "object" ? row2.emailTemplates : {};
1770
2022
  return {
1771
2023
  id: String(row2.id),
1772
2024
  name: String(row2.name ?? ""),
1773
- replyTo: str3(row2.replyTo) ?? "",
1774
- debugEmail: str3(row2.debugEmail),
1775
- fromName: str3(row2.fromName),
1776
- refundPolicyText: str3(row2.refundPolicyText),
1777
- commitmentText: str3(row2.commitmentText),
1778
- normsText: str3(row2.normsText),
2025
+ replyTo: str4(row2.replyTo) ?? "",
2026
+ debugEmail: str4(row2.debugEmail),
2027
+ fromName: str4(row2.fromName),
2028
+ refundPolicyText: str4(row2.refundPolicyText),
2029
+ commitmentText: str4(row2.commitmentText),
2030
+ normsText: str4(row2.normsText),
1779
2031
  emailTemplates: templates
1780
2032
  };
1781
2033
  }
@@ -1888,15 +2140,15 @@ function membershipDiscountPolicyVersion(value) {
1888
2140
  // src/membership-offer.ts
1889
2141
  function createMembershipCommercialOffer(input) {
1890
2142
  const offering = membershipOfferingVersion(input.offering);
1891
- const policy = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
2143
+ const policy2 = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
1892
2144
  const now = input.now;
1893
2145
  if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(input.approvedFoundingCount) || input.approvedFoundingCount < 0) {
1894
2146
  throw new TypeError("invalid offer clock or founding count");
1895
2147
  }
1896
2148
  if (offering.status !== "published" || now < offering.effectiveFrom || offering.effectiveUntil !== void 0 && now >= offering.effectiveUntil) throw new TypeError("offering is unavailable");
1897
- if (policy && policy.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
1898
- 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);
1899
- const earned = founding ? policy : null;
2149
+ if (policy2 && policy2.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
2150
+ 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);
2151
+ const earned = founding ? policy2 : null;
1900
2152
  const initialTermEndsAt = offering.term.kind === "fixed_end" ? offering.term.endsAt : addMembershipCalendarMonths(now, offering.term.months);
1901
2153
  if (initialTermEndsAt <= now) throw new TypeError("membership service term has ended");
1902
2154
  if (offering.term.kind === "rolling_months" && offering.term.months !== (offering.interval === "year" ? 12 : 1)) {
@@ -1988,15 +2240,15 @@ async function previewMembershipCommercialOffer(db, input) {
1988
2240
  }
1989
2241
  if (!Number.isSafeInteger(input.now) || input.now < 0) reject("invalid_offer_window");
1990
2242
  const state = await authoritativeState(db, input);
1991
- const { offering, policy } = await currentPolicy(db, input, false, state);
1992
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2243
+ const { offering, policy: policy2 } = await currentPolicy(db, input, false, state);
2244
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
1993
2245
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
1994
2246
  const contract = createMembershipCommercialOffer({
1995
2247
  offering,
1996
- discountPolicy: policy,
2248
+ discountPolicy: policy2,
1997
2249
  approvedFoundingCount: Number(approvedFoundingCount),
1998
2250
  now: input.now,
1999
- couponId: await membershipDiscountCouponId(input, policy?.id)
2251
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
2000
2252
  });
2001
2253
  const discount = contract.billingTerms.discount;
2002
2254
  return {
@@ -2025,12 +2277,12 @@ async function currentPolicy(db, input, restartAware, state) {
2025
2277
  });
2026
2278
  if (!Array.isArray(rows5.membershipOfferingVersion) || !Array.isArray(rows5.discountPolicyVersion) || rows5.membershipOfferingVersion.length >= 200 || rows5.discountPolicyVersion.length >= 200) return reject("policy_history_incomplete");
2027
2279
  const offerings = rows5.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));
2028
- const policies = rows5.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);
2280
+ const policies = rows5.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);
2029
2281
  const offering = offerings.sort((a, b) => b.version - a.version)[0];
2030
2282
  const restart = restartAware && input.applicationId ? await load(db, "membershipRestart", (await membershipOfferIdForRequest({ ...input, applicationId: input.applicationId, requestId: "checkout_binding" })).replace(/^offer_/, "membership_")) : void 0;
2031
- const policy = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
2032
- 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");
2033
- return { offering, policy };
2283
+ const policy2 = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
2284
+ 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");
2285
+ return { offering, policy: policy2 };
2034
2286
  }
2035
2287
  async function issueMembershipCommercialOffer(db, input) {
2036
2288
  validate(input);
@@ -2038,15 +2290,15 @@ async function issueMembershipCommercialOffer(db, input) {
2038
2290
  const state = await authoritativeState(db, input);
2039
2291
  const existing = await load(db, "membershipCheckoutOffer", input.offerId);
2040
2292
  if (existing) return restore(existing, input);
2041
- const { offering, policy } = await currentPolicy(db, input, true, state);
2042
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2293
+ const { offering, policy: policy2 } = await currentPolicy(db, input, true, state);
2294
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
2043
2295
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
2044
2296
  const contract = createMembershipCommercialOffer({
2045
2297
  offering,
2046
- discountPolicy: policy,
2298
+ discountPolicy: policy2,
2047
2299
  approvedFoundingCount: Number(approvedFoundingCount),
2048
2300
  now: input.now,
2049
- couponId: await membershipDiscountCouponId(input, policy?.id)
2301
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
2050
2302
  });
2051
2303
  const value = {
2052
2304
  id: input.offerId,
@@ -2313,90 +2565,6 @@ async function verifyMembershipPaymentReceipt(value, target) {
2313
2565
  };
2314
2566
  }
2315
2567
 
2316
- // src/tiers.ts
2317
- function tierIsFree(tier) {
2318
- return !(tier.priceCents > 0) && !tier.stripePriceId;
2319
- }
2320
- function tierPayable(tier, group, hasSecretKey) {
2321
- if (tierIsFree(tier)) return true;
2322
- return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
2323
- }
2324
- function byOrder(a, b) {
2325
- return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
2326
- }
2327
- function resolveTiers(group, rows5 = []) {
2328
- const stored = rows5.filter((tier) => tier.active);
2329
- if (rows5.length) return [...stored].sort(byOrder);
2330
- return [{
2331
- id: "standard",
2332
- groupId: String(group.id ?? ""),
2333
- name: "Membership",
2334
- priceCents: group.standardPriceCents ?? 0,
2335
- currency: group.currency ?? "usd",
2336
- interval: group.interval ?? "year",
2337
- ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
2338
- sortOrder: 0,
2339
- active: true
2340
- }];
2341
- }
2342
- function offerableTiers(tiers, group, hasSecretKey) {
2343
- return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
2344
- }
2345
- function findTier(tiers, tierId) {
2346
- if (!tierId) return tiers.length === 1 ? tiers[0] : null;
2347
- return tiers.find((tier) => tier.id === tierId) ?? null;
2348
- }
2349
- function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
2350
- return tiers.map((tier) => ({
2351
- id: tier.id,
2352
- name: tier.name,
2353
- priceCents: tier.priceCents,
2354
- currency: tier.currency ?? defaults.currency ?? "usd",
2355
- interval: tier.interval ?? defaults.interval ?? "year",
2356
- blurb: tier.blurb ?? "",
2357
- free: tierIsFree(tier),
2358
- ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
2359
- }));
2360
- }
2361
-
2362
- // src/tiers-store.ts
2363
- function rowToTier(row2, groupId) {
2364
- 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") {
2365
- throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
2366
- }
2367
- return {
2368
- id: String(row2.id ?? ""),
2369
- groupId: String(row2.groupId ?? groupId),
2370
- name: String(row2.name ?? ""),
2371
- priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
2372
- ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
2373
- ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
2374
- ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
2375
- ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
2376
- sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
2377
- // A row that never set `active` is offered; only an explicit false retires
2378
- // a tier, so a half-seeded row cannot silently hide a membership.
2379
- active: row2.active !== false
2380
- };
2381
- }
2382
- async function loadTiers(db, group) {
2383
- const groupId = String(group.id ?? "");
2384
- let rows5;
2385
- try {
2386
- rows5 = (await db.query({
2387
- tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
2388
- })).tiers;
2389
- } catch {
2390
- throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
2391
- }
2392
- if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
2393
- const list = rows5.map((row2) => rowToTier(row2, groupId));
2394
- return resolveTiers(group, list);
2395
- }
2396
- async function loadOfferableTiers(db, group, hasSecretKey) {
2397
- return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2398
- }
2399
-
2400
2568
  // src/membership-restart-seat.ts
2401
2569
  async function expireRestartedMembershipSeat(db, target, member, now) {
2402
2570
  const ops = [], guards = [];
@@ -2903,89 +3071,6 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
2903
3071
  }
2904
3072
  }
2905
3073
 
2906
- // src/notification-variables.ts
2907
- function lifecycleNotifyDeps(db, env) {
2908
- return {
2909
- db,
2910
- envName: env.ODLA_ENV,
2911
- sender: env.SEND_EMAIL,
2912
- from: env.EMAIL_FROM,
2913
- debugEmail: env.ODLA_DEBUG_EMAIL,
2914
- now: () => Date.now(),
2915
- newId: () => crypto.randomUUID()
2916
- };
2917
- }
2918
- async function frozenCheckoutVariables(db, app) {
2919
- const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
2920
- const offer = intent?.membershipOffer, contract = offer?.contract;
2921
- const offering = contract?.offering, quote = intent?.paymentQuote;
2922
- const amount = contract?.dueTodayCents ?? intent?.priceCents;
2923
- const currency = offering?.currency ?? intent?.currency;
2924
- let formatted = "";
2925
- if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
2926
- formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
2927
- }
2928
- return {
2929
- paymentAmount: formatted,
2930
- paymentAmountSummary: formatted ? ` of ${formatted}` : "",
2931
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
2932
- };
2933
- }
2934
- async function catalogTierName(db, group, app) {
2935
- try {
2936
- return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
2937
- } catch {
2938
- return "";
2939
- }
2940
- }
2941
- async function checkoutVariables(db, group, app) {
2942
- const frozen = await frozenCheckoutVariables(db, app);
2943
- return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
2944
- }
2945
- function formatInstant(at2, timeZone) {
2946
- return new Intl.DateTimeFormat("en-US", {
2947
- weekday: "long",
2948
- month: "long",
2949
- day: "numeric",
2950
- year: "numeric",
2951
- hour: "numeric",
2952
- minute: "2-digit",
2953
- timeZoneName: "short",
2954
- timeZone
2955
- }).format(new Date(at2));
2956
- }
2957
- var adminUrlFor = (origin) => `${origin}/admin/`;
2958
- var membersUrlFor = (origin) => `${origin}/members/`;
2959
- function applicantVariables(app) {
2960
- const text8 = (v) => typeof v === "string" ? v : "";
2961
- return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
2962
- }
2963
- function sampleVariables(origin, timeZone, now = Date.now()) {
2964
- const startAt = formatInstant(now + 864e5, timeZone);
2965
- return {
2966
- firstName: "Sample",
2967
- lastName: "Person",
2968
- email: "sample@example.com",
2969
- phone: "(555) 010-0100",
2970
- state: "CA",
2971
- tier: "Membership",
2972
- tierName: "Membership",
2973
- paymentAmount: "$100.00",
2974
- paymentAmountSummary: " of $100.00",
2975
- paymentNextStep: "Next, schedule your introduction call.",
2976
- submitNextStep: "Next, schedule your introduction call.",
2977
- startAt,
2978
- endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
2979
- timezone: timeZone,
2980
- meetUrl: "https://meet.google.com/sample",
2981
- htmlLink: "https://calendar.google.com/event?eid=sample",
2982
- meetingTime: startAt,
2983
- meetingLink: "https://meet.google.com/sample",
2984
- adminUrl: adminUrlFor(origin),
2985
- membersUrl: membersUrlFor(origin)
2986
- };
2987
- }
2988
-
2989
3074
  // src/payment-notifications.ts
2990
3075
  var groupFor = async (db, app) => {
2991
3076
  const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
@@ -3271,24 +3356,24 @@ async function revokeGiftClaim(db, claim, reason, now) {
3271
3356
  }
3272
3357
 
3273
3358
  // src/payments-route-support.ts
3274
- async function findPaymentApplication(db, event) {
3275
- if ("applicationId" in event && event.applicationId) {
3276
- const rows5 = await db.query({ applications: { $: { where: { id: event.applicationId }, limit: 1 } } });
3359
+ async function findPaymentApplication(db, event2) {
3360
+ if ("applicationId" in event2 && event2.applicationId) {
3361
+ const rows5 = await db.query({ applications: { $: { where: { id: event2.applicationId }, limit: 1 } } });
3277
3362
  return rows5.applications?.[0];
3278
3363
  }
3279
- if (event.kind !== "refunded" && "customerId" in event && event.customerId) {
3280
- const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event.customerId }, limit: 2 } } });
3364
+ if (event2.kind !== "refunded" && "customerId" in event2 && event2.customerId) {
3365
+ const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event2.customerId }, limit: 2 } } });
3281
3366
  return Array.isArray(applications) && applications.length === 1 ? applications[0] : void 0;
3282
3367
  }
3283
3368
  return void 0;
3284
3369
  }
3285
- function paymentEventPatch(event, status2) {
3286
- if ((status2 === "refunded" || status2 === "declined") && (event.kind === "first_payment" || event.kind === "renewal")) return {};
3287
- switch (event.kind) {
3370
+ function paymentEventPatch(event2, status2) {
3371
+ if ((status2 === "refunded" || status2 === "declined") && (event2.kind === "first_payment" || event2.kind === "renewal")) return {};
3372
+ switch (event2.kind) {
3288
3373
  case "first_payment":
3289
- return firstPaymentPatch(status2, event.renewalAt);
3374
+ return firstPaymentPatch(status2, event2.renewalAt);
3290
3375
  case "renewal":
3291
- return event.renewalAt !== void 0 ? renewalPatch(event.renewalAt) : {};
3376
+ return event2.renewalAt !== void 0 ? renewalPatch(event2.renewalAt) : {};
3292
3377
  case "refunded":
3293
3378
  return refundedPatch();
3294
3379
  case "canceled":
@@ -3309,10 +3394,10 @@ function paymentLineItems(group, tier) {
3309
3394
  }
3310
3395
 
3311
3396
  // src/member-system-contract.ts
3312
- function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
3397
+ function classifyMemberStripeEvent(identity2, target, hasProvenLegacyLocalLink) {
3313
3398
  const keys3 = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
3314
- const supplied = keys3.filter((key3) => identity[key3] !== void 0);
3315
- if (supplied.some((key3) => identity[key3] !== target[key3])) return "ignore_sibling";
3399
+ const supplied = keys3.filter((key3) => identity2[key3] !== void 0);
3400
+ if (supplied.some((key3) => identity2[key3] !== target[key3])) return "ignore_sibling";
3316
3401
  if (supplied.length === keys3.length) return "apply";
3317
3402
  return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
3318
3403
  }
@@ -3322,12 +3407,12 @@ var firstRow = async (db, ns, q) => {
3322
3407
  const rows5 = (await db.query({ [ns]: { $: q } }))[ns];
3323
3408
  return Array.isArray(rows5) ? rows5[0] : void 0;
3324
3409
  };
3325
- var stripeEventIdentity = (event) => ({
3326
- ...event.appId ? { appId: event.appId } : {},
3327
- ...event.environment ? { environment: event.environment } : {},
3328
- ...event.dataEnvironment ? { dataEnvironment: event.dataEnvironment } : {},
3329
- ...event.runtime ? { runtime: event.runtime } : {},
3330
- ...event.chapterId ? { chapterId: event.chapterId } : {}
3410
+ var stripeEventIdentity = (event2) => ({
3411
+ ...event2.appId ? { appId: event2.appId } : {},
3412
+ ...event2.environment ? { environment: event2.environment } : {},
3413
+ ...event2.dataEnvironment ? { dataEnvironment: event2.dataEnvironment } : {},
3414
+ ...event2.runtime ? { runtime: event2.runtime } : {},
3415
+ ...event2.chapterId ? { chapterId: event2.chapterId } : {}
3331
3416
  });
3332
3417
  var stripeEventTarget = (env, ctx) => ({
3333
3418
  appId: env.ODLA_APP_ID,
@@ -3337,12 +3422,12 @@ var stripeEventTarget = (env, ctx) => ({
3337
3422
  chapterId: ctx.chapter.id
3338
3423
  });
3339
3424
  var receiptFor = (db, eventId) => firstRow(db, "stripeEventReceipts", { where: { eventId }, limit: 1 });
3340
- function reportStripePartition(level, eventId, event, disposition) {
3425
+ function reportStripePartition(level, eventId, event2, disposition) {
3341
3426
  console[level]("chapter stripe partition", JSON.stringify({
3342
3427
  eventId,
3343
- kind: event.kind,
3428
+ kind: event2.kind,
3344
3429
  disposition,
3345
- ...stripeEventIdentity(event)
3430
+ ...stripeEventIdentity(event2)
3346
3431
  }));
3347
3432
  }
3348
3433
  var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
@@ -3361,8 +3446,8 @@ async function alertStripeQuarantine(db, ctx, eventId, disposition) {
3361
3446
  artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
3362
3447
  });
3363
3448
  }
3364
- async function reportStripeCollision(db, ctx, eventId, event) {
3365
- reportStripePartition("error", eventId, event, "quarantine_collision");
3449
+ async function reportStripeCollision(db, ctx, eventId, event2) {
3450
+ reportStripePartition("error", eventId, event2, "quarantine_collision");
3366
3451
  await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
3367
3452
  }
3368
3453
  async function storeStripeReceipt(db, input) {
@@ -3397,18 +3482,18 @@ async function storeStripeReceipt(db, input) {
3397
3482
  const raced = await receiptFor(db, input.eventId);
3398
3483
  return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
3399
3484
  }
3400
- async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
3485
+ async function quarantineStripeEvent(db, eventId, digest, event2, ctx) {
3401
3486
  const disposition = "quarantine_unmatched";
3402
- const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
3487
+ const stored = await storeStripeReceipt(db, { eventId, digest, event: event2, disposition, matched: false });
3403
3488
  if (stored === "collision") {
3404
- await reportStripeCollision(db, ctx, eventId, event);
3489
+ await reportStripeCollision(db, ctx, eventId, event2);
3405
3490
  return json({ ok: true, matched: false, collision: true });
3406
3491
  }
3407
- reportStripePartition("warn", eventId, event, disposition);
3492
+ reportStripePartition("warn", eventId, event2, disposition);
3408
3493
  if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
3409
3494
  return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
3410
3495
  }
3411
- async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
3496
+ async function replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx) {
3412
3497
  const existing = await receiptFor(db, eventId);
3413
3498
  if (!existing) return null;
3414
3499
  if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
@@ -3418,7 +3503,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, c
3418
3503
  collisionDigest: digest,
3419
3504
  retryReady: false
3420
3505
  } }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
3421
- await reportStripeCollision(db, ctx, eventId, event);
3506
+ await reportStripeCollision(db, ctx, eventId, event2);
3422
3507
  return json({ ok: true, matched: false, collision: true });
3423
3508
  }
3424
3509
  return json({ ok: true, matched: existing.matched === true, replayed: true });
@@ -3432,42 +3517,42 @@ async function firstRow2(db, ns, q) {
3432
3517
  }
3433
3518
  async function reconcileChapterStripeEvent(input, env, ctx) {
3434
3519
  const db = ctx.makeDb(env);
3435
- const { rawBody, eventId, event } = input;
3436
- if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3520
+ const { rawBody, eventId, event: event2 } = input;
3521
+ if (event2.kind === "ignored") return json({ ok: true, ignored: event2.type });
3437
3522
  const digest = await stripeEventDigest(rawBody);
3438
3523
  const legacyDigest = await stripePayloadDigest(rawBody);
3439
- const identity = stripeEventIdentity(event);
3524
+ const identity2 = stripeEventIdentity(event2);
3440
3525
  const target = stripeEventTarget(env, ctx);
3441
- const preliminary = classifyMemberStripeEvent(identity, target, false);
3526
+ const preliminary = classifyMemberStripeEvent(identity2, target, false);
3442
3527
  if (preliminary === "ignore_sibling") {
3443
- reportStripePartition("warn", eventId, event, preliminary);
3528
+ reportStripePartition("warn", eventId, event2, preliminary);
3444
3529
  return json({ ok: true, matched: false, disposition: preliminary });
3445
3530
  }
3446
- if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3447
- const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3531
+ if (event2.kind === "gift_paid" || event2.kind === "gift_revoked") {
3532
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event2.giftIntentId }, limit: 1 });
3448
3533
  if (intent && typeof intent.runtime === "string" && intent.runtime !== target.runtime) {
3449
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3534
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3450
3535
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3451
3536
  }
3452
- const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3537
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3453
3538
  if (replay2) return replay2;
3454
3539
  const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
3455
- const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
3540
+ const disposition2 = classifyMemberStripeEvent(identity2, target, localIntent);
3456
3541
  if (!localIntent || disposition2 === "quarantine_unmatched") {
3457
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3542
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3458
3543
  }
3459
- if (event.kind === "gift_paid") {
3460
- if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3461
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3544
+ if (event2.kind === "gift_paid") {
3545
+ if (intent.providerObjectId !== event2.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3546
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3462
3547
  }
3463
3548
  try {
3464
3549
  const issued = await issueGiftClaim(db, intent, {
3465
3550
  eventId,
3466
- objectId: event.providerObjectId
3551
+ objectId: event2.providerObjectId
3467
3552
  }, Date.now());
3468
- const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
3553
+ const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: true });
3469
3554
  if (stored3 === "collision") {
3470
- await reportStripeCollision(db, ctx, eventId, event);
3555
+ await reportStripeCollision(db, ctx, eventId, event2);
3471
3556
  return json({ ok: true, matched: false, collision: true });
3472
3557
  }
3473
3558
  return json({ ok: true, matched: true, claimId: issued.claimId });
@@ -3475,57 +3560,57 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3475
3560
  return json({ error: "gift issuance failed" }, 503);
3476
3561
  }
3477
3562
  }
3478
- await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3479
- status: event.reason,
3563
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event2.giftIntentId, attrs: {
3564
+ status: event2.reason,
3480
3565
  updatedAt: Date.now()
3481
3566
  } }], { mutationId: `gift-revoked-intent:${eventId}` });
3482
- const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3483
- if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3484
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
3567
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event2.giftIntentId }, limit: 1 });
3568
+ if (claim) await revokeGiftClaim(db, claim, event2.reason, Date.now());
3569
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: Boolean(claim) });
3485
3570
  if (stored2 === "collision") {
3486
- await reportStripeCollision(db, ctx, eventId, event);
3571
+ await reportStripeCollision(db, ctx, eventId, event2);
3487
3572
  return json({ ok: true, matched: false, collision: true });
3488
3573
  }
3489
3574
  return json({ ok: true, matched: Boolean(claim) });
3490
3575
  }
3491
- const app = await findPaymentApplication(db, event);
3576
+ const app = await findPaymentApplication(db, event2);
3492
3577
  if (app && typeof app.stripeRuntime === "string" && app.stripeRuntime !== target.runtime) {
3493
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3578
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3494
3579
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3495
3580
  }
3496
- const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3581
+ const replay = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3497
3582
  if (replay) return replay;
3498
3583
  const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
3499
- const disposition = classifyMemberStripeEvent(identity, target, localApplication);
3584
+ const disposition = classifyMemberStripeEvent(identity2, target, localApplication);
3500
3585
  if (!localApplication || disposition === "quarantine_unmatched") {
3501
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3586
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3502
3587
  }
3503
3588
  if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
3504
- await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3589
+ await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3505
3590
  return json({ ok: true, historical: true });
3506
3591
  }
3507
- if (event.kind === "seat_paid" || event.kind === "seat_refunded") {
3592
+ if (event2.kind === "seat_paid" || event2.kind === "seat_refunded") {
3508
3593
  const seats = ctx.membershipAuthority?.namedSeats;
3509
3594
  if (!seats) return json({ error: "named seat reconciliation is not configured" }, 503);
3510
3595
  try {
3511
3596
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3512
3597
  if (!intent) return json({ error: "membership checkout reconciliation pending" }, 503);
3513
3598
  const target2 = await membershipTargetForIntent(intent, app, env, ctx);
3514
- const receipt = await seats[event.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event.seatId, eventId }, env);
3515
- if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3516
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3599
+ const receipt = await seats[event2.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event2.seatId, eventId }, env);
3600
+ if (receipt.seatId !== event2.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3601
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3517
3602
  if (stored2 === "collision") {
3518
- await reportStripeCollision(db, ctx, eventId, event);
3603
+ await reportStripeCollision(db, ctx, eventId, event2);
3519
3604
  return json({ ok: true, matched: false, collision: true });
3520
3605
  }
3521
- return json({ ok: true, seatId: event.seatId });
3606
+ return json({ ok: true, seatId: event2.seatId });
3522
3607
  } catch {
3523
3608
  return json({ error: "named seat reconciliation pending" }, 503);
3524
3609
  }
3525
3610
  }
3526
- let authoritativeEvent = event;
3611
+ let authoritativeEvent = event2;
3527
3612
  let membershipProjection = {};
3528
- if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event.kind)) {
3613
+ if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event2.kind)) {
3529
3614
  try {
3530
3615
  const receipt = await renewChapterNetworkMembership(db, env, ctx, app, eventId);
3531
3616
  membershipProjection = { membershipStatus: receipt.status, membershipGraceEndsAt: receipt.graceEndsAt, membershipAutoRenew: receipt.autoRenew };
@@ -3534,13 +3619,13 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3534
3619
  return json({ error: "network membership renewal reconciliation pending" }, 503);
3535
3620
  }
3536
3621
  }
3537
- if (event.kind === "first_payment") {
3622
+ if (event2.kind === "first_payment") {
3538
3623
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3539
3624
  if (intent) {
3540
- const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
3625
+ const evidence = verifyInitialMembershipInvoice(intent, event2.invoice);
3541
3626
  if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
3542
- if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
3543
- if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
3627
+ if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3628
+ if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event2, renewalAt: evidence.renewalAt };
3544
3629
  }
3545
3630
  if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
3546
3631
  try {
@@ -3550,7 +3635,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3550
3635
  }
3551
3636
  }
3552
3637
  }
3553
- const invoiceEvent = event.kind === "first_payment" || event.kind === "renewal" || event.kind === "renewal_failed";
3638
+ const invoiceEvent = event2.kind === "first_payment" || event2.kind === "renewal" || event2.kind === "renewal_failed";
3554
3639
  const patch = { ...invoiceEvent && app.canceled === true ? {} : paymentEventPatch(authoritativeEvent, String(app.status ?? "")), ...membershipProjection };
3555
3640
  if (invoiceEvent && typeof patch.renewalAt === "number" && patch.renewalAt > 0 && typeof app.renewalAt === "number" && patch.renewalAt < app.renewalAt) delete patch.renewalAt;
3556
3641
  let next = app;
@@ -3584,7 +3669,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3584
3669
  return json({ error: "refund account access reconciliation pending" }, 503);
3585
3670
  }
3586
3671
  }
3587
- if (event.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3672
+ if (event2.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3588
3673
  try {
3589
3674
  await notifyPaymentConfirmed(db, env, eventId, next);
3590
3675
  } catch {
@@ -3603,9 +3688,9 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3603
3688
  if (!approval.ok) return json({ error: "membership approval reconciliation pending" }, 503);
3604
3689
  }
3605
3690
  }
3606
- const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3691
+ const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3607
3692
  if (stored === "collision") {
3608
- await reportStripeCollision(db, ctx, eventId, event);
3693
+ await reportStripeCollision(db, ctx, eventId, event2);
3609
3694
  return json({ ok: true, matched: false, collision: true });
3610
3695
  }
3611
3696
  return json({ ok: true });
@@ -3763,8 +3848,8 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3763
3848
  if (!String(row2.disposition).startsWith("quarantine_") && row2.retryReady !== true) return json({ error: "event is already reconciled" }, 409);
3764
3849
  try {
3765
3850
  const provider = await readStripeRecoveryEvent(db, eventId);
3766
- const event = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3767
- if (provider.event.livemode !== (env.ODLA_ENV === "prod") || classifyMemberStripeEvent(stripeEventIdentity(event), target, false) !== "apply") return json({ error: "provider event belongs to another or unknown runtime" }, 409);
3851
+ const event2 = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3852
+ if (provider.event.livemode !== (env.ODLA_ENV === "prod") || classifyMemberStripeEvent(stripeEventIdentity(event2), target, false) !== "apply") return json({ error: "provider event belongs to another or unknown runtime" }, 409);
3768
3853
  const id6 = crypto.randomUUID();
3769
3854
  await db.transact([
3770
3855
  { t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
@@ -3785,7 +3870,7 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3785
3870
  equals: { digest: row2.digest, disposition: row2.disposition, ...row2.reviewId ? { reviewId: row2.reviewId } : {} },
3786
3871
  ...!row2.reviewId ? { absent: ["reviewId"] } : {}
3787
3872
  }] });
3788
- return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event, origin: url.origin }, env, ctx);
3873
+ return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event: event2, origin: url.origin }, env, ctx);
3789
3874
  } catch {
3790
3875
  return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
3791
3876
  }
@@ -4345,7 +4430,7 @@ function stateOf(item, all, now, window, rivals) {
4345
4430
  if (closed) dates.push(mine.until);
4346
4431
  return { state: "superseded", supersededAt: Math.min(...dates) };
4347
4432
  }
4348
- var mechanism = (policy) => policy.percentOffBps === 0 || policy.discountEligibleSkuIds.length === 0 ? "none" : policy.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
4433
+ var mechanism = (policy2) => policy2.percentOffBps === 0 || policy2.discountEligibleSkuIds.length === 0 ? "none" : policy2.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy2.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
4349
4434
  var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
4350
4435
  var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
4351
4436
  function normalize(rows5, collection, parse, invalid2) {
@@ -4457,16 +4542,16 @@ var couponOf = (discount) => {
4457
4542
  async function retermPolicy(db, scope2, id6) {
4458
4543
  const row2 = await membershipLoad(db, "discountPolicyVersion", id6);
4459
4544
  if (!row2) membershipFail("membership_reterm_policy_unknown");
4460
- let policy;
4545
+ let policy2;
4461
4546
  try {
4462
- policy = membershipDiscountPolicyVersion(row2.snapshot);
4547
+ policy2 = membershipDiscountPolicyVersion(row2.snapshot);
4463
4548
  } catch {
4464
4549
  return membershipFail("membership_reterm_policy_ineligible");
4465
4550
  }
4466
- if (policy.id !== id6 || policy.chapterId !== scope2.chapterId || policy.status !== "published" || policy.benefitDuration.kind !== "while_active" || policy.percentOffBps <= 0 || policy.discountEligibleSkuIds.length === 0) membershipFail("membership_reterm_policy_ineligible");
4467
- return policy;
4551
+ if (policy2.id !== id6 || policy2.chapterId !== scope2.chapterId || policy2.status !== "published" || policy2.benefitDuration.kind !== "while_active" || policy2.percentOffBps <= 0 || policy2.discountEligibleSkuIds.length === 0) membershipFail("membership_reterm_policy_ineligible");
4552
+ return policy2;
4468
4553
  }
4469
- async function attachProviderDiscount(sk, scope2, member, policy, couponId, productId) {
4554
+ async function attachProviderDiscount(sk, scope2, member, policy2, couponId, productId) {
4470
4555
  const subId = String(member.providerSubscriptionId), path = `/v1/subscriptions/${encodeURIComponent(subId)}`;
4471
4556
  const read2 = await (0, import_stripe8.stripeRequest)({ secretKey: sk }, "GET", path, { "expand[0]": "discounts" });
4472
4557
  const sub = read2.body, items = sub?.items?.data;
@@ -4482,8 +4567,8 @@ async function attachProviderDiscount(sk, scope2, member, policy, couponId, prod
4482
4567
  const written = await (0, import_stripe8.stripeRequest)({ secretKey: sk }, "POST", path, {
4483
4568
  "discounts[0][coupon]": couponId,
4484
4569
  "expand[0]": "discounts",
4485
- "metadata[discountPolicyVersionId]": policy.id
4486
- }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy.id}` });
4570
+ "metadata[discountPolicyVersionId]": policy2.id
4571
+ }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy2.id}` });
4487
4572
  const after = written.body?.discounts;
4488
4573
  if (!written.ok || written.body?.id !== subId || !Array.isArray(after) || after.length !== 1 || couponOf(after[0]) !== couponId) {
4489
4574
  membershipFail("membership_reterm_provider_unconfirmed");
@@ -4497,8 +4582,8 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4497
4582
  if (typeof input.policyVersionId !== "string" || !SAFE.test(input.policyVersionId)) throw new TypeError("invalid policy version id");
4498
4583
  if (input.membershipIds !== void 0 && (!Array.isArray(input.membershipIds) || input.membershipIds.length === 0 || input.membershipIds.length > limit || input.membershipIds.some((id6) => typeof id6 !== "string" || !SAFE.test(id6)))) throw new TypeError("invalid membership ids");
4499
4584
  const state = await loadMembershipAuthorityState(db, scope2);
4500
- const policy = await retermPolicy(db, scope2, input.policyVersionId);
4501
- const receipt = { policyVersionId: policy.id, retermed: [], unchanged: [], skipped: [], more: false };
4585
+ const policy2 = await retermPolicy(db, scope2, input.policyVersionId);
4586
+ const receipt = { policyVersionId: policy2.id, retermed: [], unchanged: [], skipped: [], more: false };
4502
4587
  const skip = (membershipId, reason) => {
4503
4588
  receipt.skipped.push({ membershipId, reason });
4504
4589
  };
@@ -4519,7 +4604,7 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4519
4604
  }
4520
4605
  for (const member of rows5) {
4521
4606
  const id6 = String(member.id);
4522
- if (member.discountPolicyVersionId === policy.id) {
4607
+ if (member.discountPolicyVersionId === policy2.id) {
4523
4608
  receipt.unchanged.push(id6);
4524
4609
  continue;
4525
4610
  }
@@ -4531,22 +4616,22 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4531
4616
  skip(id6, "not_founding");
4532
4617
  continue;
4533
4618
  }
4534
- if (member.cohort !== policy.cohortLabel) {
4619
+ if (member.cohort !== policy2.cohortLabel) {
4535
4620
  skip(id6, "cohort_mismatch");
4536
4621
  continue;
4537
4622
  }
4538
4623
  const skuId = String(member.baseSkuId);
4539
- if (!policy.discountEligibleSkuIds.includes(skuId)) {
4624
+ if (!policy2.discountEligibleSkuIds.includes(skuId)) {
4540
4625
  skip(id6, "sku_not_discounted");
4541
4626
  continue;
4542
4627
  }
4543
4628
  const base = Number(member.baseAmountCents), current = Number(member.renewalAmountCents);
4544
- const renewal = Math.round(base * (1e4 - policy.percentOffBps) / 1e4);
4629
+ const renewal = Math.round(base * (1e4 - policy2.percentOffBps) / 1e4);
4545
4630
  if (!Number.isSafeInteger(base) || base <= 0 || !Number.isSafeInteger(current) || renewal > current) {
4546
4631
  skip(id6, "would_not_widen");
4547
4632
  continue;
4548
4633
  }
4549
- const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy.id);
4634
+ const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy2.id);
4550
4635
  let providerUpdated;
4551
4636
  try {
4552
4637
  const productId = await verifySubscriptionPrice(stripeSecretKey, {
@@ -4564,10 +4649,10 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4564
4649
  discountProductId: productId,
4565
4650
  billingTerms: {
4566
4651
  offeringVersionId: String(member.offeringVersionId),
4567
- discount: { policyVersionId: policy.id, couponId, percentOffBps: policy.percentOffBps, duration: "forever" }
4652
+ discount: { policyVersionId: policy2.id, couponId, percentOffBps: policy2.percentOffBps, duration: "forever" }
4568
4653
  }
4569
4654
  }, false);
4570
- providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy, couponId, productId);
4655
+ providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy2, couponId, productId);
4571
4656
  } catch (error) {
4572
4657
  const code = error.code;
4573
4658
  if (code === "provider_mismatch" || code === "stripe_contract_mismatch") {
@@ -4578,17 +4663,17 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4578
4663
  }
4579
4664
  try {
4580
4665
  await db.transact([membershipUpdate("networkMembership", id6, {
4581
- discountPolicyVersionId: policy.id,
4582
- discountSnapshot: policy,
4666
+ discountPolicyVersionId: policy2.id,
4667
+ discountSnapshot: policy2,
4583
4668
  renewalAmountCents: renewal,
4584
4669
  revision: Number(member.revision) + 1,
4585
4670
  updatedAt: now
4586
4671
  })], {
4587
- mutationId: `membership-reterm:${id6}:${policy.id}:${member.revision}`,
4672
+ mutationId: `membership-reterm:${id6}:${policy2.id}:${member.revision}`,
4588
4673
  guards: [
4589
4674
  { ns: "networkMembership", id: id6, exists: true, equals: { revision: member.revision, status: member.status } },
4590
4675
  { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } },
4591
- { ns: "discountPolicyVersion", id: policy.id, exists: true }
4676
+ { ns: "discountPolicyVersion", id: policy2.id, exists: true }
4592
4677
  ]
4593
4678
  });
4594
4679
  } catch (error) {
@@ -4834,18 +4919,18 @@ function memberApplication(app, meeting, defaultTimezone) {
4834
4919
 
4835
4920
  // src/member-session-application.ts
4836
4921
  var local = (row2, chapterId, runtime) => row2.groupId === chapterId && (runtime ? row2.stripeRuntime === runtime : row2.stripeRuntime === void 0 || row2.stripeRuntime === "default");
4837
- async function memberSessionApplication(db, chapterId, runtime, identity) {
4922
+ async function memberSessionApplication(db, chapterId, runtime, identity2) {
4838
4923
  const lookup = async (where) => {
4839
4924
  const rows5 = (await db.query({ applications: { $: { where: { groupId: chapterId, ...where }, limit: 100 } } })).applications;
4840
4925
  if (!Array.isArray(rows5) || rows5.length >= 100) throw new Error("member identity lookup incomplete");
4841
4926
  return rows5.filter((row2) => local(row2, chapterId, runtime));
4842
4927
  };
4843
- let apps = await lookup({ clerkUserId: identity.userId });
4928
+ let apps = await lookup({ clerkUserId: identity2.userId });
4844
4929
  let emailFallback = false;
4845
- if (!apps.length && identity.email) {
4846
- const normalized = normalizeChapterEmail(identity.email);
4930
+ if (!apps.length && identity2.email) {
4931
+ const normalized = normalizeChapterEmail(identity2.email);
4847
4932
  const normalizedRows = await lookup({ emailNormalized: normalized });
4848
- const legacyRows = await lookup({ email: identity.email });
4933
+ const legacyRows = await lookup({ email: identity2.email });
4849
4934
  apps = [...new Map([...normalizedRows, ...legacyRows].map((row2) => [row2.id, row2])).values()];
4850
4935
  if (apps.length !== 1 || apps[0]?.clerkUserId !== void 0 || apps[0]?.clerkAccountPending === true || ["declined", "canceled", "refunded"].includes(String(apps[0]?.status))) return null;
4851
4936
  emailFallback = true;
@@ -4855,8 +4940,8 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4855
4940
  if (emailFallback) {
4856
4941
  const keys3 = ["groupId", "stripeRuntime", "email", "emailNormalized", "status", "clerkAccountPending"];
4857
4942
  try {
4858
- await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity.userId } }], {
4859
- mutationId: `member-account-link:${app.id}:${identity.userId}`,
4943
+ await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity2.userId } }], {
4944
+ mutationId: `member-account-link:${app.id}:${identity2.userId}`,
4860
4945
  guards: [{
4861
4946
  ns: "applications",
4862
4947
  id: String(app.id),
@@ -4869,7 +4954,7 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4869
4954
  if (error.code !== "transact_guard_failed") throw error;
4870
4955
  }
4871
4956
  const saved = (await db.query({ applications: { $: { where: { id: app.id }, limit: 1 } } })).applications?.[0];
4872
- if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4957
+ if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity2.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4873
4958
  app = saved;
4874
4959
  }
4875
4960
  const meeting = (await db.query({ meetings: { $: {
@@ -5001,7 +5086,7 @@ function belongsToSignup(account, applicationId) {
5001
5086
  }
5002
5087
 
5003
5088
  // src/member.ts
5004
- var import_crm5 = require("@odla-ai/crm");
5089
+ var import_crm8 = require("@odla-ai/crm");
5005
5090
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
5006
5091
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
5007
5092
  var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
@@ -5035,7 +5120,7 @@ function resolveApplication(a) {
5035
5120
  if (typeof expression !== "string" || !expression.trim()) {
5036
5121
  throw new Error(`defineChapter.application.conditions.${field}.${key3}: must be a non-empty CEL condition`);
5037
5122
  }
5038
- (0, import_crm5.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
5123
+ (0, import_crm8.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key3}`);
5039
5124
  }
5040
5125
  }
5041
5126
  return {
@@ -5078,7 +5163,7 @@ function applicantProfile(chapter, fields) {
5078
5163
  }
5079
5164
  async function submitApplication(db, chapter, fields, opts) {
5080
5165
  const app = chapter.application;
5081
- const fieldStates = (0, import_crm5.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
5166
+ const fieldStates = (0, import_crm8.resolveFieldStates)(app.conditions ?? {}, fields, app.required);
5082
5167
  for (const [f, state] of Object.entries(fieldStates)) {
5083
5168
  if (!state.required) continue;
5084
5169
  const v = fields[f];
@@ -5208,7 +5293,7 @@ async function rejectUnprovisionedSignup(db, applicationId, status2) {
5208
5293
  }
5209
5294
 
5210
5295
  // src/worker-routes.ts
5211
- var import_crm6 = require("@odla-ai/crm");
5296
+ var import_crm9 = require("@odla-ai/crm");
5212
5297
 
5213
5298
  // src/discussion-reference-security.ts
5214
5299
  async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
@@ -5809,7 +5894,7 @@ function foundingOf(offer) {
5809
5894
  return offer?.contract?.founding === true && offer.contract.discountPolicy ? { cohortLabel: offer.contract.discountPolicy.cohortLabel } : void 0;
5810
5895
  }
5811
5896
  async function createPaymentQuote(input) {
5812
- const { group, tier, policy } = input;
5897
+ const { group, tier, policy: policy2 } = input;
5813
5898
  const terms2 = input.billingTerms ? subscriptionBillingTerms(input.billingTerms) : void 0;
5814
5899
  const compareAt = input.presentation?.compareAtPriceCents;
5815
5900
  const presentation = Number.isSafeInteger(compareAt) && Number(compareAt) > tier.priceCents ? { compareAtPriceCents: Number(compareAt) } : void 0;
@@ -5832,9 +5917,9 @@ async function createPaymentQuote(input) {
5832
5917
  dueTodayCents: lines.dueTodayCents
5833
5918
  } : lines,
5834
5919
  ...presentation ? { presentation } : {},
5835
- refundPolicyText: policy?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5920
+ refundPolicyText: policy2?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5836
5921
  merchantDisclosureText: String(group.merchantDisclosureText ?? ""),
5837
- trustCopy: policy ? policy.trustCopy ?? "" : String(group.trustCopy ?? ""),
5922
+ trustCopy: policy2 ? policy2.trustCopy ?? "" : String(group.trustCopy ?? ""),
5838
5923
  ...terms2 ? { commercial: {
5839
5924
  offeringVersionId: terms2.offeringVersionId,
5840
5925
  ...terms2.discount ? {
@@ -5906,13 +5991,13 @@ async function prepareMembershipQuote(db, input, issue) {
5906
5991
  if (error.code !== "membership_quote_expired") throw error;
5907
5992
  }
5908
5993
  }
5909
- const identity = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5994
+ const identity2 = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5910
5995
  const guards = [{
5911
5996
  ns: "applications",
5912
5997
  id: id6,
5913
5998
  exists: true,
5914
- equals: Object.fromEntries(identity.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
5915
- absent: identity.filter((key3) => app[key3] === void 0)
5999
+ equals: Object.fromEntries(identity2.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
6000
+ absent: identity2.filter((key3) => app[key3] === void 0)
5916
6001
  }, { ns: "subscriptionCheckoutIntents", id: id6, exists: false }];
5917
6002
  if (!row2 || row2.status === "ready" || row2.status === "restart") {
5918
6003
  const revision = Number(row2?.revision ?? 0) + 1;
@@ -6048,11 +6133,11 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
6048
6133
  const tier = findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null);
6049
6134
  if (!tier || tierIsFree(tier) || !tier.stripePriceId) throw Object.assign(new Error("tier unavailable"), { code: "tier_unresolved" });
6050
6135
  const control3 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
6051
- const policy = control3?.document.policy?.tierPolicies?.[tier.id];
6136
+ const policy2 = control3?.document.policy?.tierPolicies?.[tier.id];
6052
6137
  const presentation = tierPresentationFor(control3?.document, tier.id);
6053
6138
  if (ctx.membershipAuthority) return prepareMembershipQuote(
6054
6139
  db,
6055
- { ...target, app, group, tier, policy, presentation, now: Date.now() },
6140
+ { ...target, app, group, tier, policy: policy2, presentation, now: Date.now() },
6056
6141
  (input) => ctx.membershipAuthority.issueOffer(input, env)
6057
6142
  );
6058
6143
  const quote = await createPaymentQuote({
@@ -6063,7 +6148,7 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
6063
6148
  runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
6064
6149
  group,
6065
6150
  tier,
6066
- policy,
6151
+ policy: policy2,
6067
6152
  presentation
6068
6153
  });
6069
6154
  return { tier, quote, billingTerms: void 0 };
@@ -6706,7 +6791,7 @@ var handleCrm = async (req, url, env, ctx) => {
6706
6791
  const crmBase = ctx.crmBase;
6707
6792
  if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
6708
6793
  const db = ctx.makeDb(env);
6709
- const routes = (0, import_crm6.createCrmRoutes)({
6794
+ const routes = (0, import_crm9.createCrmRoutes)({
6710
6795
  crm: ctx.chapter.crm,
6711
6796
  db,
6712
6797
  authorize: async (r) => {
@@ -7210,15 +7295,15 @@ function policyOf(value, errors, tiers) {
7210
7295
  if (!overrides || Object.keys(overrides).length > 20) errors.push("document.policy.tierPolicies must be a bounded tier map");
7211
7296
  else for (const [id6, raw2] of Object.entries(overrides)) {
7212
7297
  const path = `document.policy.tierPolicies.${id6}`;
7213
- const policy = recordOf(raw2);
7214
- if (!tiers.some((tier) => tier.id === id6) || !policy) {
7298
+ const policy2 = recordOf(raw2);
7299
+ if (!tiers.some((tier) => tier.id === id6) || !policy2) {
7215
7300
  errors.push(`${path} must name a declared tier and policy`);
7216
7301
  continue;
7217
7302
  }
7218
- unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7303
+ unknownKeys(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7219
7304
  tierPolicies[id6] = {
7220
- refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
7221
- ...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
7305
+ refundPolicyText: text4(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
7306
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text4(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
7222
7307
  };
7223
7308
  }
7224
7309
  }
@@ -7273,7 +7358,7 @@ function validateSignupControlDocument(input) {
7273
7358
  errors.push("document.membershipPage.tiers must follow the available tier display order");
7274
7359
  }
7275
7360
  const application = applicationOf(root.application, errors);
7276
- const policy = policyOf(root.policy, errors, tiers);
7361
+ const policy2 = policyOf(root.policy, errors, tiers);
7277
7362
  let scheduling;
7278
7363
  if (root.scheduling !== void 0) {
7279
7364
  const checked = validateScheduling(root.scheduling);
@@ -7293,7 +7378,7 @@ function validateSignupControlDocument(input) {
7293
7378
  tiers,
7294
7379
  membershipPage: page.value,
7295
7380
  ...application ? { application } : {},
7296
- ...policy ? { policy } : {},
7381
+ ...policy2 ? { policy: policy2 } : {},
7297
7382
  ...scheduling ? { scheduling } : {},
7298
7383
  ...theme ? { theme } : {},
7299
7384
  ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
@@ -8110,7 +8195,7 @@ async function networkWriteAllowed(req, db, user, ctx, sensitiveRead = false) {
8110
8195
 
8111
8196
  // src/network-person-reference.ts
8112
8197
  var import_db6 = require("@odla-ai/db");
8113
- var import_crm8 = require("@odla-ai/crm");
8198
+ var import_crm11 = require("@odla-ai/crm");
8114
8199
 
8115
8200
  // src/worker-network-fetch.ts
8116
8201
  var NetworkTargetTransportError = class extends Error {
@@ -8146,7 +8231,7 @@ function fetchNetworkTarget(env, target, destination, init) {
8146
8231
  }
8147
8232
 
8148
8233
  // src/worker-network-admission-state.ts
8149
- var import_crm7 = require("@odla-ai/crm");
8234
+ var import_crm10 = require("@odla-ai/crm");
8150
8235
  var MAX_ASSIGNMENTS = 2e3;
8151
8236
  var admissionRows = (value) => Array.isArray(value) ? value : [];
8152
8237
  function assignmentId(targetId, personId) {
@@ -8173,7 +8258,7 @@ function personAdmissionEmail(ctx, record4) {
8173
8258
  return isValidEmail(normalized) ? normalized : null;
8174
8259
  }
8175
8260
  async function grantInput(db, ctx, assignment, resolveEmail) {
8176
- const person = resolveEmail ? null : await (0, import_crm7.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
8261
+ const person = resolveEmail ? null : await (0, import_crm10.getRecord)({ crm: ctx.chapter.crm, db }, assignment.personId);
8177
8262
  const email = resolveEmail ? await resolveEmail(assignment.personId) : person ? personAdmissionEmail(ctx, person) : null;
8178
8263
  if (!email) throw new Error(`person ${assignment.personId} has no valid authoritative email`);
8179
8264
  return {
@@ -8379,7 +8464,7 @@ function chapterPersonReference(targetId, recordId) {
8379
8464
  }
8380
8465
  async function resolveAdmissionPersonEmail(db, env, ctx, target, personId) {
8381
8466
  if (!personId.startsWith("chapter-person:")) {
8382
- const person2 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, personId);
8467
+ const person2 = await (0, import_crm11.getRecord)({ crm: ctx.chapter.crm, db }, personId);
8383
8468
  return person2 ? personAdmissionEmail(ctx, person2) : null;
8384
8469
  }
8385
8470
  const prefix = `chapter-person:${target.id}:`;
@@ -9037,8 +9122,8 @@ function createStripeProvider(config) {
9037
9122
  if (!webhookSecret || !await (0, import_stripe11.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
9038
9123
  return { ok: false, reason: "bad_signature" };
9039
9124
  }
9040
- const event = JSON.parse(rawBody);
9041
- return { ok: true, eventId: event.id, event: normalizeWebhookEvent(event, { requireProviderPayment: config.requireProviderPayment }) };
9125
+ const event2 = JSON.parse(rawBody);
9126
+ return { ok: true, eventId: event2.id, event: normalizeWebhookEvent(event2, { requireProviderPayment: config.requireProviderPayment }) };
9042
9127
  },
9043
9128
  async refund(input) {
9044
9129
  return refundSubscriptionPayment(requireSecret(secretKey), input);
@@ -9152,7 +9237,7 @@ async function startGiftIntent(db, input) {
9152
9237
  throw new Error("gift purchase intent does not match this checkout");
9153
9238
  }
9154
9239
  if (stored.status === "paid") throw new Error("gift purchase is already paid");
9155
- const checkout = await startGiftProviderCheckout(
9240
+ const checkout2 = await startGiftProviderCheckout(
9156
9241
  input.stripeSecret,
9157
9242
  stored,
9158
9243
  input.purchaserEmail,
@@ -9162,15 +9247,15 @@ async function startGiftIntent(db, input) {
9162
9247
  );
9163
9248
  await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
9164
9249
  status: "checkout_ready",
9165
- providerObjectId: checkout.providerObjectId,
9166
- ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
9250
+ providerObjectId: checkout2.providerObjectId,
9251
+ ...checkout2.customerId ? { providerCustomerId: checkout2.customerId } : {},
9167
9252
  checkoutStartedAt: input.now,
9168
9253
  updatedAt: input.now
9169
- } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
9254
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout2.providerObjectId}` });
9170
9255
  return {
9171
9256
  gift: true,
9172
9257
  intentId: input.id,
9173
- clientSecret: checkout.clientSecret,
9258
+ clientSecret: checkout2.clientSecret,
9174
9259
  publishableKey: input.publishableKey,
9175
9260
  lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
9176
9261
  ...giftCode ? { giftCode } : {}
@@ -9428,15 +9513,15 @@ async function startSubscription(req, env, ctx) {
9428
9513
  return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
9429
9514
  }
9430
9515
  }
9431
- let checkout;
9516
+ let checkout2;
9432
9517
  try {
9433
- checkout = await checkoutQuoteForApplication(db, app, group, env, ctx);
9518
+ checkout2 = await checkoutQuoteForApplication(db, app, group, env, ctx);
9434
9519
  } catch (error) {
9435
9520
  const expired = error.code === "membership_quote_expired";
9436
9521
  return json({ error: "payment quote unavailable", ...expired ? { code: "payment_quote_changed" } : {} }, expired ? 409 : 503);
9437
9522
  }
9438
- const { tier, quote, billingTerms } = checkout;
9439
- const membershipOffer = "membershipOffer" in checkout ? checkout.membershipOffer : void 0;
9523
+ const { tier, quote, billingTerms } = checkout2;
9524
+ const membershipOffer = "membershipOffer" in checkout2 ? checkout2.membershipOffer : void 0;
9440
9525
  if (ctx.membershipAuthority && !membershipOffer) return json({ error: "checkout requires reconciliation", code: "checkout_recovery_required" }, 409);
9441
9526
  if (!tier) return json({ error: "tier not found", code: "tier_unresolved" }, 400);
9442
9527
  if (tierIsFree(tier)) return json({ error: "tier is free", code: "tier_not_payable" }, 409);
@@ -9858,13 +9943,6 @@ var handleNamedSeat = async (req, url, env, ctx) => {
9858
9943
  }
9859
9944
  };
9860
9945
 
9861
- // src/named-seat-accepted-template.ts
9862
- var SEAT_ACCEPTED_TEMPLATE = {
9863
- enabled: true,
9864
- subject: "{{recipientName}} accepted your {{chapterName}} seat",
9865
- text: "Hi {{firstName}},\n\n{{recipientName}} has accepted the {{tierName}} seat you purchased at {{chapterName}}. They will complete their own application and any membership requirements from here; nothing more is needed from you.\n\nWarmly,\n{{chapterName}}"
9866
- };
9867
-
9868
9946
  // src/named-seat-accepted-notice.ts
9869
9947
  var text5 = (value) => typeof value === "string" ? value : "";
9870
9948
  async function notifyNamedSeatAccepted(db, env, ctx, input) {
@@ -9875,7 +9953,6 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9875
9953
  const group = await membershipLoad(db, "groups", ctx.chapter.id);
9876
9954
  if (!group) return { sent: false, reason: "group-missing" };
9877
9955
  const config = emailGroupFrom(group);
9878
- config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
9879
9956
  const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
9880
9957
  return sendTemplated({
9881
9958
  db,
@@ -9891,6 +9968,7 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9891
9968
  to: text5(purchaser.email),
9892
9969
  dedupeKey: `named-seat-accepted:${input.seatId}`,
9893
9970
  applicationId: input.primaryApplicationId,
9971
+ tierId: input.skuId,
9894
9972
  vars: {
9895
9973
  firstName: text5(purchaser.firstName),
9896
9974
  purchaserName,
@@ -9907,7 +9985,7 @@ async function tierName(db, group, skuId) {
9907
9985
  if (tier) return tier.name;
9908
9986
  } catch {
9909
9987
  }
9910
- return skuId;
9988
+ return "";
9911
9989
  }
9912
9990
 
9913
9991
  // src/worker-routes-named-seat-recipient.ts
@@ -10104,10 +10182,10 @@ var handleEmailDeliveryResolution = async (req, url, env, ctx) => {
10104
10182
  };
10105
10183
 
10106
10184
  // src/worker-routes-people-profile.ts
10107
- var import_crm10 = require("@odla-ai/crm");
10185
+ var import_crm13 = require("@odla-ai/crm");
10108
10186
 
10109
10187
  // src/people-profile.ts
10110
- var import_crm9 = require("@odla-ai/crm");
10188
+ var import_crm12 = require("@odla-ai/crm");
10111
10189
  var sameValue = (a, b) => (a == null || a === "") && (b == null || b === "") || JSON.stringify(a) === JSON.stringify(b);
10112
10190
  function profileReadOnlyFields(record4, ctx) {
10113
10191
  return [
@@ -10122,17 +10200,17 @@ async function updateChapterPerson(opts) {
10122
10200
  const { db, env, ctx, record: record4 } = opts;
10123
10201
  const input = Object.fromEntries(Object.entries(opts.input).filter(([key3, value]) => !sameValue(value, record4.fields[key3])));
10124
10202
  const forbidden = profileReadOnlyFields(record4, ctx).filter((key3) => key3 in input);
10125
- if (forbidden.length) throw new import_crm9.CrmInputError(
10203
+ if (forbidden.length) throw new import_crm12.CrmInputError(
10126
10204
  "Use the verified identity or application workflow for protected fields",
10127
10205
  Object.fromEntries(forbidden.map((key3) => [key3, "This field is managed by its authoritative workflow"]))
10128
10206
  );
10129
10207
  const appId = record4.fields.applicationId;
10130
10208
  const app = typeof appId === "string" && appId ? (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0] : null;
10131
10209
  if (appId && (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME) || String(app.email).trim().toLowerCase() !== String(record4.primaryEmail).trim().toLowerCase())) {
10132
- throw new import_crm9.CrmInputError("The application identity must be repaired before editing this profile");
10210
+ throw new import_crm12.CrmInputError("The application identity must be repaired before editing this profile");
10133
10211
  }
10134
10212
  const prepared = ctx.chapter.crm.prepare(record4.type, input, record4);
10135
- if (!prepared.ok) throw new import_crm9.CrmInputError("invalid input", prepared.errors);
10213
+ if (!prepared.ok) throw new import_crm12.CrmInputError("invalid input", prepared.errors);
10136
10214
  const appAttrs = {};
10137
10215
  if (app) {
10138
10216
  const mirrored = /* @__PURE__ */ new Set(["firstName", "lastName", "phone", "linkedin", ...ctx.chapter.application.crmFields]);
@@ -10161,7 +10239,7 @@ async function updateChapterPerson(opts) {
10161
10239
  }] });
10162
10240
  }
10163
10241
  };
10164
- return (0, import_crm9.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
10242
+ return (0, import_crm12.updateRecord)({ crm: ctx.chapter.crm, db: projectionDb }, {
10165
10243
  id: record4.id,
10166
10244
  input,
10167
10245
  expectedUpdatedAt: opts.expectedRevision ?? record4.updatedAt,
@@ -10205,7 +10283,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
10205
10283
  } catch {
10206
10284
  return json({ error: "invalid record id" }, 422);
10207
10285
  }
10208
- const record4 = await (0, import_crm10.getRecord)({ crm: ctx.chapter.crm, db }, id6);
10286
+ const record4 = await (0, import_crm13.getRecord)({ crm: ctx.chapter.crm, db }, id6);
10209
10287
  if (!record4) return json({ error: "not found" }, 404);
10210
10288
  if (record4.type !== "person") return dedicated ? json({ error: "person required" }, 422) : null;
10211
10289
  if (match?.[2]) {
@@ -10257,7 +10335,7 @@ var handlePeopleProfile = async (req, url, env, ctx) => {
10257
10335
  });
10258
10336
  return json(result);
10259
10337
  } catch (error) {
10260
- if (error instanceof import_crm10.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
10338
+ if (error instanceof import_crm13.CrmInputError) return json({ error: error.message, fields: error.fields }, 422);
10261
10339
  if (error.code === "transact_guard_failed") return json({ error: "profile changed; reload before saving" }, 409);
10262
10340
  return json({ error: "profile update failed" }, 503);
10263
10341
  }
@@ -10414,10 +10492,10 @@ async function repairAccountPeoplePage(deps, after) {
10414
10492
  }
10415
10493
 
10416
10494
  // src/crm-application-repair.ts
10417
- var import_crm11 = require("@odla-ai/crm");
10495
+ var import_crm14 = require("@odla-ai/crm");
10418
10496
  async function repairApplicationLink(db, chapter, recordId, actorId) {
10419
10497
  const deps = { crm: chapter.crm, db };
10420
- const record4 = await (0, import_crm11.getRecord)(deps, recordId);
10498
+ const record4 = await (0, import_crm14.getRecord)(deps, recordId);
10421
10499
  if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
10422
10500
  if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
10423
10501
  const fields = record4.fields;
@@ -10435,7 +10513,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
10435
10513
  }
10436
10514
  const app = [...applications.values()][0];
10437
10515
  if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
10438
- await (0, import_crm11.updateRecord)(deps, {
10516
+ await (0, import_crm14.updateRecord)(deps, {
10439
10517
  id: recordId,
10440
10518
  input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
10441
10519
  expectedUpdatedAt: record4.updatedAt,
@@ -11122,7 +11200,122 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
11122
11200
  return patchApplication(db, env, ctx, app, body);
11123
11201
  };
11124
11202
 
11203
+ // src/email-admin-config.ts
11204
+ var import_crm16 = require("@odla-ai/crm");
11205
+
11206
+ // src/email-readiness.ts
11207
+ var import_crm15 = require("@odla-ai/crm");
11208
+ function validateEmailReadiness(input) {
11209
+ const issues = [];
11210
+ for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
11211
+ issues.push({ code: "event-undeclared", message: `Stored email ${key3} has no declared event. Review its trigger before using it.` });
11212
+ }
11213
+ if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
11214
+ for (const tier of input.tiers ?? []) {
11215
+ if (!(0, import_crm15.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
11216
+ }
11217
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm15.isEmailTierName)(tier.name, tier.id));
11218
+ if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
11219
+ const events = Object.entries(input.events).map(([key3, event2]) => {
11220
+ const template = input.templates[key3];
11221
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm15.validateEmailCopy)(template, { allowedVariables: event2.variables });
11222
+ if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
11223
+ if (template) {
11224
+ const used = [...`${template.subject ?? ""}
11225
+ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
11226
+ for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
11227
+ problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
11228
+ }
11229
+ }
11230
+ if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
11231
+ else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
11232
+ return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
11233
+ });
11234
+ return { checkedAt: input.now ?? Date.now(), events, issues };
11235
+ }
11236
+
11237
+ // src/email-admin-config.ts
11238
+ var str2 = (value) => typeof value === "string" ? value : "";
11239
+ async function emailSettings(db, ctx, env) {
11240
+ const hub = ctx.chapter.mode === "hub";
11241
+ const crmDeps3 = { db, crm: ctx.chapter.crm };
11242
+ const group = hub ? await (0, import_crm16.getConfig)(crmDeps3) : (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
11243
+ const raw2 = group?.emailTemplates;
11244
+ const stored = raw2 && typeof raw2 === "object" && !Array.isArray(raw2) ? raw2 : {};
11245
+ const events = chapterEmailEvents(ctx.chapter);
11246
+ const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
11247
+ subject: str2(stored[key3]?.subject),
11248
+ text: str2(stored[key3]?.text),
11249
+ enabled: stored[key3]?.enabled
11250
+ }]));
11251
+ let tiers = [], tiersUnavailable = false;
11252
+ if (!hub) {
11253
+ try {
11254
+ tiers = await loadTiers(db, group ?? { id: ctx.chapter.id });
11255
+ } catch {
11256
+ tiersUnavailable = true;
11257
+ }
11258
+ }
11259
+ const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
11260
+ const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
11261
+ return { group, events, data: {
11262
+ groupId: ctx.chapter.id,
11263
+ name: ctx.chapter.name,
11264
+ emailTemplates,
11265
+ validation,
11266
+ replyTo: str2(group?.replyTo),
11267
+ fromName: str2(group?.fromName),
11268
+ notificationEmail: str2(group?.notificationEmail),
11269
+ debugEmail: str2(group?.debugEmail),
11270
+ ...groupValues,
11271
+ envName: env.ODLA_ENV,
11272
+ testAvailable: !hub,
11273
+ transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11274
+ fromEmail: env.EMAIL_FROM ?? null
11275
+ } };
11276
+ }
11277
+ async function updateEmailSettings(db, ctx, env, body) {
11278
+ const { group, events } = await emailSettings(db, ctx, env);
11279
+ if (!group && ctx.chapter.mode !== "hub") return json({ error: "Email settings have not been configured for this chapter." }, 409);
11280
+ const templates = body.emailTemplates;
11281
+ if (!templates || typeof templates !== "object" || Array.isArray(templates)) return json({ error: "emailTemplates object required" }, 400);
11282
+ const clean = {};
11283
+ for (const [key3, value] of Object.entries(templates)) {
11284
+ if (!events[key3]) continue;
11285
+ if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
11286
+ const t = value;
11287
+ if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
11288
+ const subject = str2(t.subject).trim(), text8 = str2(t.text);
11289
+ const issues = (0, import_crm16.validateEmailCopy)({ subject, text: text8 }, { allowedVariables: events[key3].variables });
11290
+ if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
11291
+ return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
11292
+ }
11293
+ clean[key3] = { subject, text: text8, enabled: t.enabled };
11294
+ }
11295
+ const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
11296
+ const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
11297
+ if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11298
+ if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11299
+ if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11300
+ const fromName = str2(body.fromName).trim();
11301
+ if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
11302
+ const commitmentText = str2(body.commitmentText), normsText = str2(body.normsText);
11303
+ if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11304
+ if (ctx.chapter.mode === "hub") {
11305
+ const missing = Object.fromEntries(Object.keys(events).filter((key3) => !group?.emailTemplates?.[key3]).map((key3) => [key3, { subject: "", text: "", enabled: false }]));
11306
+ await (0, import_crm16.saveConfig)({ db, crm: ctx.chapter.crm }, { emailTemplates: { ...missing, ...clean }, notificationEmail, replyTo, debugEmail });
11307
+ } else {
11308
+ const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11309
+ await db.transact([
11310
+ { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11311
+ { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11312
+ ]);
11313
+ }
11314
+ return json({ ok: true });
11315
+ }
11316
+
11125
11317
  // src/worker-routes-admin-comms.ts
11318
+ var import_crm17 = require("@odla-ai/crm");
11126
11319
  async function gate5(req, env, ctx) {
11127
11320
  const rawDb = ctx.makeDb(env);
11128
11321
  const u = await ctx.verifyUser(req, env);
@@ -11134,39 +11327,13 @@ async function loadGroup2(db, id6) {
11134
11327
  const { groups } = await db.query({ groups: { $: { where: { id: id6 }, limit: 1 } } });
11135
11328
  return groups?.[0] ?? null;
11136
11329
  }
11137
- var str2 = (v, d = "") => typeof v === "string" ? v : d;
11330
+ var str3 = (v, d = "") => typeof v === "string" ? v : d;
11138
11331
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
11139
11332
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
11140
11333
  const got = await gate5(req, env, ctx);
11141
11334
  if (got instanceof Response) return got;
11142
11335
  const db = got;
11143
- const group = await loadGroup2(db, ctx.chapter.id);
11144
- if (!group) return json({ error: "not found" }, 404);
11145
- if (req.method === "GET") {
11146
- const stored = group.emailTemplates ?? {};
11147
- const emailTemplates = {};
11148
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11149
- const t = stored[key3];
11150
- if (t) emailTemplates[key3] = { subject: str2(t.subject), text: str2(t.text), enabled: t.enabled !== false };
11151
- }
11152
- return json({
11153
- groupId: group.id,
11154
- name: group.name,
11155
- replyTo: str2(group.replyTo),
11156
- fromName: str2(group.fromName),
11157
- notificationEmail: str2(group.notificationEmail),
11158
- debugEmail: str2(group.debugEmail),
11159
- emailTemplates,
11160
- commitmentText: str2(group.commitmentText),
11161
- normsText: str2(group.normsText),
11162
- refundPolicyText: str2(group.refundPolicyText),
11163
- // Read-only delivery wiring — surfaced so "why did this not send?" is
11164
- // answerable without logs.
11165
- envName: env.ODLA_ENV,
11166
- transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11167
- fromEmail: env.EMAIL_FROM ?? null
11168
- });
11169
- }
11336
+ if (req.method === "GET") return json((await emailSettings(db, ctx, env)).data);
11170
11337
  let body;
11171
11338
  try {
11172
11339
  body = await req.json();
@@ -11174,41 +11341,16 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
11174
11341
  return json({ error: "invalid JSON body" }, 400);
11175
11342
  }
11176
11343
  if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "object body required" }, 400);
11177
- const templates = body.emailTemplates;
11178
- if (!templates || typeof templates !== "object") return json({ error: "emailTemplates object required" }, 400);
11179
- const clean = {};
11180
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11181
- const t = templates[key3];
11182
- const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
11183
- const text8 = typeof t?.text === "string" ? t.text : "";
11184
- if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
11185
- if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
11186
- if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
11187
- clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
11188
- }
11189
- const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11190
- const notificationEmail = str2(body.notificationEmail).trim();
11191
- const replyTo = str2(body.replyTo).trim();
11192
- const debugEmail = str2(body.debugEmail).trim();
11193
- if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11194
- if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11195
- if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11196
- const fromName = str2(body.fromName).trim();
11197
- if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
11198
- const commitmentText = str2(body.commitmentText);
11199
- const normsText = str2(body.normsText);
11200
- if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11201
- const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11202
- await db.transact([
11203
- { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11204
- { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11205
- ]);
11206
- return json({ ok: true });
11344
+ return updateEmailSettings(db, ctx, env, body);
11207
11345
  };
11208
11346
  var handleAdminEmailLog = async (req, url, env, ctx) => {
11209
11347
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
11210
11348
  const got = await gate5(req, env, ctx);
11211
11349
  if (got instanceof Response) return got;
11350
+ if (ctx.chapter.mode === "hub") {
11351
+ const rows5 = await (0, import_crm17.listEmailLog)({ db: got, crm: ctx.chapter.crm }, { limit: 50 });
11352
+ return json({ sends: rows5.map((row2) => ({ ...row2, template: row2.templateId })) });
11353
+ }
11212
11354
  const { emailLog } = await got.query({ emailLog: { $: { where: { groupId: ctx.chapter.id }, order: { sentAt: "desc" }, limit: 50 } } });
11213
11355
  const visible = [];
11214
11356
  for (const row2 of emailLog ?? []) {
@@ -11244,21 +11386,22 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
11244
11386
  } catch {
11245
11387
  return json({ error: "invalid JSON body" }, 400);
11246
11388
  }
11247
- const template = str2(body.template);
11248
- if (!EMAIL_TEMPLATE_NAMES.includes(template)) {
11249
- return json({ error: `template must be one of: ${EMAIL_TEMPLATE_NAMES.join(", ")}` }, 400);
11250
- }
11389
+ if (ctx.chapter.mode === "hub") return json({ error: "Send CRM mail from a selected contact." }, 400);
11390
+ const template = str3(body.template);
11391
+ const events = chapterEmailEvents(ctx.chapter);
11392
+ if (!events[template]) return json({ error: "Unknown email event." }, 400);
11251
11393
  const group = await loadGroup2(db, ctx.chapter.id);
11252
11394
  if (!group) return json({ error: "not found" }, 404);
11253
11395
  const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
11254
11396
  group: emailGroupFrom(group),
11255
11397
  template,
11256
- to: str2(group.notificationEmail),
11398
+ to: str3(group.notificationEmail),
11257
11399
  vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
11258
11400
  dedupeKey: `test:${template}:${Date.now()}`,
11259
- force: true
11401
+ force: true,
11402
+ event: events[template]
11260
11403
  });
11261
- return json({ ok: res.sent, reason: res.reason ?? null, to: str2(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11404
+ return json({ ok: res.sent, reason: res.reason ?? null, to: str3(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11262
11405
  };
11263
11406
  var handleAdminComms = async (req, url, env, ctx) => {
11264
11407
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
@@ -11280,13 +11423,13 @@ var handleAdminComms = async (req, url, env, ctx) => {
11280
11423
  });
11281
11424
  const app = data.applications?.[0] ?? null;
11282
11425
  if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
11283
- const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
11284
- const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
11426
+ const group = await loadGroup2(db, str3(app?.groupId, ctx.chapter.id));
11427
+ const vars = app ? { firstName: str3(app.firstName), lastName: str3(app.lastName), email: str3(app.email), phone: str3(app.phone), state: str3(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
11285
11428
  const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
11286
11429
  let mailBody = typeof r.body === "string" ? r.body : null;
11287
11430
  if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
11288
11431
  const channel = r.error ? "email (failed)" : r.redirected ? "email (dev-redirected)" : r.transport === "log-only" ? "email (not delivered)" : "email";
11289
- return { kind: "email", channel, label: String(r.template), subject: str2(r.subject), to: r.to ?? null, body: mailBody, at: r.sentAt, error: r.error ?? null };
11432
+ return { kind: "email", channel, label: String(r.template), subject: str3(r.subject), to: r.to ?? null, body: mailBody, at: r.sentAt, error: r.error ?? null };
11290
11433
  });
11291
11434
  const calendar = (data.meetings ?? []).map((m2) => ({
11292
11435
  kind: "calendar",
@@ -11302,7 +11445,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
11302
11445
  };
11303
11446
 
11304
11447
  // src/worker-routes-network.ts
11305
- var import_crm13 = require("@odla-ai/crm");
11448
+ var import_crm19 = require("@odla-ai/crm");
11306
11449
  var import_db9 = require("@odla-ai/db");
11307
11450
 
11308
11451
  // src/network-edge.ts
@@ -11328,7 +11471,7 @@ function unpairedResponse(unpaired) {
11328
11471
  }
11329
11472
 
11330
11473
  // src/worker-network-push.ts
11331
- var import_crm12 = require("@odla-ai/crm");
11474
+ var import_crm18 = require("@odla-ai/crm");
11332
11475
  var import_db8 = require("@odla-ai/db");
11333
11476
  async function pushNetworkRecord(db, env, ctx, target, record4) {
11334
11477
  const secret = await getVaultSecret(db, target.secretName);
@@ -11340,7 +11483,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11340
11483
  };
11341
11484
  if (!secret) {
11342
11485
  const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
11343
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11486
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11344
11487
  recordId: record4.id,
11345
11488
  targetId: target.id,
11346
11489
  status: "failed",
@@ -11379,7 +11522,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11379
11522
  const responseBody = await res.json().catch(() => ({}));
11380
11523
  if (!res.ok) {
11381
11524
  const error = responseBody.error ?? "follower rejected the record";
11382
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11525
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11383
11526
  recordId: record4.id,
11384
11527
  targetId: target.id,
11385
11528
  status: "failed",
@@ -11394,7 +11537,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11394
11537
  error
11395
11538
  };
11396
11539
  }
11397
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11540
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11398
11541
  recordId: record4.id,
11399
11542
  targetId: target.id,
11400
11543
  status: "delivered",
@@ -11410,7 +11553,7 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
11410
11553
  };
11411
11554
  } catch (err) {
11412
11555
  const error = err instanceof Error ? err.message : "delivery failed";
11413
- await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
11556
+ await (0, import_crm18.recordDeliveryAttempt)(crmDeps3, {
11414
11557
  recordId: record4.id,
11415
11558
  targetId: target.id,
11416
11559
  status: "failed",
@@ -11463,7 +11606,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
11463
11606
  const verified = await (0, import_db9.verifyFederatedRequest)(req, { secret });
11464
11607
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
11465
11608
  const types = await Promise.all(
11466
- Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm13.typeSummary)({ crm: ctx.chapter.crm, db }, type))
11609
+ Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm19.typeSummary)({ crm: ctx.chapter.crm, db }, type))
11467
11610
  );
11468
11611
  const activeConfiguration = ctx.chapter.signupControl ? await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV) : null;
11469
11612
  const snapshot = {
@@ -11610,7 +11753,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
11610
11753
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
11611
11754
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
11612
11755
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
11613
- const record4 = await (0, import_crm13.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
11756
+ const record4 = await (0, import_crm19.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
11614
11757
  if (!record4) return json({ error: "record not found" }, 404);
11615
11758
  const results = await Promise.all(
11616
11759
  targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record4))
@@ -11619,11 +11762,11 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
11619
11762
  };
11620
11763
 
11621
11764
  // src/worker-routes-network-records-follower.ts
11622
- var import_crm17 = require("@odla-ai/crm");
11765
+ var import_crm23 = require("@odla-ai/crm");
11623
11766
  var import_db10 = require("@odla-ai/db");
11624
11767
 
11625
11768
  // src/network-application-action.ts
11626
- var import_crm14 = require("@odla-ai/crm");
11769
+ var import_crm20 = require("@odla-ai/crm");
11627
11770
  var ACTION_META = "chapterNetworkMutation";
11628
11771
  async function networkApplicationAction(opts) {
11629
11772
  const { db, env, ctx, record: record4, input, sourceId, activityId, fingerprint } = opts;
@@ -11712,7 +11855,7 @@ async function networkApplicationAction(opts) {
11712
11855
  const current = (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0];
11713
11856
  if (!current || current.status !== input.stage) return json({ error: "application reconciliation pending", code: "action_pending" }, 503);
11714
11857
  await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) });
11715
- const projected = await (0, import_crm14.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11858
+ const projected = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11716
11859
  if (!projected || projected.stage !== input.stage) return json({ error: "CRM reconciliation pending", code: "action_pending" }, 503);
11717
11860
  const audit = (await db.query({ crm_activity: { $: { where: { id: activityId }, limit: 1 } } })).crm_activity?.[0];
11718
11861
  const priorMeta = audit?.meta;
@@ -11722,7 +11865,7 @@ async function networkApplicationAction(opts) {
11722
11865
  } },
11723
11866
  { t: "update", ns: "crm_activity", id: lockId, attrs: { meta: { state: "complete", activityId } } }
11724
11867
  ], { guards: [{ ns: "crm_activity", id: lockId, exists: true, equals: { meta: { state: "pending", activityId } } }] });
11725
- const updated = await (0, import_crm14.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11868
+ const updated = await (0, import_crm20.getRecord)({ crm: ctx.chapter.crm, db }, record4.id);
11726
11869
  if (!updated) return json({ error: "record not found" }, 404);
11727
11870
  return { record: updated, duplicate: Boolean(receipt) };
11728
11871
  }
@@ -11758,7 +11901,7 @@ async function operationalReaderRecord(record4, reader, db, env, ctx) {
11758
11901
  }
11759
11902
 
11760
11903
  // src/worker-routes-network-records-search.ts
11761
- var import_crm15 = require("@odla-ai/crm");
11904
+ var import_crm21 = require("@odla-ai/crm");
11762
11905
  var SEARCH_BATCH_SIZE = 1e3;
11763
11906
  var MAX_READER_SEARCH_POPULATION = 1e4;
11764
11907
  var ReaderSearchPopulationError = class extends Error {
@@ -11799,19 +11942,19 @@ async function listVisibleSearchPopulation(db, where, window, count) {
11799
11942
  (_, index2) => index2 * SEARCH_BATCH_SIZE
11800
11943
  );
11801
11944
  const pages = await Promise.all(offsets.map((offset) => db.query({
11802
- [import_crm15.CRM_NS.record]: { $: {
11945
+ [import_crm21.CRM_NS.record]: { $: {
11803
11946
  where,
11804
11947
  order: { [window.sort.field]: window.sort.dir },
11805
11948
  limit: Math.min(SEARCH_BATCH_SIZE, count - offset),
11806
11949
  offset
11807
11950
  } }
11808
11951
  })));
11809
- return pages.flatMap((page) => (page[import_crm15.CRM_NS.record] ?? []).map(import_crm15.rowToRecord));
11952
+ return pages.flatMap((page) => (page[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord));
11810
11953
  }
11811
11954
  async function listReaderRecords(db, type, allowed2, emailField, window) {
11812
11955
  const where = baseWhere(type, window.stage);
11813
11956
  const crmDb = db;
11814
- const aggregate = await crmDb.aggregate(import_crm15.CRM_NS.record, { count: true }, { where });
11957
+ const aggregate = await crmDb.aggregate(import_crm21.CRM_NS.record, { count: true }, { where });
11815
11958
  const candidateCount = aggregate.count ?? 0;
11816
11959
  if (window.search) {
11817
11960
  const population = await listVisibleSearchPopulation(crmDb, where, window, candidateCount);
@@ -11823,7 +11966,7 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
11823
11966
  };
11824
11967
  }
11825
11968
  const result = await crmDb.query({
11826
- [import_crm15.CRM_NS.record]: { $: {
11969
+ [import_crm21.CRM_NS.record]: { $: {
11827
11970
  where,
11828
11971
  order: { [window.sort.field]: window.sort.dir },
11829
11972
  limit: window.limit,
@@ -11831,19 +11974,19 @@ async function listReaderRecords(db, type, allowed2, emailField, window) {
11831
11974
  } }
11832
11975
  });
11833
11976
  return {
11834
- records: (result[import_crm15.CRM_NS.record] ?? []).map(import_crm15.rowToRecord),
11977
+ records: (result[import_crm21.CRM_NS.record] ?? []).map(import_crm21.rowToRecord),
11835
11978
  total: candidateCount
11836
11979
  };
11837
11980
  }
11838
11981
 
11839
11982
  // src/network-mutation-receipt.ts
11840
- var import_crm16 = require("@odla-ai/crm");
11983
+ var import_crm22 = require("@odla-ai/crm");
11841
11984
  var NETWORK_MUTATION_META = "chapterNetworkMutation";
11842
11985
  async function priorMutation(db, id6) {
11843
11986
  const result = await db.query({
11844
- [import_crm16.CRM_NS.activity]: { $: { where: { id: id6 }, limit: 1 } }
11987
+ [import_crm22.CRM_NS.activity]: { $: { where: { id: id6 }, limit: 1 } }
11845
11988
  });
11846
- const row2 = (result[import_crm16.CRM_NS.activity] ?? [])[0];
11989
+ const row2 = (result[import_crm22.CRM_NS.activity] ?? [])[0];
11847
11990
  const meta = row2?.meta;
11848
11991
  const receipt = meta?.[NETWORK_MUTATION_META];
11849
11992
  return typeof receipt?.fingerprint === "string" ? { fingerprint: receipt.fingerprint, pending: receipt.state === "pending", failed: receipt.state === "failed" } : null;
@@ -11867,7 +12010,7 @@ function mutationError(error) {
11867
12010
  if (error?.code === "transact_guard_failed") {
11868
12011
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
11869
12012
  }
11870
- if (error instanceof import_crm17.CrmInputError) {
12013
+ if (error instanceof import_crm23.CrmInputError) {
11871
12014
  return json({ error: error.message, code: "invalid_transition_or_fields", ...error.fields ? { fields: error.fields } : {} }, 422);
11872
12015
  }
11873
12016
  return json({ error: "follower mutation failed", code: "mutation_failed" }, 500);
@@ -11922,7 +12065,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11922
12065
  }
11923
12066
  if (prior?.failed) return json({ error: "this operation was not applied; reload before choosing a new action", code: "action_failed" }, 409);
11924
12067
  if (prior && !prior.pending) {
11925
- const replayed = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
12068
+ const replayed = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
11926
12069
  if (!replayed || replayed.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
11927
12070
  const result = {
11928
12071
  version: 1,
@@ -11932,7 +12075,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11932
12075
  };
11933
12076
  return json(result);
11934
12077
  }
11935
- const record4 = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
12078
+ const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
11936
12079
  if (!record4 || record4.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
11937
12080
  if (!prior?.pending && record4.updatedAt !== input.expectedRevision) {
11938
12081
  return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
@@ -11995,7 +12138,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
11995
12138
  record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
11996
12139
  });
11997
12140
  }
11998
- const updated = hasFields ? await (0, import_crm17.updateRecord)(deps, {
12141
+ const updated = hasFields ? await (0, import_crm23.updateRecord)(deps, {
11999
12142
  id: record4.id,
12000
12143
  input: fields,
12001
12144
  mutationId: activityId,
@@ -12006,7 +12149,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
12006
12149
  authorId: `network:${verified.sender}`,
12007
12150
  meta: auditMeta
12008
12151
  }
12009
- }) : await (0, import_crm17.setStage)(deps, {
12152
+ }) : await (0, import_crm23.setStage)(deps, {
12010
12153
  id: record4.id,
12011
12154
  to: input.stage,
12012
12155
  authorId: `network:${verified.sender}`,
@@ -12033,7 +12176,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
12033
12176
  const recordId = url.searchParams.get("recordId");
12034
12177
  if (recordId !== null) {
12035
12178
  if (!RECORD_ID.test(recordId)) return json({ error: "invalid record id" }, 400);
12036
- const record4 = await (0, import_crm17.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12179
+ const record4 = await (0, import_crm23.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12037
12180
  if (!record4 || record4.type !== type || record4.archived) return json({ error: "record not found" }, 404);
12038
12181
  return json({
12039
12182
  version: 1,
@@ -12283,13 +12426,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12283
12426
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
12284
12427
  const got = await gate8(req, env, ctx);
12285
12428
  if ("response" in got) return got.response;
12286
- const identity = noteIdentity(url, ctx);
12287
- if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12429
+ const identity2 = noteIdentity(url, ctx);
12430
+ if (!identity2) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12288
12431
  if (req.method === "GET") {
12289
12432
  const result = await got.db.query({
12290
12433
  networkNotes: {
12291
12434
  $: {
12292
- where: { recordKey: identity.recordKey },
12435
+ where: { recordKey: identity2.recordKey },
12293
12436
  order: { createdAt: "desc" },
12294
12437
  limit: 100
12295
12438
  }
@@ -12310,9 +12453,9 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12310
12453
  const id6 = crypto.randomUUID();
12311
12454
  const note = {
12312
12455
  id: id6,
12313
- targetId: identity.targetId,
12314
- recordType: identity.recordType,
12315
- recordId: identity.recordId,
12456
+ targetId: identity2.targetId,
12457
+ recordType: identity2.recordType,
12458
+ recordId: identity2.recordId,
12316
12459
  body,
12317
12460
  authorId: got.user.userId,
12318
12461
  ...got.user.email ? { authorEmail: got.user.email } : {},
@@ -12322,13 +12465,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12322
12465
  t: "update",
12323
12466
  ns: "networkNotes",
12324
12467
  id: id6,
12325
- attrs: { ...note, recordKey: identity.recordKey }
12468
+ attrs: { ...note, recordKey: identity2.recordKey }
12326
12469
  }]);
12327
12470
  return json({ note }, 201);
12328
12471
  };
12329
12472
 
12330
12473
  // src/worker-routes-network-shared-notes.ts
12331
- var import_crm18 = require("@odla-ai/crm");
12474
+ var import_crm24 = require("@odla-ai/crm");
12332
12475
  var import_db12 = require("@odla-ai/db");
12333
12476
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
12334
12477
  var RECORD_ID3 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
@@ -12359,7 +12502,7 @@ function sharedNote(activity, sourceId) {
12359
12502
  }
12360
12503
  async function listSharedNotes(db, sourceId, recordId) {
12361
12504
  const result = await db.query({
12362
- [import_crm18.CRM_NS.activity]: {
12505
+ [import_crm24.CRM_NS.activity]: {
12363
12506
  $: {
12364
12507
  where: {
12365
12508
  and: [
@@ -12372,7 +12515,7 @@ async function listSharedNotes(db, sourceId, recordId) {
12372
12515
  }
12373
12516
  }
12374
12517
  });
12375
- return (result[import_crm18.CRM_NS.activity] ?? []).flatMap((activity) => {
12518
+ return (result[import_crm24.CRM_NS.activity] ?? []).flatMap((activity) => {
12376
12519
  const note = sharedNote(activity, sourceId);
12377
12520
  return note ? [note] : [];
12378
12521
  });
@@ -12400,7 +12543,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
12400
12543
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
12401
12544
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
12402
12545
  }
12403
- const record4 = await (0, import_crm18.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12546
+ const record4 = await (0, import_crm24.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
12404
12547
  if (!record4 || record4.type !== recordType) return json({ error: "record not found" }, 404);
12405
12548
  if (req.method === "GET") {
12406
12549
  return json({
@@ -12422,7 +12565,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
12422
12565
  }
12423
12566
  const createdAt = Date.now();
12424
12567
  const id6 = `network:${verified.sender}:${mutationId}`;
12425
- const result = await (0, import_crm18.addActivity)(
12568
+ const result = await (0, import_crm24.addActivity)(
12426
12569
  {
12427
12570
  crm: ctx.chapter.crm,
12428
12571
  db,
@@ -12584,8 +12727,8 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
12584
12727
  }
12585
12728
  }
12586
12729
  const seen = /* @__PURE__ */ new Set();
12587
- for (const identity of identities) {
12588
- const key3 = `${identity.kind}:${identity.value}`;
12730
+ for (const identity2 of identities) {
12731
+ const key3 = `${identity2.kind}:${identity2.value}`;
12589
12732
  if (seen.has(key3)) errors.push("duplicate identity in import");
12590
12733
  seen.add(key3);
12591
12734
  }
@@ -12641,8 +12784,8 @@ async function networkChapterLinkId(personId, chapterId, chapterPersonId) {
12641
12784
  async function networkChapterPersonSourceKey(chapterId, chapterPersonId) {
12642
12785
  return `chapter_source_${await sha256(`${chapterId}\0${chapterPersonId}`)}`;
12643
12786
  }
12644
- function projectNetworkContactPolicy(policy, links) {
12645
- return links.map((link) => ({ ...link, contactPolicy: { ...policy } }));
12787
+ function projectNetworkContactPolicy(policy2, links) {
12788
+ return links.map((link) => ({ ...link, contactPolicy: { ...policy2 } }));
12646
12789
  }
12647
12790
  function canonicalNetworkPersonId(personId, persons) {
12648
12791
  const byId = new Map(persons.map((person) => [person.id, person]));
@@ -12948,11 +13091,11 @@ async function suppressNetworkPerson(db, input, mutationId, actor, now = Date.no
12948
13091
  if (!person || person.state !== "active" || canonicalNetworkPersonId(person.id, state.persons) !== person.id || person.revision !== input.expectedRevision) {
12949
13092
  throw new NetworkPersonRegistryError("stale_suppression", "active canonical Person changed; reload before updating suppression");
12950
13093
  }
12951
- const policy = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
12952
- const next = { ...person, contactPolicy: policy, revision: person.revision + 1, updatedAt: now };
13094
+ const policy2 = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
13095
+ const next = { ...person, contactPolicy: policy2, revision: person.revision + 1, updatedAt: now };
12953
13096
  await db.transact([
12954
13097
  { t: "update", ns: "networkPersons", id: person.id, attrs: { ...next } },
12955
- networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy.marketingSuppressed, discretionarySuppressed: policy.discretionarySuppressed }, { personId: person.id })
13098
+ networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy2.marketingSuppressed, discretionarySuppressed: policy2.discretionarySuppressed }, { personId: person.id })
12956
13099
  ], { mutationId, guards: [{ ns: "networkPersons", id: person.id, exists: true, equals: { revision: person.revision } }] });
12957
13100
  return next;
12958
13101
  }
@@ -13077,13 +13220,13 @@ async function derivedMutationId(batchMutationId, part) {
13077
13220
  return `bulk_${await networkPersonBulkSha256(`${batchMutationId}\0${part}`)}`;
13078
13221
  }
13079
13222
  function matchingEvent(events, mutationId, kind) {
13080
- return events.find((event) => event.mutationId === mutationId && event.kind === kind);
13223
+ return events.find((event2) => event2.mutationId === mutationId && event2.kind === kind);
13081
13224
  }
13082
- function eventString(event, key3) {
13083
- return typeof event.detail[key3] === "string" ? String(event.detail[key3]) : "";
13225
+ function eventString(event2, key3) {
13226
+ return typeof event2.detail[key3] === "string" ? String(event2.detail[key3]) : "";
13084
13227
  }
13085
- function eventCounts(event) {
13086
- const value = event.detail.counts;
13228
+ function eventCounts(event2) {
13229
+ const value = event2.detail.counts;
13087
13230
  return value && typeof value === "object" && !Array.isArray(value) ? { ...networkPersonBulkEmptyCounts(), ...value } : networkPersonBulkEmptyCounts();
13088
13231
  }
13089
13232
  async function applyNetworkPersonBulkImport(db, input, batchMutationId, actor, allowedChapterIds, now = Date.now()) {
@@ -13210,11 +13353,11 @@ var redactedNetworkPersonLink = async (link) => ({
13210
13353
  state: link.state,
13211
13354
  createdAt: link.createdAt
13212
13355
  });
13213
- var safeNetworkContactPolicy = (policy) => ({
13214
- marketingSuppressed: policy.marketingSuppressed,
13215
- discretionarySuppressed: policy.discretionarySuppressed,
13216
- hasReason: Boolean(policy.reason),
13217
- updatedAt: policy.updatedAt
13356
+ var safeNetworkContactPolicy = (policy2) => ({
13357
+ marketingSuppressed: policy2.marketingSuppressed,
13358
+ discretionarySuppressed: policy2.discretionarySuppressed,
13359
+ hasReason: Boolean(policy2.reason),
13360
+ updatedAt: policy2.updatedAt
13218
13361
  });
13219
13362
  var safeNetworkPersonAlias = async (alias) => ({
13220
13363
  id: alias.id,
@@ -13269,14 +13412,14 @@ async function projectNetworkPersonRegistryState(state) {
13269
13412
  aliases,
13270
13413
  reviewQueue: aliases.filter((alias) => alias.status === "ambiguous"),
13271
13414
  excluded: aliases.filter((alias) => alias.status === "excluded"),
13272
- recentEvents: state.events.slice(0, 100).map((event) => ({
13273
- id: event.id,
13274
- kind: event.kind,
13275
- personId: event.personId ?? null,
13276
- aliasId: event.aliasId ?? null,
13277
- actorId: event.actorId,
13278
- mutationId: event.mutationId,
13279
- createdAt: event.createdAt
13415
+ recentEvents: state.events.slice(0, 100).map((event2) => ({
13416
+ id: event2.id,
13417
+ kind: event2.kind,
13418
+ personId: event2.personId ?? null,
13419
+ aliasId: event2.aliasId ?? null,
13420
+ actorId: event2.actorId,
13421
+ mutationId: event2.mutationId,
13422
+ createdAt: event2.createdAt
13280
13423
  }))
13281
13424
  };
13282
13425
  }
@@ -13291,7 +13434,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13291
13434
  const target = input.kind === "person" ? state.persons.find((row2) => row2.id === input.id) : state.aliases.find((row2) => row2.id === input.id);
13292
13435
  if (!target) throw new NetworkPersonRegistryError("reveal_not_found", "registry record not found");
13293
13436
  const ids2 = input.kind === "person" ? { personId: input.id } : { aliasId: input.id };
13294
- const event = {
13437
+ const event2 = {
13295
13438
  t: "update",
13296
13439
  ns: "networkPersonEvents",
13297
13440
  id: `network_person_event:${mutationId}`,
@@ -13305,7 +13448,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13305
13448
  createdAt: now
13306
13449
  }
13307
13450
  };
13308
- await db.transact([event], {
13451
+ await db.transact([event2], {
13309
13452
  mutationId,
13310
13453
  guards: [{ ns: "networkPersonEvents", id: `network_person_event:${mutationId}`, exists: false }]
13311
13454
  });
@@ -13889,8 +14032,8 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
13889
14032
  return json({ error: "commercial webhook is not configured" }, 503);
13890
14033
  }
13891
14034
  try {
13892
- const event = await (0, import_stripe12.constructStripeEvent)(body, signature, secret);
13893
- const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
14035
+ const event2 = await (0, import_stripe12.constructStripeEvent)(body, signature, secret);
14036
+ const fact = normalizeNetworkCommercialStripeEvent(event2, await stripeEventDigest(body));
13894
14037
  if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
13895
14038
  const result = await ingestNetworkCommercialFact(
13896
14039
  db,
@@ -14001,7 +14144,7 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
14001
14144
  };
14002
14145
 
14003
14146
  // src/worker-routes-formation.ts
14004
- var import_crm19 = require("@odla-ai/crm");
14147
+ var import_crm25 = require("@odla-ai/crm");
14005
14148
  var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
14006
14149
  var handleFormation = async (req, url, env, ctx) => {
14007
14150
  const formation = ctx.chapter.formation;
@@ -14053,7 +14196,7 @@ var handleFormation = async (req, url, env, ctx) => {
14053
14196
  const db = ctx.makeDb(env);
14054
14197
  const id6 = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
14055
14198
  try {
14056
- const created = await (0, import_crm19.createRecord)(
14199
+ const created = await (0, import_crm25.createRecord)(
14057
14200
  {
14058
14201
  crm: ctx.chapter.crm,
14059
14202
  db,
@@ -15489,7 +15632,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15489
15632
  };
15490
15633
  if (action === "seat-accept") return acceptNamedSeatRecipient(db, recipientInput, now);
15491
15634
  if (action === "seat-approve") return approveNamedSeatRecipient(db, recipientInput, now);
15492
- const checkout = {
15635
+ const checkout2 = {
15493
15636
  ...target,
15494
15637
  priceId: peer.seatPriceId,
15495
15638
  recipientName: String(input.recipientName),
@@ -15497,7 +15640,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15497
15640
  purchaserEmail: String(input.purchaserEmail),
15498
15641
  purchaserUserId: String(input.purchaserUserId)
15499
15642
  };
15500
- if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout, now))) return grantIncludedNamedSeat(db, checkout, now);
15643
+ if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout2, now))) return grantIncludedNamedSeat(db, checkout2, now);
15501
15644
  const secret = await getVaultSecret(db, stripeSecretName);
15502
15645
  if (!secret) throw new Error("network Stripe provider is unavailable");
15503
15646
  if (action === "seat-decline") {
@@ -15513,7 +15656,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15513
15656
  return { seatId: seat.id, status: "refunded", refundedCents: refunded.refundedCents, subscriptionCanceled: false };
15514
15657
  }
15515
15658
  if (action === "seat-operator-refund") return refundNamedSeatByOperator(db, { ...target, seatId: String(input.seatId) }, secret, now);
15516
- if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout, secret, now);
15659
+ if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout2, secret, now);
15517
15660
  const evidence = { ...target, seatId: String(input.seatId), eventId: String(input.eventId) };
15518
15661
  if (action === "seat-payments") {
15519
15662
  const receipt = await confirmNamedMembershipSeatPayment(db, evidence, now);
@@ -15584,9 +15727,9 @@ function createMembershipAuthorityHandler(options) {
15584
15727
  const identities = /* @__PURE__ */ new Set();
15585
15728
  for (const peer of peers) {
15586
15729
  if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(peer.appId) || !SAFE2.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");
15587
- const identity = `${peer.appId}:${peer.runtime}`;
15588
- if (identities.has(identity)) throw new TypeError("ambiguous authority peer");
15589
- identities.add(identity);
15730
+ const identity2 = `${peer.appId}:${peer.runtime}`;
15731
+ if (identities.has(identity2)) throw new TypeError("ambiguous authority peer");
15732
+ identities.add(identity2);
15590
15733
  if (peer.seatPriceId !== void 0 && !/^price_[A-Za-z0-9]+$/.test(peer.seatPriceId)) throw new TypeError("invalid seat Price");
15591
15734
  }
15592
15735
  return async (req) => {
@@ -16190,6 +16333,9 @@ async function readBoundedJson(response, signal, maxBytes, label) {
16190
16333
  }
16191
16334
  }
16192
16335
 
16336
+ // src/membership-chapter-delivery.ts
16337
+ var import_crm26 = require("@odla-ai/crm");
16338
+
16193
16339
  // src/invitation-test-delivery.ts
16194
16340
  function invitationDelivery(env, recipientEmail) {
16195
16341
  const normal = { sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL };
@@ -16252,16 +16398,13 @@ function createMembershipChapterEffectRoute(options) {
16252
16398
  const join = new URL(applicationPath, origin);
16253
16399
  join.searchParams.set("seat", effect.seatId);
16254
16400
  join.searchParams.set("tier", effect.skuId);
16255
- config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: config.emailTemplates?.namedSeatInvitation ?? {
16256
- enabled: true,
16257
- subject: "{{purchaserName}} invited you to {{chapterName}}",
16258
- text: "Hello {{firstName}},\n\n{{purchaserName}} has purchased a membership for you at {{chapterName}}. Your seat is already paid for.\n\nAccept your invitation here:\n{{joinUrl}}\n\nYou will complete your own application and membership requirements. You will not be asked to pay for this seat."
16259
- } };
16260
16401
  const template = config.emailTemplates.namedSeatInvitation;
16261
- if (!template.text.includes("{{purchaserName}}")) config.emailTemplates.namedSeatInvitation = {
16262
- ...template,
16263
- text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16264
- };
16402
+ if (template && !(0, import_crm26.validateEmailCopy)(template).length && !template.text.includes("{{purchaserName}}")) {
16403
+ config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: {
16404
+ ...template,
16405
+ text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16406
+ } };
16407
+ }
16265
16408
  stage = "invitation_delivery";
16266
16409
  const result = await sendTemplated({
16267
16410
  db,
@@ -16281,7 +16424,8 @@ function createMembershipChapterEffectRoute(options) {
16281
16424
  joinUrl: join.href
16282
16425
  },
16283
16426
  dedupeKey: `membership-effect:${effect.effectId}`,
16284
- applicationId: input.applicationId
16427
+ applicationId: input.applicationId,
16428
+ tierId: effect.skuId
16285
16429
  });
16286
16430
  if (!result.sent) throw new Error("invitation delivery unconfirmed");
16287
16431
  } else {