@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.
@@ -383,12 +383,12 @@ function canonical(value) {
383
383
  return value;
384
384
  }
385
385
  async function stripeEventDigest(rawBody) {
386
- const event = JSON.parse(rawBody);
387
- if (!event || typeof event !== "object" || Array.isArray(event)) throw new TypeError("invalid Stripe event");
386
+ const event2 = JSON.parse(rawBody);
387
+ if (!event2 || typeof event2 !== "object" || Array.isArray(event2)) throw new TypeError("invalid Stripe event");
388
388
  const keys3 = ["id", "type", "created", "livemode", "account", "context", "api_version", "data"];
389
389
  return stripePayloadDigest(JSON.stringify(canonical({
390
390
  digestVersion: 1,
391
- ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event, key3)).map((key3) => [key3, event[key3]]))
391
+ ...Object.fromEntries(keys3.filter((key3) => Object.hasOwn(event2, key3)).map((key3) => [key3, event2[key3]]))
392
392
  })));
393
393
  }
394
394
 
@@ -655,13 +655,13 @@ function firstPrice(object2) {
655
655
  return { ...priceId ? { priceId } : {}, ...quantity !== void 0 ? { quantity } : {} };
656
656
  }
657
657
  function normalizeNetworkCommercialStripeEvent(input, digest) {
658
- const event = raw(input);
659
- const eventId = providerId(event.id, "evt_") ?? "";
660
- const eventType = text(event.type) ?? "";
661
- const object2 = raw(raw(event.data).object);
658
+ const event2 = raw(input);
659
+ const eventId = providerId(event2.id, "evt_") ?? "";
660
+ const eventType = text(event2.type) ?? "";
661
+ const object2 = raw(raw(event2.data).object);
662
662
  const objectId = providerId(object2.id) ?? "";
663
663
  const candidates = metadataCandidates(object2);
664
- const identity = {};
664
+ const identity2 = {};
665
665
  const identityKeys2 = {
666
666
  appId: "odlaAppId",
667
667
  chapterId: "chapterId",
@@ -671,7 +671,7 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
671
671
  };
672
672
  for (const [target, source] of Object.entries(identityKeys2)) {
673
673
  const value = metadataValue(candidates, source);
674
- if (value) identity[target] = value;
674
+ if (value) identity2[target] = value;
675
675
  }
676
676
  let objectKind = "ignored";
677
677
  if (eventType.startsWith("customer.subscription.")) objectKind = "subscription";
@@ -693,11 +693,11 @@ function normalizeNetworkCommercialStripeEvent(input, digest) {
693
693
  eventId,
694
694
  digest,
695
695
  eventType,
696
- eventCreated: integer(event.created) ?? 0,
697
- livemode: event.livemode === true,
696
+ eventCreated: integer(event2.created) ?? 0,
697
+ livemode: event2.livemode === true,
698
698
  objectKind,
699
699
  objectId,
700
- identity,
700
+ identity: identity2,
701
701
  ...metadataValue(candidates, "applicationId") ? { applicationId: metadataValue(candidates, "applicationId") } : {},
702
702
  ...metadataValue(candidates, "personId") ? { personId: metadataValue(candidates, "personId") } : {},
703
703
  ...metadataValue(candidates, "membershipId") ? { membershipId: metadataValue(candidates, "membershipId") } : {},
@@ -1174,35 +1174,35 @@ function findGiftRef(obj) {
1174
1174
  ...chapterId ? { chapterId } : {}
1175
1175
  };
1176
1176
  }
1177
- function normalizeWebhookEvent(event, options = {}) {
1178
- const obj = event.data?.object ?? {};
1179
- 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 };
1177
+ function normalizeWebhookEvent(event2, options = {}) {
1178
+ const obj = event2.data?.object ?? {};
1179
+ 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 };
1180
1180
  const gift = findGiftRef(obj);
1181
1181
  if (gift.giftIntentId) {
1182
- const { giftIntentId, providerObjectId, ...identity } = gift;
1183
- if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1184
- return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity };
1182
+ const { giftIntentId, providerObjectId, ...identity2 } = gift;
1183
+ if (event2.type === "payment_intent.succeeded" || event2.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
1184
+ return { kind: "gift_paid", giftIntentId, providerObjectId, ...identity2 };
1185
1185
  }
1186
- if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "refunded" };
1187
- if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity, reason: "canceled" };
1188
- return { kind: "ignored", type: event.type };
1186
+ if (event2.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "refunded" };
1187
+ if (event2.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId, ...identity2, reason: "canceled" };
1188
+ return { kind: "ignored", type: event2.type };
1189
1189
  }
1190
1190
  const ref = findApplicationRef(obj);
1191
1191
  const seatId = obj.metadata?.seatId;
