@odla-ai/chapter 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1633,9 +1633,9 @@ async function backfillCrm(deps) {
1633
1633
  }
1634
1634
 
1635
1635
  // src/email-retirement.ts
1636
- function isRetiredEmailCopy(subject, text9) {
1636
+ function isRetiredEmailCopy(subject, text10) {
1637
1637
  const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
1638
- const bodyCopy = text9.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1638
+ const bodyCopy = text10.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1639
1639
  return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
1640
1640
  }
1641
1641
 
@@ -1713,10 +1713,51 @@ function planDelivery(input) {
1713
1713
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1714
1714
  if (validateEmailCopy({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1715
1715
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
1716
- const text9 = redirect ? `(dev redirect; original recipient: ${input.to})
1716
+ const text10 = redirect ? `(dev redirect; original recipient: ${input.to})
1717
1717
 
1718
1718
  ` + renderedText : renderedText;
1719
- return { deliver: true, transport, to, subject, text: text9, redirected: redirect };
1719
+ return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1720
+ }
1721
+
1722
+ // src/email-html.ts
1723
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1724
+ function escapeHtml(s) {
1725
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1726
+ }
1727
+ function lineHtml(line) {
1728
+ let out = "", last = 0;
1729
+ for (const match of line.matchAll(URL_RE)) {
1730
+ out += escapeHtml(line.slice(last, match.index));
1731
+ const url = escapeHtml(match[0]);
1732
+ out += `<a href="${url}">${url}</a>`;
1733
+ last = match.index + match[0].length;
1734
+ }
1735
+ return out + escapeHtml(line.slice(last));
1736
+ }
1737
+ function textToHtml(text10) {
1738
+ const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1739
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1740
+ return `<!doctype html>
1741
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1742
+ <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
1743
+ ${body}
1744
+ </body></html>`;
1745
+ }
1746
+
1747
+ // src/email-deliverability.ts
1748
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1749
+ function isRecipientFacing(event2) {
1750
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1751
+ }
1752
+ function listUnsubscribeHeader(replyTo) {
1753
+ const address = replyTo.trim();
1754
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1755
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1756
+ }
1757
+ function deliverabilityHeaders(input) {
1758
+ if (!isRecipientFacing(input.event)) return void 0;
1759
+ const value = listUnsubscribeHeader(input.replyTo);
1760
+ return value ? { "List-Unsubscribe": value } : void 0;
1720
1761
  }
1721
1762
 
1722
1763
  // src/notify.ts
@@ -1864,8 +1905,8 @@ function formatInstant(at2, timeZone) {
1864
1905
  var adminUrlFor = (origin) => `${origin}/admin/`;
1865
1906
  var membersUrlFor = (origin) => `${origin}/members/`;
1866
1907
  function applicantVariables(app) {
1867
- const text9 = (v) => typeof v === "string" ? v : "";
1868
- return { firstName: text9(app.firstName), lastName: text9(app.lastName), email: text9(app.email), phone: text9(app.phone), state: text9(app.state) };
1908
+ const text10 = (v) => typeof v === "string" ? v : "";
1909
+ return { firstName: text10(app.firstName), lastName: text10(app.lastName), email: text10(app.email), phone: text10(app.phone), state: text10(app.state) };
1869
1910
  }
1870
1911
  function sampleVariables(origin, timeZone, now = Date.now()) {
1871
1912
  const startAt = formatInstant(now + 864e5, timeZone);
@@ -1990,11 +2031,16 @@ async function sendTemplated(deps, input) {
1990
2031
  if (decision.transport === "log-only") return { sent: true };
1991
2032
  let messageId;
1992
2033
  try {
2034
+ const text10 = String(row2.body);
2035
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
2036
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
1993
2037
  const response = await deps.sender.send({
1994
2038
  from: fromAddress(deps.from, input.group.fromName),
1995
2039
  to: [decision.to],
1996
2040
  subject: String(row2.subject),
1997
- text: String(row2.body),
2041
+ text: text10,
2042
+ ...html ? { html } : {},
2043
+ ...headers ? { headers } : {},
1998
2044
  replyTo: input.group.replyTo
1999
2045
  });
2000
2046
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -3599,12 +3645,87 @@ function paymentLineItems(group, tier) {
3599
3645
 
3600
3646
  // src/membership-dispute-authority.ts
3601
3647
  import { stripeRequest as stripeRequest4 } from "@odla-ai/stripe";
3648
+
3649
+ // src/membership-dispute-transition.ts
3602
3650
  var MEMBERSHIP_DISPUTED_STATUS = "payment_disputed";
3603
3651
  var DISPUTE_WON_STATUSES = ["won", "warning_closed"];
3604
3652
  var CLOSED_STATUSES = [...DISPUTE_WON_STATUSES, "lost"];
3605
- var TERMINAL = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
3653
+ function disputeTransition(row2, fact, eventId, now, terminal) {
3654
+ const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
3655
+ const sameDispute = row2.disputeId === fact.objectId;
3656
+ if (sameDispute && Number(row2.disputeClosedAt ?? 0) > 0 && !closed) return null;
3657
+ const won = DISPUTE_WON_STATUSES.includes(status2);
3658
+ const suspended = row2.status === MEMBERSHIP_DISPUTED_STATUS, done = terminal.includes(String(row2.status));
3659
+ const restored = String(row2.disputePriorStatus ?? (row2.journeyApprovedAt ? "active" : "paid_pending_approval"));
3660
+ const next = done ? String(row2.status) : won ? suspended ? restored : String(row2.status) : MEMBERSHIP_DISPUTED_STATUS;
3661
+ return {
3662
+ status: next,
3663
+ disputeId: fact.objectId,
3664
+ disputeStatus: status2,
3665
+ disputeEventId: eventId,
3666
+ disputeClosedAt: closed ? now : 0,
3667
+ ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
3668
+ ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
3669
+ ...sameDispute ? {} : { disputedAt: now },
3670
+ ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(row2.status) } : {},
3671
+ revision: Number(row2.revision) + 1,
3672
+ updatedAt: now
3673
+ };
3674
+ }
3675
+
3676
+ // src/membership-seat-dispute.ts
3606
3677
  var text2 = (value) => typeof value === "string" && value ? value : void 0;
3607
- var refOf = (value) => typeof value === "string" ? text2(value) : text2(value?.id);
3678
+ var TERMINAL = ["payment_pending", "payment_processing", "payment_canceling", "refund_pending", "refunded", "canceled"];
3679
+ var namedSeatDisputeStanding = (seat, applied) => ({ seatId: String(seat.id), status: String(seat.status), ...text2(seat.disputeStatus) ? { disputeStatus: text2(seat.disputeStatus) } : {}, applied });
3680
+ function disputedChargeCoversSeat(charge, seat) {
3681
+ return charge.seatId === seat.id && charge.customerId === seat.providerCustomerId && charge.paymentIntentId !== void 0 && (charge.paymentIntentId === seat.providerPaymentIntentId || charge.paymentIntentId === seat.rechargePaymentIntentId);
3682
+ }
3683
+ async function reconcileNamedSeatDispute(db, input, now, charge, evidence) {
3684
+ if (!charge.seatId) membershipFail("membership_dispute_scope_mismatch");
3685
+ const membership = async (applied, seat) => {
3686
+ const id7 = await networkMembershipId(input), member = await membershipLoad(db, "networkMembership", id7);
3687
+ requireMembershipScope(member, input);
3688
+ return {
3689
+ membershipId: id7,
3690
+ status: String(member.status),
3691
+ termEndsAt: Number(member.termEndsAt),
3692
+ graceEndsAt: Number(member.graceEndsAt ?? 0),
3693
+ autoRenew: member.autoRenew === true,
3694
+ applied,
3695
+ ...text2(member.disputeStatus) ? { disputeStatus: text2(member.disputeStatus) } : {},
3696
+ ...seat ? { seat: namedSeatDisputeStanding(seat, applied) } : {}
3697
+ };
3698
+ };
3699
+ for (let attempt = 0; attempt < 8; attempt++) {
3700
+ const state = await loadMembershipAuthorityState(db, input), seat = await membershipLoad(db, "networkSeat", charge.seatId);
3701
+ if (!seat) return membership(false);
3702
+ requireMembershipScope(seat, input);
3703
+ if (charge.customerId !== seat.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3704
+ if (!disputedChargeCoversSeat(charge, seat) || seat.disputeEventId === input.eventId) return membership(seat.disputeEventId === input.eventId, seat);
3705
+ const attrs = disputeTransition(seat, evidence.fact, input.eventId, now, TERMINAL);
3706
+ if (!attrs) return membership(false, seat);
3707
+ try {
3708
+ await db.transact([membershipUpdate("networkSeat", String(seat.id), attrs)], { mutationId: `seat-dispute:${seat.id}:${input.eventId}`, guards: [
3709
+ { ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision } },
3710
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
3711
+ { ns: "networkCommercialEvents", id: String(evidence.row.id), exists: true, equals: { digest: evidence.fact.digest } },
3712
+ evidence.guard
3713
+ ] });
3714
+ } catch (error) {
3715
+ if (error.code !== "transact_guard_failed") throw error;
3716
+ continue;
3717
+ }
3718
+ const saved = await membershipLoad(db, "networkSeat", String(seat.id));
3719
+ requireMembershipScope(saved, input);
3720
+ return membership(true, saved);
3721
+ }
3722
+ return membershipFail("membership_dispute_retry_required");
3723
+ }
3724
+
3725
+ // src/membership-dispute-authority.ts
3726
+ var TERMINAL2 = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
3727
+ var text3 = (value) => typeof value === "string" && value ? value : void 0;
3728
+ var refOf = (value) => typeof value === "string" ? text3(value) : text3(value?.id);
3608
3729
  async function readDisputedCharge(secretKey, chargeId, fetch2) {
3609
3730
  if (!/^ch_[A-Za-z0-9]+$/.test(chargeId)) membershipFail("invalid_dispute_charge");
3610
3731
  const config = { secretKey, ...fetch2 ? { fetch: fetch2 } : {} };
@@ -3618,7 +3739,7 @@ async function readDisputedCharge(secretKey, chargeId, fetch2) {
3618
3739
  if (!invoice.ok || invoice.body.id !== invoiceId) membershipFail("membership_dispute_provider_unavailable");
3619
3740
  subscriptionId = refOf(invoice.body.subscription) ?? refOf(invoice.body.parent?.subscription_details?.subscription);
3620
3741
  }
3621
- const seatId = text2(charge.body.metadata?.seatId);
3742
+ const seatId = text3(charge.body.metadata?.seatId);
3622
3743
  return {
3623
3744
  chargeId,
3624
3745
  customerId,
@@ -3644,37 +3765,23 @@ async function reconcileMembershipDispute(db, input, now, secretKey, resolved) {
3644
3765
  termEndsAt: Number(value.termEndsAt),
3645
3766
  graceEndsAt: Number(value.graceEndsAt ?? 0),
3646
3767
  autoRenew: value.autoRenew === true,
3647
- ...text2(value.disputeStatus) ? { disputeStatus: text2(value.disputeStatus) } : {},
3768
+ ...text3(value.disputeStatus) ? { disputeStatus: text3(value.disputeStatus) } : {},
3648
3769
  applied
3649
3770
  });
3650
3771
  const known = await membershipLoad(db, "networkMembership", id7);
3651
3772
  requireMembershipScope(known, input);
3652
3773
  if (known.disputeEventId === input.eventId) return receipt(known);
3774
+ const seatKnown = known.namedSeatId ? await membershipLoad(db, "networkSeat", String(known.namedSeatId)) : void 0;
3775
+ if (seatKnown?.disputeEventId === input.eventId) return { ...receipt(known, true), seat: namedSeatDisputeStanding(seatKnown, true) };
3653
3776
  const charge = resolved && resolved.chargeId === fact.chargeId ? resolved : await readDisputedCharge(secretKey, fact.chargeId);
3777
+ if (charge.customerId !== known.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3778
+ if (charge.seatId) return reconcileNamedSeatDispute(db, input, now, charge, { row: row2, fact, guard: evidenceGuard });
3654
3779
  for (let attempt = 0; attempt < 8; attempt++) {
3655
3780
  const state = await loadMembershipAuthorityState(db, input), member = await membershipLoad(db, "networkMembership", id7);
3656
3781
  requireMembershipScope(member, input);
3657
- if (charge.customerId !== member.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3658
3782
  if (!disputedChargeCoversMembership(charge, member) || member.disputeEventId === input.eventId) return receipt(member);
3659
- const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
3660
- const sameDispute = member.disputeId === fact.objectId;
3661
- if (sameDispute && Number(member.disputeClosedAt ?? 0) > 0 && !closed) return receipt(member, false);
3662
- const won = DISPUTE_WON_STATUSES.includes(status2);
3663
- const suspended = member.status === MEMBERSHIP_DISPUTED_STATUS, terminal = TERMINAL.includes(String(member.status));
3664
- const next = terminal ? String(member.status) : won ? suspended ? String(member.disputePriorStatus ?? (member.journeyApprovedAt ? "active" : "paid_pending_approval")) : String(member.status) : MEMBERSHIP_DISPUTED_STATUS;
3665
- const attrs = {
3666
- status: next,
3667
- disputeId: fact.objectId,
3668
- disputeStatus: status2,
3669
- disputeEventId: input.eventId,
3670
- disputeClosedAt: closed ? now : 0,
3671
- ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
3672
- ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
3673
- ...sameDispute ? {} : { disputedAt: now },
3674
- ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(member.status) } : {},
3675
- revision: Number(member.revision) + 1,
3676
- updatedAt: now
3677
- };
3783
+ const attrs = disputeTransition(member, fact, input.eventId, now, TERMINAL2);
3784
+ if (!attrs) return receipt(member, false);
3678
3785
  try {
3679
3786
  await db.transact([membershipUpdate("networkMembership", id7, attrs)], { mutationId: `membership-dispute:${id7}:${input.eventId}`, guards: [
3680
3787
  { ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision } },
@@ -4796,6 +4903,9 @@ function namedSeatRecipientState(seat) {
4796
4903
  return seat.refundReason === "declined" ? "declined" : "refunded";
4797
4904
  case "canceled":
4798
4905
  return "canceled";
4906
+ // A card-network dispute on the seat's own payment pauses the recipient until it is won or re-charged.
4907
+ case "payment_disputed":
4908
+ return "disputed";
4799
4909
  default:
4800
4910
  return "payment_pending";
4801
4911
  }
@@ -5124,6 +5234,7 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
5124
5234
 
5125
5235
  // src/membership-dispute-recharge.ts
5126
5236
  import { stripeRequest as stripeRequest7 } from "@odla-ai/stripe";
5237
+ var NS = { membership: "networkMembership", seat: "networkSeat" };
5127
5238
  var fail6 = (code, extra = {}) => {
5128
5239
  throw Object.assign(new Error(code), { code, ...extra });
5129
5240
  };
@@ -5137,65 +5248,64 @@ async function defaultPaymentMethod(config, subscriptionId, customerId) {
5137
5248
  if (!customer.ok) fail6("membership_dispute_provider_unavailable");
5138
5249
  return refOf2(customer.body.invoice_settings?.default_payment_method);
5139
5250
  }
5140
- async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5251
+ async function rechargeDisputed(db, kind, id7, secretKey, now) {
5141
5252
  membershipClock(now);
5142
- let member = await membershipLoad(db, "networkMembership", membershipId);
5143
- if (!member) fail6("membership_not_found");
5144
- const scope2 = { chapterId: String(member.chapterId), environment: String(member.environment) };
5145
- const amountCents = Number(member.disputeAmountCents ?? member.amountCents), currency = String(member.currency);
5253
+ const ns = NS[kind];
5254
+ let row2 = await membershipLoad(db, ns, id7);
5255
+ if (!row2) fail6("membership_not_found");
5256
+ const scope2 = { chapterId: String(row2.chapterId), environment: String(row2.environment) };
5257
+ const membershipId = kind === "membership" ? id7 : String(row2.primaryMembershipId);
5258
+ const amountCents = Number(row2.disputeAmountCents ?? row2.amountCents), currency = String(row2.currency);
5146
5259
  const done = (value, replayed) => ({
5147
5260
  membershipId,
5261
+ ...kind === "seat" ? { seatId: id7 } : {},
5148
5262
  status: String(value.status),
5149
5263
  paymentIntentId: String(value.rechargePaymentIntentId),
5150
5264
  amountCents,
5151
5265
  currency,
5152
5266
  ...replayed ? { replayed } : {}
5153
5267
  });
5154
- if (member.status !== MEMBERSHIP_DISPUTED_STATUS) {
5155
- if (member.disputeStatus === "recharged" && member.rechargePaymentIntentId) return done(member, true);
5268
+ if (row2.status !== MEMBERSHIP_DISPUTED_STATUS) {
5269
+ if (row2.disputeStatus === "recharged" && row2.rechargePaymentIntentId) return done(row2, true);
5156
5270
  fail6("membership_not_disputed");
5157
5271
  }
5158
- if (member.disputeStatus !== "lost") fail6("membership_dispute_open");
5272
+ if (row2.disputeStatus !== "lost") fail6("membership_dispute_open");
5159
5273
  if (!Number.isSafeInteger(amountCents) || amountCents <= 0 || !/^[a-z]{3}$/.test(currency)) fail6("membership_dispute_amount_invalid");
5160
- let attempt = member.rechargeAttempt;
5274
+ let attempt = row2.rechargeAttempt;
5161
5275
  if (!attempt || attempt.finishedAt !== void 0) {
5162
- attempt = { key: `membership-recharge:${membershipId}:${String(member.disputeId)}:${now}`, startedAt: now };
5276
+ attempt = { key: `${kind}-recharge:${id7}:${String(row2.disputeId)}:${now}`, startedAt: now };
5163
5277
  await db.transact(
5164
- [membershipUpdate("networkMembership", membershipId, { rechargeAttempt: attempt, revision: Number(member.revision) + 1, updatedAt: now })],
5165
- { mutationId: `membership-recharge-attempt:${attempt.key}`, guards: [{
5166
- ns: "networkMembership",
5167
- id: membershipId,
5168
- exists: true,
5169
- equals: { revision: member.revision, status: MEMBERSHIP_DISPUTED_STATUS }
5170
- }] }
5278
+ [membershipUpdate(ns, id7, { rechargeAttempt: attempt, revision: Number(row2.revision) + 1, updatedAt: now })],
5279
+ { mutationId: `membership-recharge-attempt:${attempt.key}`, guards: [{ ns, id: id7, exists: true, equals: { revision: row2.revision, status: MEMBERSHIP_DISPUTED_STATUS } }] }
5171
5280
  );
5172
- member = await membershipLoad(db, "networkMembership", membershipId);
5173
- if (!member || member.status !== MEMBERSHIP_DISPUTED_STATUS) fail6("membership_dispute_changed");
5281
+ row2 = await membershipLoad(db, ns, id7);
5282
+ if (!row2 || row2.status !== MEMBERSHIP_DISPUTED_STATUS) fail6("membership_dispute_changed");
5174
5283
  }
5175
5284
  const config = { secretKey };
5176
- const paymentMethod = await defaultPaymentMethod(config, String(member.providerSubscriptionId), String(member.providerCustomerId));
5285
+ const paymentMethod = await defaultPaymentMethod(config, String(row2.providerSubscriptionId), String(row2.providerCustomerId));
5177
5286
  if (!paymentMethod) fail6("recharge_payment_method_unavailable");
5178
5287
  const intent = await stripeRequest7(config, "POST", "/v1/payment_intents", {
5179
5288
  amount: amountCents,
5180
5289
  currency,
5181
- customer: String(member.providerCustomerId),
5290
+ customer: String(row2.providerCustomerId),
5182
5291
  payment_method: paymentMethod,
5183
5292
  off_session: "true",
5184
5293
  confirm: "true",
5185
- description: `Membership payment reissued after dispute ${String(member.disputeId)}`,
5294
+ description: `${kind === "seat" ? "Named seat" : "Membership"} payment reissued after dispute ${String(row2.disputeId)}`,
5186
5295
  metadata: {
5187
- ...stripePartitionMetadata({ appId: String(member.appId), chapterId: scope2.chapterId, environment: scope2.environment, runtime: String(member.runtime) }),
5188
- applicationId: String(member.applicationId),
5296
+ ...stripePartitionMetadata({ appId: String(row2.appId), chapterId: scope2.chapterId, environment: scope2.environment, runtime: String(row2.runtime) }),
5297
+ applicationId: String(row2.applicationId),
5189
5298
  membershipId,
5190
- disputeId: String(member.disputeId),
5299
+ ...kind === "seat" ? { seatId: id7 } : {},
5300
+ disputeId: String(row2.disputeId),
5191
5301
  membershipRecharge: "true"
5192
5302
  }
5193
5303
  }, { idempotencyKey: attempt.key });
5194
5304
  const body = intent.body, error = body.error ?? {}, paymentStatus = String(body.status ?? "");
5195
5305
  const finish = async (outcome) => {
5196
- const current = await membershipLoad(db, "networkMembership", membershipId);
5306
+ const current = await membershipLoad(db, ns, id7);
5197
5307
  if (!current) return;
5198
- await db.transact([membershipUpdate("networkMembership", membershipId, {
5308
+ await db.transact([membershipUpdate(ns, id7, {
5199
5309
  rechargeAttempt: { ...attempt, finishedAt: now, outcome },
5200
5310
  revision: Number(current.revision) + 1,
5201
5311
  updatedAt: now
@@ -5213,15 +5323,15 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5213
5323
  fail6("recharge_declined", { declineCode: String(body.last_payment_error?.decline_code ?? paymentStatus) });
5214
5324
  }
5215
5325
  if (paymentStatus !== "succeeded") fail6("recharge_pending", { paymentStatus });
5216
- if (refOf2(body.customer) !== member.providerCustomerId || body.amount !== amountCents) fail6("recharge_mismatch");
5326
+ if (refOf2(body.customer) !== row2.providerCustomerId || body.amount !== amountCents) fail6("recharge_mismatch");
5217
5327
  for (let retry = 0; retry < 8; retry++) {
5218
- const state = await loadMembershipAuthorityState(db, scope2), current = await membershipLoad(db, "networkMembership", membershipId);
5328
+ const state = await loadMembershipAuthorityState(db, scope2), current = await membershipLoad(db, ns, id7);
5219
5329
  if (!current) fail6("membership_not_found");
5220
5330
  if (current.rechargePaymentIntentId === body.id && current.status !== MEMBERSHIP_DISPUTED_STATUS) return done(current, true);
5221
- const status2 = String(current.disputePriorStatus ?? (current.journeyApprovedAt ? "active" : "paid_pending_approval"));
5331
+ const status2 = String(current.disputePriorStatus ?? (kind === "seat" ? "active" : current.journeyApprovedAt ? "active" : "paid_pending_approval"));
5222
5332
  try {
5223
5333
  await db.transact(
5224
- [membershipUpdate("networkMembership", membershipId, {
5334
+ [membershipUpdate(ns, id7, {
5225
5335
  status: status2,
5226
5336
  disputeStatus: "recharged",
5227
5337
  rechargePaymentIntentId: String(body.id),
@@ -5231,7 +5341,7 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5231
5341
  updatedAt: now
5232
5342
  })],
5233
5343
  { mutationId: `membership-recharged:${attempt.key}`, guards: [
5234
- { ns: "networkMembership", id: membershipId, exists: true, equals: { revision: current.revision, status: MEMBERSHIP_DISPUTED_STATUS } },
5344
+ { ns, id: id7, exists: true, equals: { revision: current.revision, status: MEMBERSHIP_DISPUTED_STATUS } },
5235
5345
  { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
5236
5346
  ] }
5237
5347
  );
@@ -5239,10 +5349,12 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5239
5349
  if (cause.code !== "transact_guard_failed") throw cause;
5240
5350
  continue;
5241
5351
  }
5242
- return done(await membershipLoad(db, "networkMembership", membershipId));
5352
+ return done(await membershipLoad(db, ns, id7));
5243
5353
  }
5244
5354
  return fail6("membership_dispute_retry_required");
5245
5355
  }
5356
+ var rechargeDisputedMembership = (db, membershipId, secretKey, now) => rechargeDisputed(db, "membership", membershipId, secretKey, now);
5357
+ var rechargeDisputedSeat = (db, seatId, secretKey, now) => rechargeDisputed(db, "seat", seatId, secretKey, now);
5246
5358
 
5247
5359
  // src/worker-routes-admin-membership-disputes.ts
5248
5360
  var LIST2 = "/api/admin/membership-disputes";
@@ -5259,6 +5371,9 @@ var PUBLIC2 = [
5259
5371
  "memberNumber",
5260
5372
  "amountCents",
5261
5373
  "currency",
5374
+ "recipientName",
5375
+ "primaryMembershipId",
5376
+ "purchasedAt",
5262
5377
  "disputeId",
5263
5378
  "disputeStatus",
5264
5379
  "disputeReason",
@@ -5306,14 +5421,17 @@ var handleAdminMembershipDisputes = async (req, url, env, ctx) => {
5306
5421
  return json({ error: "the membership authority is not hosted by this deployment" }, 404);
5307
5422
  }
5308
5423
  if (!one) {
5309
- const rows5 = (await db.query({ networkMembership: { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 500 } } })).networkMembership ?? [];
5424
+ const page = { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 500 } };
5425
+ const found = await db.query({ networkMembership: page, networkSeat: page });
5426
+ const memberships = found.networkMembership ?? [], seats = found.networkSeat ?? [];
5427
+ const rows5 = [...memberships.map((row2) => ({ ...project2(row2), kind: "membership" })), ...seats.map((row2) => ({ ...project2(row2), kind: "seat" }))];
5310
5428
  const disputes = rows5.filter((row2) => typeof row2.disputeId === "string").sort((a, b) => Number(b.disputedAt ?? 0) - Number(a.disputedAt ?? 0));
5311
- return json({ disputes: disputes.map(project2), truncated: rows5.length >= 500 });
5429
+ return json({ disputes, truncated: memberships.length >= 500 || seats.length >= 500 });
5312
5430
  }
5313
5431
  const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
5314
5432
  if (!secret) return json({ error: "network payment provider unavailable" }, 503);
5315
5433
  try {
5316
- const receipt = await rechargeDisputedMembership(db, one[1], secret, Date.now());
5434
+ const receipt = await (one[1].startsWith("seat_") ? rechargeDisputedSeat : rechargeDisputedMembership)(db, one[1], secret, Date.now());
5317
5435
  return json({ ok: true, ...receipt });
5318
5436
  } catch (error) {
5319
5437
  const failure = error;
@@ -5333,13 +5451,13 @@ var handleAdminMembershipDisputes = async (req, url, env, ctx) => {
5333
5451
 
5334
5452
  // src/membership-policy-versions.ts
5335
5453
  var LIMIT = 200;
5336
- var text3 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
5454
+ var text4 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
5337
5455
  var instant = (value) => {
5338
5456
  const at2 = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
5339
5457
  return Number.isSafeInteger(at2) && at2 >= 0 ? at2 : void 0;
5340
5458
  };
5341
5459
  function provenance(row2) {
5342
- const source = text3(row2.source), actor = text3(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
5460
+ const source = text4(row2.source), actor = text4(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
5343
5461
  return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
5344
5462
  }
5345
5463
  var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
@@ -6340,8 +6458,8 @@ var APPLICATION_LABELS = {
6340
6458
  state: "State"
6341
6459
  };
6342
6460
  function humanize(id7) {
6343
- const text9 = id7.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
6344
- return text9 ? `${text9[0].toUpperCase()}${text9.slice(1)}` : id7;
6461
+ const text10 = id7.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
6462
+ return text10 ? `${text10[0].toUpperCase()}${text10.slice(1)}` : id7;
6345
6463
  }
6346
6464
  function applicationDefs(chapter) {
6347
6465
  try {
@@ -6408,7 +6526,7 @@ var ID3 = /^[a-z][a-z0-9-]{1,79}$/;
6408
6526
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
6409
6527
  var CURRENCY = /^[a-z]{3}$/;
6410
6528
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
6411
- var text4 = (value, path, errors, max) => {
6529
+ var text5 = (value, path, errors, max) => {
6412
6530
  if (typeof value !== "string" || !value.trim()) {
6413
6531
  errors.push(`${path} is required`);
6414
6532
  return "";
@@ -6436,23 +6554,23 @@ function validateGiftOfferCatalog(input, tierIds) {
6436
6554
  }
6437
6555
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
6438
6556
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
6439
- const id7 = text4(item.id, `${path}.id`, errors, 80);
6557
+ const id7 = text5(item.id, `${path}.id`, errors, 80);
6440
6558
  if (id7 && !ID3.test(id7)) errors.push(`${path}.id must be a lowercase kebab-case id`);
6441
6559
  if (ids2.has(id7)) errors.push(`${path}.id duplicates "${id7}"`);
6442
6560
  ids2.add(id7);
6443
6561
  const targetRaw = record(item.target);
6444
6562
  let target = { type: "entitlement", entitlementKey: "invalid" };
6445
6563
  if (targetRaw?.type === "tier") {
6446
- const tierId = text4(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
6564
+ const tierId = text5(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
6447
6565
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
6448
6566
  target = { type: "tier", tierId };
6449
6567
  } else if (targetRaw?.type === "entitlement") {
6450
- const entitlementKey = text4(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
6568
+ const entitlementKey = text5(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
6451
6569
  if (entitlementKey && !ID3.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
6452
6570
  target = { type: "entitlement", entitlementKey };
6453
6571
  } else errors.push(`${path}.target.type must be tier or entitlement`);
6454
6572
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
6455
- const stripePriceId = text4(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
6573
+ const stripePriceId = text5(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
6456
6574
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
6457
6575
  if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
6458
6576
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -6474,9 +6592,9 @@ function validateGiftOfferCatalog(input, tierIds) {
6474
6592
  redemptionLifetimeHours: lifetime,
6475
6593
  ...activeFrom !== void 0 ? { activeFrom } : {},
6476
6594
  ...activeUntil !== void 0 ? { activeUntil } : {},
6477
- title: text4(item.title, `${path}.title`, errors, 160),
6478
- description: text4(item.description, `${path}.description`, errors, 2e3),
6479
- ctaLabel: text4(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
6595
+ title: text5(item.title, `${path}.title`, errors, 160),
6596
+ description: text5(item.description, `${path}.description`, errors, 2e3),
6597
+ ctaLabel: text5(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
6480
6598
  });
6481
6599
  }
6482
6600
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -6892,17 +7010,17 @@ async function readBoundedRequestText(req, maxBytes) {
6892
7010
  if (!reader) return "";
6893
7011
  const decoder = new TextDecoder();
6894
7012
  let bytes = 0;
6895
- let text9 = "";
7013
+ let text10 = "";
6896
7014
  for (; ; ) {
6897
7015
  const { done, value } = await reader.read();
6898
7016
  if (req.signal.aborted) return invalid2();
6899
7017
  if (done) {
6900
7018
  complete = true;
6901
- return text9 + decoder.decode();
7019
+ return text10 + decoder.decode();
6902
7020
  }
6903
7021
  bytes += value.byteLength;
6904
7022
  if (bytes > maxBytes) return json({ error: "request body too large" }, 413);
6905
- text9 += decoder.decode(value, { stream: true });
7023
+ text10 += decoder.decode(value, { stream: true });
6906
7024
  }
6907
7025
  } catch {
6908
7026
  return invalid2();
@@ -7006,9 +7124,9 @@ async function paymentQuoteRoute(url, env, ctx, prepare = false, request) {
7006
7124
  let selection;
7007
7125
  if (request?.body) {
7008
7126
  try {
7009
- const text9 = await readBoundedRequestText(request, 2048);
7010
- if (text9 instanceof Response) return text9;
7011
- const body = JSON.parse(text9);
7127
+ const text10 = await readBoundedRequestText(request, 2048);
7128
+ if (text10 instanceof Response) return text10;
7129
+ const body = JSON.parse(text10);
7012
7130
  if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key3) => key3 !== "namedSeat")) throw new Error("invalid body");
7013
7131
  selection = body.namedSeat;
7014
7132
  } catch {
@@ -7890,10 +8008,10 @@ function boundedString(value, path, errors, max, required = false) {
7890
8008
  errors.push(`${path} must be a string`);
7891
8009
  return void 0;
7892
8010
  }
7893
- const text9 = value.trim();
7894
- if (required && !text9) errors.push(`${path} is required`);
7895
- if (text9.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
7896
- return text9;
8011
+ const text10 = value.trim();
8012
+ if (required && !text10) errors.push(`${path} is required`);
8013
+ if (text10.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
8014
+ return text10;
7897
8015
  }
7898
8016
  function validateMembershipPageDocument(input, options = {}) {
7899
8017
  const errors = [];
@@ -7942,8 +8060,8 @@ function validateMembershipPageDocument(input, options = {}) {
7942
8060
  else {
7943
8061
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
7944
8062
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
7945
- const text9 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
7946
- return text9 ? [text9] : [];
8063
+ const text10 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
8064
+ return text10 ? [text10] : [];
7947
8065
  });
7948
8066
  }
7949
8067
  }
@@ -8037,7 +8155,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
8037
8155
  function unknownKeys(value, allowed2, path, errors) {
8038
8156
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
8039
8157
  }
8040
- function text5(value, path, errors, max = 65536) {
8158
+ function text6(value, path, errors, max = 65536) {
8041
8159
  if (typeof value !== "string" || !value.trim()) {
8042
8160
  errors.push(`${path} is required`);
8043
8161
  return "";
@@ -8060,11 +8178,11 @@ function tiersOf(value, errors) {
8060
8178
  return [];
8061
8179
  }
8062
8180
  unknownKeys(item, TIER_KEYS2, path, errors);
8063
- const id7 = text5(item.id, `${path}.id`, errors, 80);
8181
+ const id7 = text6(item.id, `${path}.id`, errors, 80);
8064
8182
  if (id7 && !TIER.test(id7)) errors.push(`${path}.id must be a lowercase kebab-case id`);
8065
8183
  if (seen.has(id7)) errors.push(`${path}.id duplicates "${id7}"`);
8066
8184
  seen.add(id7);
8067
- const name = text5(item.name, `${path}.name`, errors, 160);
8185
+ const name = text6(item.name, `${path}.name`, errors, 160);
8068
8186
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
8069
8187
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
8070
8188
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -8082,7 +8200,7 @@ function tiersOf(value, errors) {
8082
8200
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
8083
8201
  errors.push(`${path}.interval must be year or month`);
8084
8202
  }
8085
- const blurb = item.blurb === void 0 ? void 0 : text5(item.blurb, `${path}.blurb`, errors, 4e3);
8203
+ const blurb = item.blurb === void 0 ? void 0 : text6(item.blurb, `${path}.blurb`, errors, 4e3);
8086
8204
  return [{
8087
8205
  id: id7,
8088
8206
  name,
@@ -8140,7 +8258,7 @@ function policyOf(value, errors, tiers) {
8140
8258
  const result = {};
8141
8259
  for (const key3 of allowed2) {
8142
8260
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
8143
- result[key3] = text5(input[key3], `document.policy.${key3}`, errors);
8261
+ result[key3] = text6(input[key3], `document.policy.${key3}`, errors);
8144
8262
  }
8145
8263
  const tierPolicies = {};
8146
8264
  if (input.tierPolicies !== void 0) {
@@ -8155,8 +8273,8 @@ function policyOf(value, errors, tiers) {
8155
8273
  }
8156
8274
  unknownKeys(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
8157
8275
  tierPolicies[id7] = {
8158
- refundPolicyText: text5(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
8159
- ...policy2.trustCopy !== void 0 ? { trustCopy: text5(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
8276
+ refundPolicyText: text6(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
8277
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text6(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
8160
8278
  };
8161
8279
  }
8162
8280
  }
@@ -8196,9 +8314,9 @@ function validateSignupControlDocument(input) {
8196
8314
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
8197
8315
  const source = recordOf(root.source);
8198
8316
  const target = recordOf(root.target);
8199
- const appId = text5(source?.appId, "document.source.appId", errors, 63);
8200
- const chapterId = text5(target?.chapterId, "document.target.chapterId", errors, 63);
8201
- const environment = text5(target?.environment, "document.target.environment", errors, 63);
8317
+ const appId = text6(source?.appId, "document.source.appId", errors, 63);
8318
+ const chapterId = text6(target?.chapterId, "document.target.chapterId", errors, 63);
8319
+ const environment = text6(target?.environment, "document.target.environment", errors, 63);
8202
8320
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
8203
8321
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
8204
8322
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -10469,7 +10587,7 @@ async function startSubscription(req, env, ctx) {
10469
10587
 
10470
10588
  // src/worker-payment-receipt.ts
10471
10589
  var json2 = (value, status2 = 200) => Response.json(value, { status: status2, headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" } });
10472
- var escape = (text9) => text9.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
10590
+ var escape = (text10) => text10.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
10473
10591
  async function paymentReceiptRoute(req, url, env, ctx) {
10474
10592
  const id7 = url.searchParams.get("application");
10475
10593
  if (!id7 || id7.length > 160) return json2({ error: "application required" }, 400);
@@ -10480,7 +10598,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10480
10598
  if (!app || app.groupId !== ctx.chapter.id || !intent || intent.applicationId !== id7 || intent.appId !== env.ODLA_APP_ID || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV || intent.runtime !== stripeRuntimeIdentity(env.ODLA_RUNTIME) || app.stripeRuntime !== intent.runtime || app.stripeSubscriptionId !== intent.providerSubscriptionId) return json2({ error: "receipt not found" }, 404);
10481
10599
  const receipt = intent.paymentReceipt;
10482
10600
  if (!receipt) return json2({ pending: true }, 202);
10483
- const text9 = paymentReceiptText(receipt, ctx.chapter.name);
10601
+ const text10 = paymentReceiptText(receipt, ctx.chapter.name);
10484
10602
  if (req.method === "POST") {
10485
10603
  if (!env.SEND_EMAIL || !env.EMAIL_FROM) return json2({ error: "Email is unavailable. You can print or save your receipt." }, 503);
10486
10604
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
@@ -10498,7 +10616,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10498
10616
  template: "paymentReceipt",
10499
10617
  to: String(intent.purchaserEmailNormalized),
10500
10618
  applicationId: id7,
10501
- vars: { chapterName: ctx.chapter.name, receiptText: text9 },
10619
+ vars: { chapterName: ctx.chapter.name, receiptText: text10 },
10502
10620
  dedupeKey: `requested-payment-receipt:${receipt.invoiceId}`,
10503
10621
  event: { label: "Payment receipt", trigger: "Purchaser requests their receipt", audience: "Purchaser", variables: ["chapterName", "receiptText"] }
10504
10622
  });
@@ -10508,7 +10626,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10508
10626
  const nonce = crypto.randomUUID();
10509
10627
  return new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Payment receipt</title>
10510
10628
  <style>body{font:16px/1.6 system-ui;max-width:680px;margin:48px auto;padding:24px;color:#17252d}pre{font:inherit;white-space:pre-wrap}button{font:inherit;padding:10px 16px}@media print{button{display:none}body{margin:0}}</style>
10511
- <h1>Payment receipt</h1><pre>${escape(text9)}</pre><button id="print">Print / save PDF</button>
10629
+ <h1>Payment receipt</h1><pre>${escape(text10)}</pre><button id="print">Print / save PDF</button>
10512
10630
  <script nonce="${nonce}">document.getElementById('print').addEventListener('click',()=>window.print())</script></html>`, {
10513
10631
  headers: {
10514
10632
  "content-type": "text/html; charset=utf-8",
@@ -10978,16 +11096,16 @@ var handleNamedSeat = async (req, url, env, ctx) => {
10978
11096
  };
10979
11097
 
10980
11098
  // src/named-seat-accepted-notice.ts
10981
- var text6 = (value) => typeof value === "string" ? value : "";
11099
+ var text7 = (value) => typeof value === "string" ? value : "";
10982
11100
  async function notifyNamedSeatAccepted(db, env, ctx, input) {
10983
11101
  const purchaser = await membershipLoad(db, "applications", input.primaryApplicationId);
10984
- if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text6(purchaser.email)) {
11102
+ if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text7(purchaser.email)) {
10985
11103
  return { sent: false, reason: "purchaser-missing" };
10986
11104
  }
10987
11105
  const group = await membershipLoad(db, "groups", ctx.chapter.id);
10988
11106
  if (!group) return { sent: false, reason: "group-missing" };
10989
11107
  const config = emailGroupFrom(group);
10990
- const purchaserName = [purchaser.firstName, purchaser.lastName].map(text6).join(" ").replace(/\s+/g, " ").trim();
11108
+ const purchaserName = [purchaser.firstName, purchaser.lastName].map(text7).join(" ").replace(/\s+/g, " ").trim();
10991
11109
  return sendTemplated({
10992
11110
  db,
10993
11111
  envName: env.ODLA_ENV,
@@ -10999,12 +11117,12 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
10999
11117
  }, {
11000
11118
  group: config,
11001
11119
  template: "seatAccepted",
11002
- to: text6(purchaser.email),
11120
+ to: text7(purchaser.email),
11003
11121
  dedupeKey: `named-seat-accepted:${input.seatId}`,
11004
11122
  applicationId: input.primaryApplicationId,
11005
11123
  tierId: input.skuId,
11006
11124
  vars: {
11007
- firstName: text6(purchaser.firstName),
11125
+ firstName: text7(purchaser.firstName),
11008
11126
  purchaserName,
11009
11127
  recipientName: input.recipientName,
11010
11128
  tierName: await tierName(db, group, input.skuId),
@@ -12391,6 +12509,84 @@ import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "
12391
12509
 
12392
12510
  // src/email-readiness.ts
12393
12511
  import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
12512
+
12513
+ // src/email-spam-signals.ts
12514
+ var TRIGGER_PHRASES = [
12515
+ "act now",
12516
+ "click here",
12517
+ "limited time",
12518
+ "guaranteed",
12519
+ "risk-free",
12520
+ "risk free",
12521
+ "no obligation",
12522
+ "100% free",
12523
+ "winner",
12524
+ "congratulations",
12525
+ "urgent",
12526
+ "double your",
12527
+ "earn money",
12528
+ "make money",
12529
+ "cash bonus",
12530
+ "once in a lifetime",
12531
+ "exclusive deal",
12532
+ "order now",
12533
+ "buy now",
12534
+ "this is not spam",
12535
+ "dear friend",
12536
+ "dear customer"
12537
+ ];
12538
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12539
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12540
+ var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
12541
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12542
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12543
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12544
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12545
+ function emailSpamSignals(copy) {
12546
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12547
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12548
+ const out = [];
12549
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12550
+ const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
12551
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12552
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12553
+ if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
12554
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12555
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12556
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12557
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12558
+ if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text10))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
12559
+ if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12560
+ const urls = [...text10.matchAll(URL_RE2)].map((match) => match[0]);
12561
+ const hosts = urls.map((url) => {
12562
+ try {
12563
+ return new URL(url).hostname;
12564
+ } catch {
12565
+ return "";
12566
+ }
12567
+ });
12568
+ if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
12569
+ if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
12570
+ if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
12571
+ if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
12572
+ if (urls.length && words(text10).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
12573
+ return out;
12574
+ }
12575
+ function emailSenderWarnings(input) {
12576
+ const out = [];
12577
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12578
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12579
+ const domain = (address) => address.split("@")[1] ?? "";
12580
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12581
+ out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
12582
+ }
12583
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12584
+ out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
12585
+ }
12586
+ return out;
12587
+ }
12588
+
12589
+ // src/email-readiness.ts
12394
12590
  function validateEmailReadiness(input) {
12395
12591
  const issues = [];
12396
12592
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12415,9 +12611,15 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12415
12611
  }
12416
12612
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12417
12613
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
12418
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
12614
+ return {
12615
+ ...event2,
12616
+ key: key3,
12617
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12618
+ issues: problems,
12619
+ warnings: template ? emailSpamSignals(template) : []
12620
+ };
12419
12621
  });
12420
- return { checkedAt: input.now ?? Date.now(), events, issues };
12622
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12421
12623
  }
12422
12624
 
12423
12625
  // src/email-admin-config.ts
@@ -12443,7 +12645,14 @@ async function emailSettings(db, ctx, env) {
12443
12645
  }
12444
12646
  }
12445
12647
  const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
12446
- const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
12648
+ const validation = validateEmailReadiness({
12649
+ events,
12650
+ templates: stored,
12651
+ groupValues,
12652
+ tiers: hub ? void 0 : tiers,
12653
+ tiersUnavailable,
12654
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12655
+ });
12447
12656
  return { group, events, data: {
12448
12657
  groupId: ctx.chapter.id,
12449
12658
  name: ctx.chapter.name,
@@ -12471,12 +12680,12 @@ async function updateEmailSettings(db, ctx, env, body) {
12471
12680
  if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
12472
12681
  const t = value;
12473
12682
  if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
12474
- const subject = str2(t.subject).trim(), text9 = str2(t.text);
12475
- const issues = validateEmailCopy4({ subject, text: text9 }, { allowedVariables: events[key3].variables });
12683
+ const subject = str2(t.subject).trim(), text10 = str2(t.text);
12684
+ const issues = validateEmailCopy4({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12476
12685
  if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
12477
12686
  return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
12478
12687
  }
12479
- clean[key3] = { subject, text: text9, enabled: t.enabled };
12688
+ clean[key3] = { subject, text: text10, enabled: t.enabled };
12480
12689
  }
12481
12690
  const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
12482
12691
  const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
@@ -12497,7 +12706,8 @@ async function updateEmailSettings(db, ctx, env, body) {
12497
12706
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12498
12707
  ]);
12499
12708
  }
12500
- return json({ ok: true });
12709
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12710
+ return json({ ok: true, warnings });
12501
12711
  }
12502
12712
 
12503
12713
  // src/worker-routes-admin-comms.ts
@@ -14689,9 +14899,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
14689
14899
  if (req.method === "POST") {
14690
14900
  let body;
14691
14901
  try {
14692
- const text9 = await req.text();
14693
- if (new TextEncoder().encode(text9).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
14694
- const parsed = JSON.parse(text9);
14902
+ const text10 = await req.text();
14903
+ if (new TextEncoder().encode(text10).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
14904
+ const parsed = JSON.parse(text10);
14695
14905
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
14696
14906
  body = parsed;
14697
14907
  } catch {
@@ -14860,7 +15070,7 @@ import { signFederatedRequest as signFederatedRequest7, verifyFederatedRequest a
14860
15070
  // src/network-commercial-parity.ts
14861
15071
  var DIGEST = /^[0-9a-f]{64}$/;
14862
15072
  var MAX_RECORDS = 500;
14863
- var text7 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
15073
+ var text8 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
14864
15074
  function parityMessage(partition, kind, value) {
14865
15075
  return [
14866
15076
  "chapter-commercial-parity",
@@ -14892,18 +15102,18 @@ async function digestWithKey(key3, partition, kind, value) {
14892
15102
  async function buildChapterCommercialParitySnapshot(input) {
14893
15103
  const requestedMax = input.maxRecords ?? MAX_RECORDS;
14894
15104
  const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
14895
- const eligible = input.rows.filter((row2) => text7(row2.id) && (text7(row2.stripeCustomerId) || text7(row2.stripeSubscriptionId)));
15105
+ const eligible = input.rows.filter((row2) => text8(row2.id) && (text8(row2.stripeCustomerId) || text8(row2.stripeSubscriptionId)));
14896
15106
  const selected2 = eligible.slice(0, maxRecords);
14897
15107
  const key3 = await parityKey(input.secret);
14898
15108
  const records = await Promise.all(selected2.map(async (row2) => {
14899
- const applicationId = text7(row2.id);
14900
- const customerId = text7(row2.stripeCustomerId);
14901
- const subscriptionId = text7(row2.stripeSubscriptionId);
15109
+ const applicationId = text8(row2.id);
15110
+ const customerId = text8(row2.stripeCustomerId);
15111
+ const subscriptionId = text8(row2.stripeSubscriptionId);
14902
15112
  return {
14903
15113
  applicationDigest: await digestWithKey(key3, input.partition, "application", applicationId),
14904
15114
  customerDigest: customerId ? await digestWithKey(key3, input.partition, "customer", customerId) : null,
14905
15115
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.partition, "subscription", subscriptionId) : null,
14906
- status: text7(row2.status, 64),
15116
+ status: text8(row2.status, 64),
14907
15117
  canceled: row2.canceled === true
14908
15118
  };
14909
15119
  }));
@@ -14929,7 +15139,7 @@ function validSnapshot(snapshot, source) {
14929
15139
  if (snapshot?.version !== 1 || snapshot.site?.id !== source.chapterId || !partition || !Array.isArray(records) || records.length > MAX_RECORDS || partition.appId !== source.appId || partition.chapterId !== source.chapterId || partition.environment !== source.environment || partition.dataEnvironment !== source.dataEnvironment || partition.runtime !== source.runtime || typeof snapshot.complete !== "boolean" || typeof snapshot.truncated !== "boolean" || !snapshot.counts) return false;
14930
15140
  const seen = /* @__PURE__ */ new Set();
14931
15141
  for (const record4 of records) {
14932
- if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text7(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
15142
+ if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text8(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
14933
15143
  const key3 = recordKey(record4);
14934
15144
  if (seen.has(key3)) return false;
14935
15145
  seen.add(key3);
@@ -14940,16 +15150,16 @@ async function reconcileCommercialParitySnapshot(input) {
14940
15150
  if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
14941
15151
  const sourceKey = networkCommercialSourceKey(input.source);
14942
15152
  const key3 = await parityKey(input.secret);
14943
- const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text7(row2.applicationId) && text7(row2.objectId)).map(async (row2) => {
14944
- const customerId = text7(row2.customerId);
14945
- const subscriptionId = text7(row2.subscriptionId) ?? text7(row2.objectId);
15153
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text8(row2.applicationId) && text8(row2.objectId)).map(async (row2) => {
15154
+ const customerId = text8(row2.customerId);
15155
+ const subscriptionId = text8(row2.subscriptionId) ?? text8(row2.objectId);
14946
15156
  return {
14947
15157
  applicationDigest: await digestWithKey(key3, input.snapshot.partition, "application", row2.applicationId),
14948
15158
  customerDigest: customerId ? await digestWithKey(key3, input.snapshot.partition, "customer", customerId) : null,
14949
15159
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.snapshot.partition, "subscription", subscriptionId) : null,
14950
15160
  status: null,
14951
15161
  canceled: false,
14952
- stripeStatus: text7(row2.status, 64),
15162
+ stripeStatus: text8(row2.status, 64),
14953
15163
  stripeCanceled: row2.status === "canceled"
14954
15164
  };
14955
15165
  }));
@@ -15159,18 +15369,24 @@ async function applyHostedMembershipDispute(env, ctx, fact, now) {
15159
15369
  const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
15160
15370
  if (!secret) throw Object.assign(new Error("membership_dispute_provider_unavailable"), { code: "membership_dispute_provider_unavailable" });
15161
15371
  const charge = await readDisputedCharge(secret, fact.chargeId);
15372
+ const scopeOf = (row2) => ({
15373
+ appId: String(row2.appId),
15374
+ chapterId: String(row2.chapterId),
15375
+ environment: String(row2.environment),
15376
+ runtime: String(row2.runtime),
15377
+ applicationId: String(row2.applicationId),
15378
+ skuId: String(row2.skuId)
15379
+ });
15380
+ if (charge.seatId) {
15381
+ const seat = await membershipLoad(db, "networkSeat", charge.seatId);
15382
+ if (!seat || seat.environment !== env.ODLA_ENV || !disputedChargeCoversSeat(charge, seat)) return { disposition: "not_applicable" };
15383
+ const receipt2 = await reconcileMembershipDispute(db, { ...scopeOf(seat), eventId: fact.eventId }, now, secret, charge);
15384
+ return { disposition: "applied", membershipId: receipt2.membershipId, seatId: charge.seatId };
15385
+ }
15162
15386
  const rows5 = (await db.query({ networkMembership: { $: { where: { providerCustomerId: charge.customerId, environment: env.ODLA_ENV }, limit: 5 } } })).networkMembership ?? [];
15163
15387
  const member = rows5.find((row2) => disputedChargeCoversMembership(charge, row2));
15164
15388
  if (!member) return { disposition: "not_applicable" };
15165
- const target = {
15166
- appId: String(member.appId),
15167
- chapterId: String(member.chapterId),
15168
- environment: String(member.environment),
15169
- runtime: String(member.runtime),
15170
- applicationId: String(member.applicationId),
15171
- skuId: String(member.skuId)
15172
- };
15173
- const receipt = await reconcileMembershipDispute(db, { ...target, eventId: fact.eventId }, now, secret, charge);
15389
+ const receipt = await reconcileMembershipDispute(db, { ...scopeOf(member), eventId: fact.eventId }, now, secret, charge);
15174
15390
  return { disposition: "applied", membershipId: receipt.membershipId };
15175
15391
  }
15176
15392
 
@@ -15238,11 +15454,11 @@ function safeProjection(projection) {
15238
15454
  amountReceivedCents: seat.amountReceivedCents,
15239
15455
  refundedCents: seat.refundedCents ?? 0
15240
15456
  })),
15241
- disputes: (projection.disputes ?? []).map((dispute) => ({
15242
- ...row2(dispute),
15243
- chargeId: dispute.chargeId ?? null,
15244
- paymentIntentId: dispute.paymentIntentId ?? null,
15245
- reason: dispute.disputeReason ?? null
15457
+ disputes: (projection.disputes ?? []).map((dispute2) => ({
15458
+ ...row2(dispute2),
15459
+ chargeId: dispute2.chargeId ?? null,
15460
+ paymentIntentId: dispute2.paymentIntentId ?? null,
15461
+ reason: dispute2.disputeReason ?? null
15246
15462
  })),
15247
15463
  exceptions: projection.exceptions
15248
15464
  };
@@ -15276,10 +15492,10 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
15276
15492
  if (result.status === "collision") {
15277
15493
  console.error("network commercial event collision", JSON.stringify({ eventId: fact.eventId, eventType: fact.eventType }));
15278
15494
  }
15279
- let dispute;
15495
+ let dispute2;
15280
15496
  if (fact.objectKind === "dispute" && result.status !== "collision") {
15281
15497
  try {
15282
- dispute = await applyHostedMembershipDispute(env, ctx, fact, Date.now());
15498
+ dispute2 = await applyHostedMembershipDispute(env, ctx, fact, Date.now());
15283
15499
  } catch (error) {
15284
15500
  console.warn("membership dispute pending", JSON.stringify({ eventId: fact.eventId, code: error.code ?? "unknown" }));
15285
15501
  return json({ error: "membership dispute reconciliation pending", eventId: fact.eventId }, 503);
@@ -15289,7 +15505,7 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
15289
15505
  ok: true,
15290
15506
  status: result.status,
15291
15507
  eventId: fact.eventId,
15292
- ...dispute ? { dispute: dispute.disposition } : {},
15508
+ ...dispute2 ? { dispute: dispute2.disposition } : {},
15293
15509
  ...result.status === "replayed" && result.resolvedCollision ? { resolvedCollision: true } : {}
15294
15510
  });
15295
15511
  } catch (error) {
@@ -15487,7 +15703,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
15487
15703
  function unknownKeys3(value, allowed2, path, errors) {
15488
15704
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
15489
15705
  }
15490
- function text8(value, path, errors, max = MAX_TEXT2, required = true) {
15706
+ function text9(value, path, errors, max = MAX_TEXT2, required = true) {
15491
15707
  if (value === void 0 && !required) return void 0;
15492
15708
  if (typeof value !== "string" || required && !value.trim()) {
15493
15709
  errors.push(`${path} must be a non-empty string`);
@@ -15513,7 +15729,7 @@ function scopeValue(value, path, errors) {
15513
15729
  return { kind: raw2.kind };
15514
15730
  }
15515
15731
  if (raw2.kind === "chapter") {
15516
- const chapterId = text8(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
15732
+ const chapterId = text9(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
15517
15733
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
15518
15734
  return { kind: "chapter", chapterId };
15519
15735
  }
@@ -15527,22 +15743,22 @@ function resourceValue(value, path, errors) {
15527
15743
  return null;
15528
15744
  }
15529
15745
  unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
15530
- const id7 = text8(raw2.id, `${path}.id`, errors, 80) ?? "";
15746
+ const id7 = text9(raw2.id, `${path}.id`, errors, 80) ?? "";
15531
15747
  if (id7 && !ID5.test(id7)) errors.push(`${path}.id must be lowercase kebab-case`);
15532
- const kind = text8(raw2.kind, `${path}.kind`, errors) ?? "";
15748
+ const kind = text9(raw2.kind, `${path}.kind`, errors) ?? "";
15533
15749
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
15534
- const title = text8(raw2.title, `${path}.title`, errors, 240) ?? "";
15535
- const summary = text8(raw2.summary, `${path}.summary`, errors, 1e3, false);
15536
- const status2 = text8(raw2.status, `${path}.status`, errors) ?? "";
15750
+ const title = text9(raw2.title, `${path}.title`, errors, 240) ?? "";
15751
+ const summary = text9(raw2.summary, `${path}.summary`, errors, 1e3, false);
15752
+ const status2 = text9(raw2.status, `${path}.status`, errors) ?? "";
15537
15753
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
15538
15754
  const revision = integer4(raw2.revision, `${path}.revision`, errors);
15539
15755
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
15540
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text8(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
15756
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text9(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
15541
15757
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
15542
15758
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
15543
15759
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
15544
- const bodyMarkdown = text8(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
15545
- const url = text8(raw2.url, `${path}.url`, errors, 2048, false);
15760
+ const bodyMarkdown = text9(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
15761
+ const url = text9(raw2.url, `${path}.url`, errors, 2048, false);
15546
15762
  if (url) {
15547
15763
  try {
15548
15764
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -15554,9 +15770,9 @@ function resourceValue(value, path, errors) {
15554
15770
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
15555
15771
  if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
15556
15772
  const brandAsset = asset ? {
15557
- bookId: text8(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
15558
- assetId: text8(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
15559
- ...asset.filename === void 0 ? {} : { filename: text8(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
15773
+ bookId: text9(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
15774
+ assetId: text9(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
15775
+ ...asset.filename === void 0 ? {} : { filename: text9(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
15560
15776
  } : void 0;
15561
15777
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
15562
15778
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -15571,17 +15787,17 @@ function resourceValue(value, path, errors) {
15571
15787
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
15572
15788
  if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
15573
15789
  const promotedFrom = promotedRaw ? {
15574
- chapterId: text8(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
15575
- resourceId: text8(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
15790
+ chapterId: text9(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
15791
+ resourceId: text9(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
15576
15792
  resourceRevision: integer4(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
15577
15793
  documentRevision: integer4(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
15578
- promotedBy: text8(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
15794
+ promotedBy: text9(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
15579
15795
  promotedAt: integer4(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
15580
15796
  } : void 0;
15581
15797
  const provenance2 = {
15582
- createdBy: text8(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
15798
+ createdBy: text9(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
15583
15799
  createdAt: integer4(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
15584
- updatedBy: text8(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
15800
+ updatedBy: text9(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
15585
15801
  updatedAt: integer4(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
15586
15802
  ...promotedFrom ? { promotedFrom } : {}
15587
15803
  };
@@ -15600,7 +15816,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
15600
15816
  if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
15601
15817
  const sourceType = sourceRaw?.type;
15602
15818
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
15603
- const sourceAppId = text8(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
15819
+ const sourceAppId = text9(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
15604
15820
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
15605
15821
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
15606
15822
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -15608,8 +15824,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
15608
15824
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
15609
15825
  if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
15610
15826
  const target = targetRaw ? {
15611
- appId: text8(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
15612
- environment: text8(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
15827
+ appId: text9(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
15828
+ environment: text9(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
15613
15829
  } : void 0;
15614
15830
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
15615
15831
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -17134,6 +17350,19 @@ var id5 = () => attr("string", false, true, true);
17134
17350
  var key = (optional = false) => attr("string", optional, false, true);
17135
17351
  var scope = () => ({ appId: key(), chapterId: key(), environment: key(), runtime: key(), applicationId: key(), skuId: key() });
17136
17352
  var times = () => ({ revision: attr("number"), createdAt: attr("number"), updatedAt: attr("number") });
17353
+ var dispute = () => ({
17354
+ disputeId: key(true),
17355
+ disputeStatus: attr("string", true),
17356
+ disputeReason: attr("string", true),
17357
+ disputeAmountCents: attr("number", true),
17358
+ disputedAt: attr("number", true),
17359
+ disputeClosedAt: attr("number", true),
17360
+ disputePriorStatus: attr("string", true),
17361
+ disputeEventId: key(true),
17362
+ rechargePaymentIntentId: key(true),
17363
+ rechargedAt: attr("number", true),
17364
+ rechargeAttempt: attr("json", true)
17365
+ });
17137
17366
  var membershipAuthorityEntities = {
17138
17367
  networkMembership: { attrs: {
17139
17368
  id: id5(),
@@ -17174,19 +17403,7 @@ var membershipAuthorityEntities = {
17174
17403
  ...times(),
17175
17404
  refundRequestedAt: attr("number", true),
17176
17405
  restartId: key(true),
17177
- // A card-network dispute on this membership's payment: Stripe's dispute id and status, the reason the cardholder gave,
17178
- // the status the membership held before it was suspended, and the re-issued charge once an operator collected again.
17179
- disputeId: key(true),
17180
- disputeStatus: attr("string", true),
17181
- disputeReason: attr("string", true),
17182
- disputeAmountCents: attr("number", true),
17183
- disputedAt: attr("number", true),
17184
- disputeClosedAt: attr("number", true),
17185
- disputePriorStatus: attr("string", true),
17186
- disputeEventId: key(true),
17187
- rechargePaymentIntentId: key(true),
17188
- rechargedAt: attr("number", true),
17189
- rechargeAttempt: attr("json", true)
17406
+ ...dispute()
17190
17407
  } },
17191
17408
  membershipRestart: { attrs: {
17192
17409
  id: id5(),
@@ -17262,7 +17479,8 @@ var membershipAuthorityEntities = {
17262
17479
  refundedCents: attr("number", true),
17263
17480
  providerRefundIds: attr("json", true),
17264
17481
  regiftedAt: attr("number", true),
17265
- formerRecipients: attr("json", true)
17482
+ formerRecipients: attr("json", true),
17483
+ ...dispute()
17266
17484
  } },
17267
17485
  networkSeatRecipient: { attrs: { id: id5(), appId: key(), chapterId: key(), environment: key(), runtime: key(), seatId: key(), recipientEmailNormalized: key(), createdAt: attr("number") } },
17268
17486
  membershipEffect: { attrs: {
@@ -17599,7 +17817,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
17599
17817
  };
17600
17818
  signal.addEventListener("abort", cancel, { once: true });
17601
17819
  let size = 0;
17602
- let text9 = "";
17820
+ let text10 = "";
17603
17821
  const decoder = new TextDecoder();
17604
17822
  try {
17605
17823
  while (true) {
@@ -17609,10 +17827,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
17609
17827
  if (done) break;
17610
17828
  size += value.byteLength;
17611
17829
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
17612
- text9 += decoder.decode(value, { stream: true });
17830
+ text10 += decoder.decode(value, { stream: true });
17613
17831
  }
17614
17832
  try {
17615
- return JSON.parse(text9 + decoder.decode());
17833
+ return JSON.parse(text10 + decoder.decode());
17616
17834
  } catch {
17617
17835
  throw new TypeError(`invalid ${label}`);
17618
17836
  }
@@ -18390,6 +18608,7 @@ export {
18390
18608
  deliverMemberContent,
18391
18609
  deliverMembershipChapterEffect,
18392
18610
  deliverSignupControl,
18611
+ disputedChargeCoversSeat,
18393
18612
  drainMembershipEffects,
18394
18613
  grantIncludedNamedSeat,
18395
18614
  liftSeatPurchaseWindow,
@@ -18401,7 +18620,9 @@ export {
18401
18620
  readDisputedCharge,
18402
18621
  readNamedSeatOffer,
18403
18622
  rechargeDisputedMembership,
18623
+ rechargeDisputedSeat,
18404
18624
  reconcileMembershipDispute,
18625
+ reconcileNamedSeatDispute,
18405
18626
  refundNamedSeatByOperator,
18406
18627
  regiftNamedSeat,
18407
18628
  releaseNamedSeatRecipientApplications,