@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.
@@ -42,6 +42,7 @@ __export(worker_exports, {
42
42
  deliverMemberContent: () => deliverMemberContent,
43
43
  deliverMembershipChapterEffect: () => deliverMembershipChapterEffect,
44
44
  deliverSignupControl: () => deliverSignupControl,
45
+ disputedChargeCoversSeat: () => disputedChargeCoversSeat,
45
46
  drainMembershipEffects: () => drainMembershipEffects,
46
47
  grantIncludedNamedSeat: () => grantIncludedNamedSeat,
47
48
  liftSeatPurchaseWindow: () => liftSeatPurchaseWindow,
@@ -53,7 +54,9 @@ __export(worker_exports, {
53
54
  readDisputedCharge: () => readDisputedCharge,
54
55
  readNamedSeatOffer: () => readNamedSeatOffer,
55
56
  rechargeDisputedMembership: () => rechargeDisputedMembership,
57
+ rechargeDisputedSeat: () => rechargeDisputedSeat,
56
58
  reconcileMembershipDispute: () => reconcileMembershipDispute,
59
+ reconcileNamedSeatDispute: () => reconcileNamedSeatDispute,
57
60
  refundNamedSeatByOperator: () => refundNamedSeatByOperator,
58
61
  regiftNamedSeat: () => regiftNamedSeat,
59
62
  releaseNamedSeatRecipientApplications: () => releaseNamedSeatRecipientApplications,
@@ -1697,9 +1700,9 @@ async function backfillCrm(deps) {
1697
1700
  }
1698
1701
 
1699
1702
  // src/email-retirement.ts
1700
- function isRetiredEmailCopy(subject, text9) {
1703
+ function isRetiredEmailCopy(subject, text10) {
1701
1704
  const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
1702
- const bodyCopy = text9.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1705
+ const bodyCopy = text10.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
1703
1706
  return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
1704
1707
  }
1705
1708
 
@@ -1777,10 +1780,51 @@ function planDelivery(input) {
1777
1780
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
1778
1781
  if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
1779
1782
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
1780
- const text9 = redirect ? `(dev redirect; original recipient: ${input.to})
1783
+ const text10 = redirect ? `(dev redirect; original recipient: ${input.to})
1781
1784
 
1782
1785
  ` + renderedText : renderedText;
1783
- return { deliver: true, transport, to, subject, text: text9, redirected: redirect };
1786
+ return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1787
+ }
1788
+
1789
+ // src/email-html.ts
1790
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1791
+ function escapeHtml(s) {
1792
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1793
+ }
1794
+ function lineHtml(line) {
1795
+ let out = "", last = 0;
1796
+ for (const match of line.matchAll(URL_RE)) {
1797
+ out += escapeHtml(line.slice(last, match.index));
1798
+ const url = escapeHtml(match[0]);
1799
+ out += `<a href="${url}">${url}</a>`;
1800
+ last = match.index + match[0].length;
1801
+ }
1802
+ return out + escapeHtml(line.slice(last));
1803
+ }
1804
+ function textToHtml(text10) {
1805
+ const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1806
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1807
+ return `<!doctype html>
1808
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1809
+ <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">
1810
+ ${body}
1811
+ </body></html>`;
1812
+ }
1813
+
1814
+ // src/email-deliverability.ts
1815
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1816
+ function isRecipientFacing(event2) {
1817
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1818
+ }
1819
+ function listUnsubscribeHeader(replyTo) {
1820
+ const address = replyTo.trim();
1821
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1822
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1823
+ }
1824
+ function deliverabilityHeaders(input) {
1825
+ if (!isRecipientFacing(input.event)) return void 0;
1826
+ const value = listUnsubscribeHeader(input.replyTo);
1827
+ return value ? { "List-Unsubscribe": value } : void 0;
1784
1828
  }
1785
1829
 
1786
1830
  // src/notify.ts
@@ -1928,8 +1972,8 @@ function formatInstant(at2, timeZone) {
1928
1972
  var adminUrlFor = (origin) => `${origin}/admin/`;
1929
1973
  var membersUrlFor = (origin) => `${origin}/members/`;
1930
1974
  function applicantVariables(app) {
1931
- const text9 = (v) => typeof v === "string" ? v : "";
1932
- return { firstName: text9(app.firstName), lastName: text9(app.lastName), email: text9(app.email), phone: text9(app.phone), state: text9(app.state) };
1975
+ const text10 = (v) => typeof v === "string" ? v : "";
1976
+ return { firstName: text10(app.firstName), lastName: text10(app.lastName), email: text10(app.email), phone: text10(app.phone), state: text10(app.state) };
1933
1977
  }
1934
1978
  function sampleVariables(origin, timeZone, now = Date.now()) {
1935
1979
  const startAt = formatInstant(now + 864e5, timeZone);
@@ -2054,11 +2098,16 @@ async function sendTemplated(deps, input) {
2054
2098
  if (decision.transport === "log-only") return { sent: true };
2055
2099
  let messageId;
2056
2100
  try {
2101
+ const text10 = String(row2.body);
2102
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
2103
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
2057
2104
  const response = await deps.sender.send({
2058
2105
  from: fromAddress(deps.from, input.group.fromName),
2059
2106
  to: [decision.to],
2060
2107
  subject: String(row2.subject),
2061
- text: String(row2.body),
2108
+ text: text10,
2109
+ ...html ? { html } : {},
2110
+ ...headers ? { headers } : {},
2062
2111
  replyTo: input.group.replyTo
2063
2112
  });
2064
2113
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -3663,12 +3712,87 @@ function paymentLineItems(group, tier) {
3663
3712
 
3664
3713
  // src/membership-dispute-authority.ts
3665
3714
  var import_stripe6 = require("@odla-ai/stripe");
3715
+
3716
+ // src/membership-dispute-transition.ts
3666
3717
  var MEMBERSHIP_DISPUTED_STATUS = "payment_disputed";
3667
3718
  var DISPUTE_WON_STATUSES = ["won", "warning_closed"];
3668
3719
  var CLOSED_STATUSES = [...DISPUTE_WON_STATUSES, "lost"];
3669
- var TERMINAL = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
3720
+ function disputeTransition(row2, fact, eventId, now, terminal) {
3721
+ const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
3722
+ const sameDispute = row2.disputeId === fact.objectId;
3723
+ if (sameDispute && Number(row2.disputeClosedAt ?? 0) > 0 && !closed) return null;
3724
+ const won = DISPUTE_WON_STATUSES.includes(status2);
3725
+ const suspended = row2.status === MEMBERSHIP_DISPUTED_STATUS, done = terminal.includes(String(row2.status));
3726
+ const restored = String(row2.disputePriorStatus ?? (row2.journeyApprovedAt ? "active" : "paid_pending_approval"));
3727
+ const next = done ? String(row2.status) : won ? suspended ? restored : String(row2.status) : MEMBERSHIP_DISPUTED_STATUS;
3728
+ return {
3729
+ status: next,
3730
+ disputeId: fact.objectId,
3731
+ disputeStatus: status2,
3732
+ disputeEventId: eventId,
3733
+ disputeClosedAt: closed ? now : 0,
3734
+ ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
3735
+ ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
3736
+ ...sameDispute ? {} : { disputedAt: now },
3737
+ ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(row2.status) } : {},
3738
+ revision: Number(row2.revision) + 1,
3739
+ updatedAt: now
3740
+ };
3741
+ }
3742
+
3743
+ // src/membership-seat-dispute.ts
3670
3744
  var text2 = (value) => typeof value === "string" && value ? value : void 0;
3671
- var refOf = (value) => typeof value === "string" ? text2(value) : text2(value?.id);
3745
+ var TERMINAL = ["payment_pending", "payment_processing", "payment_canceling", "refund_pending", "refunded", "canceled"];
3746
+ var namedSeatDisputeStanding = (seat, applied) => ({ seatId: String(seat.id), status: String(seat.status), ...text2(seat.disputeStatus) ? { disputeStatus: text2(seat.disputeStatus) } : {}, applied });
3747
+ function disputedChargeCoversSeat(charge, seat) {
3748
+ return charge.seatId === seat.id && charge.customerId === seat.providerCustomerId && charge.paymentIntentId !== void 0 && (charge.paymentIntentId === seat.providerPaymentIntentId || charge.paymentIntentId === seat.rechargePaymentIntentId);
3749
+ }
3750
+ async function reconcileNamedSeatDispute(db, input, now, charge, evidence) {
3751
+ if (!charge.seatId) membershipFail("membership_dispute_scope_mismatch");
3752
+ const membership = async (applied, seat) => {
3753
+ const id7 = await networkMembershipId(input), member = await membershipLoad(db, "networkMembership", id7);
3754
+ requireMembershipScope(member, input);
3755
+ return {
3756
+ membershipId: id7,
3757
+ status: String(member.status),
3758
+ termEndsAt: Number(member.termEndsAt),
3759
+ graceEndsAt: Number(member.graceEndsAt ?? 0),
3760
+ autoRenew: member.autoRenew === true,
3761
+ applied,
3762
+ ...text2(member.disputeStatus) ? { disputeStatus: text2(member.disputeStatus) } : {},
3763
+ ...seat ? { seat: namedSeatDisputeStanding(seat, applied) } : {}
3764
+ };
3765
+ };
3766
+ for (let attempt = 0; attempt < 8; attempt++) {
3767
+ const state = await loadMembershipAuthorityState(db, input), seat = await membershipLoad(db, "networkSeat", charge.seatId);
3768
+ if (!seat) return membership(false);
3769
+ requireMembershipScope(seat, input);
3770
+ if (charge.customerId !== seat.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3771
+ if (!disputedChargeCoversSeat(charge, seat) || seat.disputeEventId === input.eventId) return membership(seat.disputeEventId === input.eventId, seat);
3772
+ const attrs = disputeTransition(seat, evidence.fact, input.eventId, now, TERMINAL);
3773
+ if (!attrs) return membership(false, seat);
3774
+ try {
3775
+ await db.transact([membershipUpdate("networkSeat", String(seat.id), attrs)], { mutationId: `seat-dispute:${seat.id}:${input.eventId}`, guards: [
3776
+ { ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision } },
3777
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
3778
+ { ns: "networkCommercialEvents", id: String(evidence.row.id), exists: true, equals: { digest: evidence.fact.digest } },
3779
+ evidence.guard
3780
+ ] });
3781
+ } catch (error) {
3782
+ if (error.code !== "transact_guard_failed") throw error;
3783
+ continue;
3784
+ }
3785
+ const saved = await membershipLoad(db, "networkSeat", String(seat.id));
3786
+ requireMembershipScope(saved, input);
3787
+ return membership(true, saved);
3788
+ }
3789
+ return membershipFail("membership_dispute_retry_required");
3790
+ }
3791
+
3792
+ // src/membership-dispute-authority.ts
3793
+ var TERMINAL2 = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
3794
+ var text3 = (value) => typeof value === "string" && value ? value : void 0;
3795
+ var refOf = (value) => typeof value === "string" ? text3(value) : text3(value?.id);
3672
3796
  async function readDisputedCharge(secretKey, chargeId, fetch2) {
3673
3797
  if (!/^ch_[A-Za-z0-9]+$/.test(chargeId)) membershipFail("invalid_dispute_charge");
3674
3798
  const config = { secretKey, ...fetch2 ? { fetch: fetch2 } : {} };
@@ -3682,7 +3806,7 @@ async function readDisputedCharge(secretKey, chargeId, fetch2) {
3682
3806
  if (!invoice.ok || invoice.body.id !== invoiceId) membershipFail("membership_dispute_provider_unavailable");
3683
3807
  subscriptionId = refOf(invoice.body.subscription) ?? refOf(invoice.body.parent?.subscription_details?.subscription);
3684
3808
  }
3685
- const seatId = text2(charge.body.metadata?.seatId);
3809
+ const seatId = text3(charge.body.metadata?.seatId);
3686
3810
  return {
3687
3811
  chargeId,
3688
3812
  customerId,
@@ -3708,37 +3832,23 @@ async function reconcileMembershipDispute(db, input, now, secretKey, resolved) {
3708
3832
  termEndsAt: Number(value.termEndsAt),
3709
3833
  graceEndsAt: Number(value.graceEndsAt ?? 0),
3710
3834
  autoRenew: value.autoRenew === true,
3711
- ...text2(value.disputeStatus) ? { disputeStatus: text2(value.disputeStatus) } : {},
3835
+ ...text3(value.disputeStatus) ? { disputeStatus: text3(value.disputeStatus) } : {},
3712
3836
  applied
3713
3837
  });
3714
3838
  const known = await membershipLoad(db, "networkMembership", id7);
3715
3839
  requireMembershipScope(known, input);
3716
3840
  if (known.disputeEventId === input.eventId) return receipt(known);
3841
+ const seatKnown = known.namedSeatId ? await membershipLoad(db, "networkSeat", String(known.namedSeatId)) : void 0;
3842
+ if (seatKnown?.disputeEventId === input.eventId) return { ...receipt(known, true), seat: namedSeatDisputeStanding(seatKnown, true) };
3717
3843
  const charge = resolved && resolved.chargeId === fact.chargeId ? resolved : await readDisputedCharge(secretKey, fact.chargeId);
3844
+ if (charge.customerId !== known.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3845
+ if (charge.seatId) return reconcileNamedSeatDispute(db, input, now, charge, { row: row2, fact, guard: evidenceGuard });
3718
3846
  for (let attempt = 0; attempt < 8; attempt++) {
3719
3847
  const state = await loadMembershipAuthorityState(db, input), member = await membershipLoad(db, "networkMembership", id7);
3720
3848
  requireMembershipScope(member, input);
3721
- if (charge.customerId !== member.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
3722
3849
  if (!disputedChargeCoversMembership(charge, member) || member.disputeEventId === input.eventId) return receipt(member);
3723
- const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
3724
- const sameDispute = member.disputeId === fact.objectId;
3725
- if (sameDispute && Number(member.disputeClosedAt ?? 0) > 0 && !closed) return receipt(member, false);
3726
- const won = DISPUTE_WON_STATUSES.includes(status2);
3727
- const suspended = member.status === MEMBERSHIP_DISPUTED_STATUS, terminal = TERMINAL.includes(String(member.status));
3728
- const next = terminal ? String(member.status) : won ? suspended ? String(member.disputePriorStatus ?? (member.journeyApprovedAt ? "active" : "paid_pending_approval")) : String(member.status) : MEMBERSHIP_DISPUTED_STATUS;
3729
- const attrs = {
3730
- status: next,
3731
- disputeId: fact.objectId,
3732
- disputeStatus: status2,
3733
- disputeEventId: input.eventId,
3734
- disputeClosedAt: closed ? now : 0,
3735
- ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
3736
- ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
3737
- ...sameDispute ? {} : { disputedAt: now },
3738
- ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(member.status) } : {},
3739
- revision: Number(member.revision) + 1,
3740
- updatedAt: now
3741
- };
3850
+ const attrs = disputeTransition(member, fact, input.eventId, now, TERMINAL2);
3851
+ if (!attrs) return receipt(member, false);
3742
3852
  try {
3743
3853
  await db.transact([membershipUpdate("networkMembership", id7, attrs)], { mutationId: `membership-dispute:${id7}:${input.eventId}`, guards: [
3744
3854
  { ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision } },
@@ -4860,6 +4970,9 @@ function namedSeatRecipientState(seat) {
4860
4970
  return seat.refundReason === "declined" ? "declined" : "refunded";
4861
4971
  case "canceled":
4862
4972
  return "canceled";
4973
+ // A card-network dispute on the seat's own payment pauses the recipient until it is won or re-charged.
4974
+ case "payment_disputed":
4975
+ return "disputed";
4863
4976
  default:
4864
4977
  return "payment_pending";
4865
4978
  }
@@ -5188,6 +5301,7 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
5188
5301
 
5189
5302
  // src/membership-dispute-recharge.ts
5190
5303
  var import_stripe9 = require("@odla-ai/stripe");
5304
+ var NS = { membership: "networkMembership", seat: "networkSeat" };
5191
5305
  var fail6 = (code, extra = {}) => {
5192
5306
  throw Object.assign(new Error(code), { code, ...extra });
5193
5307
  };
@@ -5201,65 +5315,64 @@ async function defaultPaymentMethod(config, subscriptionId, customerId) {
5201
5315
  if (!customer.ok) fail6("membership_dispute_provider_unavailable");
5202
5316
  return refOf2(customer.body.invoice_settings?.default_payment_method);
5203
5317
  }
5204
- async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5318
+ async function rechargeDisputed(db, kind, id7, secretKey, now) {
5205
5319
  membershipClock(now);
5206
- let member = await membershipLoad(db, "networkMembership", membershipId);
5207
- if (!member) fail6("membership_not_found");
5208
- const scope2 = { chapterId: String(member.chapterId), environment: String(member.environment) };
5209
- const amountCents = Number(member.disputeAmountCents ?? member.amountCents), currency = String(member.currency);
5320
+ const ns = NS[kind];
5321
+ let row2 = await membershipLoad(db, ns, id7);
5322
+ if (!row2) fail6("membership_not_found");
5323
+ const scope2 = { chapterId: String(row2.chapterId), environment: String(row2.environment) };
5324
+ const membershipId = kind === "membership" ? id7 : String(row2.primaryMembershipId);
5325
+ const amountCents = Number(row2.disputeAmountCents ?? row2.amountCents), currency = String(row2.currency);
5210
5326
  const done = (value, replayed) => ({
5211
5327
  membershipId,
5328
+ ...kind === "seat" ? { seatId: id7 } : {},
5212
5329
  status: String(value.status),
5213
5330
  paymentIntentId: String(value.rechargePaymentIntentId),
5214
5331
  amountCents,
5215
5332
  currency,
5216
5333
  ...replayed ? { replayed } : {}
5217
5334
  });
5218
- if (member.status !== MEMBERSHIP_DISPUTED_STATUS) {
5219
- if (member.disputeStatus === "recharged" && member.rechargePaymentIntentId) return done(member, true);
5335
+ if (row2.status !== MEMBERSHIP_DISPUTED_STATUS) {
5336
+ if (row2.disputeStatus === "recharged" && row2.rechargePaymentIntentId) return done(row2, true);
5220
5337
  fail6("membership_not_disputed");
5221
5338
  }
5222
- if (member.disputeStatus !== "lost") fail6("membership_dispute_open");
5339
+ if (row2.disputeStatus !== "lost") fail6("membership_dispute_open");
5223
5340
  if (!Number.isSafeInteger(amountCents) || amountCents <= 0 || !/^[a-z]{3}$/.test(currency)) fail6("membership_dispute_amount_invalid");
5224
- let attempt = member.rechargeAttempt;
5341
+ let attempt = row2.rechargeAttempt;
5225
5342
  if (!attempt || attempt.finishedAt !== void 0) {
5226
- attempt = { key: `membership-recharge:${membershipId}:${String(member.disputeId)}:${now}`, startedAt: now };
5343
+ attempt = { key: `${kind}-recharge:${id7}:${String(row2.disputeId)}:${now}`, startedAt: now };
5227
5344
  await db.transact(
5228
- [membershipUpdate("networkMembership", membershipId, { rechargeAttempt: attempt, revision: Number(member.revision) + 1, updatedAt: now })],
5229
- { mutationId: `membership-recharge-attempt:${attempt.key}`, guards: [{
5230
- ns: "networkMembership",
5231
- id: membershipId,
5232
- exists: true,
5233
- equals: { revision: member.revision, status: MEMBERSHIP_DISPUTED_STATUS }
5234
- }] }
5345
+ [membershipUpdate(ns, id7, { rechargeAttempt: attempt, revision: Number(row2.revision) + 1, updatedAt: now })],
5346
+ { mutationId: `membership-recharge-attempt:${attempt.key}`, guards: [{ ns, id: id7, exists: true, equals: { revision: row2.revision, status: MEMBERSHIP_DISPUTED_STATUS } }] }
5235
5347
  );
5236
- member = await membershipLoad(db, "networkMembership", membershipId);
5237
- if (!member || member.status !== MEMBERSHIP_DISPUTED_STATUS) fail6("membership_dispute_changed");
5348
+ row2 = await membershipLoad(db, ns, id7);
5349
+ if (!row2 || row2.status !== MEMBERSHIP_DISPUTED_STATUS) fail6("membership_dispute_changed");
5238
5350
  }
5239
5351
  const config = { secretKey };
5240
- const paymentMethod = await defaultPaymentMethod(config, String(member.providerSubscriptionId), String(member.providerCustomerId));
5352
+ const paymentMethod = await defaultPaymentMethod(config, String(row2.providerSubscriptionId), String(row2.providerCustomerId));
5241
5353
  if (!paymentMethod) fail6("recharge_payment_method_unavailable");
5242
5354
  const intent = await (0, import_stripe9.stripeRequest)(config, "POST", "/v1/payment_intents", {
5243
5355
  amount: amountCents,
5244
5356
  currency,
5245
- customer: String(member.providerCustomerId),
5357
+ customer: String(row2.providerCustomerId),
5246
5358
  payment_method: paymentMethod,
5247
5359
  off_session: "true",
5248
5360
  confirm: "true",
5249
- description: `Membership payment reissued after dispute ${String(member.disputeId)}`,
5361
+ description: `${kind === "seat" ? "Named seat" : "Membership"} payment reissued after dispute ${String(row2.disputeId)}`,
5250
5362
  metadata: {
5251
- ...stripePartitionMetadata({ appId: String(member.appId), chapterId: scope2.chapterId, environment: scope2.environment, runtime: String(member.runtime) }),
5252
- applicationId: String(member.applicationId),
5363
+ ...stripePartitionMetadata({ appId: String(row2.appId), chapterId: scope2.chapterId, environment: scope2.environment, runtime: String(row2.runtime) }),
5364
+ applicationId: String(row2.applicationId),
5253
5365
  membershipId,
5254
- disputeId: String(member.disputeId),
5366
+ ...kind === "seat" ? { seatId: id7 } : {},
5367
+ disputeId: String(row2.disputeId),
5255
5368
  membershipRecharge: "true"
5256
5369
  }
5257
5370
  }, { idempotencyKey: attempt.key });
5258
5371
  const body = intent.body, error = body.error ?? {}, paymentStatus = String(body.status ?? "");
5259
5372
  const finish = async (outcome) => {
5260
- const current = await membershipLoad(db, "networkMembership", membershipId);
5373
+ const current = await membershipLoad(db, ns, id7);
5261
5374
  if (!current) return;
5262
- await db.transact([membershipUpdate("networkMembership", membershipId, {
5375
+ await db.transact([membershipUpdate(ns, id7, {
5263
5376
  rechargeAttempt: { ...attempt, finishedAt: now, outcome },
5264
5377
  revision: Number(current.revision) + 1,
5265
5378
  updatedAt: now
@@ -5277,15 +5390,15 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5277
5390
  fail6("recharge_declined", { declineCode: String(body.last_payment_error?.decline_code ?? paymentStatus) });
5278
5391
  }
5279
5392
  if (paymentStatus !== "succeeded") fail6("recharge_pending", { paymentStatus });
5280
- if (refOf2(body.customer) !== member.providerCustomerId || body.amount !== amountCents) fail6("recharge_mismatch");
5393
+ if (refOf2(body.customer) !== row2.providerCustomerId || body.amount !== amountCents) fail6("recharge_mismatch");
5281
5394
  for (let retry = 0; retry < 8; retry++) {
5282
- const state = await loadMembershipAuthorityState(db, scope2), current = await membershipLoad(db, "networkMembership", membershipId);
5395
+ const state = await loadMembershipAuthorityState(db, scope2), current = await membershipLoad(db, ns, id7);
5283
5396
  if (!current) fail6("membership_not_found");
5284
5397
  if (current.rechargePaymentIntentId === body.id && current.status !== MEMBERSHIP_DISPUTED_STATUS) return done(current, true);
5285
- const status2 = String(current.disputePriorStatus ?? (current.journeyApprovedAt ? "active" : "paid_pending_approval"));
5398
+ const status2 = String(current.disputePriorStatus ?? (kind === "seat" ? "active" : current.journeyApprovedAt ? "active" : "paid_pending_approval"));
5286
5399
  try {
5287
5400
  await db.transact(
5288
- [membershipUpdate("networkMembership", membershipId, {
5401
+ [membershipUpdate(ns, id7, {
5289
5402
  status: status2,
5290
5403
  disputeStatus: "recharged",
5291
5404
  rechargePaymentIntentId: String(body.id),
@@ -5295,7 +5408,7 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5295
5408
  updatedAt: now
5296
5409
  })],
5297
5410
  { mutationId: `membership-recharged:${attempt.key}`, guards: [
5298
- { ns: "networkMembership", id: membershipId, exists: true, equals: { revision: current.revision, status: MEMBERSHIP_DISPUTED_STATUS } },
5411
+ { ns, id: id7, exists: true, equals: { revision: current.revision, status: MEMBERSHIP_DISPUTED_STATUS } },
5299
5412
  { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
5300
5413
  ] }
5301
5414
  );
@@ -5303,10 +5416,12 @@ async function rechargeDisputedMembership(db, membershipId, secretKey, now) {
5303
5416
  if (cause.code !== "transact_guard_failed") throw cause;
5304
5417
  continue;
5305
5418
  }
5306
- return done(await membershipLoad(db, "networkMembership", membershipId));
5419
+ return done(await membershipLoad(db, ns, id7));
5307
5420
  }
5308
5421
  return fail6("membership_dispute_retry_required");
5309
5422
  }
5423
+ var rechargeDisputedMembership = (db, membershipId, secretKey, now) => rechargeDisputed(db, "membership", membershipId, secretKey, now);
5424
+ var rechargeDisputedSeat = (db, seatId, secretKey, now) => rechargeDisputed(db, "seat", seatId, secretKey, now);
5310
5425
 
5311
5426
  // src/worker-routes-admin-membership-disputes.ts
5312
5427
  var LIST2 = "/api/admin/membership-disputes";
@@ -5323,6 +5438,9 @@ var PUBLIC2 = [
5323
5438
  "memberNumber",
5324
5439
  "amountCents",
5325
5440
  "currency",
5441
+ "recipientName",
5442
+ "primaryMembershipId",
5443
+ "purchasedAt",
5326
5444
  "disputeId",
5327
5445
  "disputeStatus",
5328
5446
  "disputeReason",
@@ -5370,14 +5488,17 @@ var handleAdminMembershipDisputes = async (req, url, env, ctx) => {
5370
5488
  return json({ error: "the membership authority is not hosted by this deployment" }, 404);
5371
5489
  }
5372
5490
  if (!one) {
5373
- const rows5 = (await db.query({ networkMembership: { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 500 } } })).networkMembership ?? [];
5491
+ const page = { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 500 } };
5492
+ const found = await db.query({ networkMembership: page, networkSeat: page });
5493
+ const memberships = found.networkMembership ?? [], seats = found.networkSeat ?? [];
5494
+ const rows5 = [...memberships.map((row2) => ({ ...project2(row2), kind: "membership" })), ...seats.map((row2) => ({ ...project2(row2), kind: "seat" }))];
5374
5495
  const disputes = rows5.filter((row2) => typeof row2.disputeId === "string").sort((a, b) => Number(b.disputedAt ?? 0) - Number(a.disputedAt ?? 0));
5375
- return json({ disputes: disputes.map(project2), truncated: rows5.length >= 500 });
5496
+ return json({ disputes, truncated: memberships.length >= 500 || seats.length >= 500 });
5376
5497
  }
5377
5498
  const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
5378
5499
  if (!secret) return json({ error: "network payment provider unavailable" }, 503);
5379
5500
  try {
5380
- const receipt = await rechargeDisputedMembership(db, one[1], secret, Date.now());
5501
+ const receipt = await (one[1].startsWith("seat_") ? rechargeDisputedSeat : rechargeDisputedMembership)(db, one[1], secret, Date.now());
5381
5502
  return json({ ok: true, ...receipt });
5382
5503
  } catch (error) {
5383
5504
  const failure = error;
@@ -5397,13 +5518,13 @@ var handleAdminMembershipDisputes = async (req, url, env, ctx) => {
5397
5518
 
5398
5519
  // src/membership-policy-versions.ts
5399
5520
  var LIMIT = 200;
5400
- var text3 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
5521
+ var text4 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
5401
5522
  var instant = (value) => {
5402
5523
  const at2 = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
5403
5524
  return Number.isSafeInteger(at2) && at2 >= 0 ? at2 : void 0;
5404
5525
  };
5405
5526
  function provenance(row2) {
5406
- const source = text3(row2.source), actor = text3(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
5527
+ const source = text4(row2.source), actor = text4(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
5407
5528
  return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
5408
5529
  }
5409
5530
  var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
@@ -6404,8 +6525,8 @@ var APPLICATION_LABELS = {
6404
6525
  state: "State"
6405
6526
  };
6406
6527
  function humanize(id7) {
6407
- const text9 = id7.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
6408
- return text9 ? `${text9[0].toUpperCase()}${text9.slice(1)}` : id7;
6528
+ const text10 = id7.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
6529
+ return text10 ? `${text10[0].toUpperCase()}${text10.slice(1)}` : id7;
6409
6530
  }
6410
6531
  function applicationDefs(chapter) {
6411
6532
  try {
@@ -6472,7 +6593,7 @@ var ID3 = /^[a-z][a-z0-9-]{1,79}$/;
6472
6593
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
6473
6594
  var CURRENCY = /^[a-z]{3}$/;
6474
6595
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
6475
- var text4 = (value, path, errors, max) => {
6596
+ var text5 = (value, path, errors, max) => {
6476
6597
  if (typeof value !== "string" || !value.trim()) {
6477
6598
  errors.push(`${path} is required`);
6478
6599
  return "";
@@ -6500,23 +6621,23 @@ function validateGiftOfferCatalog(input, tierIds) {
6500
6621
  }
6501
6622
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
6502
6623
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
6503
- const id7 = text4(item.id, `${path}.id`, errors, 80);
6624
+ const id7 = text5(item.id, `${path}.id`, errors, 80);
6504
6625
  if (id7 && !ID3.test(id7)) errors.push(`${path}.id must be a lowercase kebab-case id`);
6505
6626
  if (ids2.has(id7)) errors.push(`${path}.id duplicates "${id7}"`);
6506
6627
  ids2.add(id7);
6507
6628
  const targetRaw = record(item.target);
6508
6629
  let target = { type: "entitlement", entitlementKey: "invalid" };
6509
6630
  if (targetRaw?.type === "tier") {
6510
- const tierId = text4(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
6631
+ const tierId = text5(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
6511
6632
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
6512
6633
  target = { type: "tier", tierId };
6513
6634
  } else if (targetRaw?.type === "entitlement") {
6514
- const entitlementKey = text4(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
6635
+ const entitlementKey = text5(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
6515
6636
  if (entitlementKey && !ID3.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
6516
6637
  target = { type: "entitlement", entitlementKey };
6517
6638
  } else errors.push(`${path}.target.type must be tier or entitlement`);
6518
6639
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
6519
- const stripePriceId = text4(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
6640
+ const stripePriceId = text5(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
6520
6641
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
6521
6642
  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`);
6522
6643
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -6538,9 +6659,9 @@ function validateGiftOfferCatalog(input, tierIds) {
6538
6659
  redemptionLifetimeHours: lifetime,
6539
6660
  ...activeFrom !== void 0 ? { activeFrom } : {},
6540
6661
  ...activeUntil !== void 0 ? { activeUntil } : {},
6541
- title: text4(item.title, `${path}.title`, errors, 160),
6542
- description: text4(item.description, `${path}.description`, errors, 2e3),
6543
- ctaLabel: text4(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
6662
+ title: text5(item.title, `${path}.title`, errors, 160),
6663
+ description: text5(item.description, `${path}.description`, errors, 2e3),
6664
+ ctaLabel: text5(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
6544
6665
  });
6545
6666
  }
6546
6667
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -6956,17 +7077,17 @@ async function readBoundedRequestText(req, maxBytes) {
6956
7077
  if (!reader) return "";
6957
7078
  const decoder = new TextDecoder();
6958
7079
  let bytes = 0;
6959
- let text9 = "";
7080
+ let text10 = "";
6960
7081
  for (; ; ) {
6961
7082
  const { done, value } = await reader.read();
6962
7083
  if (req.signal.aborted) return invalid2();
6963
7084
  if (done) {
6964
7085
  complete = true;
6965
- return text9 + decoder.decode();
7086
+ return text10 + decoder.decode();
6966
7087
  }
6967
7088
  bytes += value.byteLength;
6968
7089
  if (bytes > maxBytes) return json({ error: "request body too large" }, 413);
6969
- text9 += decoder.decode(value, { stream: true });
7090
+ text10 += decoder.decode(value, { stream: true });
6970
7091
  }
6971
7092
  } catch {
6972
7093
  return invalid2();
@@ -7070,9 +7191,9 @@ async function paymentQuoteRoute(url, env, ctx, prepare = false, request) {
7070
7191
  let selection;
7071
7192
  if (request?.body) {
7072
7193
  try {
7073
- const text9 = await readBoundedRequestText(request, 2048);
7074
- if (text9 instanceof Response) return text9;
7075
- const body = JSON.parse(text9);
7194
+ const text10 = await readBoundedRequestText(request, 2048);
7195
+ if (text10 instanceof Response) return text10;
7196
+ const body = JSON.parse(text10);
7076
7197
  if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key3) => key3 !== "namedSeat")) throw new Error("invalid body");
7077
7198
  selection = body.namedSeat;
7078
7199
  } catch {
@@ -7954,10 +8075,10 @@ function boundedString(value, path, errors, max, required = false) {
7954
8075
  errors.push(`${path} must be a string`);
7955
8076
  return void 0;
7956
8077
  }
7957
- const text9 = value.trim();
7958
- if (required && !text9) errors.push(`${path} is required`);
7959
- if (text9.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
7960
- return text9;
8078
+ const text10 = value.trim();
8079
+ if (required && !text10) errors.push(`${path} is required`);
8080
+ if (text10.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
8081
+ return text10;
7961
8082
  }
7962
8083
  function validateMembershipPageDocument(input, options = {}) {
7963
8084
  const errors = [];
@@ -8006,8 +8127,8 @@ function validateMembershipPageDocument(input, options = {}) {
8006
8127
  else {
8007
8128
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
8008
8129
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
8009
- const text9 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
8010
- return text9 ? [text9] : [];
8130
+ const text10 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
8131
+ return text10 ? [text10] : [];
8011
8132
  });
8012
8133
  }
8013
8134
  }
@@ -8101,7 +8222,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
8101
8222
  function unknownKeys(value, allowed2, path, errors) {
8102
8223
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
8103
8224
  }
8104
- function text5(value, path, errors, max = 65536) {
8225
+ function text6(value, path, errors, max = 65536) {
8105
8226
  if (typeof value !== "string" || !value.trim()) {
8106
8227
  errors.push(`${path} is required`);
8107
8228
  return "";
@@ -8124,11 +8245,11 @@ function tiersOf(value, errors) {
8124
8245
  return [];
8125
8246
  }
8126
8247
  unknownKeys(item, TIER_KEYS2, path, errors);
8127
- const id7 = text5(item.id, `${path}.id`, errors, 80);
8248
+ const id7 = text6(item.id, `${path}.id`, errors, 80);
8128
8249
  if (id7 && !TIER.test(id7)) errors.push(`${path}.id must be a lowercase kebab-case id`);
8129
8250
  if (seen.has(id7)) errors.push(`${path}.id duplicates "${id7}"`);
8130
8251
  seen.add(id7);
8131
- const name = text5(item.name, `${path}.name`, errors, 160);
8252
+ const name = text6(item.name, `${path}.name`, errors, 160);
8132
8253
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
8133
8254
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
8134
8255
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -8146,7 +8267,7 @@ function tiersOf(value, errors) {
8146
8267
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
8147
8268
  errors.push(`${path}.interval must be year or month`);
8148
8269
  }
8149
- const blurb = item.blurb === void 0 ? void 0 : text5(item.blurb, `${path}.blurb`, errors, 4e3);
8270
+ const blurb = item.blurb === void 0 ? void 0 : text6(item.blurb, `${path}.blurb`, errors, 4e3);
8150
8271
  return [{
8151
8272
  id: id7,
8152
8273
  name,
@@ -8204,7 +8325,7 @@ function policyOf(value, errors, tiers) {
8204
8325
  const result = {};
8205
8326
  for (const key3 of allowed2) {
8206
8327
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
8207
- result[key3] = text5(input[key3], `document.policy.${key3}`, errors);
8328
+ result[key3] = text6(input[key3], `document.policy.${key3}`, errors);
8208
8329
  }
8209
8330
  const tierPolicies = {};
8210
8331
  if (input.tierPolicies !== void 0) {
@@ -8219,8 +8340,8 @@ function policyOf(value, errors, tiers) {
8219
8340
  }
8220
8341
  unknownKeys(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
8221
8342
  tierPolicies[id7] = {
8222
- refundPolicyText: text5(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
8223
- ...policy2.trustCopy !== void 0 ? { trustCopy: text5(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
8343
+ refundPolicyText: text6(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
8344
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text6(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
8224
8345
  };
8225
8346
  }
8226
8347
  }
@@ -8260,9 +8381,9 @@ function validateSignupControlDocument(input) {
8260
8381
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
8261
8382
  const source = recordOf(root.source);
8262
8383
  const target = recordOf(root.target);
8263
- const appId = text5(source?.appId, "document.source.appId", errors, 63);
8264
- const chapterId = text5(target?.chapterId, "document.target.chapterId", errors, 63);
8265
- const environment = text5(target?.environment, "document.target.environment", errors, 63);
8384
+ const appId = text6(source?.appId, "document.source.appId", errors, 63);
8385
+ const chapterId = text6(target?.chapterId, "document.target.chapterId", errors, 63);
8386
+ const environment = text6(target?.environment, "document.target.environment", errors, 63);
8266
8387
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
8267
8388
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
8268
8389
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -10533,7 +10654,7 @@ async function startSubscription(req, env, ctx) {
10533
10654
 
10534
10655
  // src/worker-payment-receipt.ts
10535
10656
  var json2 = (value, status2 = 200) => Response.json(value, { status: status2, headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" } });
10536
- var escape = (text9) => text9.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
10657
+ var escape = (text10) => text10.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
10537
10658
  async function paymentReceiptRoute(req, url, env, ctx) {
10538
10659
  const id7 = url.searchParams.get("application");
10539
10660
  if (!id7 || id7.length > 160) return json2({ error: "application required" }, 400);
@@ -10544,7 +10665,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10544
10665
  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);
10545
10666
  const receipt = intent.paymentReceipt;
10546
10667
  if (!receipt) return json2({ pending: true }, 202);
10547
- const text9 = paymentReceiptText(receipt, ctx.chapter.name);
10668
+ const text10 = paymentReceiptText(receipt, ctx.chapter.name);
10548
10669
  if (req.method === "POST") {
10549
10670
  if (!env.SEND_EMAIL || !env.EMAIL_FROM) return json2({ error: "Email is unavailable. You can print or save your receipt." }, 503);
10550
10671
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
@@ -10562,7 +10683,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10562
10683
  template: "paymentReceipt",
10563
10684
  to: String(intent.purchaserEmailNormalized),
10564
10685
  applicationId: id7,
10565
- vars: { chapterName: ctx.chapter.name, receiptText: text9 },
10686
+ vars: { chapterName: ctx.chapter.name, receiptText: text10 },
10566
10687
  dedupeKey: `requested-payment-receipt:${receipt.invoiceId}`,
10567
10688
  event: { label: "Payment receipt", trigger: "Purchaser requests their receipt", audience: "Purchaser", variables: ["chapterName", "receiptText"] }
10568
10689
  });
@@ -10572,7 +10693,7 @@ async function paymentReceiptRoute(req, url, env, ctx) {
10572
10693
  const nonce = crypto.randomUUID();
10573
10694
  return new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Payment receipt</title>
10574
10695
  <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>
10575
- <h1>Payment receipt</h1><pre>${escape(text9)}</pre><button id="print">Print / save PDF</button>
10696
+ <h1>Payment receipt</h1><pre>${escape(text10)}</pre><button id="print">Print / save PDF</button>
10576
10697
  <script nonce="${nonce}">document.getElementById('print').addEventListener('click',()=>window.print())</script></html>`, {
10577
10698
  headers: {
10578
10699
  "content-type": "text/html; charset=utf-8",
@@ -11042,16 +11163,16 @@ var handleNamedSeat = async (req, url, env, ctx) => {
11042
11163
  };
11043
11164
 
11044
11165
  // src/named-seat-accepted-notice.ts
11045
- var text6 = (value) => typeof value === "string" ? value : "";
11166
+ var text7 = (value) => typeof value === "string" ? value : "";
11046
11167
  async function notifyNamedSeatAccepted(db, env, ctx, input) {
11047
11168
  const purchaser = await membershipLoad(db, "applications", input.primaryApplicationId);
11048
- if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text6(purchaser.email)) {
11169
+ if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text7(purchaser.email)) {
11049
11170
  return { sent: false, reason: "purchaser-missing" };
11050
11171
  }
11051
11172
  const group = await membershipLoad(db, "groups", ctx.chapter.id);
11052
11173
  if (!group) return { sent: false, reason: "group-missing" };
11053
11174
  const config = emailGroupFrom(group);
11054
- const purchaserName = [purchaser.firstName, purchaser.lastName].map(text6).join(" ").replace(/\s+/g, " ").trim();
11175
+ const purchaserName = [purchaser.firstName, purchaser.lastName].map(text7).join(" ").replace(/\s+/g, " ").trim();
11055
11176
  return sendTemplated({
11056
11177
  db,
11057
11178
  envName: env.ODLA_ENV,
@@ -11063,12 +11184,12 @@ async function notifyNamedSeatAccepted(db, env, ctx, input) {
11063
11184
  }, {
11064
11185
  group: config,
11065
11186
  template: "seatAccepted",
11066
- to: text6(purchaser.email),
11187
+ to: text7(purchaser.email),
11067
11188
  dedupeKey: `named-seat-accepted:${input.seatId}`,
11068
11189
  applicationId: input.primaryApplicationId,
11069
11190
  tierId: input.skuId,
11070
11191
  vars: {
11071
- firstName: text6(purchaser.firstName),
11192
+ firstName: text7(purchaser.firstName),
11072
11193
  purchaserName,
11073
11194
  recipientName: input.recipientName,
11074
11195
  tierName: await tierName(db, group, input.skuId),
@@ -12455,6 +12576,84 @@ var import_crm16 = require("@odla-ai/crm");
12455
12576
 
12456
12577
  // src/email-readiness.ts
12457
12578
  var import_crm15 = require("@odla-ai/crm");
12579
+
12580
+ // src/email-spam-signals.ts
12581
+ var TRIGGER_PHRASES = [
12582
+ "act now",
12583
+ "click here",
12584
+ "limited time",
12585
+ "guaranteed",
12586
+ "risk-free",
12587
+ "risk free",
12588
+ "no obligation",
12589
+ "100% free",
12590
+ "winner",
12591
+ "congratulations",
12592
+ "urgent",
12593
+ "double your",
12594
+ "earn money",
12595
+ "make money",
12596
+ "cash bonus",
12597
+ "once in a lifetime",
12598
+ "exclusive deal",
12599
+ "order now",
12600
+ "buy now",
12601
+ "this is not spam",
12602
+ "dear friend",
12603
+ "dear customer"
12604
+ ];
12605
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12606
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12607
+ 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;
12608
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12609
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12610
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12611
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12612
+ function emailSpamSignals(copy) {
12613
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12614
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12615
+ const out = [];
12616
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12617
+ 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, "")));
12618
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12619
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12620
+ 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." });
12621
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12622
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12623
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12624
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12625
+ 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." });
12626
+ if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12627
+ const urls = [...text10.matchAll(URL_RE2)].map((match) => match[0]);
12628
+ const hosts = urls.map((url) => {
12629
+ try {
12630
+ return new URL(url).hostname;
12631
+ } catch {
12632
+ return "";
12633
+ }
12634
+ });
12635
+ 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." });
12636
+ 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." });
12637
+ 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." });
12638
+ 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." });
12639
+ 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." });
12640
+ return out;
12641
+ }
12642
+ function emailSenderWarnings(input) {
12643
+ const out = [];
12644
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12645
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12646
+ const domain = (address) => address.split("@")[1] ?? "";
12647
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12648
+ 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." });
12649
+ }
12650
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12651
+ 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.` });
12652
+ }
12653
+ return out;
12654
+ }
12655
+
12656
+ // src/email-readiness.ts
12458
12657
  function validateEmailReadiness(input) {
12459
12658
  const issues = [];
12460
12659
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12479,9 +12678,15 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12479
12678
  }
12480
12679
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12481
12680
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
12482
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
12681
+ return {
12682
+ ...event2,
12683
+ key: key3,
12684
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12685
+ issues: problems,
12686
+ warnings: template ? emailSpamSignals(template) : []
12687
+ };
12483
12688
  });
12484
- return { checkedAt: input.now ?? Date.now(), events, issues };
12689
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12485
12690
  }
12486
12691
 
12487
12692
  // src/email-admin-config.ts
@@ -12507,7 +12712,14 @@ async function emailSettings(db, ctx, env) {
12507
12712
  }
12508
12713
  }
12509
12714
  const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
12510
- const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
12715
+ const validation = validateEmailReadiness({
12716
+ events,
12717
+ templates: stored,
12718
+ groupValues,
12719
+ tiers: hub ? void 0 : tiers,
12720
+ tiersUnavailable,
12721
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12722
+ });
12511
12723
  return { group, events, data: {
12512
12724
  groupId: ctx.chapter.id,
12513
12725
  name: ctx.chapter.name,
@@ -12535,12 +12747,12 @@ async function updateEmailSettings(db, ctx, env, body) {
12535
12747
  if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
12536
12748
  const t = value;
12537
12749
  if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
12538
- const subject = str2(t.subject).trim(), text9 = str2(t.text);
12539
- const issues = (0, import_crm16.validateEmailCopy)({ subject, text: text9 }, { allowedVariables: events[key3].variables });
12750
+ const subject = str2(t.subject).trim(), text10 = str2(t.text);
12751
+ const issues = (0, import_crm16.validateEmailCopy)({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12540
12752
  if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
12541
12753
  return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
12542
12754
  }
12543
- clean[key3] = { subject, text: text9, enabled: t.enabled };
12755
+ clean[key3] = { subject, text: text10, enabled: t.enabled };
12544
12756
  }
12545
12757
  const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
12546
12758
  const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
@@ -12561,7 +12773,8 @@ async function updateEmailSettings(db, ctx, env, body) {
12561
12773
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12562
12774
  ]);
12563
12775
  }
12564
- return json({ ok: true });
12776
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12777
+ return json({ ok: true, warnings });
12565
12778
  }
12566
12779
 
12567
12780
  // src/worker-routes-admin-comms.ts
@@ -14744,9 +14957,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
14744
14957
  if (req.method === "POST") {
14745
14958
  let body;
14746
14959
  try {
14747
- const text9 = await req.text();
14748
- if (new TextEncoder().encode(text9).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
14749
- const parsed = JSON.parse(text9);
14960
+ const text10 = await req.text();
14961
+ if (new TextEncoder().encode(text10).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
14962
+ const parsed = JSON.parse(text10);
14750
14963
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
14751
14964
  body = parsed;
14752
14965
  } catch {
@@ -14915,7 +15128,7 @@ var import_db13 = require("@odla-ai/db");
14915
15128
  // src/network-commercial-parity.ts
14916
15129
  var DIGEST = /^[0-9a-f]{64}$/;
14917
15130
  var MAX_RECORDS = 500;
14918
- var text7 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
15131
+ var text8 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
14919
15132
  function parityMessage(partition, kind, value) {
14920
15133
  return [
14921
15134
  "chapter-commercial-parity",
@@ -14947,18 +15160,18 @@ async function digestWithKey(key3, partition, kind, value) {
14947
15160
  async function buildChapterCommercialParitySnapshot(input) {
14948
15161
  const requestedMax = input.maxRecords ?? MAX_RECORDS;
14949
15162
  const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
14950
- const eligible = input.rows.filter((row2) => text7(row2.id) && (text7(row2.stripeCustomerId) || text7(row2.stripeSubscriptionId)));
15163
+ const eligible = input.rows.filter((row2) => text8(row2.id) && (text8(row2.stripeCustomerId) || text8(row2.stripeSubscriptionId)));
14951
15164
  const selected2 = eligible.slice(0, maxRecords);
14952
15165
  const key3 = await parityKey(input.secret);
14953
15166
  const records = await Promise.all(selected2.map(async (row2) => {
14954
- const applicationId = text7(row2.id);
14955
- const customerId = text7(row2.stripeCustomerId);
14956
- const subscriptionId = text7(row2.stripeSubscriptionId);
15167
+ const applicationId = text8(row2.id);
15168
+ const customerId = text8(row2.stripeCustomerId);
15169
+ const subscriptionId = text8(row2.stripeSubscriptionId);
14957
15170
  return {
14958
15171
  applicationDigest: await digestWithKey(key3, input.partition, "application", applicationId),
14959
15172
  customerDigest: customerId ? await digestWithKey(key3, input.partition, "customer", customerId) : null,
14960
15173
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.partition, "subscription", subscriptionId) : null,
14961
- status: text7(row2.status, 64),
15174
+ status: text8(row2.status, 64),
14962
15175
  canceled: row2.canceled === true
14963
15176
  };
14964
15177
  }));
@@ -14984,7 +15197,7 @@ function validSnapshot(snapshot, source) {
14984
15197
  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;
14985
15198
  const seen = /* @__PURE__ */ new Set();
14986
15199
  for (const record4 of records) {
14987
- 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;
15200
+ 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;
14988
15201
  const key3 = recordKey(record4);
14989
15202
  if (seen.has(key3)) return false;
14990
15203
  seen.add(key3);
@@ -14995,16 +15208,16 @@ async function reconcileCommercialParitySnapshot(input) {
14995
15208
  if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
14996
15209
  const sourceKey = networkCommercialSourceKey(input.source);
14997
15210
  const key3 = await parityKey(input.secret);
14998
- const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text7(row2.applicationId) && text7(row2.objectId)).map(async (row2) => {
14999
- const customerId = text7(row2.customerId);
15000
- const subscriptionId = text7(row2.subscriptionId) ?? text7(row2.objectId);
15211
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text8(row2.applicationId) && text8(row2.objectId)).map(async (row2) => {
15212
+ const customerId = text8(row2.customerId);
15213
+ const subscriptionId = text8(row2.subscriptionId) ?? text8(row2.objectId);
15001
15214
  return {
15002
15215
  applicationDigest: await digestWithKey(key3, input.snapshot.partition, "application", row2.applicationId),
15003
15216
  customerDigest: customerId ? await digestWithKey(key3, input.snapshot.partition, "customer", customerId) : null,
15004
15217
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.snapshot.partition, "subscription", subscriptionId) : null,
15005
15218
  status: null,
15006
15219
  canceled: false,
15007
- stripeStatus: text7(row2.status, 64),
15220
+ stripeStatus: text8(row2.status, 64),
15008
15221
  stripeCanceled: row2.status === "canceled"
15009
15222
  };
15010
15223
  }));
@@ -15214,18 +15427,24 @@ async function applyHostedMembershipDispute(env, ctx, fact, now) {
15214
15427
  const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
15215
15428
  if (!secret) throw Object.assign(new Error("membership_dispute_provider_unavailable"), { code: "membership_dispute_provider_unavailable" });
15216
15429
  const charge = await readDisputedCharge(secret, fact.chargeId);
15430
+ const scopeOf = (row2) => ({
15431
+ appId: String(row2.appId),
15432
+ chapterId: String(row2.chapterId),
15433
+ environment: String(row2.environment),
15434
+ runtime: String(row2.runtime),
15435
+ applicationId: String(row2.applicationId),
15436
+ skuId: String(row2.skuId)
15437
+ });
15438
+ if (charge.seatId) {
15439
+ const seat = await membershipLoad(db, "networkSeat", charge.seatId);
15440
+ if (!seat || seat.environment !== env.ODLA_ENV || !disputedChargeCoversSeat(charge, seat)) return { disposition: "not_applicable" };
15441
+ const receipt2 = await reconcileMembershipDispute(db, { ...scopeOf(seat), eventId: fact.eventId }, now, secret, charge);
15442
+ return { disposition: "applied", membershipId: receipt2.membershipId, seatId: charge.seatId };
15443
+ }
15217
15444
  const rows5 = (await db.query({ networkMembership: { $: { where: { providerCustomerId: charge.customerId, environment: env.ODLA_ENV }, limit: 5 } } })).networkMembership ?? [];
15218
15445
  const member = rows5.find((row2) => disputedChargeCoversMembership(charge, row2));
15219
15446
  if (!member) return { disposition: "not_applicable" };
15220
- const target = {
15221
- appId: String(member.appId),
15222
- chapterId: String(member.chapterId),
15223
- environment: String(member.environment),
15224
- runtime: String(member.runtime),
15225
- applicationId: String(member.applicationId),
15226
- skuId: String(member.skuId)
15227
- };
15228
- const receipt = await reconcileMembershipDispute(db, { ...target, eventId: fact.eventId }, now, secret, charge);
15447
+ const receipt = await reconcileMembershipDispute(db, { ...scopeOf(member), eventId: fact.eventId }, now, secret, charge);
15229
15448
  return { disposition: "applied", membershipId: receipt.membershipId };
15230
15449
  }
15231
15450
 
@@ -15293,11 +15512,11 @@ function safeProjection(projection) {
15293
15512
  amountReceivedCents: seat.amountReceivedCents,
15294
15513
  refundedCents: seat.refundedCents ?? 0
15295
15514
  })),
15296
- disputes: (projection.disputes ?? []).map((dispute) => ({
15297
- ...row2(dispute),
15298
- chargeId: dispute.chargeId ?? null,
15299
- paymentIntentId: dispute.paymentIntentId ?? null,
15300
- reason: dispute.disputeReason ?? null
15515
+ disputes: (projection.disputes ?? []).map((dispute2) => ({
15516
+ ...row2(dispute2),
15517
+ chargeId: dispute2.chargeId ?? null,
15518
+ paymentIntentId: dispute2.paymentIntentId ?? null,
15519
+ reason: dispute2.disputeReason ?? null
15301
15520
  })),
15302
15521
  exceptions: projection.exceptions
15303
15522
  };
@@ -15331,10 +15550,10 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
15331
15550
  if (result.status === "collision") {
15332
15551
  console.error("network commercial event collision", JSON.stringify({ eventId: fact.eventId, eventType: fact.eventType }));
15333
15552
  }
15334
- let dispute;
15553
+ let dispute2;
15335
15554
  if (fact.objectKind === "dispute" && result.status !== "collision") {
15336
15555
  try {
15337
- dispute = await applyHostedMembershipDispute(env, ctx, fact, Date.now());
15556
+ dispute2 = await applyHostedMembershipDispute(env, ctx, fact, Date.now());
15338
15557
  } catch (error) {
15339
15558
  console.warn("membership dispute pending", JSON.stringify({ eventId: fact.eventId, code: error.code ?? "unknown" }));
15340
15559
  return json({ error: "membership dispute reconciliation pending", eventId: fact.eventId }, 503);
@@ -15344,7 +15563,7 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
15344
15563
  ok: true,
15345
15564
  status: result.status,
15346
15565
  eventId: fact.eventId,
15347
- ...dispute ? { dispute: dispute.disposition } : {},
15566
+ ...dispute2 ? { dispute: dispute2.disposition } : {},
15348
15567
  ...result.status === "replayed" && result.resolvedCollision ? { resolvedCollision: true } : {}
15349
15568
  });
15350
15569
  } catch (error) {
@@ -15542,7 +15761,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
15542
15761
  function unknownKeys3(value, allowed2, path, errors) {
15543
15762
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
15544
15763
  }
15545
- function text8(value, path, errors, max = MAX_TEXT2, required = true) {
15764
+ function text9(value, path, errors, max = MAX_TEXT2, required = true) {
15546
15765
  if (value === void 0 && !required) return void 0;
15547
15766
  if (typeof value !== "string" || required && !value.trim()) {
15548
15767
  errors.push(`${path} must be a non-empty string`);
@@ -15568,7 +15787,7 @@ function scopeValue(value, path, errors) {
15568
15787
  return { kind: raw2.kind };
15569
15788
  }
15570
15789
  if (raw2.kind === "chapter") {
15571
- const chapterId = text8(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
15790
+ const chapterId = text9(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
15572
15791
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
15573
15792
  return { kind: "chapter", chapterId };
15574
15793
  }
@@ -15582,22 +15801,22 @@ function resourceValue(value, path, errors) {
15582
15801
  return null;
15583
15802
  }
15584
15803
  unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
15585
- const id7 = text8(raw2.id, `${path}.id`, errors, 80) ?? "";
15804
+ const id7 = text9(raw2.id, `${path}.id`, errors, 80) ?? "";
15586
15805
  if (id7 && !ID5.test(id7)) errors.push(`${path}.id must be lowercase kebab-case`);
15587
- const kind = text8(raw2.kind, `${path}.kind`, errors) ?? "";
15806
+ const kind = text9(raw2.kind, `${path}.kind`, errors) ?? "";
15588
15807
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
15589
- const title = text8(raw2.title, `${path}.title`, errors, 240) ?? "";
15590
- const summary = text8(raw2.summary, `${path}.summary`, errors, 1e3, false);
15591
- const status2 = text8(raw2.status, `${path}.status`, errors) ?? "";
15808
+ const title = text9(raw2.title, `${path}.title`, errors, 240) ?? "";
15809
+ const summary = text9(raw2.summary, `${path}.summary`, errors, 1e3, false);
15810
+ const status2 = text9(raw2.status, `${path}.status`, errors) ?? "";
15592
15811
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
15593
15812
  const revision = integer4(raw2.revision, `${path}.revision`, errors);
15594
15813
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
15595
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text8(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
15814
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text9(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
15596
15815
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
15597
15816
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
15598
15817
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
15599
- const bodyMarkdown = text8(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
15600
- const url = text8(raw2.url, `${path}.url`, errors, 2048, false);
15818
+ const bodyMarkdown = text9(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
15819
+ const url = text9(raw2.url, `${path}.url`, errors, 2048, false);
15601
15820
  if (url) {
15602
15821
  try {
15603
15822
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -15609,9 +15828,9 @@ function resourceValue(value, path, errors) {
15609
15828
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
15610
15829
  if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
15611
15830
  const brandAsset = asset ? {
15612
- bookId: text8(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
15613
- assetId: text8(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
15614
- ...asset.filename === void 0 ? {} : { filename: text8(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
15831
+ bookId: text9(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
15832
+ assetId: text9(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
15833
+ ...asset.filename === void 0 ? {} : { filename: text9(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
15615
15834
  } : void 0;
15616
15835
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
15617
15836
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -15626,17 +15845,17 @@ function resourceValue(value, path, errors) {
15626
15845
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
15627
15846
  if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
15628
15847
  const promotedFrom = promotedRaw ? {
15629
- chapterId: text8(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
15630
- resourceId: text8(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
15848
+ chapterId: text9(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
15849
+ resourceId: text9(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
15631
15850
  resourceRevision: integer4(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
15632
15851
  documentRevision: integer4(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
15633
- promotedBy: text8(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
15852
+ promotedBy: text9(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
15634
15853
  promotedAt: integer4(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
15635
15854
  } : void 0;
15636
15855
  const provenance2 = {
15637
- createdBy: text8(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
15856
+ createdBy: text9(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
15638
15857
  createdAt: integer4(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
15639
- updatedBy: text8(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
15858
+ updatedBy: text9(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
15640
15859
  updatedAt: integer4(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
15641
15860
  ...promotedFrom ? { promotedFrom } : {}
15642
15861
  };
@@ -15655,7 +15874,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
15655
15874
  if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
15656
15875
  const sourceType = sourceRaw?.type;
15657
15876
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
15658
- const sourceAppId = text8(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
15877
+ const sourceAppId = text9(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
15659
15878
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
15660
15879
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
15661
15880
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -15663,8 +15882,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
15663
15882
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
15664
15883
  if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
15665
15884
  const target = targetRaw ? {
15666
- appId: text8(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
15667
- environment: text8(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
15885
+ appId: text9(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
15886
+ environment: text9(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
15668
15887
  } : void 0;
15669
15888
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
15670
15889
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -17189,6 +17408,19 @@ var id5 = () => attr("string", false, true, true);
17189
17408
  var key = (optional = false) => attr("string", optional, false, true);
17190
17409
  var scope = () => ({ appId: key(), chapterId: key(), environment: key(), runtime: key(), applicationId: key(), skuId: key() });
17191
17410
  var times = () => ({ revision: attr("number"), createdAt: attr("number"), updatedAt: attr("number") });
17411
+ var dispute = () => ({
17412
+ disputeId: key(true),
17413
+ disputeStatus: attr("string", true),
17414
+ disputeReason: attr("string", true),
17415
+ disputeAmountCents: attr("number", true),
17416
+ disputedAt: attr("number", true),
17417
+ disputeClosedAt: attr("number", true),
17418
+ disputePriorStatus: attr("string", true),
17419
+ disputeEventId: key(true),
17420
+ rechargePaymentIntentId: key(true),
17421
+ rechargedAt: attr("number", true),
17422
+ rechargeAttempt: attr("json", true)
17423
+ });
17192
17424
  var membershipAuthorityEntities = {
17193
17425
  networkMembership: { attrs: {
17194
17426
  id: id5(),
@@ -17229,19 +17461,7 @@ var membershipAuthorityEntities = {
17229
17461
  ...times(),
17230
17462
  refundRequestedAt: attr("number", true),
17231
17463
  restartId: key(true),
17232
- // A card-network dispute on this membership's payment: Stripe's dispute id and status, the reason the cardholder gave,
17233
- // the status the membership held before it was suspended, and the re-issued charge once an operator collected again.
17234
- disputeId: key(true),
17235
- disputeStatus: attr("string", true),
17236
- disputeReason: attr("string", true),
17237
- disputeAmountCents: attr("number", true),
17238
- disputedAt: attr("number", true),
17239
- disputeClosedAt: attr("number", true),
17240
- disputePriorStatus: attr("string", true),
17241
- disputeEventId: key(true),
17242
- rechargePaymentIntentId: key(true),
17243
- rechargedAt: attr("number", true),
17244
- rechargeAttempt: attr("json", true)
17464
+ ...dispute()
17245
17465
  } },
17246
17466
  membershipRestart: { attrs: {
17247
17467
  id: id5(),
@@ -17317,7 +17537,8 @@ var membershipAuthorityEntities = {
17317
17537
  refundedCents: attr("number", true),
17318
17538
  providerRefundIds: attr("json", true),
17319
17539
  regiftedAt: attr("number", true),
17320
- formerRecipients: attr("json", true)
17540
+ formerRecipients: attr("json", true),
17541
+ ...dispute()
17321
17542
  } },
17322
17543
  networkSeatRecipient: { attrs: { id: id5(), appId: key(), chapterId: key(), environment: key(), runtime: key(), seatId: key(), recipientEmailNormalized: key(), createdAt: attr("number") } },
17323
17544
  membershipEffect: { attrs: {
@@ -17654,7 +17875,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
17654
17875
  };
17655
17876
  signal.addEventListener("abort", cancel, { once: true });
17656
17877
  let size = 0;
17657
- let text9 = "";
17878
+ let text10 = "";
17658
17879
  const decoder = new TextDecoder();
17659
17880
  try {
17660
17881
  while (true) {
@@ -17664,10 +17885,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
17664
17885
  if (done) break;
17665
17886
  size += value.byteLength;
17666
17887
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
17667
- text9 += decoder.decode(value, { stream: true });
17888
+ text10 += decoder.decode(value, { stream: true });
17668
17889
  }
17669
17890
  try {
17670
- return JSON.parse(text9 + decoder.decode());
17891
+ return JSON.parse(text10 + decoder.decode());
17671
17892
  } catch {
17672
17893
  throw new TypeError(`invalid ${label}`);
17673
17894
  }