1192
1192
  if (typeof seatId === "string" && seatId) {
1193
- if (!options.requireProviderPayment) return { kind: "ignored", type: event.type };
1194
- if (event.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1195
- if (event.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1196
- return { kind: "ignored", type: event.type };
1193
+ if (!options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1194
+ if (event2.type === "payment_intent.succeeded") return { kind: "seat_paid", seatId, ...ref };
1195
+ if (event2.type === "charge.refunded") return { kind: "seat_refunded", seatId, ...ref };
1196
+ return { kind: "ignored", type: event2.type };
1197
1197
  }
1198
- if (event.type === "invoice.paid" && options.requireProviderPayment || event.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event.type };
1199
- switch (event.type) {
1198
+ if (event2.type === "invoice.paid" && options.requireProviderPayment || event2.type === "invoice.payment_succeeded" && !options.requireProviderPayment) return { kind: "ignored", type: event2.type };
1199
+ switch (event2.type) {
1200
1200
  case "invoice.payment_failed":
1201
- return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event.type };
1201
+ return options.requireProviderPayment && obj.billing_reason === "subscription_cycle" ? { kind: "renewal_failed", ...ref } : { kind: "ignored", type: event2.type };
1202
1202
  case "invoice.paid":
1203
1203
  case "invoice.payment_succeeded": {
1204
1204
  if (obj.billing_reason !== "subscription_create" && obj.billing_reason !== "subscription_cycle") {
1205
- return { kind: "ignored", type: event.type };
1205
+ return { kind: "ignored", type: event2.type };
1206
1206
  }
1207
1207
  const renewalAt = membershipInvoicePeriodEnd(obj);
1208
1208
  const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
@@ -1216,11 +1216,11 @@ function normalizeWebhookEvent(event, options = {}) {
1216
1216
  case "charge.refunded":
1217
1217
  return { kind: "refunded", ...ref };
1218
1218
  case "customer.subscription.updated":
1219
- return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event.type };
1219
+ return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "ignored", type: event2.type };
1220
1220
  case "customer.subscription.deleted":
1221
1221
  return options.requireProviderPayment ? { kind: "subscription_changed", ...ref } : { kind: "canceled", ...ref };
1222
1222
  default:
1223
- return { kind: "ignored", type: event.type };
1223
+ return { kind: "ignored", type: event2.type };
1224
1224
  }
1225
1225
  }
1226
1226
  function firstPaymentPatch(currentStatus, renewalAt) {
@@ -1584,6 +1584,41 @@ async function backfillCrm(deps) {
1584
1584
  return { synced, errors };
1585
1585
  }
1586
1586
 
1587
+ // src/email-retirement.ts
1588
+ function isRetiredEmailCopy(subject, text8) {
1589
+ const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
1590
+ const bodyCopy = text8.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1591
+ return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
1592
+ }
1593
+
1594
+ // src/email.ts
1595
+ import { validateEmailCopy } from "@odla-ai/crm";
1596
+
1597
+ // src/email-events.ts
1598
+ var identity = ["firstName", "lastName", "email", "phone", "state"];
1599
+ var policy = ["refundPolicyText", "commitmentText", "normsText"];
1600
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
1601
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
1602
+ var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
1603
+ var CHAPTER_EMAIL_EVENTS = {
1604
+ adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
1605
+ submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
1606
+ paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
1607
+ prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
1608
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
1609
+ namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
1610
+ seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
1611
+ };
1612
+ function chapterEmailEvents(chapter) {
1613
+ const base = chapter.mode === "hub" ? Object.fromEntries(Object.entries(chapter.crm.config.templates ?? {}).map(([key3, spec]) => [key3, {
1614
+ label: key3,
1615
+ trigger: "Administrator sends a message",
1616
+ audience: "Selected contact",
1617
+ variables: spec.vars
1618
+ }])) : CHAPTER_EMAIL_EVENTS;
1619
+ return { ...base, ...chapter.config.emails?.events };
1620
+ }
1621
+
1587
1622
  // src/email.ts
1588
1623
  function render(template, vars) {
1589
1624
  return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
@@ -1607,21 +1642,233 @@ function isAlreadySent(priorRows) {
1607
1642
  function planDelivery(input) {
1608
1643
  const tpl = input.group.emailTemplates?.[input.template];
1609
1644
  if (!tpl) return { deliver: false, reason: "template-missing" };
1610
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
1645
+ if (typeof tpl.enabled !== "boolean") return { deliver: false, reason: "template-invalid", issues: ["Choose explicitly whether this email is enabled."] };
1646
+ if (tpl.enabled !== true && !input.force) return { deliver: false, reason: "disabled" };
1647
+ if (!/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(input.to)) return { deliver: false, reason: "recipient-invalid" };
1611
1648
  const vars = groupVars(input.group, input.vars);
1649
+ if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
1650
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1651
+ const issues = validateEmailCopy(tpl, {
1652
+ allowedVariables: event2?.variables,
1653
+ optionalVariables: event2?.optionalVariables,
1654
+ requiredVariables: event2?.requiresTier ? ["tierName"] : [],
1655
+ values: vars,
1656
+ tierId: vars.tierId
1657
+ });
1658
+ if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
1612
1659
  const isProd = input.envName === "prod";
1613
1660
  const redirect = !isProd && !!input.group.debugEmail;
1614
1661
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1615
1662
  const to = redirect ? input.group.debugEmail : input.to;
1616
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1663
+ const renderedSubject = render(tpl.subject, vars);
1664
+ const renderedText = render(tpl.text, vars);
1665
+ if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1666
+ if (validateEmailCopy({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1667
+ const subject = (redirect ? "[dev] " : "") + renderedSubject;
1617
1668
  const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
1618
1669
 
1619
- ` + render(tpl.text, vars) : render(tpl.text, vars);
1670
+ ` + renderedText : renderedText;
1620
1671
  return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
1621
1672
  }
1622
1673
 
1623
1674
  // src/notify.ts
1624
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1675
+ import { validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
1676
+
1677
+ // src/email-event-values.ts
1678
+ import { isEmailTierName } from "@odla-ai/crm";
1679
+
1680
+ // src/tiers.ts
1681
+ function tierIsFree(tier) {
1682
+ return !(tier.priceCents > 0) && !tier.stripePriceId;
1683
+ }
1684
+ function tierPayable(tier, group, hasSecretKey) {
1685
+ if (tierIsFree(tier)) return true;
1686
+ return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
1687
+ }
1688
+ function byOrder(a, b) {
1689
+ return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
1690
+ }
1691
+ function resolveTiers(group, rows5 = []) {
1692
+ const stored = rows5.filter((tier) => tier.active);
1693
+ if (rows5.length) return [...stored].sort(byOrder);
1694
+ return [{
1695
+ id: "standard",
1696
+ groupId: String(group.id ?? ""),
1697
+ name: "Membership",
1698
+ priceCents: group.standardPriceCents ?? 0,
1699
+ currency: group.currency ?? "usd",
1700
+ interval: group.interval ?? "year",
1701
+ ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
1702
+ sortOrder: 0,
1703
+ active: true
1704
+ }];
1705
+ }
1706
+ function offerableTiers(tiers, group, hasSecretKey) {
1707
+ return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
1708
+ }
1709
+ function findTier(tiers, tierId) {
1710
+ if (!tierId) return tiers.length === 1 ? tiers[0] : null;
1711
+ return tiers.find((tier) => tier.id === tierId) ?? null;
1712
+ }
1713
+ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
1714
+ return tiers.map((tier) => ({
1715
+ id: tier.id,
1716
+ name: tier.name,
1717
+ priceCents: tier.priceCents,
1718
+ currency: tier.currency ?? defaults.currency ?? "usd",
1719
+ interval: tier.interval ?? defaults.interval ?? "year",
1720
+ blurb: tier.blurb ?? "",
1721
+ free: tierIsFree(tier),
1722
+ ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
1723
+ }));
1724
+ }
1725
+
1726
+ // src/tiers-store.ts
1727
+ function rowToTier(row2, groupId) {
1728
+ 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") {
1729
+ throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
1730
+ }
1731
+ return {
1732
+ id: String(row2.id ?? ""),
1733
+ groupId: String(row2.groupId ?? groupId),
1734
+ name: String(row2.name ?? ""),
1735
+ priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
1736
+ ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
1737
+ ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
1738
+ ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
1739
+ ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
1740
+ sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
1741
+ // A row that never set `active` is offered; only an explicit false retires
1742
+ // a tier, so a half-seeded row cannot silently hide a membership.
1743
+ active: row2.active !== false
1744
+ };
1745
+ }
1746
+ async function loadTiers(db, group) {
1747
+ const groupId = String(group.id ?? "");
1748
+ let rows5;
1749
+ try {
1750
+ rows5 = (await db.query({
1751
+ tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
1752
+ })).tiers;
1753
+ } catch {
1754
+ throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
1755
+ }
1756
+ if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
1757
+ const list = rows5.map((row2) => rowToTier(row2, groupId));
1758
+ return resolveTiers(group, list);
1759
+ }
1760
+ async function loadOfferableTiers(db, group, hasSecretKey) {
1761
+ return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
1762
+ }
1763
+
1764
+ // src/notification-variables.ts
1765
+ function lifecycleNotifyDeps(db, env) {
1766
+ return {
1767
+ db,
1768
+ envName: env.ODLA_ENV,
1769
+ sender: env.SEND_EMAIL,
1770
+ from: env.EMAIL_FROM,
1771
+ debugEmail: env.ODLA_DEBUG_EMAIL,
1772
+ now: () => Date.now(),
1773
+ newId: () => crypto.randomUUID()
1774
+ };
1775
+ }
1776
+ async function frozenCheckoutVariables(db, app) {
1777
+ const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
1778
+ const offer = intent?.membershipOffer, contract = offer?.contract;
1779
+ const offering = contract?.offering, quote = intent?.paymentQuote;
1780
+ const amount = contract?.dueTodayCents ?? intent?.priceCents;
1781
+ const currency = offering?.currency ?? intent?.currency;
1782
+ let formatted = "";
1783
+ if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1784
+ formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1785
+ }
1786
+ return {
1787
+ paymentAmount: formatted,
1788
+ paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1789
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
1790
+ };
1791
+ }
1792
+ async function catalogTierName(db, group, app) {
1793
+ try {
1794
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
1795
+ } catch {
1796
+ return "";
1797
+ }
1798
+ }
1799
+ async function checkoutVariables(db, group, app) {
1800
+ const frozen = await frozenCheckoutVariables(db, app);
1801
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
1802
+ }
1803
+ function formatInstant(at2, timeZone) {
1804
+ return new Intl.DateTimeFormat("en-US", {
1805
+ weekday: "long",
1806
+ month: "long",
1807
+ day: "numeric",
1808
+ year: "numeric",
1809
+ hour: "numeric",
1810
+ minute: "2-digit",
1811
+ timeZoneName: "short",
1812
+ timeZone
1813
+ }).format(new Date(at2));
1814
+ }
1815
+ var adminUrlFor = (origin) => `${origin}/admin/`;
1816
+ var membersUrlFor = (origin) => `${origin}/members/`;
1817
+ function applicantVariables(app) {
1818
+ const text8 = (v) => typeof v === "string" ? v : "";
1819
+ return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
1820
+ }
1821
+ function sampleVariables(origin, timeZone, now = Date.now()) {
1822
+ const startAt = formatInstant(now + 864e5, timeZone);
1823
+ return {
1824
+ firstName: "Sample",
1825
+ lastName: "Person",
1826
+ email: "sample@example.com",
1827
+ phone: "(555) 010-0100",
1828
+ state: "CA",
1829
+ tier: "Membership",
1830
+ tierName: "Membership",
1831
+ paymentAmount: "$100.00",
1832
+ paymentAmountSummary: " of $100.00",
1833
+ paymentNextStep: "Next, schedule your introduction call.",
1834
+ submitNextStep: "Next, schedule your introduction call.",
1835
+ startAt,
1836
+ endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
1837
+ timezone: timeZone,
1838
+ meetUrl: "https://meet.google.com/sample",
1839
+ htmlLink: "https://calendar.google.com/event?eid=sample",
1840
+ meetingTime: startAt,
1841
+ meetingLink: "https://meet.google.com/sample",
1842
+ adminUrl: adminUrlFor(origin),
1843
+ membersUrl: membersUrlFor(origin),
1844
+ purchaserName: "Sample Purchaser",
1845
+ recipientName: "Sample Recipient",
1846
+ chapterName: "Sample Chapter",
1847
+ acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1848
+ joinUrl: `${origin}/join?seat=sample`
1849
+ };
1850
+ }
1851
+
1852
+ // src/email-event-values.ts
1853
+ async function emailTierVariables(db, input) {
1854
+ try {
1855
+ const [apps, groups] = await Promise.all([
1856
+ db.query({ applications: { $: { where: { id: input.applicationId }, limit: 1 } } }),
1857
+ db.query({ groups: { $: { where: { id: input.groupId }, limit: 1 } } })
1858
+ ]);
1859
+ const app = apps.applications?.[0], group = groups.groups?.[0];
1860
+ if (!app || !group || app.groupId !== input.groupId) return { tierName: "", tier: "" };
1861
+ const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
1862
+ if (!tierId) return { tierName: "", tier: "" };
1863
+ const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
1864
+ const tierName2 = isEmailTierName(name, tierId) ? name.trim() : "";
1865
+ return { tierName: tierName2, tier: tierName2, tierId };
1866
+ } catch {
1867
+ return { tierName: "", tier: "" };
1868
+ }
1869
+ }
1870
+
1871
+ // src/notify.ts
1625
1872
  function fromAddress(configured, name) {
1626
1873
  const display = name?.trim();
1627
1874
  if (!display) return configured;
@@ -1638,14 +1885,17 @@ async function sendTemplated(deps, input) {
1638
1885
  const cloudflareReady = Boolean(deps.sender && deps.from);
1639
1886
  const override = deps.debugEmail?.trim();
1640
1887
  const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1888
+ const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
1889
+ const vars = event2?.requiresTier && input.applicationId ? { ...input.vars, ...await emailTierVariables(deps.db, { applicationId: input.applicationId, groupId: group.id, tierId: input.tierId }) } : input.vars;
1641
1890
  const decision = planDelivery({
1642
1891
  envName: deps.envName,
1643
1892
  group,
1644
1893
  template: input.template,
1645
1894
  to: input.to,
1646
- vars: input.vars,
1895
+ vars,
1647
1896
  cloudflareReady,
1648
- force: input.force
1897
+ force: input.force,
1898
+ event: event2
1649
1899
  });
1650
1900
  if (!decision.deliver) return { sent: false, reason: decision.reason };
1651
1901
  const previous = prior[0];
@@ -1670,6 +1920,8 @@ async function sendTemplated(deps, input) {
1670
1920
  error: decision.transport === "log-only" ? "" : "delivery-pending",
1671
1921
  ...input.applicationId ? { applicationId: input.applicationId } : {}
1672
1922
  };
1923
+ if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
1924
+ if (validateEmailCopy2({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
1673
1925
  try {
1674
1926
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1675
1927
  mutationId: `email-claim:${id6}:${attempt}`,
@@ -1716,17 +1968,17 @@ async function sendTemplated(deps, input) {
1716
1968
  return { sent: true };
1717
1969
  }
1718
1970
  function emailGroupFrom(row2) {
1719
- const str3 = (v) => typeof v === "string" ? v : void 0;
1971
+ const str4 = (v) => typeof v === "string" ? v : void 0;
1720
1972
  const templates = row2.emailTemplates && typeof row2.emailTemplates === "object" ? row2.emailTemplates : {};
1721
1973
  return {
1722
1974
  id: String(row2.id),
1723
1975
  name: String(row2.name ?? ""),
1724
- replyTo: str3(row2.replyTo) ?? "",
1725
- debugEmail: str3(row2.debugEmail),
1726
- fromName: str3(row2.fromName),
1727
- refundPolicyText: str3(row2.refundPolicyText),
1728
- commitmentText: str3(row2.commitmentText),
1729
- normsText: str3(row2.normsText),
1976
+ replyTo: str4(row2.replyTo) ?? "",
1977
+ debugEmail: str4(row2.debugEmail),
1978
+ fromName: str4(row2.fromName),
1979
+ refundPolicyText: str4(row2.refundPolicyText),
1980
+ commitmentText: str4(row2.commitmentText),
1981
+ normsText: str4(row2.normsText),
1730
1982
  emailTemplates: templates
1731
1983
  };
1732
1984
  }
@@ -1839,15 +2091,15 @@ function membershipDiscountPolicyVersion(value) {
1839
2091
  // src/membership-offer.ts
1840
2092
  function createMembershipCommercialOffer(input) {
1841
2093
  const offering = membershipOfferingVersion(input.offering);
1842
- const policy = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
2094
+ const policy2 = input.discountPolicy === void 0 || input.discountPolicy === null ? null : membershipDiscountPolicyVersion(input.discountPolicy);
1843
2095
  const now = input.now;
1844
2096
  if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(input.approvedFoundingCount) || input.approvedFoundingCount < 0) {
1845
2097
  throw new TypeError("invalid offer clock or founding count");
1846
2098
  }
1847
2099
  if (offering.status !== "published" || now < offering.effectiveFrom || offering.effectiveUntil !== void 0 && now >= offering.effectiveUntil) throw new TypeError("offering is unavailable");
1848
- if (policy && policy.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
1849
- 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);
1850
- const earned = founding ? policy : null;
2100
+ if (policy2 && policy2.chapterId !== offering.chapterId) throw new TypeError("policy belongs to another chapter");
2101
+ 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);
2102
+ const earned = founding ? policy2 : null;
1851
2103
  const initialTermEndsAt = offering.term.kind === "fixed_end" ? offering.term.endsAt : addMembershipCalendarMonths(now, offering.term.months);
1852
2104
  if (initialTermEndsAt <= now) throw new TypeError("membership service term has ended");
1853
2105
  if (offering.term.kind === "rolling_months" && offering.term.months !== (offering.interval === "year" ? 12 : 1)) {
@@ -1939,15 +2191,15 @@ async function previewMembershipCommercialOffer(db, input) {
1939
2191
  }
1940
2192
  if (!Number.isSafeInteger(input.now) || input.now < 0) reject("invalid_offer_window");
1941
2193
  const state = await authoritativeState(db, input);
1942
- const { offering, policy } = await currentPolicy(db, input, false, state);
1943
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2194
+ const { offering, policy: policy2 } = await currentPolicy(db, input, false, state);
2195
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
1944
2196
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
1945
2197
  const contract = createMembershipCommercialOffer({
1946
2198
  offering,
1947
- discountPolicy: policy,
2199
+ discountPolicy: policy2,
1948
2200
  approvedFoundingCount: Number(approvedFoundingCount),
1949
2201
  now: input.now,
1950
- couponId: await membershipDiscountCouponId(input, policy?.id)
2202
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
1951
2203
  });
1952
2204
  const discount = contract.billingTerms.discount;
1953
2205
  return {
@@ -1976,12 +2228,12 @@ async function currentPolicy(db, input, restartAware, state) {
1976
2228
  });
1977
2229
  if (!Array.isArray(rows5.membershipOfferingVersion) || !Array.isArray(rows5.discountPolicyVersion) || rows5.membershipOfferingVersion.length >= 200 || rows5.discountPolicyVersion.length >= 200) return reject("policy_history_incomplete");
1978
2230
  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));
1979
- 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);
2231
+ 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);
1980
2232
  const offering = offerings.sort((a, b) => b.version - a.version)[0];
1981
2233
  const restart = restartAware && input.applicationId ? await load(db, "membershipRestart", (await membershipOfferIdForRequest({ ...input, applicationId: input.applicationId, requestId: "checkout_binding" })).replace(/^offer_/, "membership_")) : void 0;
1982
- const policy = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
1983
- 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");
1984
- return { offering, policy };
2234
+ const policy2 = restart ? void 0 : policies.sort((a, b) => b.version - a.version)[0];
2235
+ 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");
2236
+ return { offering, policy: policy2 };
1985
2237
  }
1986
2238
  async function issueMembershipCommercialOffer(db, input) {
1987
2239
  validate(input);
@@ -1989,15 +2241,15 @@ async function issueMembershipCommercialOffer(db, input) {
1989
2241
  const state = await authoritativeState(db, input);
1990
2242
  const existing = await load(db, "membershipCheckoutOffer", input.offerId);
1991
2243
  if (existing) return restore(existing, input);
1992
- const { offering, policy } = await currentPolicy(db, input, true, state);
1993
- const approvedFoundingCount = policy ? state.cohortCounts[policy.cohortLabel] ?? 0 : 0;
2244
+ const { offering, policy: policy2 } = await currentPolicy(db, input, true, state);
2245
+ const approvedFoundingCount = policy2 ? state.cohortCounts[policy2.cohortLabel] ?? 0 : 0;
1994
2246
  if (!Number.isSafeInteger(approvedFoundingCount) || Number(approvedFoundingCount) < 0) reject("invalid_cohort_count");
1995
2247
  const contract = createMembershipCommercialOffer({
1996
2248
  offering,
1997
- discountPolicy: policy,
2249
+ discountPolicy: policy2,
1998
2250
  approvedFoundingCount: Number(approvedFoundingCount),
1999
2251
  now: input.now,
2000
- couponId: await membershipDiscountCouponId(input, policy?.id)
2252
+ couponId: await membershipDiscountCouponId(input, policy2?.id)
2001
2253
  });
2002
2254
  const value = {
2003
2255
  id: input.offerId,
@@ -2264,90 +2516,6 @@ async function verifyMembershipPaymentReceipt(value, target) {
2264
2516
  };
2265
2517
  }
2266
2518
 
2267
- // src/tiers.ts
2268
- function tierIsFree(tier) {
2269
- return !(tier.priceCents > 0) && !tier.stripePriceId;
2270
- }
2271
- function tierPayable(tier, group, hasSecretKey) {
2272
- if (tierIsFree(tier)) return true;
2273
- return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
2274
- }
2275
- function byOrder(a, b) {
2276
- return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
2277
- }
2278
- function resolveTiers(group, rows5 = []) {
2279
- const stored = rows5.filter((tier) => tier.active);
2280
- if (rows5.length) return [...stored].sort(byOrder);
2281
- return [{
2282
- id: "standard",
2283
- groupId: String(group.id ?? ""),
2284
- name: "Membership",
2285
- priceCents: group.standardPriceCents ?? 0,
2286
- currency: group.currency ?? "usd",
2287
- interval: group.interval ?? "year",
2288
- ...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
2289
- sortOrder: 0,
2290
- active: true
2291
- }];
2292
- }
2293
- function offerableTiers(tiers, group, hasSecretKey) {
2294
- return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
2295
- }
2296
- function findTier(tiers, tierId) {
2297
- if (!tierId) return tiers.length === 1 ? tiers[0] : null;
2298
- return tiers.find((tier) => tier.id === tierId) ?? null;
2299
- }
2300
- function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
2301
- return tiers.map((tier) => ({
2302
- id: tier.id,
2303
- name: tier.name,
2304
- priceCents: tier.priceCents,
2305
- currency: tier.currency ?? defaults.currency ?? "usd",
2306
- interval: tier.interval ?? defaults.interval ?? "year",
2307
- blurb: tier.blurb ?? "",
2308
- free: tierIsFree(tier),
2309
- ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
2310
- }));
2311
- }
2312
-
2313
- // src/tiers-store.ts
2314
- function rowToTier(row2, groupId) {
2315
- 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") {
2316
- throw Object.assign(new Error("stored membership tier is invalid"), { code: "tiers_unavailable" });
2317
- }
2318
- return {
2319
- id: String(row2.id ?? ""),
2320
- groupId: String(row2.groupId ?? groupId),
2321
- name: String(row2.name ?? ""),
2322
- priceCents: typeof row2.priceCents === "number" ? row2.priceCents : 0,
2323
- ...typeof row2.currency === "string" && row2.currency ? { currency: row2.currency } : {},
2324
- ...row2.interval === "year" || row2.interval === "month" ? { interval: row2.interval } : {},
2325
- ...typeof row2.stripePriceId === "string" && row2.stripePriceId ? { stripePriceId: row2.stripePriceId } : {},
2326
- ...typeof row2.blurb === "string" ? { blurb: row2.blurb } : {},
2327
- sortOrder: typeof row2.sortOrder === "number" ? row2.sortOrder : 0,
2328
- // A row that never set `active` is offered; only an explicit false retires
2329
- // a tier, so a half-seeded row cannot silently hide a membership.
2330
- active: row2.active !== false
2331
- };
2332
- }
2333
- async function loadTiers(db, group) {
2334
- const groupId = String(group.id ?? "");
2335
- let rows5;
2336
- try {
2337
- rows5 = (await db.query({
2338
- tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
2339
- })).tiers;
2340
- } catch {
2341
- throw Object.assign(new Error("membership tiers are unavailable"), { code: "tiers_unavailable" });
2342
- }
2343
- if (!Array.isArray(rows5)) throw Object.assign(new Error("membership tiers response is invalid"), { code: "tiers_unavailable" });
2344
- const list = rows5.map((row2) => rowToTier(row2, groupId));
2345
- return resolveTiers(group, list);
2346
- }
2347
- async function loadOfferableTiers(db, group, hasSecretKey) {
2348
- return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2349
- }
2350
-
2351
2519
  // src/membership-restart-seat.ts
2352
2520
  async function expireRestartedMembershipSeat(db, target, member, now) {
2353
2521
  const ops = [], guards = [];
@@ -2854,89 +3022,6 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
2854
3022
  }
2855
3023
  }
2856
3024
 
2857
- // src/notification-variables.ts
2858
- function lifecycleNotifyDeps(db, env) {
2859
- return {
2860
- db,
2861
- envName: env.ODLA_ENV,
2862
- sender: env.SEND_EMAIL,
2863
- from: env.EMAIL_FROM,
2864
- debugEmail: env.ODLA_DEBUG_EMAIL,
2865
- now: () => Date.now(),
2866
- newId: () => crypto.randomUUID()
2867
- };
2868
- }
2869
- async function frozenCheckoutVariables(db, app) {
2870
- const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
2871
- const offer = intent?.membershipOffer, contract = offer?.contract;
2872
- const offering = contract?.offering, quote = intent?.paymentQuote;
2873
- const amount = contract?.dueTodayCents ?? intent?.priceCents;
2874
- const currency = offering?.currency ?? intent?.currency;
2875
- let formatted = "";
2876
- if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
2877
- formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
2878
- }
2879
- return {
2880
- paymentAmount: formatted,
2881
- paymentAmountSummary: formatted ? ` of ${formatted}` : "",
2882
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
2883
- };
2884
- }
2885
- async function catalogTierName(db, group, app) {
2886
- try {
2887
- return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
2888
- } catch {
2889
- return "";
2890
- }
2891
- }
2892
- async function checkoutVariables(db, group, app) {
2893
- const frozen = await frozenCheckoutVariables(db, app);
2894
- return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
2895
- }
2896
- function formatInstant(at2, timeZone) {
2897
- return new Intl.DateTimeFormat("en-US", {
2898
- weekday: "long",
2899
- month: "long",
2900
- day: "numeric",
2901
- year: "numeric",
2902
- hour: "numeric",
2903
- minute: "2-digit",
2904
- timeZoneName: "short",
2905
- timeZone
2906
- }).format(new Date(at2));
2907
- }
2908
- var adminUrlFor = (origin) => `${origin}/admin/`;
2909
- var membersUrlFor = (origin) => `${origin}/members/`;
2910
- function applicantVariables(app) {
2911
- const text8 = (v) => typeof v === "string" ? v : "";
2912
- return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
2913
- }
2914
- function sampleVariables(origin, timeZone, now = Date.now()) {
2915
- const startAt = formatInstant(now + 864e5, timeZone);
2916
- return {
2917
- firstName: "Sample",
2918
- lastName: "Person",
2919
- email: "sample@example.com",
2920
- phone: "(555) 010-0100",
2921
- state: "CA",
2922
- tier: "Membership",
2923
- tierName: "Membership",
2924
- paymentAmount: "$100.00",
2925
- paymentAmountSummary: " of $100.00",
2926
- paymentNextStep: "Next, schedule your introduction call.",
2927
- submitNextStep: "Next, schedule your introduction call.",
2928
- startAt,
2929
- endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
2930
- timezone: timeZone,
2931
- meetUrl: "https://meet.google.com/sample",
2932
- htmlLink: "https://calendar.google.com/event?eid=sample",
2933
- meetingTime: startAt,
2934
- meetingLink: "https://meet.google.com/sample",
2935
- adminUrl: adminUrlFor(origin),
2936
- membersUrl: membersUrlFor(origin)
2937
- };
2938
- }
2939
-
2940
3025
  // src/payment-notifications.ts
2941
3026
  var groupFor = async (db, app) => {
2942
3027
  const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
@@ -3222,24 +3307,24 @@ async function revokeGiftClaim(db, claim, reason, now) {
3222
3307
  }
3223
3308
 
3224
3309
  // src/payments-route-support.ts
3225
- async function findPaymentApplication(db, event) {
3226
- if ("applicationId" in event && event.applicationId) {
3227
- const rows5 = await db.query({ applications: { $: { where: { id: event.applicationId }, limit: 1 } } });
3310
+ async function findPaymentApplication(db, event2) {
3311
+ if ("applicationId" in event2 && event2.applicationId) {
3312
+ const rows5 = await db.query({ applications: { $: { where: { id: event2.applicationId }, limit: 1 } } });
3228
3313
  return rows5.applications?.[0];
3229
3314
  }
3230
- if (event.kind !== "refunded" && "customerId" in event && event.customerId) {
3231
- const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event.customerId }, limit: 2 } } });
3315
+ if (event2.kind !== "refunded" && "customerId" in event2 && event2.customerId) {
3316
+ const { applications } = await db.query({ applications: { $: { where: { stripeCustomerId: event2.customerId }, limit: 2 } } });
3232
3317
  return Array.isArray(applications) && applications.length === 1 ? applications[0] : void 0;
3233
3318
  }
3234
3319
  return void 0;
3235
3320
  }
3236
- function paymentEventPatch(event, status2) {
3237
- if ((status2 === "refunded" || status2 === "declined") && (event.kind === "first_payment" || event.kind === "renewal")) return {};
3238
- switch (event.kind) {
3321
+ function paymentEventPatch(event2, status2) {
3322
+ if ((status2 === "refunded" || status2 === "declined") && (event2.kind === "first_payment" || event2.kind === "renewal")) return {};
3323
+ switch (event2.kind) {
3239
3324
  case "first_payment":
3240
- return firstPaymentPatch(status2, event.renewalAt);
3325
+ return firstPaymentPatch(status2, event2.renewalAt);
3241
3326
  case "renewal":
3242
- return event.renewalAt !== void 0 ? renewalPatch(event.renewalAt) : {};
3327
+ return event2.renewalAt !== void 0 ? renewalPatch(event2.renewalAt) : {};
3243
3328
  case "refunded":
3244
3329
  return refundedPatch();
3245
3330
  case "canceled":
@@ -3260,10 +3345,10 @@ function paymentLineItems(group, tier) {
3260
3345
  }
3261
3346
 
3262
3347
  // src/member-system-contract.ts
3263
- function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
3348
+ function classifyMemberStripeEvent(identity2, target, hasProvenLegacyLocalLink) {
3264
3349
  const keys3 = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
3265
- const supplied = keys3.filter((key3) => identity[key3] !== void 0);
3266
- if (supplied.some((key3) => identity[key3] !== target[key3])) return "ignore_sibling";
3350
+ const supplied = keys3.filter((key3) => identity2[key3] !== void 0);
3351
+ if (supplied.some((key3) => identity2[key3] !== target[key3])) return "ignore_sibling";
3267
3352
  if (supplied.length === keys3.length) return "apply";
3268
3353
  return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
3269
3354
  }
@@ -3273,12 +3358,12 @@ var firstRow = async (db, ns, q) => {
3273
3358
  const rows5 = (await db.query({ [ns]: { $: q } }))[ns];
3274
3359
  return Array.isArray(rows5) ? rows5[0] : void 0;
3275
3360
  };
3276
- var stripeEventIdentity = (event) => ({
3277
- ...event.appId ? { appId: event.appId } : {},
3278
- ...event.environment ? { environment: event.environment } : {},
3279
- ...event.dataEnvironment ? { dataEnvironment: event.dataEnvironment } : {},
3280
- ...event.runtime ? { runtime: event.runtime } : {},
3281
- ...event.chapterId ? { chapterId: event.chapterId } : {}
3361
+ var stripeEventIdentity = (event2) => ({
3362
+ ...event2.appId ? { appId: event2.appId } : {},
3363
+ ...event2.environment ? { environment: event2.environment } : {},
3364
+ ...event2.dataEnvironment ? { dataEnvironment: event2.dataEnvironment } : {},
3365
+ ...event2.runtime ? { runtime: event2.runtime } : {},
3366
+ ...event2.chapterId ? { chapterId: event2.chapterId } : {}
3282
3367
  });
3283
3368
  var stripeEventTarget = (env, ctx) => ({
3284
3369
  appId: env.ODLA_APP_ID,
@@ -3288,12 +3373,12 @@ var stripeEventTarget = (env, ctx) => ({
3288
3373
  chapterId: ctx.chapter.id
3289
3374
  });
3290
3375
  var receiptFor = (db, eventId) => firstRow(db, "stripeEventReceipts", { where: { eventId }, limit: 1 });
3291
- function reportStripePartition(level, eventId, event, disposition) {
3376
+ function reportStripePartition(level, eventId, event2, disposition) {
3292
3377
  console[level]("chapter stripe partition", JSON.stringify({
3293
3378
  eventId,
3294
- kind: event.kind,
3379
+ kind: event2.kind,
3295
3380
  disposition,
3296
- ...stripeEventIdentity(event)
3381
+ ...stripeEventIdentity(event2)
3297
3382
  }));
3298
3383
  }
3299
3384
  var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
@@ -3312,8 +3397,8 @@ async function alertStripeQuarantine(db, ctx, eventId, disposition) {
3312
3397
  artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
3313
3398
  });
3314
3399
  }
3315
- async function reportStripeCollision(db, ctx, eventId, event) {
3316
- reportStripePartition("error", eventId, event, "quarantine_collision");
3400
+ async function reportStripeCollision(db, ctx, eventId, event2) {
3401
+ reportStripePartition("error", eventId, event2, "quarantine_collision");
3317
3402
  await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
3318
3403
  }
3319
3404
  async function storeStripeReceipt(db, input) {
@@ -3348,18 +3433,18 @@ async function storeStripeReceipt(db, input) {
3348
3433
  const raced = await receiptFor(db, input.eventId);
3349
3434
  return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
3350
3435
  }
3351
- async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
3436
+ async function quarantineStripeEvent(db, eventId, digest, event2, ctx) {
3352
3437
  const disposition = "quarantine_unmatched";
3353
- const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
3438
+ const stored = await storeStripeReceipt(db, { eventId, digest, event: event2, disposition, matched: false });
3354
3439
  if (stored === "collision") {
3355
- await reportStripeCollision(db, ctx, eventId, event);
3440
+ await reportStripeCollision(db, ctx, eventId, event2);
3356
3441
  return json({ ok: true, matched: false, collision: true });
3357
3442
  }
3358
- reportStripePartition("warn", eventId, event, disposition);
3443
+ reportStripePartition("warn", eventId, event2, disposition);
3359
3444
  if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
3360
3445
  return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
3361
3446
  }
3362
- async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
3447
+ async function replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx) {
3363
3448
  const existing = await receiptFor(db, eventId);
3364
3449
  if (!existing) return null;
3365
3450
  if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
@@ -3369,7 +3454,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, c
3369
3454
  collisionDigest: digest,
3370
3455
  retryReady: false
3371
3456
  } }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
3372
- await reportStripeCollision(db, ctx, eventId, event);
3457
+ await reportStripeCollision(db, ctx, eventId, event2);
3373
3458
  return json({ ok: true, matched: false, collision: true });
3374
3459
  }
3375
3460
  return json({ ok: true, matched: existing.matched === true, replayed: true });
@@ -3383,42 +3468,42 @@ async function firstRow2(db, ns, q) {
3383
3468
  }
3384
3469
  async function reconcileChapterStripeEvent(input, env, ctx) {
3385
3470
  const db = ctx.makeDb(env);
3386
- const { rawBody, eventId, event } = input;
3387
- if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3471
+ const { rawBody, eventId, event: event2 } = input;
3472
+ if (event2.kind === "ignored") return json({ ok: true, ignored: event2.type });
3388
3473
  const digest = await stripeEventDigest(rawBody);
3389
3474
  const legacyDigest = await stripePayloadDigest(rawBody);
3390
- const identity = stripeEventIdentity(event);
3475
+ const identity2 = stripeEventIdentity(event2);
3391
3476
  const target = stripeEventTarget(env, ctx);
3392
- const preliminary = classifyMemberStripeEvent(identity, target, false);
3477
+ const preliminary = classifyMemberStripeEvent(identity2, target, false);
3393
3478
  if (preliminary === "ignore_sibling") {
3394
- reportStripePartition("warn", eventId, event, preliminary);
3479
+ reportStripePartition("warn", eventId, event2, preliminary);
3395
3480
  return json({ ok: true, matched: false, disposition: preliminary });
3396
3481
  }
3397
- if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3398
- const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3482
+ if (event2.kind === "gift_paid" || event2.kind === "gift_revoked") {
3483
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event2.giftIntentId }, limit: 1 });
3399
3484
  if (intent && typeof intent.runtime === "string" && intent.runtime !== target.runtime) {
3400
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3485
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3401
3486
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3402
3487
  }
3403
- const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3488
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3404
3489
  if (replay2) return replay2;
3405
3490
  const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
3406
- const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
3491
+ const disposition2 = classifyMemberStripeEvent(identity2, target, localIntent);
3407
3492
  if (!localIntent || disposition2 === "quarantine_unmatched") {
3408
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3493
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3409
3494
  }
3410
- if (event.kind === "gift_paid") {
3411
- if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3412
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3495
+ if (event2.kind === "gift_paid") {
3496
+ if (intent.providerObjectId !== event2.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3497
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3413
3498
  }
3414
3499
  try {
3415
3500
  const issued = await issueGiftClaim(db, intent, {
3416
3501
  eventId,
3417
- objectId: event.providerObjectId
3502
+ objectId: event2.providerObjectId
3418
3503
  }, Date.now());
3419
- const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
3504
+ const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: true });
3420
3505
  if (stored3 === "collision") {
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: true, claimId: issued.claimId });
@@ -3426,57 +3511,57 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3426
3511
  return json({ error: "gift issuance failed" }, 503);
3427
3512
  }
3428
3513
  }
3429
- await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3430
- status: event.reason,
3514
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event2.giftIntentId, attrs: {
3515
+ status: event2.reason,
3431
3516
  updatedAt: Date.now()
3432
3517
  } }], { mutationId: `gift-revoked-intent:${eventId}` });
3433
- const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3434
- if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3435
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
3518
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event2.giftIntentId }, limit: 1 });
3519
+ if (claim) await revokeGiftClaim(db, claim, event2.reason, Date.now());
3520
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition: disposition2, matched: Boolean(claim) });
3436
3521
  if (stored2 === "collision") {
3437
- await reportStripeCollision(db, ctx, eventId, event);
3522
+ await reportStripeCollision(db, ctx, eventId, event2);
3438
3523
  return json({ ok: true, matched: false, collision: true });
3439
3524
  }
3440
3525
  return json({ ok: true, matched: Boolean(claim) });
3441
3526
  }
3442
- const app = await findPaymentApplication(db, event);
3527
+ const app = await findPaymentApplication(db, event2);
3443
3528
  if (app && typeof app.stripeRuntime === "string" && app.stripeRuntime !== target.runtime) {
3444
- reportStripePartition("warn", eventId, event, "ignore_sibling");
3529
+ reportStripePartition("warn", eventId, event2, "ignore_sibling");
3445
3530
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3446
3531
  }
3447
- const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3532
+ const replay = await replayedStripeReceipt(db, eventId, digest, event2, legacyDigest, ctx);
3448
3533
  if (replay) return replay;
3449
3534
  const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
3450
- const disposition = classifyMemberStripeEvent(identity, target, localApplication);
3535
+ const disposition = classifyMemberStripeEvent(identity2, target, localApplication);
3451
3536
  if (!localApplication || disposition === "quarantine_unmatched") {
3452
- return quarantineStripeEvent(db, eventId, digest, event, ctx);
3537
+ return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3453
3538
  }
3454
3539
  if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
3455
- await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3540
+ await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3456
3541
  return json({ ok: true, historical: true });
3457
3542
  }
3458
- if (event.kind === "seat_paid" || event.kind === "seat_refunded") {
3543
+ if (event2.kind === "seat_paid" || event2.kind === "seat_refunded") {
3459
3544
  const seats = ctx.membershipAuthority?.namedSeats;
3460
3545
  if (!seats) return json({ error: "named seat reconciliation is not configured" }, 503);
3461
3546
  try {
3462
3547
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3463
3548
  if (!intent) return json({ error: "membership checkout reconciliation pending" }, 503);
3464
3549
  const target2 = await membershipTargetForIntent(intent, app, env, ctx);
3465
- const receipt = await seats[event.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event.seatId, eventId }, env);
3466
- if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3467
- const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3550
+ const receipt = await seats[event2.kind === "seat_paid" ? "confirmPayment" : "confirmRefund"]({ ...target2, seatId: event2.seatId, eventId }, env);
3551
+ if (receipt.seatId !== event2.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3552
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3468
3553
  if (stored2 === "collision") {
3469
- await reportStripeCollision(db, ctx, eventId, event);
3554
+ await reportStripeCollision(db, ctx, eventId, event2);
3470
3555
  return json({ ok: true, matched: false, collision: true });
3471
3556
  }
3472
- return json({ ok: true, seatId: event.seatId });
3557
+ return json({ ok: true, seatId: event2.seatId });
3473
3558
  } catch {
3474
3559
  return json({ error: "named seat reconciliation pending" }, 503);
3475
3560
  }
3476
3561
  }
3477
- let authoritativeEvent = event;
3562
+ let authoritativeEvent = event2;
3478
3563
  let membershipProjection = {};
3479
- if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event.kind)) {
3564
+ if (ctx.membershipAuthority && ["renewal", "renewal_failed", "subscription_changed", "refunded"].includes(event2.kind)) {
3480
3565
  try {
3481
3566
  const receipt = await renewChapterNetworkMembership(db, env, ctx, app, eventId);
3482
3567
  membershipProjection = { membershipStatus: receipt.status, membershipGraceEndsAt: receipt.graceEndsAt, membershipAutoRenew: receipt.autoRenew };
@@ -3485,13 +3570,13 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3485
3570
  return json({ error: "network membership renewal reconciliation pending" }, 503);
3486
3571
  }
3487
3572
  }
3488
- if (event.kind === "first_payment") {
3573
+ if (event2.kind === "first_payment") {
3489
3574
  const intent = await firstRow2(db, "subscriptionCheckoutIntents", { where: { id: String(app.id) }, limit: 1 });
3490
3575
  if (intent) {
3491
- const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
3576
+ const evidence = verifyInitialMembershipInvoice(intent, event2.invoice);
3492
3577
  if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
3493
- if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
3494
- if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
3578
+ if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event2, ctx);
3579
+ if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event2, renewalAt: evidence.renewalAt };
3495
3580
  }
3496
3581
  if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
3497
3582
  try {
@@ -3501,7 +3586,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3501
3586
  }
3502
3587
  }
3503
3588
  }
3504
- const invoiceEvent = event.kind === "first_payment" || event.kind === "renewal" || event.kind === "renewal_failed";
3589
+ const invoiceEvent = event2.kind === "first_payment" || event2.kind === "renewal" || event2.kind === "renewal_failed";
3505
3590
  const patch = { ...invoiceEvent && app.canceled === true ? {} : paymentEventPatch(authoritativeEvent, String(app.status ?? "")), ...membershipProjection };
3506
3591
  if (invoiceEvent && typeof patch.renewalAt === "number" && patch.renewalAt > 0 && typeof app.renewalAt === "number" && patch.renewalAt < app.renewalAt) delete patch.renewalAt;
3507
3592
  let next = app;
@@ -3535,7 +3620,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3535
3620
  return json({ error: "refund account access reconciliation pending" }, 503);
3536
3621
  }
3537
3622
  }
3538
- if (event.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3623
+ if (event2.kind === "first_payment" && next.status !== "refunded" && next.status !== "declined" && next.canceled !== true) {
3539
3624
  try {
3540
3625
  await notifyPaymentConfirmed(db, env, eventId, next);
3541
3626
  } catch {
@@ -3554,9 +3639,9 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3554
3639
  if (!approval.ok) return json({ error: "membership approval reconciliation pending" }, 503);
3555
3640
  }
3556
3641
  }
3557
- const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3642
+ const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event: event2, disposition, matched: true });
3558
3643
  if (stored === "collision") {
3559
- await reportStripeCollision(db, ctx, eventId, event);
3644
+ await reportStripeCollision(db, ctx, eventId, event2);
3560
3645
  return json({ ok: true, matched: false, collision: true });
3561
3646
  }
3562
3647
  return json({ ok: true });
@@ -3714,8 +3799,8 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3714
3799
  if (!String(row2.disposition).startsWith("quarantine_") && row2.retryReady !== true) return json({ error: "event is already reconciled" }, 409);
3715
3800
  try {
3716
3801
  const provider = await readStripeRecoveryEvent(db, eventId);
3717
- const event = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3718
- 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);
3802
+ const event2 = normalizeWebhookEvent(provider.event, { requireProviderPayment: Boolean(ctx.membershipAuthority) });
3803
+ 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);
3719
3804
  const id6 = crypto.randomUUID();
3720
3805
  await db.transact([
3721
3806
  { t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
@@ -3736,7 +3821,7 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3736
3821
  equals: { digest: row2.digest, disposition: row2.disposition, ...row2.reviewId ? { reviewId: row2.reviewId } : {} },
3737
3822
  ...!row2.reviewId ? { absent: ["reviewId"] } : {}
3738
3823
  }] });
3739
- return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event, origin: url.origin }, env, ctx);
3824
+ return reconcileChapterStripeEvent({ rawBody: provider.rawBody, eventId, event: event2, origin: url.origin }, env, ctx);
3740
3825
  } catch {
3741
3826
  return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
3742
3827
  }
@@ -4296,7 +4381,7 @@ function stateOf(item, all, now, window, rivals) {
4296
4381
  if (closed) dates.push(mine.until);
4297
4382
  return { state: "superseded", supersededAt: Math.min(...dates) };
4298
4383
  }
4299
- 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";
4384
+ 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";
4300
4385
  var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
4301
4386
  var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
4302
4387
  function normalize(rows5, collection, parse, invalid2) {
@@ -4408,16 +4493,16 @@ var couponOf = (discount) => {
4408
4493
  async function retermPolicy(db, scope2, id6) {
4409
4494
  const row2 = await membershipLoad(db, "discountPolicyVersion", id6);
4410
4495
  if (!row2) membershipFail("membership_reterm_policy_unknown");
4411
- let policy;
4496
+ let policy2;
4412
4497
  try {
4413
- policy = membershipDiscountPolicyVersion(row2.snapshot);
4498
+ policy2 = membershipDiscountPolicyVersion(row2.snapshot);
4414
4499
  } catch {
4415
4500
  return membershipFail("membership_reterm_policy_ineligible");
4416
4501
  }
4417
- 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");
4418
- return policy;
4502
+ 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");
4503
+ return policy2;
4419
4504
  }
4420
- async function attachProviderDiscount(sk, scope2, member, policy, couponId, productId) {
4505
+ async function attachProviderDiscount(sk, scope2, member, policy2, couponId, productId) {
4421
4506
  const subId = String(member.providerSubscriptionId), path = `/v1/subscriptions/${encodeURIComponent(subId)}`;
4422
4507
  const read2 = await stripeRequest6({ secretKey: sk }, "GET", path, { "expand[0]": "discounts" });
4423
4508
  const sub = read2.body, items = sub?.items?.data;
@@ -4433,8 +4518,8 @@ async function attachProviderDiscount(sk, scope2, member, policy, couponId, prod
4433
4518
  const written = await stripeRequest6({ secretKey: sk }, "POST", path, {
4434
4519
  "discounts[0][coupon]": couponId,
4435
4520
  "expand[0]": "discounts",
4436
- "metadata[discountPolicyVersionId]": policy.id
4437
- }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy.id}` });
4521
+ "metadata[discountPolicyVersionId]": policy2.id
4522
+ }, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy2.id}` });
4438
4523
  const after = written.body?.discounts;
4439
4524
  if (!written.ok || written.body?.id !== subId || !Array.isArray(after) || after.length !== 1 || couponOf(after[0]) !== couponId) {
4440
4525
  membershipFail("membership_reterm_provider_unconfirmed");
@@ -4448,8 +4533,8 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4448
4533
  if (typeof input.policyVersionId !== "string" || !SAFE.test(input.policyVersionId)) throw new TypeError("invalid policy version id");
4449
4534
  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");
4450
4535
  const state = await loadMembershipAuthorityState(db, scope2);
4451
- const policy = await retermPolicy(db, scope2, input.policyVersionId);
4452
- const receipt = { policyVersionId: policy.id, retermed: [], unchanged: [], skipped: [], more: false };
4536
+ const policy2 = await retermPolicy(db, scope2, input.policyVersionId);
4537
+ const receipt = { policyVersionId: policy2.id, retermed: [], unchanged: [], skipped: [], more: false };
4453
4538
  const skip = (membershipId, reason) => {
4454
4539
  receipt.skipped.push({ membershipId, reason });
4455
4540
  };
@@ -4470,7 +4555,7 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4470
4555
  }
4471
4556
  for (const member of rows5) {
4472
4557
  const id6 = String(member.id);
4473
- if (member.discountPolicyVersionId === policy.id) {
4558
+ if (member.discountPolicyVersionId === policy2.id) {
4474
4559
  receipt.unchanged.push(id6);
4475
4560
  continue;
4476
4561
  }
@@ -4482,22 +4567,22 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4482
4567
  skip(id6, "not_founding");
4483
4568
  continue;
4484
4569
  }
4485
- if (member.cohort !== policy.cohortLabel) {
4570
+ if (member.cohort !== policy2.cohortLabel) {
4486
4571
  skip(id6, "cohort_mismatch");
4487
4572
  continue;
4488
4573
  }
4489
4574
  const skuId = String(member.baseSkuId);
4490
- if (!policy.discountEligibleSkuIds.includes(skuId)) {
4575
+ if (!policy2.discountEligibleSkuIds.includes(skuId)) {
4491
4576
  skip(id6, "sku_not_discounted");
4492
4577
  continue;
4493
4578
  }
4494
4579
  const base = Number(member.baseAmountCents), current = Number(member.renewalAmountCents);
4495
- const renewal = Math.round(base * (1e4 - policy.percentOffBps) / 1e4);
4580
+ const renewal = Math.round(base * (1e4 - policy2.percentOffBps) / 1e4);
4496
4581
  if (!Number.isSafeInteger(base) || base <= 0 || !Number.isSafeInteger(current) || renewal > current) {
4497
4582
  skip(id6, "would_not_widen");
4498
4583
  continue;
4499
4584
  }
4500
- const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy.id);
4585
+ const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy2.id);
4501
4586
  let providerUpdated;
4502
4587
  try {
4503
4588
  const productId = await verifySubscriptionPrice(stripeSecretKey, {
@@ -4515,10 +4600,10 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4515
4600
  discountProductId: productId,
4516
4601
  billingTerms: {
4517
4602
  offeringVersionId: String(member.offeringVersionId),
4518
- discount: { policyVersionId: policy.id, couponId, percentOffBps: policy.percentOffBps, duration: "forever" }
4603
+ discount: { policyVersionId: policy2.id, couponId, percentOffBps: policy2.percentOffBps, duration: "forever" }
4519
4604
  }
4520
4605
  }, false);
4521
- providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy, couponId, productId);
4606
+ providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy2, couponId, productId);
4522
4607
  } catch (error) {
4523
4608
  const code = error.code;
4524
4609
  if (code === "provider_mismatch" || code === "stripe_contract_mismatch") {
@@ -4529,17 +4614,17 @@ async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now)
4529
4614
  }
4530
4615
  try {
4531
4616
  await db.transact([membershipUpdate("networkMembership", id6, {
4532
- discountPolicyVersionId: policy.id,
4533
- discountSnapshot: policy,
4617
+ discountPolicyVersionId: policy2.id,
4618
+ discountSnapshot: policy2,
4534
4619
  renewalAmountCents: renewal,
4535
4620
  revision: Number(member.revision) + 1,
4536
4621
  updatedAt: now
4537
4622
  })], {
4538
- mutationId: `membership-reterm:${id6}:${policy.id}:${member.revision}`,
4623
+ mutationId: `membership-reterm:${id6}:${policy2.id}:${member.revision}`,
4539
4624
  guards: [
4540
4625
  { ns: "networkMembership", id: id6, exists: true, equals: { revision: member.revision, status: member.status } },
4541
4626
  { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } },
4542
- { ns: "discountPolicyVersion", id: policy.id, exists: true }
4627
+ { ns: "discountPolicyVersion", id: policy2.id, exists: true }
4543
4628
  ]
4544
4629
  });
4545
4630
  } catch (error) {
@@ -4785,18 +4870,18 @@ function memberApplication(app, meeting, defaultTimezone) {
4785
4870
 
4786
4871
  // src/member-session-application.ts
4787
4872
  var local = (row2, chapterId, runtime) => row2.groupId === chapterId && (runtime ? row2.stripeRuntime === runtime : row2.stripeRuntime === void 0 || row2.stripeRuntime === "default");
4788
- async function memberSessionApplication(db, chapterId, runtime, identity) {
4873
+ async function memberSessionApplication(db, chapterId, runtime, identity2) {
4789
4874
  const lookup = async (where) => {
4790
4875
  const rows5 = (await db.query({ applications: { $: { where: { groupId: chapterId, ...where }, limit: 100 } } })).applications;
4791
4876
  if (!Array.isArray(rows5) || rows5.length >= 100) throw new Error("member identity lookup incomplete");
4792
4877
  return rows5.filter((row2) => local(row2, chapterId, runtime));
4793
4878
  };
4794
- let apps = await lookup({ clerkUserId: identity.userId });
4879
+ let apps = await lookup({ clerkUserId: identity2.userId });
4795
4880
  let emailFallback = false;
4796
- if (!apps.length && identity.email) {
4797
- const normalized = normalizeChapterEmail(identity.email);
4881
+ if (!apps.length && identity2.email) {
4882
+ const normalized = normalizeChapterEmail(identity2.email);
4798
4883
  const normalizedRows = await lookup({ emailNormalized: normalized });
4799
- const legacyRows = await lookup({ email: identity.email });
4884
+ const legacyRows = await lookup({ email: identity2.email });
4800
4885
  apps = [...new Map([...normalizedRows, ...legacyRows].map((row2) => [row2.id, row2])).values()];
4801
4886
  if (apps.length !== 1 || apps[0]?.clerkUserId !== void 0 || apps[0]?.clerkAccountPending === true || ["declined", "canceled", "refunded"].includes(String(apps[0]?.status))) return null;
4802
4887
  emailFallback = true;
@@ -4806,8 +4891,8 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4806
4891
  if (emailFallback) {
4807
4892
  const keys3 = ["groupId", "stripeRuntime", "email", "emailNormalized", "status", "clerkAccountPending"];
4808
4893
  try {
4809
- await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity.userId } }], {
4810
- mutationId: `member-account-link:${app.id}:${identity.userId}`,
4894
+ await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity2.userId } }], {
4895
+ mutationId: `member-account-link:${app.id}:${identity2.userId}`,
4811
4896
  guards: [{
4812
4897
  ns: "applications",
4813
4898
  id: String(app.id),
@@ -4820,7 +4905,7 @@ async function memberSessionApplication(db, chapterId, runtime, identity) {
4820
4905
  if (error.code !== "transact_guard_failed") throw error;
4821
4906
  }
4822
4907
  const saved = (await db.query({ applications: { $: { where: { id: app.id }, limit: 1 } } })).applications?.[0];
4823
- if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4908
+ if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity2.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
4824
4909
  app = saved;
4825
4910
  }
4826
4911
  const meeting = (await db.query({ meetings: { $: {
@@ -5760,7 +5845,7 @@ function foundingOf(offer) {
5760
5845
  return offer?.contract?.founding === true && offer.contract.discountPolicy ? { cohortLabel: offer.contract.discountPolicy.cohortLabel } : void 0;
5761
5846
  }
5762
5847
  async function createPaymentQuote(input) {
5763
- const { group, tier, policy } = input;
5848
+ const { group, tier, policy: policy2 } = input;
5764
5849
  const terms2 = input.billingTerms ? subscriptionBillingTerms(input.billingTerms) : void 0;
5765
5850
  const compareAt = input.presentation?.compareAtPriceCents;
5766
5851
  const presentation = Number.isSafeInteger(compareAt) && Number(compareAt) > tier.priceCents ? { compareAtPriceCents: Number(compareAt) } : void 0;
@@ -5783,9 +5868,9 @@ async function createPaymentQuote(input) {
5783
5868
  dueTodayCents: lines.dueTodayCents
5784
5869
  } : lines,
5785
5870
  ...presentation ? { presentation } : {},
5786
- refundPolicyText: policy?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5871
+ refundPolicyText: policy2?.refundPolicyText ?? String(group.refundPolicyText ?? ""),
5787
5872
  merchantDisclosureText: String(group.merchantDisclosureText ?? ""),
5788
- trustCopy: policy ? policy.trustCopy ?? "" : String(group.trustCopy ?? ""),
5873
+ trustCopy: policy2 ? policy2.trustCopy ?? "" : String(group.trustCopy ?? ""),
5789
5874
  ...terms2 ? { commercial: {
5790
5875
  offeringVersionId: terms2.offeringVersionId,
5791
5876
  ...terms2.discount ? {
@@ -5857,13 +5942,13 @@ async function prepareMembershipQuote(db, input, issue) {
5857
5942
  if (error.code !== "membership_quote_expired") throw error;
5858
5943
  }
5859
5944
  }
5860
- const identity = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5945
+ const identity2 = ["groupId", "tierId", "stripeRuntime", "email", "emailNormalized", "status", "canceled", "membershipRestartId", "namedSeatId", "namedSeatClaimPending", "giftClaimId", "giftOfferId"];
5861
5946
  const guards = [{
5862
5947
  ns: "applications",
5863
5948
  id: id6,
5864
5949
  exists: true,
5865
- equals: Object.fromEntries(identity.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
5866
- absent: identity.filter((key3) => app[key3] === void 0)
5950
+ equals: Object.fromEntries(identity2.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
5951
+ absent: identity2.filter((key3) => app[key3] === void 0)
5867
5952
  }, { ns: "subscriptionCheckoutIntents", id: id6, exists: false }];
5868
5953
  if (!row2 || row2.status === "ready" || row2.status === "restart") {
5869
5954
  const revision = Number(row2?.revision ?? 0) + 1;
@@ -5999,11 +6084,11 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
5999
6084
  const tier = findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null);
6000
6085
  if (!tier || tierIsFree(tier) || !tier.stripePriceId) throw Object.assign(new Error("tier unavailable"), { code: "tier_unresolved" });
6001
6086
  const control3 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
6002
- const policy = control3?.document.policy?.tierPolicies?.[tier.id];
6087
+ const policy2 = control3?.document.policy?.tierPolicies?.[tier.id];
6003
6088
  const presentation = tierPresentationFor(control3?.document, tier.id);
6004
6089
  if (ctx.membershipAuthority) return prepareMembershipQuote(
6005
6090
  db,
6006
- { ...target, app, group, tier, policy, presentation, now: Date.now() },
6091
+ { ...target, app, group, tier, policy: policy2, presentation, now: Date.now() },
6007
6092
  (input) => ctx.membershipAuthority.issueOffer(input, env)
6008
6093
  );
6009
6094
  const quote = await createPaymentQuote({
@@ -6014,7 +6099,7 @@ async function checkoutQuoteForApplication(db, app, group, env, ctx, prepare = f
6014
6099
  runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
6015
6100
  group,
6016
6101
  tier,
6017
- policy,
6102
+ policy: policy2,
6018
6103
  presentation
6019
6104
  });
6020
6105
  return { tier, quote, billingTerms: void 0 };
@@ -7161,15 +7246,15 @@ function policyOf(value, errors, tiers) {
7161
7246
  if (!overrides || Object.keys(overrides).length > 20) errors.push("document.policy.tierPolicies must be a bounded tier map");
7162
7247
  else for (const [id6, raw2] of Object.entries(overrides)) {
7163
7248
  const path = `document.policy.tierPolicies.${id6}`;
7164
- const policy = recordOf(raw2);
7165
- if (!tiers.some((tier) => tier.id === id6) || !policy) {
7249
+ const policy2 = recordOf(raw2);
7250
+ if (!tiers.some((tier) => tier.id === id6) || !policy2) {
7166
7251
  errors.push(`${path} must name a declared tier and policy`);
7167
7252
  continue;
7168
7253
  }
7169
- unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7254
+ unknownKeys(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
7170
7255
  tierPolicies[id6] = {
7171
- refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
7172
- ...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
7256
+ refundPolicyText: text4(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
7257
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text4(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
7173
7258
  };
7174
7259
  }
7175
7260
  }
@@ -7224,7 +7309,7 @@ function validateSignupControlDocument(input) {
7224
7309
  errors.push("document.membershipPage.tiers must follow the available tier display order");
7225
7310
  }
7226
7311
  const application = applicationOf(root.application, errors);
7227
- const policy = policyOf(root.policy, errors, tiers);
7312
+ const policy2 = policyOf(root.policy, errors, tiers);
7228
7313
  let scheduling;
7229
7314
  if (root.scheduling !== void 0) {
7230
7315
  const checked = validateScheduling(root.scheduling);
@@ -7244,7 +7329,7 @@ function validateSignupControlDocument(input) {
7244
7329
  tiers,
7245
7330
  membershipPage: page.value,
7246
7331
  ...application ? { application } : {},
7247
- ...policy ? { policy } : {},
7332
+ ...policy2 ? { policy: policy2 } : {},
7248
7333
  ...scheduling ? { scheduling } : {},
7249
7334
  ...theme ? { theme } : {},
7250
7335
  ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
@@ -8988,8 +9073,8 @@ function createStripeProvider(config) {
8988
9073
  if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
8989
9074
  return { ok: false, reason: "bad_signature" };
8990
9075
  }
8991
- const event = JSON.parse(rawBody);
8992
- return { ok: true, eventId: event.id, event: normalizeWebhookEvent(event, { requireProviderPayment: config.requireProviderPayment }) };
9076
+ const event2 = JSON.parse(rawBody);
9077
+ return { ok: true, eventId: event2.id, event: normalizeWebhookEvent(event2, { requireProviderPayment: config.requireProviderPayment }) };
8993
9078
  },
8994
9079
  async refund(input) {
8995
9080
  return refundSubscriptionPayment(requireSecret(secretKey), input);
@@ -9103,7 +9188,7 @@ async function startGiftIntent(db, input) {
9103
9188
  throw new Error("gift purchase intent does not match this checkout");
9104
9189
  }
9105
9190
  if (stored.status === "paid") throw new Error("gift purchase is already paid");
9106
- const checkout = await startGiftProviderCheckout(
9191
+ const checkout2 = await startGiftProviderCheckout(
9107
9192
  input.stripeSecret,
9108
9193
  stored,
9109
9194
  input.purchaserEmail,
@@ -9113,15 +9198,15 @@ async function startGiftIntent(db, input) {
9113
9198
  );
9114
9199
  await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
9115
9200
  status: "checkout_ready",
9116
- providerObjectId: checkout.providerObjectId,
9117
- ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
9201
+ providerObjectId: checkout2.providerObjectId,
9202
+ ...checkout2.customerId ? { providerCustomerId: checkout2.customerId } : {},
9118
9203
  checkoutStartedAt: input.now,
9119
9204
  updatedAt: input.now
9120
- } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
9205
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout2.providerObjectId}` });
9121
9206
  return {
9122
9207
  gift: true,
9123
9208
  intentId: input.id,
9124
- clientSecret: checkout.clientSecret,
9209
+ clientSecret: checkout2.clientSecret,
9125
9210
  publishableKey: input.publishableKey,
9126
9211
  lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
9127
9212
  ...giftCode ? { giftCode } : {}
@@ -9379,15 +9464,15 @@ async function startSubscription(req, env, ctx) {
9379
9464
  return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
9380
9465
  }
9381
9466
  }
9382
- let checkout;
9467
+ let checkout2;
9383
9468
  try {
9384
- checkout = await checkoutQuoteForApplication(db, app, group, env, ctx);
9469
+ checkout2 = await checkoutQuoteForApplication(db, app, group, env, ctx);
9385
9470
  } catch (error) {
9386
9471
  const expired = error.code === "membership_quote_expired";
9387
9472
  return json({ error: "payment quote unavailable", ...expired ? { code: "payment_quote_changed" } : {} }, expired ? 409 : 503);
9388
9473
  }
9389
- const { tier, quote, billingTerms } = checkout;
9390
- const membershipOffer = "membershipOffer" in checkout ? checkout.membershipOffer : void 0;
9474
+ const { tier, quote, billingTerms } = checkout2;
9475
+ const membershipOffer = "membershipOffer" in checkout2 ? checkout2.membershipOffer : void 0;
9391
9476
  if (ctx.membershipAuthority && !membershipOffer) return json({ error: "checkout requires reconciliation", code: "checkout_recovery_required" }, 409);
9392
9477
  if (!tier) return json({ error: "tier not found", code: "tier_unresolved" }, 400);
9393
9478
  if (tierIsFree(tier)) return json({ error: "tier is free", code: "tier_not_payable" }, 409);
@@ -9809,13 +9894,6 @@ var handleNamedSeat = async (req, url, env, ctx) => {
9809
9894
  }
9810
9895
  };
9811
9896
 
9812
- // src/named-seat-accepted-template.ts
9813
- var SEAT_ACCEPTED_TEMPLATE = {
9814
- enabled: true,
9815
- subject: "{{recipientName}} accepted your {{chapterName}} seat",
9816
- 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}}"
9817
- };
9818
-
9819
9897
  // src/named-seat-accepted-notice.ts
9820
9898
  var text5 = (value) => typeof value === "string" ? value : "";
9821
9899
  async function notifyNamedSeatAccepted(db, env, ctx, input) {
@@ -9826,7 +9904,6 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9826
9904
  const group = await membershipLoad(db, "groups", ctx.chapter.id);
9827
9905
  if (!group) return { sent: false, reason: "group-missing" };
9828
9906
  const config = emailGroupFrom(group);
9829
- config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
9830
9907
  const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
9831
9908
  return sendTemplated({
9832
9909
  db,
@@ -9842,6 +9919,7 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9842
9919
  to: text5(purchaser.email),
9843
9920
  dedupeKey: `named-seat-accepted:${input.seatId}`,
9844
9921
  applicationId: input.primaryApplicationId,
9922
+ tierId: input.skuId,
9845
9923
  vars: {
9846
9924
  firstName: text5(purchaser.firstName),
9847
9925
  purchaserName,
@@ -9858,7 +9936,7 @@ async function tierName(db, group, skuId) {
9858
9936
  if (tier) return tier.name;
9859
9937
  } catch {
9860
9938
  }
9861
- return skuId;
9939
+ return "";
9862
9940
  }
9863
9941
 
9864
9942
  // src/worker-routes-named-seat-recipient.ts
@@ -11073,7 +11151,122 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
11073
11151
  return patchApplication(db, env, ctx, app, body);
11074
11152
  };
11075
11153
 
11154
+ // src/email-admin-config.ts
11155
+ import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "@odla-ai/crm";
11156
+
11157
+ // src/email-readiness.ts
11158
+ import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
11159
+ function validateEmailReadiness(input) {
11160
+ const issues = [];
11161
+ for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
11162
+ issues.push({ code: "event-undeclared", message: `Stored email ${key3} has no declared event. Review its trigger before using it.` });
11163
+ }
11164
+ if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
11165
+ for (const tier of input.tiers ?? []) {
11166
+ if (!isEmailTierName2(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
11167
+ }
11168
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => isEmailTierName2(tier.name, tier.id));
11169
+ if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
11170
+ const events = Object.entries(input.events).map(([key3, event2]) => {
11171
+ const template = input.templates[key3];
11172
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy3(template, { allowedVariables: event2.variables });
11173
+ if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
11174
+ if (template) {
11175
+ const used = [...`${template.subject ?? ""}
11176
+ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
11177
+ for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
11178
+ problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
11179
+ }
11180
+ }
11181
+ if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
11182
+ else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
11183
+ return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
11184
+ });
11185
+ return { checkedAt: input.now ?? Date.now(), events, issues };
11186
+ }
11187
+
11188
+ // src/email-admin-config.ts
11189
+ var str2 = (value) => typeof value === "string" ? value : "";
11190
+ async function emailSettings(db, ctx, env) {
11191
+ const hub = ctx.chapter.mode === "hub";
11192
+ const crmDeps3 = { db, crm: ctx.chapter.crm };
11193
+ const group = hub ? await getConfig(crmDeps3) : (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
11194
+ const raw2 = group?.emailTemplates;
11195
+ const stored = raw2 && typeof raw2 === "object" && !Array.isArray(raw2) ? raw2 : {};
11196
+ const events = chapterEmailEvents(ctx.chapter);
11197
+ const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
11198
+ subject: str2(stored[key3]?.subject),
11199
+ text: str2(stored[key3]?.text),
11200
+ enabled: stored[key3]?.enabled
11201
+ }]));
11202
+ let tiers = [], tiersUnavailable = false;
11203
+ if (!hub) {
11204
+ try {
11205
+ tiers = await loadTiers(db, group ?? { id: ctx.chapter.id });
11206
+ } catch {
11207
+ tiersUnavailable = true;
11208
+ }
11209
+ }
11210
+ const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
11211
+ const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
11212
+ return { group, events, data: {
11213
+ groupId: ctx.chapter.id,
11214
+ name: ctx.chapter.name,
11215
+ emailTemplates,
11216
+ validation,
11217
+ replyTo: str2(group?.replyTo),
11218
+ fromName: str2(group?.fromName),
11219
+ notificationEmail: str2(group?.notificationEmail),
11220
+ debugEmail: str2(group?.debugEmail),
11221
+ ...groupValues,
11222
+ envName: env.ODLA_ENV,
11223
+ testAvailable: !hub,
11224
+ transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11225
+ fromEmail: env.EMAIL_FROM ?? null
11226
+ } };
11227
+ }
11228
+ async function updateEmailSettings(db, ctx, env, body) {
11229
+ const { group, events } = await emailSettings(db, ctx, env);
11230
+ if (!group && ctx.chapter.mode !== "hub") return json({ error: "Email settings have not been configured for this chapter." }, 409);
11231
+ const templates = body.emailTemplates;
11232
+ if (!templates || typeof templates !== "object" || Array.isArray(templates)) return json({ error: "emailTemplates object required" }, 400);
11233
+ const clean = {};
11234
+ for (const [key3, value] of Object.entries(templates)) {
11235
+ if (!events[key3]) continue;
11236
+ if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
11237
+ const t = value;
11238
+ if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
11239
+ const subject = str2(t.subject).trim(), text8 = str2(t.text);
11240
+ const issues = validateEmailCopy4({ subject, text: text8 }, { allowedVariables: events[key3].variables });
11241
+ if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
11242
+ return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
11243
+ }
11244
+ clean[key3] = { subject, text: text8, enabled: t.enabled };
11245
+ }
11246
+ const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
11247
+ const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
11248
+ if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11249
+ if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11250
+ if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11251
+ const fromName = str2(body.fromName).trim();
11252
+ 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);
11253
+ const commitmentText = str2(body.commitmentText), normsText = str2(body.normsText);
11254
+ if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11255
+ if (ctx.chapter.mode === "hub") {
11256
+ const missing = Object.fromEntries(Object.keys(events).filter((key3) => !group?.emailTemplates?.[key3]).map((key3) => [key3, { subject: "", text: "", enabled: false }]));
11257
+ await saveConfig({ db, crm: ctx.chapter.crm }, { emailTemplates: { ...missing, ...clean }, notificationEmail, replyTo, debugEmail });
11258
+ } else {
11259
+ const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11260
+ await db.transact([
11261
+ { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11262
+ { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11263
+ ]);
11264
+ }
11265
+ return json({ ok: true });
11266
+ }
11267
+
11076
11268
  // src/worker-routes-admin-comms.ts
11269
+ import { listEmailLog } from "@odla-ai/crm";
11077
11270
  async function gate5(req, env, ctx) {
11078
11271
  const rawDb = ctx.makeDb(env);
11079
11272
  const u = await ctx.verifyUser(req, env);
@@ -11085,39 +11278,13 @@ async function loadGroup2(db, id6) {
11085
11278
  const { groups } = await db.query({ groups: { $: { where: { id: id6 }, limit: 1 } } });
11086
11279
  return groups?.[0] ?? null;
11087
11280
  }
11088
- var str2 = (v, d = "") => typeof v === "string" ? v : d;
11281
+ var str3 = (v, d = "") => typeof v === "string" ? v : d;
11089
11282
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
11090
11283
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
11091
11284
  const got = await gate5(req, env, ctx);
11092
11285
  if (got instanceof Response) return got;
11093
11286
  const db = got;
11094
- const group = await loadGroup2(db, ctx.chapter.id);
11095
- if (!group) return json({ error: "not found" }, 404);
11096
- if (req.method === "GET") {
11097
- const stored = group.emailTemplates ?? {};
11098
- const emailTemplates = {};
11099
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11100
- const t = stored[key3];
11101
- if (t) emailTemplates[key3] = { subject: str2(t.subject), text: str2(t.text), enabled: t.enabled !== false };
11102
- }
11103
- return json({
11104
- groupId: group.id,
11105
- name: group.name,
11106
- replyTo: str2(group.replyTo),
11107
- fromName: str2(group.fromName),
11108
- notificationEmail: str2(group.notificationEmail),
11109
- debugEmail: str2(group.debugEmail),
11110
- emailTemplates,
11111
- commitmentText: str2(group.commitmentText),
11112
- normsText: str2(group.normsText),
11113
- refundPolicyText: str2(group.refundPolicyText),
11114
- // Read-only delivery wiring — surfaced so "why did this not send?" is
11115
- // answerable without logs.
11116
- envName: env.ODLA_ENV,
11117
- transport: env.SEND_EMAIL && env.EMAIL_FROM ? "cloudflare" : "log-only",
11118
- fromEmail: env.EMAIL_FROM ?? null
11119
- });
11120
- }
11287
+ if (req.method === "GET") return json((await emailSettings(db, ctx, env)).data);
11121
11288
  let body;
11122
11289
  try {
11123
11290
  body = await req.json();
@@ -11125,41 +11292,16 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
11125
11292
  return json({ error: "invalid JSON body" }, 400);
11126
11293
  }
11127
11294
  if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "object body required" }, 400);
11128
- const templates = body.emailTemplates;
11129
- if (!templates || typeof templates !== "object") return json({ error: "emailTemplates object required" }, 400);
11130
- const clean = {};
11131
- for (const key3 of EMAIL_TEMPLATE_NAMES) {
11132
- const t = templates[key3];
11133
- const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
11134
- const text8 = typeof t?.text === "string" ? t.text : "";
11135
- if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
11136
- if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
11137
- if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
11138
- clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
11139
- }
11140
- const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11141
- const notificationEmail = str2(body.notificationEmail).trim();
11142
- const replyTo = str2(body.replyTo).trim();
11143
- const debugEmail = str2(body.debugEmail).trim();
11144
- if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
11145
- if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
11146
- if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
11147
- const fromName = str2(body.fromName).trim();
11148
- 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);
11149
- const commitmentText = str2(body.commitmentText);
11150
- const normsText = str2(body.normsText);
11151
- if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
11152
- const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
11153
- await db.transact([
11154
- { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
11155
- { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
11156
- ]);
11157
- return json({ ok: true });
11295
+ return updateEmailSettings(db, ctx, env, body);
11158
11296
  };
11159
11297
  var handleAdminEmailLog = async (req, url, env, ctx) => {
11160
11298
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
11161
11299
  const got = await gate5(req, env, ctx);
11162
11300
  if (got instanceof Response) return got;
11301
+ if (ctx.chapter.mode === "hub") {
11302
+ const rows5 = await listEmailLog({ db: got, crm: ctx.chapter.crm }, { limit: 50 });
11303
+ return json({ sends: rows5.map((row2) => ({ ...row2, template: row2.templateId })) });
11304
+ }
11163
11305
  const { emailLog } = await got.query({ emailLog: { $: { where: { groupId: ctx.chapter.id }, order: { sentAt: "desc" }, limit: 50 } } });
11164
11306
  const visible = [];
11165
11307
  for (const row2 of emailLog ?? []) {
@@ -11195,21 +11337,22 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
11195
11337
  } catch {
11196
11338
  return json({ error: "invalid JSON body" }, 400);
11197
11339
  }
11198
- const template = str2(body.template);
11199
- if (!EMAIL_TEMPLATE_NAMES.includes(template)) {
11200
- return json({ error: `template must be one of: ${EMAIL_TEMPLATE_NAMES.join(", ")}` }, 400);
11201
- }
11340
+ if (ctx.chapter.mode === "hub") return json({ error: "Send CRM mail from a selected contact." }, 400);
11341
+ const template = str3(body.template);
11342
+ const events = chapterEmailEvents(ctx.chapter);
11343
+ if (!events[template]) return json({ error: "Unknown email event." }, 400);
11202
11344
  const group = await loadGroup2(db, ctx.chapter.id);
11203
11345
  if (!group) return json({ error: "not found" }, 404);
11204
11346
  const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
11205
11347
  group: emailGroupFrom(group),
11206
11348
  template,
11207
- to: str2(group.notificationEmail),
11349
+ to: str3(group.notificationEmail),
11208
11350
  vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
11209
11351
  dedupeKey: `test:${template}:${Date.now()}`,
11210
- force: true
11352
+ force: true,
11353
+ event: events[template]
11211
11354
  });
11212
- return json({ ok: res.sent, reason: res.reason ?? null, to: str2(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11355
+ return json({ ok: res.sent, reason: res.reason ?? null, to: str3(group.notificationEmail), redirected: env.ODLA_ENV !== "prod" && !!group.debugEmail });
11213
11356
  };
11214
11357
  var handleAdminComms = async (req, url, env, ctx) => {
11215
11358
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
@@ -11231,13 +11374,13 @@ var handleAdminComms = async (req, url, env, ctx) => {
11231
11374
  });
11232
11375
  const app = data.applications?.[0] ?? null;
11233
11376
  if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
11234
- const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
11235
- 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;
11377
+ const group = await loadGroup2(db, str3(app?.groupId, ctx.chapter.id));
11378
+ 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;
11236
11379
  const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
11237
11380
  let mailBody = typeof r.body === "string" ? r.body : null;
11238
11381
  if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
11239
11382
  const channel = r.error ? "email (failed)" : r.redirected ? "email (dev-redirected)" : r.transport === "log-only" ? "email (not delivered)" : "email";
11240
- 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 };
11383
+ 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 };
11241
11384
  });
11242
11385
  const calendar = (data.meetings ?? []).map((m2) => ({
11243
11386
  kind: "calendar",
@@ -12239,13 +12382,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12239
12382
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
12240
12383
  const got = await gate8(req, env, ctx);
12241
12384
  if ("response" in got) return got.response;
12242
- const identity = noteIdentity(url, ctx);
12243
- if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12385
+ const identity2 = noteIdentity(url, ctx);
12386
+ if (!identity2) return json({ error: "valid targetId, type, and recordId are required" }, 400);
12244
12387
  if (req.method === "GET") {
12245
12388
  const result = await got.db.query({
12246
12389
  networkNotes: {
12247
12390
  $: {
12248
- where: { recordKey: identity.recordKey },
12391
+ where: { recordKey: identity2.recordKey },
12249
12392
  order: { createdAt: "desc" },
12250
12393
  limit: 100
12251
12394
  }
@@ -12266,9 +12409,9 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12266
12409
  const id6 = crypto.randomUUID();
12267
12410
  const note = {
12268
12411
  id: id6,
12269
- targetId: identity.targetId,
12270
- recordType: identity.recordType,
12271
- recordId: identity.recordId,
12412
+ targetId: identity2.targetId,
12413
+ recordType: identity2.recordType,
12414
+ recordId: identity2.recordId,
12272
12415
  body,
12273
12416
  authorId: got.user.userId,
12274
12417
  ...got.user.email ? { authorEmail: got.user.email } : {},
@@ -12278,7 +12421,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
12278
12421
  t: "update",
12279
12422
  ns: "networkNotes",
12280
12423
  id: id6,
12281
- attrs: { ...note, recordKey: identity.recordKey }
12424
+ attrs: { ...note, recordKey: identity2.recordKey }
12282
12425
  }]);
12283
12426
  return json({ note }, 201);
12284
12427
  };
@@ -12544,8 +12687,8 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
12544
12687
  }
12545
12688
  }
12546
12689
  const seen = /* @__PURE__ */ new Set();
12547
- for (const identity of identities) {
12548
- const key3 = `${identity.kind}:${identity.value}`;
12690
+ for (const identity2 of identities) {
12691
+ const key3 = `${identity2.kind}:${identity2.value}`;
12549
12692
  if (seen.has(key3)) errors.push("duplicate identity in import");
12550
12693
  seen.add(key3);
12551
12694
  }
@@ -12601,8 +12744,8 @@ async function networkChapterLinkId(personId, chapterId, chapterPersonId) {
12601
12744
  async function networkChapterPersonSourceKey(chapterId, chapterPersonId) {
12602
12745
  return `chapter_source_${await sha256(`${chapterId}\0${chapterPersonId}`)}`;
12603
12746
  }
12604
- function projectNetworkContactPolicy(policy, links) {
12605
- return links.map((link) => ({ ...link, contactPolicy: { ...policy } }));
12747
+ function projectNetworkContactPolicy(policy2, links) {
12748
+ return links.map((link) => ({ ...link, contactPolicy: { ...policy2 } }));
12606
12749
  }
12607
12750
  function canonicalNetworkPersonId(personId, persons) {
12608
12751
  const byId = new Map(persons.map((person) => [person.id, person]));
@@ -12908,11 +13051,11 @@ async function suppressNetworkPerson(db, input, mutationId, actor, now = Date.no
12908
13051
  if (!person || person.state !== "active" || canonicalNetworkPersonId(person.id, state.persons) !== person.id || person.revision !== input.expectedRevision) {
12909
13052
  throw new NetworkPersonRegistryError("stale_suppression", "active canonical Person changed; reload before updating suppression");
12910
13053
  }
12911
- const policy = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
12912
- const next = { ...person, contactPolicy: policy, revision: person.revision + 1, updatedAt: now };
13054
+ const policy2 = { marketingSuppressed: input.marketingSuppressed, discretionarySuppressed: input.discretionarySuppressed, ...input.reason ? { reason: input.reason } : {}, updatedAt: now };
13055
+ const next = { ...person, contactPolicy: policy2, revision: person.revision + 1, updatedAt: now };
12913
13056
  await db.transact([
12914
13057
  { t: "update", ns: "networkPersons", id: person.id, attrs: { ...next } },
12915
- networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy.marketingSuppressed, discretionarySuppressed: policy.discretionarySuppressed }, { personId: person.id })
13058
+ networkPersonEventOp("contact_suppression_changed", mutationId, actor, now, { marketingSuppressed: policy2.marketingSuppressed, discretionarySuppressed: policy2.discretionarySuppressed }, { personId: person.id })
12916
13059
  ], { mutationId, guards: [{ ns: "networkPersons", id: person.id, exists: true, equals: { revision: person.revision } }] });
12917
13060
  return next;
12918
13061
  }
@@ -13037,13 +13180,13 @@ async function derivedMutationId(batchMutationId, part) {
13037
13180
  return `bulk_${await networkPersonBulkSha256(`${batchMutationId}\0${part}`)}`;
13038
13181
  }
13039
13182
  function matchingEvent(events, mutationId, kind) {
13040
- return events.find((event) => event.mutationId === mutationId && event.kind === kind);
13183
+ return events.find((event2) => event2.mutationId === mutationId && event2.kind === kind);
13041
13184
  }
13042
- function eventString(event, key3) {
13043
- return typeof event.detail[key3] === "string" ? String(event.detail[key3]) : "";
13185
+ function eventString(event2, key3) {
13186
+ return typeof event2.detail[key3] === "string" ? String(event2.detail[key3]) : "";
13044
13187
  }
13045
- function eventCounts(event) {
13046
- const value = event.detail.counts;
13188
+ function eventCounts(event2) {
13189
+ const value = event2.detail.counts;
13047
13190
  return value && typeof value === "object" && !Array.isArray(value) ? { ...networkPersonBulkEmptyCounts(), ...value } : networkPersonBulkEmptyCounts();
13048
13191
  }
13049
13192
  async function applyNetworkPersonBulkImport(db, input, batchMutationId, actor, allowedChapterIds, now = Date.now()) {
@@ -13170,11 +13313,11 @@ var redactedNetworkPersonLink = async (link) => ({
13170
13313
  state: link.state,
13171
13314
  createdAt: link.createdAt
13172
13315
  });
13173
- var safeNetworkContactPolicy = (policy) => ({
13174
- marketingSuppressed: policy.marketingSuppressed,
13175
- discretionarySuppressed: policy.discretionarySuppressed,
13176
- hasReason: Boolean(policy.reason),
13177
- updatedAt: policy.updatedAt
13316
+ var safeNetworkContactPolicy = (policy2) => ({
13317
+ marketingSuppressed: policy2.marketingSuppressed,
13318
+ discretionarySuppressed: policy2.discretionarySuppressed,
13319
+ hasReason: Boolean(policy2.reason),
13320
+ updatedAt: policy2.updatedAt
13178
13321
  });
13179
13322
  var safeNetworkPersonAlias = async (alias) => ({
13180
13323
  id: alias.id,
@@ -13229,14 +13372,14 @@ async function projectNetworkPersonRegistryState(state) {
13229
13372
  aliases,
13230
13373
  reviewQueue: aliases.filter((alias) => alias.status === "ambiguous"),
13231
13374
  excluded: aliases.filter((alias) => alias.status === "excluded"),
13232
- recentEvents: state.events.slice(0, 100).map((event) => ({
13233
- id: event.id,
13234
- kind: event.kind,
13235
- personId: event.personId ?? null,
13236
- aliasId: event.aliasId ?? null,
13237
- actorId: event.actorId,
13238
- mutationId: event.mutationId,
13239
- createdAt: event.createdAt
13375
+ recentEvents: state.events.slice(0, 100).map((event2) => ({
13376
+ id: event2.id,
13377
+ kind: event2.kind,
13378
+ personId: event2.personId ?? null,
13379
+ aliasId: event2.aliasId ?? null,
13380
+ actorId: event2.actorId,
13381
+ mutationId: event2.mutationId,
13382
+ createdAt: event2.createdAt
13240
13383
  }))
13241
13384
  };
13242
13385
  }
@@ -13251,7 +13394,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13251
13394
  const target = input.kind === "person" ? state.persons.find((row2) => row2.id === input.id) : state.aliases.find((row2) => row2.id === input.id);
13252
13395
  if (!target) throw new NetworkPersonRegistryError("reveal_not_found", "registry record not found");
13253
13396
  const ids2 = input.kind === "person" ? { personId: input.id } : { aliasId: input.id };
13254
- const event = {
13397
+ const event2 = {
13255
13398
  t: "update",
13256
13399
  ns: "networkPersonEvents",
13257
13400
  id: `network_person_event:${mutationId}`,
@@ -13265,7 +13408,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
13265
13408
  createdAt: now
13266
13409
  }
13267
13410
  };
13268
- await db.transact([event], {
13411
+ await db.transact([event2], {
13269
13412
  mutationId,
13270
13413
  guards: [{ ns: "networkPersonEvents", id: `network_person_event:${mutationId}`, exists: false }]
13271
13414
  });
@@ -13849,8 +13992,8 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
13849
13992
  return json({ error: "commercial webhook is not configured" }, 503);
13850
13993
  }
13851
13994
  try {
13852
- const event = await constructStripeEvent(body, signature, secret);
13853
- const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
13995
+ const event2 = await constructStripeEvent(body, signature, secret);
13996
+ const fact = normalizeNetworkCommercialStripeEvent(event2, await stripeEventDigest(body));
13854
13997
  if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
13855
13998
  const result = await ingestNetworkCommercialFact(
13856
13999
  db,
@@ -15449,7 +15592,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15449
15592
  };
15450
15593
  if (action === "seat-accept") return acceptNamedSeatRecipient(db, recipientInput, now);
15451
15594
  if (action === "seat-approve") return approveNamedSeatRecipient(db, recipientInput, now);
15452
- const checkout = {
15595
+ const checkout2 = {
15453
15596
  ...target,
15454
15597
  priceId: peer.seatPriceId,
15455
15598
  recipientName: String(input.recipientName),
@@ -15457,7 +15600,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15457
15600
  purchaserEmail: String(input.purchaserEmail),
15458
15601
  purchaserUserId: String(input.purchaserUserId)
15459
15602
  };
15460
- if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout, now))) return grantIncludedNamedSeat(db, checkout, now);
15603
+ if (action === "seat-checkouts" && namedSeatIncluded(await reserveNamedMembershipSeat(db, checkout2, now))) return grantIncludedNamedSeat(db, checkout2, now);
15461
15604
  const secret = await getVaultSecret(db, stripeSecretName);
15462
15605
  if (!secret) throw new Error("network Stripe provider is unavailable");
15463
15606
  if (action === "seat-decline") {
@@ -15473,7 +15616,7 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
15473
15616
  return { seatId: seat.id, status: "refunded", refundedCents: refunded.refundedCents, subscriptionCanceled: false };
15474
15617
  }
15475
15618
  if (action === "seat-operator-refund") return refundNamedSeatByOperator(db, { ...target, seatId: String(input.seatId) }, secret, now);
15476
- if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout, secret, now);
15619
+ if (action === "seat-checkouts") return prepareNamedMembershipSeatPayment(db, checkout2, secret, now);
15477
15620
  const evidence = { ...target, seatId: String(input.seatId), eventId: String(input.eventId) };
15478
15621
  if (action === "seat-payments") {
15479
15622
  const receipt = await confirmNamedMembershipSeatPayment(db, evidence, now);
@@ -15544,9 +15687,9 @@ function createMembershipAuthorityHandler(options) {
15544
15687
  const identities = /* @__PURE__ */ new Set();
15545
15688
  for (const peer of peers) {
15546
15689
  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");
15547
- const identity = `${peer.appId}:${peer.runtime}`;
15548
- if (identities.has(identity)) throw new TypeError("ambiguous authority peer");
15549
- identities.add(identity);
15690
+ const identity2 = `${peer.appId}:${peer.runtime}`;
15691
+ if (identities.has(identity2)) throw new TypeError("ambiguous authority peer");
15692
+ identities.add(identity2);
15550
15693
  if (peer.seatPriceId !== void 0 && !/^price_[A-Za-z0-9]+$/.test(peer.seatPriceId)) throw new TypeError("invalid seat Price");
15551
15694
  }
15552
15695
  return async (req) => {
@@ -16150,6 +16293,9 @@ async function readBoundedJson(response, signal, maxBytes, label) {
16150
16293
  }
16151
16294
  }
16152
16295
 
16296
+ // src/membership-chapter-delivery.ts
16297
+ import { validateEmailCopy as validateEmailCopy5 } from "@odla-ai/crm";
16298
+
16153
16299
  // src/invitation-test-delivery.ts
16154
16300
  function invitationDelivery(env, recipientEmail) {
16155
16301
  const normal = { sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL };
@@ -16212,16 +16358,13 @@ function createMembershipChapterEffectRoute(options) {
16212
16358
  const join = new URL(applicationPath, origin);
16213
16359
  join.searchParams.set("seat", effect.seatId);
16214
16360
  join.searchParams.set("tier", effect.skuId);
16215
- config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: config.emailTemplates?.namedSeatInvitation ?? {
16216
- enabled: true,
16217
- subject: "{{purchaserName}} invited you to {{chapterName}}",
16218
- 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."
16219
- } };
16220
16361
  const template = config.emailTemplates.namedSeatInvitation;
16221
- if (!template.text.includes("{{purchaserName}}")) config.emailTemplates.namedSeatInvitation = {
16222
- ...template,
16223
- text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16224
- };
16362
+ if (template && !validateEmailCopy5(template).length && !template.text.includes("{{purchaserName}}")) {
16363
+ config.emailTemplates = { ...config.emailTemplates, namedSeatInvitation: {
16364
+ ...template,
16365
+ text: "{{purchaserName}} has purchased a membership for you at {{chapterName}}.\n\n" + template.text
16366
+ } };
16367
+ }
16225
16368
  stage = "invitation_delivery";
16226
16369
  const result = await sendTemplated({
16227
16370
  db,
@@ -16241,7 +16384,8 @@ function createMembershipChapterEffectRoute(options) {
16241
16384
  joinUrl: join.href
16242
16385
  },
16243
16386
  dedupeKey: `membership-effect:${effect.effectId}`,
16244
- applicationId: input.applicationId
16387
+ applicationId: input.applicationId,
16388
+ tierId: effect.skuId
16245
16389
  });
16246
16390
  if (!result.sent) throw new Error("invitation delivery unconfirmed");
16247
16391
  } else {