@odla-ai/chapter 0.50.0 → 0.52.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.
@@ -143,11 +143,11 @@ async function clerkVerifiedPrimaryEmail(db, userId) {
143
143
 
144
144
  // src/crm-person-projection.ts
145
145
  import { createRecord } from "@odla-ai/crm";
146
- async function ensureProjectedPerson(deps2, input, options = {}) {
146
+ async function ensureProjectedPerson(deps, input, options = {}) {
147
147
  const email = String(input.email ?? "").trim().toLowerCase();
148
148
  if (!email) throw new Error("crm_projection_email_required");
149
149
  const find = async () => {
150
- const records = (await deps2.db.query({ crm_record: { $: {
150
+ const records = (await deps.db.query({ crm_record: { $: {
151
151
  where: { type: "person", primaryEmail: email },
152
152
  limit: 2
153
153
  } } })).crm_record ?? [];
@@ -160,7 +160,7 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
160
160
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`chapter:person:v1:${email}`)));
161
161
  const id6 = `person-${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
162
162
  try {
163
- const created = await createRecord({ ...deps2, db: deps2.db }, {
163
+ const created = await createRecord({ ...deps, db: deps.db }, {
164
164
  id: id6,
165
165
  type: "person",
166
166
  input: { ...input, email },
@@ -176,14 +176,14 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
176
176
  }
177
177
 
178
178
  // src/crm-account-projection.ts
179
- async function syncAccountToCrm(deps2, userId) {
180
- const user = (await deps2.db.query({ $users: { $: { where: { id: userId }, limit: 1 } } })).$users?.[0];
179
+ async function syncAccountToCrm(deps, userId) {
180
+ const user = (await deps.db.query({ $users: { $: { where: { id: userId }, limit: 1 } } })).$users?.[0];
181
181
  if (!user || user.deleted === true || typeof user.email !== "string" || !user.email.trim()) return null;
182
- const linked = (await deps2.db.query({ crm_record: { $: { where: { type: "person", clerkUserId: userId }, limit: 2 } } })).crm_record ?? [];
182
+ const linked = (await deps.db.query({ crm_record: { $: { where: { type: "person", clerkUserId: userId }, limit: 2 } } })).crm_record ?? [];
183
183
  if (linked.length > 1) throw new Error("crm_projection_identity_ambiguous");
184
184
  if (linked.length === 1) return String(linked[0].id);
185
185
  const email = user.email.trim().toLowerCase();
186
- const peers = (await deps2.db.query({ $users: { $: { where: { email }, limit: 2 } } })).$users ?? [];
186
+ const peers = (await deps.db.query({ $users: { $: { where: { email }, limit: 2 } } })).$users ?? [];
187
187
  if (peers.length !== 1 || peers[0]?.id !== userId) throw new Error("crm_projection_identity_ambiguous");
188
188
  const userGuard = {
189
189
  ns: "$users",
@@ -192,14 +192,14 @@ async function syncAccountToCrm(deps2, userId) {
192
192
  equals: { email: user.email, ...user.deleted !== void 0 ? { deleted: user.deleted } : {} },
193
193
  ...user.deleted === void 0 ? { absent: ["deleted"] } : {}
194
194
  };
195
- const { record: record4 } = await ensureProjectedPerson(deps2, {
195
+ const { record: record4 } = await ensureProjectedPerson(deps, {
196
196
  name: typeof user.name === "string" && user.name.trim() ? user.name : email,
197
197
  email
198
198
  }, { stage: null, guards: [userGuard], emailConsent: { state: "unsubscribed", source: "account-no-marketing-consent" } });
199
199
  if (record4.clerkUserId === userId) return String(record4.id);
200
200
  if (record4.clerkUserId != null) throw new Error("crm_projection_identity_conflict");
201
201
  try {
202
- await deps2.db.transact([{ t: "update", ns: "crm_record", id: String(record4.id), attrs: { clerkUserId: userId } }], {
202
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: String(record4.id), attrs: { clerkUserId: userId } }], {
203
203
  guards: [userGuard, {
204
204
  ns: "crm_record",
205
205
  id: String(record4.id),
@@ -215,12 +215,37 @@ async function syncAccountToCrm(deps2, userId) {
215
215
  });
216
216
  } catch (error) {
217
217
  if (error.code !== "transact_guard_failed") throw error;
218
- const current = (await deps2.db.query({ crm_record: { $: { where: { id: record4.id }, limit: 1 } } })).crm_record?.[0];
218
+ const current = (await deps.db.query({ crm_record: { $: { where: { id: record4.id }, limit: 1 } } })).crm_record?.[0];
219
219
  if (current?.clerkUserId !== userId) throw error;
220
220
  }
221
221
  return String(record4.id);
222
222
  }
223
223
 
224
+ // src/worker-alerts.ts
225
+ var consoleErrorRecorder = (error, report) => {
226
+ console.error("chapter_alert", JSON.stringify({ message: error.message, ...report }));
227
+ };
228
+ function reportChapterAlert(ctx, error, report) {
229
+ (ctx?.recordError ?? consoleErrorRecorder)(error, report);
230
+ }
231
+ var ACTIVATION_LIMIT = 50;
232
+ function networkCommercialAlerts(ctx, route) {
233
+ return {
234
+ exceptionsActivated(activated, activeCount) {
235
+ if (activated.length === 0) return;
236
+ reportChapterAlert(ctx, new Error("network commercial exception activated"), {
237
+ code: "network_commercial_exception_activated",
238
+ route,
239
+ attributes: {
240
+ "odla.commercial.active_exceptions": activeCount,
241
+ "odla.commercial.activated_exceptions": activated.length
242
+ },
243
+ artifacts: { exceptions: activated.slice(0, ACTIVATION_LIMIT).map(({ eventId, kind }) => ({ eventId, kind })) }
244
+ });
245
+ }
246
+ };
247
+ }
248
+
224
249
  // src/worker-context.ts
225
250
  var json = (body, status2 = 200) => new Response(JSON.stringify(body), { status: status2, headers: { "content-type": "application/json" } });
226
251
  function createWorkerContext(options) {
@@ -330,6 +355,7 @@ function createWorkerContext(options) {
330
355
  requirePaymentQuote: options.requirePaymentQuote === true,
331
356
  membershipAuthority: options.membershipAuthority,
332
357
  membershipAuthorityOperator: options.membershipAuthorityOperator,
358
+ recordError: options.recordError ?? consoleErrorRecorder,
333
359
  getPublicConfig,
334
360
  verifyUser,
335
361
  makeDb,
@@ -823,6 +849,17 @@ function projectNetworkCommercialEvents(input, sources) {
823
849
  };
824
850
  }
825
851
 
852
+ // src/network-commercial-fact-identity.ts
853
+ var stable = (value) => JSON.stringify(value, function(_key, item) {
854
+ return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
855
+ });
856
+ function sameNetworkCommercialFact(stored, incoming) {
857
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) return false;
858
+ const comparable = { ...incoming };
859
+ if (!stored.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
860
+ return stable(comparable) === stable(stored);
861
+ }
862
+
826
863
  // src/network-commercial-store.ts
827
864
  var eventRowId = (eventId) => `network-commercial-event:${eventId}`;
828
865
  var projectionId = (kind, sourceKey, providerId3) => `network-commercial:${kind}:${sourceKey}:${providerId3}`;
@@ -834,6 +871,7 @@ var materializedNamespaces = [
834
871
  "networkCommercialRefunds",
835
872
  "networkCommercialSeats"
836
873
  ];
874
+ var RESOLUTION_ATTRS = ["resolvedDigest", "reviewedDigest", "resolvedBy", "reviewId"];
837
875
  async function rows(db, ns) {
838
876
  const all = [];
839
877
  const pageSize = 500;
@@ -883,13 +921,14 @@ function projectionOps(projection, now) {
883
921
  }
884
922
  })));
885
923
  }
886
- async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now()) {
924
+ async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now(), alerts) {
887
925
  const projection = await loadNetworkCommercialProjection(db, sources);
888
926
  const [existing, ...materialized] = await Promise.all([
889
927
  rows(db, "networkCommercialExceptions"),
890
928
  ...materializedNamespaces.map((ns) => rows(db, ns))
891
929
  ]);
892
930
  const activeIds = new Set(projection.exceptions.map((item) => exceptionId(item.eventId, item.kind)));
931
+ const wasActive = new Set(existing.filter((row2) => row2.status === "active").map((row2) => String(row2.id)));
893
932
  const projectedIds = new Set(projectionOps(projection, now).map((op) => String(op.id)));
894
933
  const ops = [
895
934
  ...projectionOps(projection, now),
@@ -915,27 +954,80 @@ async function rebuildNetworkCommercialProjection(db, sources, mutationId, now =
915
954
  }
916
955
  }))
917
956
  ];
918
- if (ops.length > 0) await db.transact(ops, { mutationId });
957
+ if (ops.length === 0) return projection;
958
+ const write = await db.transact(ops, { mutationId });
959
+ if (!write.duplicate) {
960
+ alerts?.exceptionsActivated(
961
+ projection.exceptions.filter((item) => !wasActive.has(exceptionId(item.eventId, item.kind))),
962
+ projection.exceptions.length
963
+ );
964
+ }
919
965
  return projection;
920
966
  }
921
- async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(), legacyDigest) {
922
- const found = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
923
- const existing = found.networkCommercialEvents?.[0];
924
- if (existing) {
925
- const projection2 = await loadNetworkCommercialProjection(db, sources);
926
- if (existing.digest === fact.digest || existing.digest === legacyDigest) return { status: "replayed", projection: projection2 };
927
- const id7 = exceptionId(fact.eventId, "collision");
928
- await db.transact([{ t: "update", ns: "networkCommercialExceptions", id: id7, attrs: {
929
- id: id7,
967
+ async function collide(db, sources, stored, fact, now, legacyDigest, alerts) {
968
+ const id6 = exceptionId(fact.eventId, "collision");
969
+ const exception = (await db.query({ networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } } })).networkCommercialExceptions?.[0];
970
+ const exact = stored.digest === fact.digest || stored.digest === legacyDigest;
971
+ const canonical3 = !exact && sameNetworkCommercialFact(stored.fact, fact);
972
+ if (exact || canonical3) {
973
+ const recorded = exception?.incomingFact;
974
+ const resolvable = canonical3 && exception?.status === "active" && (recorded === void 0 || sameNetworkCommercialFact(stored.fact, recorded));
975
+ if (!resolvable) return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
976
+ const reviewId = crypto.randomUUID();
977
+ await db.transact([
978
+ { t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
979
+ id: reviewId,
980
+ eventId: fact.eventId,
981
+ actorUserId: "system",
982
+ reviewedAt: now,
983
+ previousDigest: String(stored.digest),
984
+ providerDigest: fact.digest,
985
+ scope: "network",
986
+ previousState: exception,
987
+ action: "canonical_replay",
988
+ reason: "a resend matched the retained canonical fact"
989
+ } },
990
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
991
+ status: "resolved",
992
+ resolvedDigest: stored.digest,
993
+ reviewedDigest: fact.digest,
994
+ resolvedBy: "system",
995
+ reviewId,
996
+ updatedAt: now
997
+ } }
998
+ ], { mutationId: `network-commercial-resolve:${fact.eventId}:${fact.digest}`, guards: [
999
+ { ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
1000
+ ] });
1001
+ const projection2 = await rebuildNetworkCommercialProjection(db, sources, `network-commercial-resolve-rebuild:${fact.eventId}:${fact.digest}`, now, alerts);
1002
+ return { status: "replayed", resolvedCollision: true, projection: projection2 };
1003
+ }
1004
+ if (exception?.status === "resolved" && exception.reviewedDigest === fact.digest) {
1005
+ return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
1006
+ }
1007
+ const stale = RESOLUTION_ATTRS.filter((attr3) => exception !== void 0 && Object.hasOwn(exception, attr3));
1008
+ await db.transact([
1009
+ ...stale.length ? [{ t: "retract", ns: "networkCommercialExceptions", id: id6, attrs: stale }] : [],
1010
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
1011
+ id: id6,
930
1012
  eventId: fact.eventId,
931
1013
  kind: "collision",
932
1014
  status: "active",
933
1015
  incomingDigest: fact.digest,
1016
+ incomingFact: fact,
934
1017
  detail: { eventType: fact.eventType, objectId: fact.objectId },
935
1018
  updatedAt: now
936
- } }]);
937
- return { status: "collision", projection: await loadNetworkCommercialProjection(db, sources) };
1019
+ } }
1020
+ ]);
1021
+ const projection = await loadNetworkCommercialProjection(db, sources);
1022
+ if (exception?.status !== "active") {
1023
+ alerts?.exceptionsActivated([{ eventId: fact.eventId, kind: "collision", detail: { eventType: fact.eventType, objectId: fact.objectId } }], projection.exceptions.length);
938
1024
  }
1025
+ return { status: "collision", projection };
1026
+ }
1027
+ async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(), legacyDigest, alerts) {
1028
+ const found = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
1029
+ const existing = found.networkCommercialEvents?.[0];
1030
+ if (existing) return collide(db, sources, existing, fact, now, legacyDigest, alerts);
939
1031
  const id6 = eventRowId(fact.eventId);
940
1032
  let lostRace = false;
941
1033
  let duplicate = false;
@@ -961,25 +1053,15 @@ async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(),
961
1053
  if (duplicate || lostRace) {
962
1054
  const raced = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
963
1055
  const row2 = raced.networkCommercialEvents?.[0];
964
- const projection2 = await loadNetworkCommercialProjection(db, sources);
965
- if (row2?.digest === fact.digest || row2?.digest === legacyDigest) return { status: "replayed", projection: projection2 };
966
- const id7 = exceptionId(fact.eventId, "collision");
967
- await db.transact([{ t: "update", ns: "networkCommercialExceptions", id: id7, attrs: {
968
- id: id7,
969
- eventId: fact.eventId,
970
- kind: "collision",
971
- status: "active",
972
- incomingDigest: fact.digest,
973
- detail: { eventType: fact.eventType, objectId: fact.objectId },
974
- updatedAt: now
975
- } }]);
976
- return { status: "collision", projection: await loadNetworkCommercialProjection(db, sources) };
1056
+ if (!row2) throw new Error("network commercial event vanished after a lost race");
1057
+ return collide(db, sources, row2, fact, now, legacyDigest, alerts);
977
1058
  }
978
1059
  const projection = await rebuildNetworkCommercialProjection(
979
1060
  db,
980
1061
  sources,
981
1062
  `network-commercial-project:${fact.eventId}:${fact.digest}`,
982
- now
1063
+ now,
1064
+ alerts
983
1065
  );
984
1066
  return { status: "stored", projection };
985
1067
  }
@@ -994,10 +1076,7 @@ async function readStripeRecoveryEvent(db, eventId) {
994
1076
  const rawBody = JSON.stringify(response.body);
995
1077
  return { event: response.body, rawBody, digest: await stripeEventDigest(rawBody) };
996
1078
  }
997
- var stable = (value) => JSON.stringify(value, function(_key, item) {
998
- return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
999
- });
1000
- async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now()) {
1079
+ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
1001
1080
  const eventRowId2 = `network-commercial-event:${eventId}`, exceptionId2 = `network-commercial-exception:${eventId}:collision`;
1002
1081
  const rows5 = await db.query({
1003
1082
  networkCommercialEvents: { $: { where: { id: eventRowId2 }, limit: 1 } },
@@ -1007,9 +1086,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1007
1086
  if (!stored) throw new Error("stored event not found");
1008
1087
  const provider = await readStripeRecoveryEvent(db, eventId);
1009
1088
  const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
1010
- const comparable = { ...fact };
1011
- if (!stored.fact?.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
1012
- if (stable(comparable) !== stable(stored.fact)) throw new Error("Stripe facts differ; collision requires investigation");
1089
+ if (!sameNetworkCommercialFact(stored.fact, fact)) throw new Error("Stripe facts differ; collision requires investigation");
1013
1090
  const id6 = crypto.randomUUID();
1014
1091
  await db.transact([
1015
1092
  { t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
@@ -1017,6 +1094,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1017
1094
  eventId,
1018
1095
  actorUserId,
1019
1096
  reviewedAt: now,
1097
+ action: "recover",
1020
1098
  previousDigest: stored.digest,
1021
1099
  providerDigest: provider.digest,
1022
1100
  scope: "network",
@@ -1027,7 +1105,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1027
1105
  t: "update",
1028
1106
  ns: "networkCommercialExceptions",
1029
1107
  id: exceptionId2,
1030
- attrs: { status: "resolved", resolvedDigest: provider.digest, updatedAt: now }
1108
+ attrs: { status: "resolved", resolvedDigest: provider.digest, resolvedBy: actorUserId, reviewId: id6, updatedAt: now }
1031
1109
  }] : []
1032
1110
  ], { mutationId: `payment-recovery:${id6}`, guards: [
1033
1111
  { ns: "networkCommercialEvents", id: eventRowId2, exists: true, equals: { digest: stored.digest } },
@@ -1038,7 +1116,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1038
1116
  ...exception ? { equals: { status: exception.status, updatedAt: exception.updatedAt } } : {}
1039
1117
  }
1040
1118
  ] });
1041
- await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now);
1119
+ await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now, alerts);
1042
1120
  return { recovered: true, eventId, reviewId: id6 };
1043
1121
  }
1044
1122
 
@@ -1200,12 +1278,12 @@ function applicationCrmFields(crm, app) {
1200
1278
  }
1201
1279
 
1202
1280
  // src/applicant-projection.ts
1203
- async function upsertPerson(deps2, opts) {
1281
+ async function upsertPerson(deps, opts) {
1204
1282
  const email = opts.email.toLowerCase();
1205
- const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1206
- const { record: record4, created } = await ensureProjectedPerson(deps2, { ...opts.input, email });
1283
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1284
+ const { record: record4, created } = await ensureProjectedPerson(deps, { ...opts.input, email });
1207
1285
  if (!created) await updateRecord(crmDeps3, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
1208
- const initial = deps2.crm.initialStage("person");
1286
+ const initial = deps.crm.initialStage("person");
1209
1287
  if (!created && record4.stage == null && initial) {
1210
1288
  await setStage(crmDeps3, {
1211
1289
  id: String(record4.id),
@@ -1217,7 +1295,7 @@ async function upsertPerson(deps2, opts) {
1217
1295
  }
1218
1296
  return { recordId: String(record4.id) };
1219
1297
  }
1220
- async function projectApplicant(deps2, applicant) {
1298
+ async function projectApplicant(deps, applicant) {
1221
1299
  const base = sharedPersonInput({
1222
1300
  email: applicant.email,
1223
1301
  firstName: applicant.firstName,
@@ -1226,18 +1304,18 @@ async function projectApplicant(deps2, applicant) {
1226
1304
  linkedin: applicant.linkedin,
1227
1305
  hubRecordId: applicant.applicationId
1228
1306
  });
1229
- Object.assign(base, applicationCrmFields(deps2.crm, applicant));
1307
+ Object.assign(base, applicationCrmFields(deps.crm, applicant));
1230
1308
  const mutationId = `apply:${applicant.applicationId}`;
1231
1309
  const extra = applicant.extra ?? {};
1232
- if (Object.keys(extra).length === 0) return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1310
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1233
1311
  try {
1234
- return await upsertPerson(deps2, { email: applicant.email, input: {
1312
+ return await upsertPerson(deps, { email: applicant.email, input: {
1235
1313
  ...base,
1236
1314
  ...extra,
1237
- ...applicationCrmFields(deps2.crm, applicant)
1315
+ ...applicationCrmFields(deps.crm, applicant)
1238
1316
  }, mutationId });
1239
1317
  } catch {
1240
- return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1318
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1241
1319
  }
1242
1320
  }
1243
1321
 
@@ -1335,24 +1413,24 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
1335
1413
  if (input[nameField] === void 0) input[nameField] = record4.name;
1336
1414
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
1337
1415
  }
1338
- async function findSharedRecord(deps2, record4, tag) {
1416
+ async function findSharedRecord(deps, record4, tag) {
1339
1417
  const structured = await getRecordByOrigin(
1340
- { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId },
1418
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1341
1419
  record4.sourceId,
1342
1420
  record4.sourceRecordId
1343
1421
  );
1344
1422
  if (structured) return structured;
1345
- const mapped = await deps2.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1423
+ const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1346
1424
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1347
1425
  if (typeof mappedId === "string") {
1348
- const found = await deps2.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1426
+ const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1349
1427
  if (found.crm_record?.[0]) return found.crm_record[0];
1350
1428
  }
1351
- const def = deps2.crm.type(record4.type);
1429
+ const def = deps.crm.type(record4.type);
1352
1430
  const emailField = def.emailField;
1353
1431
  if (emailField && typeof record4.input[emailField] === "string") {
1354
1432
  const primaryEmail = record4.input[emailField].toLowerCase();
1355
- const found = await deps2.db.query({
1433
+ const found = await deps.db.query({
1356
1434
  crm_record: { $: { where: { type: record4.type, primaryEmail }, limit: 1 } }
1357
1435
  });
1358
1436
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1360,7 +1438,7 @@ async function findSharedRecord(deps2, record4, tag) {
1360
1438
  const domain = record4.input.domain;
1361
1439
  const domainSlot = def.fields.domain?.slot;
1362
1440
  if (typeof domain === "string" && domainSlot) {
1363
- const found = await deps2.db.query({
1441
+ const found = await deps.db.query({
1364
1442
  crm_record: { $: { where: { type: record4.type, [domainSlot]: domain }, limit: 1 } }
1365
1443
  });
1366
1444
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1368,14 +1446,14 @@ async function findSharedRecord(deps2, record4, tag) {
1368
1446
  const nameField = def.nameField ?? "name";
1369
1447
  const name = record4.input[nameField];
1370
1448
  if (record4.type === "company" && typeof name === "string" && name.trim()) {
1371
- const found = await deps2.db.query({
1449
+ const found = await deps.db.query({
1372
1450
  crm_record: { $: { where: { type: record4.type, name: name.trim() }, limit: 1 } }
1373
1451
  });
1374
1452
  if (found.crm_record?.[0]) return found.crm_record[0];
1375
1453
  }
1376
1454
  return void 0;
1377
1455
  }
1378
- async function projectSharedRecord(deps2, shared, options = {}) {
1456
+ async function projectSharedRecord(deps, shared, options = {}) {
1379
1457
  const record4 = normalizeSharedRecord(shared, options.sourceId);
1380
1458
  if (!record4.type.trim() || !record4.sourceId.trim() || !record4.sourceRecordId.trim()) {
1381
1459
  throw new Error("type, source site id, and source record id must be non-empty");
@@ -1384,14 +1462,14 @@ async function projectSharedRecord(deps2, shared, options = {}) {
1384
1462
  throw new Error("signed sender does not match payload source");
1385
1463
  }
1386
1464
  const tag = networkSourceTag(record4.type, record4.sourceRecordId, record4.version === 2 ? record4.sourceId : void 0);
1387
- const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1388
- const existing = await findSharedRecord(deps2, record4, tag);
1465
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1466
+ const existing = await findSharedRecord(deps, record4, tag);
1389
1467
  let recordId;
1390
1468
  if (existing && typeof existing.id === "string") {
1391
1469
  await updateRecord2(crmDeps3, { id: existing.id, input: record4.input });
1392
1470
  recordId = existing.id;
1393
1471
  } else if (record4.type === "person" && typeof record4.input.email === "string") {
1394
- const projected = await ensureProjectedPerson(deps2, record4.input);
1472
+ const projected = await ensureProjectedPerson(deps, record4.input);
1395
1473
  recordId = String(projected.record.id);
1396
1474
  if (!projected.created) await updateRecord2(crmDeps3, { id: recordId, input: record4.input });
1397
1475
  } else {
@@ -1402,8 +1480,8 @@ async function projectSharedRecord(deps2, shared, options = {}) {
1402
1480
  mutationId: `share-create:${tag}`
1403
1481
  });
1404
1482
  }
1405
- await deps2.db.transact(
1406
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps2.now() } }],
1483
+ await deps.db.transact(
1484
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1407
1485
  { mutationId: `share-map:${tag}:${recordId}` }
1408
1486
  );
1409
1487
  await upsertRecordOrigin(crmDeps3, {
@@ -1451,21 +1529,21 @@ function billingColumns(app) {
1451
1529
  if (billingStatus !== "canceled" && billingStatus !== "refunded" && Number.isFinite(renewal) && renewal > 0) cols.renewalAt = renewal;
1452
1530
  return cols;
1453
1531
  }
1454
- async function syncApplicationToCrm(deps2, opts) {
1532
+ async function syncApplicationToCrm(deps, opts) {
1455
1533
  if (!opts.app.id) throw new Error("crm_application_id_required");
1456
1534
  const emailKey = str(opts.app.email).toLowerCase();
1457
1535
  if (!emailKey) return null;
1458
- const recordDeps = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1459
- const input = personInputFromApp(deps2.chapter, opts.app);
1536
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1537
+ const input = personInputFromApp(deps.chapter, opts.app);
1460
1538
  const stage = opts.stage || void 0;
1461
- const { record: existing, created } = await ensureProjectedPerson(deps2, input, { stage });
1539
+ const { record: existing, created } = await ensureProjectedPerson(deps, input, { stage });
1462
1540
  const recordId = String(existing.id);
1463
1541
  if (!created) await updateRecord3(recordDeps, { id: recordId, input });
1464
1542
  if (!created && stage && existing.stage !== stage) {
1465
1543
  await setStage2(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
1466
1544
  }
1467
1545
  const billing = billingColumns(opts.app);
1468
- await deps2.db.transact([
1546
+ await deps.db.transact([
1469
1547
  { t: "update", ns: "crm_record", id: recordId, attrs: billing },
1470
1548
  // Optional typed dates are removed with a retract, never an invalid null.
1471
1549
  ...!("renewalAt" in billing) ? [{ t: "retract", ns: "crm_record", id: recordId, attrs: ["renewalAt"] }] : []
@@ -1473,8 +1551,8 @@ async function syncApplicationToCrm(deps2, opts) {
1473
1551
  await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1474
1552
  return recordId;
1475
1553
  }
1476
- async function backfillCrm(deps2) {
1477
- const data = await deps2.db.query({
1554
+ async function backfillCrm(deps) {
1555
+ const data = await deps.db.query({
1478
1556
  applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } }
1479
1557
  });
1480
1558
  const seen = /* @__PURE__ */ new Set();
@@ -1485,7 +1563,7 @@ async function backfillCrm(deps2) {
1485
1563
  if (!key3 || seen.has(key3)) return;
1486
1564
  seen.add(key3);
1487
1565
  try {
1488
- await syncApplicationToCrm(deps2, { app, stage });
1566
+ await syncApplicationToCrm(deps, { app, stage });
1489
1567
  synced += 1;
1490
1568
  } catch (err) {
1491
1569
  errors.push({ email: key3, error: err instanceof Error ? err.message : String(err) });
@@ -1493,10 +1571,10 @@ async function backfillCrm(deps2) {
1493
1571
  };
1494
1572
  for (const a of data.applications ?? []) await run(a, str(a.status));
1495
1573
  for (let offset = 0; ; offset += 200) {
1496
- const users = (await deps2.db.query({ $users: { $: { order: { id: "asc" }, limit: 200, offset } } })).$users ?? [];
1574
+ const users = (await deps.db.query({ $users: { $: { order: { id: "asc" }, limit: 200, offset } } })).$users ?? [];
1497
1575
  for (const u of users) {
1498
1576
  try {
1499
- if (await syncAccountToCrm(deps2, String(u.id))) synced += 1;
1577
+ if (await syncAccountToCrm(deps, String(u.id))) synced += 1;
1500
1578
  } catch (err) {
1501
1579
  errors.push({ email: str(u.email), error: err instanceof Error ? err.message : String(err) });
1502
1580
  }
@@ -1536,26 +1614,32 @@ function planDelivery(input) {
1536
1614
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1537
1615
  const to = redirect ? input.group.debugEmail : input.to;
1538
1616
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1539
- const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
1617
+ const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
1540
1618
 
1541
1619
  ` + render(tpl.text, vars) : render(tpl.text, vars);
1542
- return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
1620
+ return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
1543
1621
  }
1544
1622
 
1545
1623
  // src/notify.ts
1546
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1547
- async function sendTemplated(deps2, input) {
1548
- const { emailLog } = await deps2.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1624
+ var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1625
+ function fromAddress(configured, name) {
1626
+ const display = name?.trim();
1627
+ if (!display) return configured;
1628
+ const angled = configured.match(/<([^>]+)>/);
1629
+ return { name: display, email: (angled ? angled[1] : configured).trim() };
1630
+ }
1631
+ async function sendTemplated(deps, input) {
1632
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1549
1633
  const prior = Array.isArray(emailLog) ? emailLog : [];
1550
1634
  if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
1551
1635
  if (prior.length > 1 || prior.length === 1 && prior[0].deliveryState !== "retry_ready") {
1552
1636
  return { sent: false, reason: "delivery-uncertain" };
1553
1637
  }
1554
- const cloudflareReady = Boolean(deps2.sender && deps2.from);
1555
- const override = deps2.debugEmail?.trim();
1556
- const group = deps2.envName !== "prod" && deps2.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1638
+ const cloudflareReady = Boolean(deps.sender && deps.from);
1639
+ const override = deps.debugEmail?.trim();
1640
+ const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1557
1641
  const decision = planDelivery({
1558
- envName: deps2.envName,
1642
+ envName: deps.envName,
1559
1643
  group,
1560
1644
  template: input.template,
1561
1645
  to: input.to,
@@ -1580,14 +1664,14 @@ async function sendTemplated(deps2, input) {
1580
1664
  transport: decision.transport,
1581
1665
  redirected: decision.redirected,
1582
1666
  dedupeKey: input.dedupeKey,
1583
- sentAt: deps2.now(),
1667
+ sentAt: deps.now(),
1584
1668
  deliveryState: decision.transport === "log-only" ? "sent" : "sending",
1585
1669
  deliveryAttempt: attempt,
1586
1670
  error: decision.transport === "log-only" ? "" : "delivery-pending",
1587
1671
  ...input.applicationId ? { applicationId: input.applicationId } : {}
1588
1672
  };
1589
1673
  try {
1590
- const claim = await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1674
+ const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1591
1675
  mutationId: `email-claim:${id6}:${attempt}`,
1592
1676
  guards: [previous ? {
1593
1677
  ns: "emailLog",
@@ -1605,8 +1689,8 @@ async function sendTemplated(deps2, input) {
1605
1689
  if (decision.transport === "log-only") return { sent: true };
1606
1690
  let messageId;
1607
1691
  try {
1608
- const response = await deps2.sender.send({
1609
- from: deps2.from,
1692
+ const response = await deps.sender.send({
1693
+ from: fromAddress(deps.from, input.group.fromName),
1610
1694
  to: [decision.to],
1611
1695
  subject: String(row2.subject),
1612
1696
  text: String(row2.body),
@@ -1615,12 +1699,12 @@ async function sendTemplated(deps2, input) {
1615
1699
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
1616
1700
  messageId = response.messageId;
1617
1701
  } catch {
1618
- await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "uncertain", error: "delivery-uncertain" } }], {
1702
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "uncertain", error: "delivery-uncertain" } }], {
1619
1703
  guards: [{ ns: "emailLog", id: id6, exists: true, equals: { deliveryState: "sending", deliveryAttempt: attempt } }]
1620
1704
  });
1621
1705
  return { sent: false, reason: "delivery-uncertain" };
1622
1706
  }
1623
- await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "sent", error: "", messageId, sentAt: deps2.now() } }], {
1707
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "sent", error: "", messageId, sentAt: deps.now() } }], {
1624
1708
  mutationId: `email-confirmed:${id6}:${attempt}`,
1625
1709
  guards: [{
1626
1710
  ns: "emailLog",
@@ -1639,6 +1723,7 @@ function emailGroupFrom(row2) {
1639
1723
  name: String(row2.name ?? ""),
1640
1724
  replyTo: str3(row2.replyTo) ?? "",
1641
1725
  debugEmail: str3(row2.debugEmail),
1726
+ fromName: str3(row2.fromName),
1642
1727
  refundPolicyText: str3(row2.refundPolicyText),
1643
1728
  commitmentText: str3(row2.commitmentText),
1644
1729
  normsText: str3(row2.normsText),
@@ -1660,6 +1745,11 @@ function canApprove(status2, p) {
1660
1745
  }
1661
1746
 
1662
1747
  // src/membership-policy.ts
1748
+ function seatPurchaseDeadline(offering, member) {
1749
+ if (offering.seatPurchaseWindowCalendarMonths === void 0) return Number(member.termEndsAt);
1750
+ if (Number.isSafeInteger(member.seatPurchaseEndsAt)) return Number(member.seatPurchaseEndsAt);
1751
+ return addMembershipCalendarMonths(Number(member.initialTermStartedAt ?? member.termStartedAt), offering.seatPurchaseWindowCalendarMonths);
1752
+ }
1663
1753
  var fail2 = (field) => {
1664
1754
  throw new TypeError(`invalid membership policy: ${field}`);
1665
1755
  };
@@ -1716,7 +1806,7 @@ function membershipOfferingVersion(value) {
1716
1806
  autoRenew: true,
1717
1807
  paymentGraceDays: integer2(input.paymentGraceDays, 0, 60),
1718
1808
  seatPriceCents: integer2(input.seatPriceCents, 1, Number.MAX_SAFE_INTEGER),
1719
- seatPurchaseWindowCalendarMonths: integer2(input.seatPurchaseWindowCalendarMonths, 1, 120),
1809
+ ...input.seatPurchaseWindowCalendarMonths === void 0 || input.seatPurchaseWindowCalendarMonths === null ? {} : { seatPurchaseWindowCalendarMonths: integer2(input.seatPurchaseWindowCalendarMonths, 1, 120) },
1720
1810
  requiredDisclosures: ids(input.requiredDisclosures)
1721
1811
  };
1722
1812
  }
@@ -2107,7 +2197,7 @@ async function confirmMembershipPayment(db, input, now) {
2107
2197
  initialTermEndsAt: endsAt,
2108
2198
  termStartedAt: paidAt,
2109
2199
  termEndsAt: endsAt,
2110
- seatPurchaseEndsAt: addMembershipCalendarMonths(Number(paidAt), offering.seatPurchaseWindowCalendarMonths),
2200
+ seatPurchaseEndsAt: seatPurchaseDeadline(offering, { initialTermStartedAt: paidAt, termEndsAt: endsAt }),
2111
2201
  autoRenew: true,
2112
2202
  refundedCents: 0,
2113
2203
  revision: 1,
@@ -2764,24 +2854,22 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
2764
2854
  }
2765
2855
  }
2766
2856
 
2767
- // src/payment-notifications.ts
2768
- var groupFor = async (db, app) => {
2769
- const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
2770
- return result.groups?.[0];
2771
- };
2772
- var deps = (db, env) => ({
2773
- db,
2774
- envName: env.ODLA_ENV,
2775
- sender: env.SEND_EMAIL,
2776
- from: env.EMAIL_FROM,
2777
- debugEmail: env.ODLA_DEBUG_EMAIL,
2778
- now: () => Date.now(),
2779
- newId: () => crypto.randomUUID()
2780
- });
2781
- async function paymentConfirmationVariables(db, app) {
2857
+ // src/notification-variables.ts
2858
+ function lifecycleNotifyDeps(db, env) {
2859
+ return {
2860
+ db,
2861
+ envName: env.ODLA_ENV,
2862
+ sender: env.SEND_EMAIL,
2863
+ from: env.EMAIL_FROM,
2864
+ debugEmail: env.ODLA_DEBUG_EMAIL,
2865
+ now: () => Date.now(),
2866
+ newId: () => crypto.randomUUID()
2867
+ };
2868
+ }
2869
+ async function frozenCheckoutVariables(db, app) {
2782
2870
  const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
2783
2871
  const offer = intent?.membershipOffer, contract = offer?.contract;
2784
- const offering = contract?.offering;
2872
+ const offering = contract?.offering, quote = intent?.paymentQuote;
2785
2873
  const amount = contract?.dueTodayCents ?? intent?.priceCents;
2786
2874
  const currency = offering?.currency ?? intent?.currency;
2787
2875
  let formatted = "";
@@ -2789,9 +2877,75 @@ async function paymentConfirmationVariables(db, app) {
2789
2877
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
2790
2878
  }
2791
2879
  return {
2792
- firstName: typeof app.firstName === "string" ? app.firstName : "",
2793
2880
  paymentAmount: formatted,
2794
2881
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
2882
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
2883
+ };
2884
+ }
2885
+ async function catalogTierName(db, group, app) {
2886
+ try {
2887
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
2888
+ } catch {
2889
+ return "";
2890
+ }
2891
+ }
2892
+ async function checkoutVariables(db, group, app) {
2893
+ const frozen = await frozenCheckoutVariables(db, app);
2894
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
2895
+ }
2896
+ function formatInstant(at, timeZone) {
2897
+ return new Intl.DateTimeFormat("en-US", {
2898
+ weekday: "long",
2899
+ month: "long",
2900
+ day: "numeric",
2901
+ year: "numeric",
2902
+ hour: "numeric",
2903
+ minute: "2-digit",
2904
+ timeZoneName: "short",
2905
+ timeZone
2906
+ }).format(new Date(at));
2907
+ }
2908
+ var adminUrlFor = (origin) => `${origin}/admin/`;
2909
+ var membersUrlFor = (origin) => `${origin}/members/`;
2910
+ function applicantVariables(app) {
2911
+ const text8 = (v) => typeof v === "string" ? v : "";
2912
+ return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
2913
+ }
2914
+ function sampleVariables(origin, timeZone, now = Date.now()) {
2915
+ const startAt = formatInstant(now + 864e5, timeZone);
2916
+ return {
2917
+ firstName: "Sample",
2918
+ lastName: "Person",
2919
+ email: "sample@example.com",
2920
+ phone: "(555) 010-0100",
2921
+ state: "CA",
2922
+ tier: "Membership",
2923
+ tierName: "Membership",
2924
+ paymentAmount: "$100.00",
2925
+ paymentAmountSummary: " of $100.00",
2926
+ paymentNextStep: "Next, schedule your introduction call.",
2927
+ submitNextStep: "Next, schedule your introduction call.",
2928
+ startAt,
2929
+ endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
2930
+ timezone: timeZone,
2931
+ meetUrl: "https://meet.google.com/sample",
2932
+ htmlLink: "https://calendar.google.com/event?eid=sample",
2933
+ meetingTime: startAt,
2934
+ meetingLink: "https://meet.google.com/sample",
2935
+ adminUrl: adminUrlFor(origin),
2936
+ membersUrl: membersUrlFor(origin)
2937
+ };
2938
+ }
2939
+
2940
+ // src/payment-notifications.ts
2941
+ var groupFor = async (db, app) => {
2942
+ const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
2943
+ return result.groups?.[0];
2944
+ };
2945
+ async function paymentConfirmationVariables(db, app) {
2946
+ return {
2947
+ firstName: typeof app.firstName === "string" ? app.firstName : "",
2948
+ ...await frozenCheckoutVariables(db, app),
2795
2949
  paymentNextStep: app.status === "approved" ? "Your membership is approved." : typeof app.interviewWaivedAt === "number" ? "Your interview is waived. We are completing your membership approval." : "Next, schedule your introduction call."
2796
2950
  };
2797
2951
  }
@@ -2799,7 +2953,7 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
2799
2953
  if (typeof app.email !== "string" || !app.email) throw new Error("payment notice recipient missing");
2800
2954
  const group = await groupFor(db, app);
2801
2955
  if (!group) throw new Error("payment notice configuration missing");
2802
- const result = await sendTemplated(deps(db, env), {
2956
+ const result = await sendTemplated(lifecycleNotifyDeps(db, env), {
2803
2957
  group: emailGroupFrom(group),
2804
2958
  template: "paymentConfirmation",
2805
2959
  to: app.email,
@@ -2809,21 +2963,20 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
2809
2963
  });
2810
2964
  if (!result.sent && !["disabled", "template-missing"].includes(result.reason ?? "")) throw new Error("payment notice reconciliation pending");
2811
2965
  }
2812
- async function notifyAdminOfPayment(db, env, eventId, app) {
2966
+ async function notifyAdminOfPayment(db, env, eventId, app, origin = "") {
2813
2967
  try {
2814
2968
  const group = await groupFor(db, app);
2815
2969
  if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
2816
- const text6 = (value) => typeof value === "string" ? value : "";
2817
- await sendTemplated(deps(db, env), {
2970
+ const { tierName: tierName2 } = await frozenCheckoutVariables(db, app);
2971
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
2818
2972
  group: emailGroupFrom(group),
2819
2973
  template: "adminNotification",
2820
2974
  to: group.notificationEmail,
2821
2975
  vars: {
2822
- firstName: text6(app.firstName),
2823
- lastName: text6(app.lastName),
2824
- email: text6(app.email),
2825
- phone: text6(app.phone),
2826
- state: text6(app.state)
2976
+ ...applicantVariables(app),
2977
+ tier: tierName2,
2978
+ tierName: tierName2,
2979
+ ...origin ? { adminUrl: adminUrlFor(origin), membersUrl: membersUrlFor(origin) } : {}
2827
2980
  },
2828
2981
  dedupeKey: `${eventId}:admin`,
2829
2982
  applicationId: String(app.id)
@@ -3143,6 +3296,26 @@ function reportStripePartition(level, eventId, event, disposition) {
3143
3296
  ...stripeEventIdentity(event)
3144
3297
  }));
3145
3298
  }
3299
+ var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
3300
+ var QUEUE_LIMIT = 500;
3301
+ var QUEUE_IDS = 50;
3302
+ async function alertStripeQuarantine(db, ctx, eventId, disposition) {
3303
+ const pages = await Promise.all(QUARANTINE_DISPOSITIONS.map(async (value) => (await db.query({ stripeEventReceipts: { $: { where: { disposition: value }, limit: QUEUE_LIMIT } } })).stripeEventReceipts ?? []));
3304
+ const queued = pages.flat();
3305
+ reportChapterAlert(ctx, new Error("stripe webhook quarantined"), {
3306
+ code: "stripe_webhook_quarantined",
3307
+ attributes: {
3308
+ "odla.stripe.disposition": disposition,
3309
+ "odla.stripe.quarantined_count": queued.length,
3310
+ "odla.stripe.queue_truncated": pages.some((page) => page.length >= QUEUE_LIMIT)
3311
+ },
3312
+ artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
3313
+ });
3314
+ }
3315
+ async function reportStripeCollision(db, ctx, eventId, event) {
3316
+ reportStripePartition("error", eventId, event, "quarantine_collision");
3317
+ await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
3318
+ }
3146
3319
  async function storeStripeReceipt(db, input) {
3147
3320
  const existing = await receiptFor(db, input.eventId);
3148
3321
  if (existing?.retryReady === true && existing.reviewedDigest === input.digest) {
@@ -3175,17 +3348,18 @@ async function storeStripeReceipt(db, input) {
3175
3348
  const raced = await receiptFor(db, input.eventId);
3176
3349
  return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
3177
3350
  }
3178
- async function quarantineStripeEvent(db, eventId, digest, event) {
3351
+ async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
3179
3352
  const disposition = "quarantine_unmatched";
3180
3353
  const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
3181
3354
  if (stored === "collision") {
3182
- reportStripePartition("error", eventId, event, "quarantine_collision");
3355
+ await reportStripeCollision(db, ctx, eventId, event);
3183
3356
  return json({ ok: true, matched: false, collision: true });
3184
3357
  }
3185
3358
  reportStripePartition("warn", eventId, event, disposition);
3359
+ if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
3186
3360
  return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
3187
3361
  }
3188
- async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
3362
+ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
3189
3363
  const existing = await receiptFor(db, eventId);
3190
3364
  if (!existing) return null;
3191
3365
  if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
@@ -3195,7 +3369,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
3195
3369
  collisionDigest: digest,
3196
3370
  retryReady: false
3197
3371
  } }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
3198
- reportStripePartition("error", eventId, event, "quarantine_collision");
3372
+ await reportStripeCollision(db, ctx, eventId, event);
3199
3373
  return json({ ok: true, matched: false, collision: true });
3200
3374
  }
3201
3375
  return json({ ok: true, matched: existing.matched === true, replayed: true });
@@ -3226,16 +3400,16 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3226
3400
  reportStripePartition("warn", eventId, event, "ignore_sibling");
3227
3401
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3228
3402
  }
3229
- const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
3403
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3230
3404
  if (replay2) return replay2;
3231
3405
  const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
3232
3406
  const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
3233
3407
  if (!localIntent || disposition2 === "quarantine_unmatched") {
3234
- return quarantineStripeEvent(db, eventId, digest, event);
3408
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3235
3409
  }
3236
3410
  if (event.kind === "gift_paid") {
3237
3411
  if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3238
- return quarantineStripeEvent(db, eventId, digest, event);
3412
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3239
3413
  }
3240
3414
  try {
3241
3415
  const issued = await issueGiftClaim(db, intent, {
@@ -3244,7 +3418,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3244
3418
  }, Date.now());
3245
3419
  const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
3246
3420
  if (stored3 === "collision") {
3247
- reportStripePartition("error", eventId, event, "quarantine_collision");
3421
+ await reportStripeCollision(db, ctx, eventId, event);
3248
3422
  return json({ ok: true, matched: false, collision: true });
3249
3423
  }
3250
3424
  return json({ ok: true, matched: true, claimId: issued.claimId });
@@ -3260,7 +3434,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3260
3434
  if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3261
3435
  const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
3262
3436
  if (stored2 === "collision") {
3263
- reportStripePartition("error", eventId, event, "quarantine_collision");
3437
+ await reportStripeCollision(db, ctx, eventId, event);
3264
3438
  return json({ ok: true, matched: false, collision: true });
3265
3439
  }
3266
3440
  return json({ ok: true, matched: Boolean(claim) });
@@ -3270,12 +3444,12 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3270
3444
  reportStripePartition("warn", eventId, event, "ignore_sibling");
3271
3445
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3272
3446
  }
3273
- const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
3447
+ const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3274
3448
  if (replay) return replay;
3275
3449
  const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
3276
3450
  const disposition = classifyMemberStripeEvent(identity, target, localApplication);
3277
3451
  if (!localApplication || disposition === "quarantine_unmatched") {
3278
- return quarantineStripeEvent(db, eventId, digest, event);
3452
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3279
3453
  }
3280
3454
  if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
3281
3455
  await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
@@ -3292,7 +3466,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3292
3466
  if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3293
3467
  const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3294
3468
  if (stored2 === "collision") {
3295
- reportStripePartition("error", eventId, event, "quarantine_collision");
3469
+ await reportStripeCollision(db, ctx, eventId, event);
3296
3470
  return json({ ok: true, matched: false, collision: true });
3297
3471
  }
3298
3472
  return json({ ok: true, seatId: event.seatId });
@@ -3316,7 +3490,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3316
3490
  if (intent) {
3317
3491
  const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
3318
3492
  if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
3319
- if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event);
3493
+ if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
3320
3494
  if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
3321
3495
  }
3322
3496
  if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
@@ -3367,7 +3541,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3367
3541
  } catch {
3368
3542
  return json({ error: "payment notice reconciliation pending" }, 503);
3369
3543
  }
3370
- if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3544
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app, input.origin);
3371
3545
  if (typeof next.interviewWaivedAt === "number") {
3372
3546
  const approval = await approveApplication({
3373
3547
  db,
@@ -3382,15 +3556,132 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3382
3556
  }
3383
3557
  const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3384
3558
  if (stored === "collision") {
3385
- reportStripePartition("error", eventId, event, "quarantine_collision");
3559
+ await reportStripeCollision(db, ctx, eventId, event);
3386
3560
  return json({ ok: true, matched: false, collision: true });
3387
3561
  }
3388
3562
  return json({ ok: true });
3389
3563
  }
3390
3564
 
3565
+ // src/network-commercial-resolve.ts
3566
+ var EVENT_ID = /^evt_[A-Za-z0-9_]{1,150}$/;
3567
+ var fail3 = (message, code) => Object.assign(new Error(message), { code });
3568
+ async function resolveNetworkCommercialCollision(db, sources, input, now = Date.now(), alerts) {
3569
+ if (!EVENT_ID.test(input.eventId)) throw fail3("invalid event reference", "invalid_event");
3570
+ const id6 = exceptionId(input.eventId, "collision"), reviewId = `resolve:${input.mutationId}`;
3571
+ const found = await db.query({
3572
+ networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } },
3573
+ networkCommercialEvents: { $: { where: { eventId: input.eventId }, limit: 1 } }
3574
+ });
3575
+ const exception = found.networkCommercialExceptions?.[0], stored = found.networkCommercialEvents?.[0];
3576
+ if (exception?.status === "resolved" && exception.reviewId === reviewId) {
3577
+ return { eventId: input.eventId, reviewId, projection: await loadNetworkCommercialProjection(db, sources) };
3578
+ }
3579
+ if (!exception || exception.status !== "active") throw fail3("no active collision for this event", "collision_not_active");
3580
+ if (!stored) throw fail3("stored event not found", "event_not_found");
3581
+ const dismissed = typeof exception.incomingDigest === "string" ? exception.incomingDigest : void 0;
3582
+ const write = await db.transact([
3583
+ { t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
3584
+ id: reviewId,
3585
+ eventId: input.eventId,
3586
+ actorUserId: input.actorUserId,
3587
+ reviewedAt: now,
3588
+ previousDigest: String(stored.digest),
3589
+ providerDigest: dismissed ?? "",
3590
+ scope: "network",
3591
+ previousState: exception,
3592
+ action: "resolve",
3593
+ reason: input.reason
3594
+ } },
3595
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
3596
+ status: "resolved",
3597
+ resolvedDigest: stored.digest,
3598
+ ...dismissed ? { reviewedDigest: dismissed } : {},
3599
+ resolvedBy: input.actorUserId,
3600
+ reviewId,
3601
+ updatedAt: now
3602
+ } }
3603
+ ], { mutationId: input.mutationId, guards: [
3604
+ { ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
3605
+ ] });
3606
+ if (write.duplicate) throw fail3("mutationId was already used for another change", "mutation_reused");
3607
+ const projection = await rebuildNetworkCommercialProjection(db, sources, `${input.mutationId}:rebuild`, now, alerts);
3608
+ return { eventId: input.eventId, reviewId, projection };
3609
+ }
3610
+ async function retryNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
3611
+ if (!EVENT_ID.test(eventId)) throw fail3("invalid event reference", "invalid_event");
3612
+ const active = (await loadNetworkCommercialProjection(db, sources)).exceptions.filter((item) => item.eventId === eventId);
3613
+ if (active.length === 0) throw fail3("event has no active exception", "exception_not_active");
3614
+ const provider = await readStripeRecoveryEvent(db, eventId);
3615
+ const stored = (await db.query({ networkCommercialEvents: { $: { where: { eventId }, limit: 1 } } })).networkCommercialEvents?.[0];
3616
+ const reviewId = crypto.randomUUID();
3617
+ await db.transact([{ t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
3618
+ id: reviewId,
3619
+ eventId,
3620
+ actorUserId,
3621
+ reviewedAt: now,
3622
+ previousDigest: String(stored?.digest ?? ""),
3623
+ providerDigest: provider.digest,
3624
+ scope: "network",
3625
+ previousState: { exceptions: active },
3626
+ action: "retry"
3627
+ } }], { mutationId: `payment-recovery:${reviewId}` });
3628
+ const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
3629
+ const result = await ingestNetworkCommercialFact(db, sources, fact, now, await stripePayloadDigest(provider.rawBody), alerts);
3630
+ return {
3631
+ eventId,
3632
+ reviewId,
3633
+ status: result.status,
3634
+ resolvedCollision: result.status === "replayed" && result.resolvedCollision === true,
3635
+ exceptions: result.projection.exceptions.length
3636
+ };
3637
+ }
3638
+
3639
+ // src/worker-routes-network-commercial-recovery.ts
3640
+ var PATH = "/api/admin/payment-recovery";
3641
+ var handleNetworkCommercialRecovery = async (req, url, env, ctx) => {
3642
+ const model = ctx.chapter.networkCommercialReadModel;
3643
+ if (url.pathname !== PATH || !model) return null;
3644
+ if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
3645
+ const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3646
+ if (!user) return json({ error: "unauthorized" }, 401);
3647
+ if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "forbidden" }, 403);
3648
+ const db = raw2;
3649
+ if (req.method === "GET") {
3650
+ const { exceptions } = await loadNetworkCommercialProjection(db, model.sources);
3651
+ return json({
3652
+ scope: "network",
3653
+ count: exceptions.length,
3654
+ truncated: false,
3655
+ rows: exceptions.map((item) => ({
3656
+ eventId: item.eventId,
3657
+ kind: item.detail.eventType,
3658
+ disposition: item.kind,
3659
+ sourceKey: item.sourceKey ?? null,
3660
+ retryReady: /^evt_/.test(item.eventId)
3661
+ }))
3662
+ });
3663
+ }
3664
+ let eventId;
3665
+ try {
3666
+ eventId = String((await req.json()).eventId ?? "");
3667
+ } catch {
3668
+ return json({ error: "eventId required" }, 400);
3669
+ }
3670
+ try {
3671
+ const retried = await retryNetworkCommercialEvent(db, model.sources, eventId, user.userId, Date.now(), networkCommercialAlerts(ctx, PATH));
3672
+ return json({ ok: true, ...retried });
3673
+ } catch (error) {
3674
+ const code = error.code;
3675
+ if (code === "invalid_event") return json({ error: "invalid event reference" }, 400);
3676
+ if (code === "exception_not_active") return json({ error: "event has no active exception" }, 409);
3677
+ return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
3678
+ }
3679
+ };
3680
+
3391
3681
  // src/worker-routes-payment-recovery.ts
3392
3682
  var handlePaymentRecovery = async (req, url, env, ctx) => {
3393
- if (url.pathname !== "/api/admin/payment-recovery" || ctx.chapter.mode !== "chapter") return null;
3683
+ if (url.pathname !== "/api/admin/payment-recovery") return null;
3684
+ if (ctx.chapter.mode !== "chapter") return handleNetworkCommercialRecovery(req, url, env, ctx);
3394
3685
  if (!["GET", "POST"].includes(req.method)) return json({ error: "method not allowed" }, 405);
3395
3686
  const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3396
3687
  if (!user) return json({ error: "unauthorized" }, 401);
@@ -3452,10 +3743,10 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3452
3743
  };
3453
3744
 
3454
3745
  // src/worker-routes-admin-named-seat.ts
3455
- var PATH = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund)?$/;
3746
+ var PATH2 = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund)?$/;
3456
3747
  var REFUNDABLE = /* @__PURE__ */ new Set(["renewal_pending", "pending_acceptance", "pending_approval", "pending_primary", "active"]);
3457
3748
  var handleAdminNamedSeat = async (req, url, env, ctx) => {
3458
- const match = url.pathname.match(PATH);
3749
+ const match = url.pathname.match(PATH2);
3459
3750
  if (!match) return null;
3460
3751
  const seats = ctx.membershipAuthority?.namedSeats;
3461
3752
  if (ctx.chapter.mode !== "chapter" || !seats) return json({ error: "named seats are not configured" }, 404);
@@ -3508,42 +3799,42 @@ async function closeRecipientApplication(db, env, ctx, seatId) {
3508
3799
 
3509
3800
  // src/membership-seat-refund-stripe.ts
3510
3801
  import { stripeRequest as stripeRequest4 } from "@odla-ai/stripe";
3511
- var fail3 = (code = "named_seat_refund_mismatch") => {
3802
+ var fail4 = (code = "named_seat_refund_mismatch") => {
3512
3803
  throw Object.assign(new Error(code), { code });
3513
3804
  };
3514
3805
  async function refundNamedSeatStripePayment(secretKey, seat) {
3515
- if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail3();
3806
+ if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail4();
3516
3807
  const call = async (method, path, params, idempotencyKey) => {
3517
3808
  const result = await stripeRequest4({ secretKey }, method, path, params, idempotencyKey ? { idempotencyKey } : {});
3518
- if (!result.ok || !result.body) return fail3("named_seat_refund_provider_unavailable");
3809
+ if (!result.ok || !result.body) return fail4("named_seat_refund_provider_unavailable");
3519
3810
  return result.body;
3520
3811
  };
3521
3812
  const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId, membershipId: seat.primaryMembershipId, seatId: seat.id };
3522
3813
  const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(seat.providerPaymentIntentId)}`);
3523
- if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value)) fail3();
3814
+ if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value)) fail4();
3524
3815
  const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
3525
- if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data)) fail3();
3816
+ if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data)) fail4();
3526
3817
  const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
3527
- if (items.length > 1) fail3();
3818
+ if (items.length > 1) fail4();
3528
3819
  if (items.length && sub.status !== "canceled") {
3529
3820
  const item = items[0];
3530
- if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail3();
3821
+ if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail4();
3531
3822
  const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
3532
- if (removed.id !== item.id || removed.deleted !== true) fail3("named_seat_renewal_removal_unconfirmed");
3823
+ if (removed.id !== item.id || removed.deleted !== true) fail4("named_seat_renewal_removal_unconfirmed");
3533
3824
  }
3534
3825
  const list = await call("GET", "/v1/refunds", { payment_intent: seat.providerPaymentIntentId, limit: 100 });
3535
- if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail3("named_seat_refund_history_incomplete");
3826
+ if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail4("named_seat_refund_history_incomplete");
3536
3827
  let refundedCents = 0, pendingCents = 0;
3537
3828
  const refundIds = [];
3538
3829
  for (const refund of list.data) {
3539
- if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail3();
3830
+ if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail4();
3540
3831
  if (refund.status === "succeeded") {
3541
3832
  refundedCents += refund.amount;
3542
3833
  refundIds.push(refund.id);
3543
3834
  } else if (refund.status === "pending" || refund.status === "requires_action") pendingCents += refund.amount;
3544
3835
  }
3545
- if (refundedCents + pendingCents > seat.amountCents) fail3();
3546
- if (pendingCents) fail3("named_seat_refund_pending");
3836
+ if (refundedCents + pendingCents > seat.amountCents) fail4();
3837
+ if (pendingCents) fail4("named_seat_refund_pending");
3547
3838
  if (refundedCents < seat.amountCents) {
3548
3839
  const remaining = seat.amountCents - refundedCents;
3549
3840
  const refund = await call("POST", "/v1/refunds", {
@@ -3552,8 +3843,8 @@ async function refundNamedSeatStripePayment(secretKey, seat) {
3552
3843
  reason: "requested_by_customer",
3553
3844
  metadata
3554
3845
  }, `named-seat-refund:${seat.id}:${remaining}`);
3555
- if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail3();
3556
- if (refund.status !== "succeeded") fail3("named_seat_refund_pending");
3846
+ if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail4();
3847
+ if (refund.status !== "succeeded") fail4("named_seat_refund_pending");
3557
3848
  refundedCents += remaining;
3558
3849
  refundIds.push(refund.id);
3559
3850
  }
@@ -3642,9 +3933,37 @@ async function refundNamedSeatByOperator(db, input, stripeSecretKey, now) {
3642
3933
  return { seatId: String(seat.id), status: "refunded", refundedCents: Number(refunded.refundedCents), subscriptionCanceled: false };
3643
3934
  }
3644
3935
 
3936
+ // src/membership-seat-window.ts
3937
+ async function liftSeatPurchaseWindow(db, scope2, now, limit = 100) {
3938
+ membershipClock(now);
3939
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError("invalid lift limit");
3940
+ const state = await loadMembershipAuthorityState(db, scope2);
3941
+ const rows5 = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
3942
+ let lifted = 0;
3943
+ for (const member of rows5.slice(0, limit)) {
3944
+ if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) throw new Error("membership lift scope mismatch");
3945
+ const offering = membershipOfferingVersion(member.offeringSnapshot);
3946
+ if (offering.seatPurchaseWindowCalendarMonths === void 0) continue;
3947
+ const { seatPurchaseWindowCalendarMonths: _window, ...windowless } = offering;
3948
+ await db.transact([membershipUpdate("networkMembership", String(member.id), {
3949
+ offeringSnapshot: windowless,
3950
+ seatPurchaseEndsAt: Number(member.termEndsAt),
3951
+ revision: Number(member.revision) + 1,
3952
+ updatedAt: now
3953
+ })], { mutationId: `seat-window-lift:${member.id}:${member.revision}`, guards: [
3954
+ { ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision } },
3955
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } }
3956
+ ] });
3957
+ lifted++;
3958
+ }
3959
+ return { lifted, more: rows5.length > limit };
3960
+ }
3961
+
3645
3962
  // src/worker-routes-admin-membership-seats.ts
3646
3963
  var LIST = "/api/admin/membership-seats";
3964
+ var LIFT = "/api/admin/membership-seats/lift-purchase-window";
3647
3965
  var ONE = /^\/api\/admin\/membership-seats\/([A-Za-z0-9][A-Za-z0-9_.:-]{2,159})\/refund$/;
3966
+ var SCOPE = /^[a-z0-9][a-z0-9-]{1,62}$/;
3648
3967
  var PUBLIC = [
3649
3968
  "id",
3650
3969
  "appId",
@@ -3669,11 +3988,11 @@ var PUBLIC = [
3669
3988
  ];
3670
3989
  var project = (row2) => Object.fromEntries(PUBLIC.filter((key3) => row2[key3] !== void 0).map((key3) => [key3, row2[key3]]));
3671
3990
  var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3672
- const one = url.pathname.match(ONE);
3673
- if (url.pathname !== LIST && !one) return null;
3991
+ const one = url.pathname.match(ONE), lift = url.pathname === LIFT;
3992
+ if (url.pathname !== LIST && !one && !lift) return null;
3674
3993
  const operator = ctx.membershipAuthorityOperator;
3675
3994
  if (!operator) return json({ error: "not found" }, 404);
3676
- if (req.method !== (one ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
3995
+ if (req.method !== (one || lift ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
3677
3996
  const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3678
3997
  if (!user) return json({ error: "unauthorized" }, 401);
3679
3998
  if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
@@ -3683,6 +4002,24 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3683
4002
  } catch {
3684
4003
  return json({ error: "the membership authority is not hosted by this deployment" }, 404);
3685
4004
  }
4005
+ if (lift) {
4006
+ let body;
4007
+ try {
4008
+ body = await req.json();
4009
+ } catch {
4010
+ return json({ error: "chapterId and runtime are required" }, 400);
4011
+ }
4012
+ const appId = body.appId ?? body.chapterId;
4013
+ if (typeof body.chapterId !== "string" || !SCOPE.test(body.chapterId) || typeof appId !== "string" || !SCOPE.test(appId) || typeof body.runtime !== "string" || !/^[a-z0-9]{2,12}$/.test(body.runtime)) {
4014
+ return json({ error: "chapterId and runtime are required" }, 400);
4015
+ }
4016
+ try {
4017
+ return json({ ok: true, ...await liftSeatPurchaseWindow(db, { appId, chapterId: body.chapterId, environment: env.ODLA_ENV, runtime: body.runtime }, Date.now()) });
4018
+ } catch (error) {
4019
+ const code = error.code;
4020
+ return json({ error: "purchase window lift is pending; retry to confirm", ...code ? { code } : {} }, code ? 409 : 503);
4021
+ }
4022
+ }
3686
4023
  if (!one) {
3687
4024
  const rows5 = (await db.query({ networkSeat: { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 201 } } })).networkSeat ?? [];
3688
4025
  return json({ seats: rows5.slice(0, 200).map(project), truncated: rows5.length > 200 });
@@ -3712,6 +4049,107 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3712
4049
  }
3713
4050
  };
3714
4051
 
4052
+ // src/membership-policy-versions.ts
4053
+ var LIMIT = 200;
4054
+ var text2 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
4055
+ var instant = (value) => {
4056
+ const at = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
4057
+ return Number.isSafeInteger(at) && at >= 0 ? at : void 0;
4058
+ };
4059
+ function provenance(row2) {
4060
+ const source = text2(row2.source), actor = text2(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
4061
+ return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
4062
+ }
4063
+ var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
4064
+ function stateOf(item, all, now, window, rivals) {
4065
+ if (item.status !== "published") return { state: item.status };
4066
+ const mine = window(item);
4067
+ if (now < mine.from) return { state: "scheduled" };
4068
+ const closed = mine.until !== void 0 && mine.until <= now;
4069
+ const higher = all.filter((other) => other.id !== item.id && other.status === "published" && other.version > item.version && rivals(item, other)).map(window).filter((w) => w.from <= now && (w.until === void 0 || w.until > mine.from));
4070
+ if (!closed && !higher.some((w) => live(w, now))) return { state: "active" };
4071
+ const dates = higher.map((w) => Math.max(w.from, mine.from));
4072
+ if (closed) dates.push(mine.until);
4073
+ return { state: "superseded", supersededAt: Math.min(...dates) };
4074
+ }
4075
+ var mechanism = (policy) => policy.percentOffBps === 0 || policy.discountEligibleSkuIds.length === 0 ? "none" : policy.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
4076
+ var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
4077
+ var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
4078
+ function normalize(rows5, collection, parse, invalid2) {
4079
+ const out = [];
4080
+ for (const row2 of rows5) {
4081
+ try {
4082
+ out.push({ value: parse(row2.snapshot), row: row2 });
4083
+ } catch (error) {
4084
+ invalid2.push({ collection, id: String(row2.id ?? ""), error: error instanceof Error ? error.message : String(error) });
4085
+ }
4086
+ }
4087
+ return out;
4088
+ }
4089
+ async function listMembershipPolicyVersions(db, input) {
4090
+ const where = input.chapterId ? { chapterId: input.chapterId } : {};
4091
+ const rows5 = await db.query({
4092
+ membershipOfferingVersion: { $: { where, limit: LIMIT } },
4093
+ discountPolicyVersion: { $: { where, limit: LIMIT } }
4094
+ });
4095
+ const offeringRows = rows5.membershipOfferingVersion ?? [], policyRows = rows5.discountPolicyVersion ?? [];
4096
+ const invalid2 = [];
4097
+ const offerings = normalize(offeringRows, "membershipOfferingVersion", membershipOfferingVersion, invalid2);
4098
+ const policies = normalize(policyRows, "discountPolicyVersion", membershipDiscountPolicyVersion, invalid2);
4099
+ const offeringValues = offerings.map((item) => item.value), policyValues = policies.map((item) => item.value);
4100
+ return {
4101
+ now: input.now,
4102
+ chapterId: input.chapterId ?? null,
4103
+ offerings: offerings.map(({ value, row: row2 }) => ({
4104
+ ...value,
4105
+ ...provenance(row2),
4106
+ ...stateOf(
4107
+ value,
4108
+ offeringValues,
4109
+ input.now,
4110
+ (v) => ({ from: v.effectiveFrom, ...v.effectiveUntil !== void 0 ? { until: v.effectiveUntil } : {} }),
4111
+ (a, b) => a.chapterId === b.chapterId && a.baseSkuId === b.baseSkuId
4112
+ )
4113
+ })).sort((a, b) => byChapter(a, b) || a.baseSkuId.localeCompare(b.baseSkuId) || b.version - a.version),
4114
+ discountPolicies: policies.map(({ value, row: row2 }) => ({
4115
+ ...value,
4116
+ ...provenance(row2),
4117
+ mechanism: mechanism(value),
4118
+ ...stateOf(
4119
+ value,
4120
+ policyValues,
4121
+ input.now,
4122
+ (v) => ({ from: v.qualifyingStartsAt, until: v.qualifyingEndsAt }),
4123
+ (a, b) => a.chapterId === b.chapterId && sharesSku(a, b)
4124
+ )
4125
+ })).sort((a, b) => byChapter(a, b) || b.version - a.version),
4126
+ invalid: invalid2,
4127
+ truncated: offeringRows.length >= LIMIT || policyRows.length >= LIMIT
4128
+ };
4129
+ }
4130
+
4131
+ // src/worker-routes-admin-membership-policy.ts
4132
+ var PATH3 = "/api/admin/membership-policy-versions";
4133
+ var SCOPE2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
4134
+ var handleAdminMembershipPolicyVersions = async (req, url, env, ctx) => {
4135
+ if (url.pathname !== PATH3) return null;
4136
+ const operator = ctx.membershipAuthorityOperator;
4137
+ if (!operator) return json({ error: "not found" }, 404);
4138
+ if (req.method !== "GET") return json({ error: "method not allowed" }, 405);
4139
+ const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
4140
+ if (!user) return json({ error: "unauthorized" }, 401);
4141
+ if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
4142
+ const chapterId = url.searchParams.get("chapterId");
4143
+ if (chapterId !== null && !SCOPE2.test(chapterId)) return json({ error: "chapterId is invalid" }, 400);
4144
+ let db;
4145
+ try {
4146
+ db = operator.makeDb(env);
4147
+ } catch {
4148
+ return json({ error: "the membership authority is not hosted by this deployment" }, 404);
4149
+ }
4150
+ return json(await listMembershipPolicyVersions(db, { chapterId, now: Date.now() }));
4151
+ };
4152
+
3715
4153
  // src/worker-routes-membership-cancel.ts
3716
4154
  var handleMembershipCancellation = async (req, url, env, ctx) => {
3717
4155
  const match = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/cancel-renewal$/);
@@ -4564,8 +5002,8 @@ var APPLICATION_LABELS = {
4564
5002
  state: "State"
4565
5003
  };
4566
5004
  function humanize(id6) {
4567
- const text6 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
4568
- return text6 ? `${text6[0].toUpperCase()}${text6.slice(1)}` : id6;
5005
+ const text8 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
5006
+ return text8 ? `${text8[0].toUpperCase()}${text8.slice(1)}` : id6;
4569
5007
  }
4570
5008
  function applicationDefs(chapter) {
4571
5009
  try {
@@ -4632,7 +5070,7 @@ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
4632
5070
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
4633
5071
  var CURRENCY = /^[a-z]{3}$/;
4634
5072
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
4635
- var text2 = (value, path, errors, max) => {
5073
+ var text3 = (value, path, errors, max) => {
4636
5074
  if (typeof value !== "string" || !value.trim()) {
4637
5075
  errors.push(`${path} is required`);
4638
5076
  return "";
@@ -4660,23 +5098,23 @@ function validateGiftOfferCatalog(input, tierIds) {
4660
5098
  }
4661
5099
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
4662
5100
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
4663
- const id6 = text2(item.id, `${path}.id`, errors, 80);
5101
+ const id6 = text3(item.id, `${path}.id`, errors, 80);
4664
5102
  if (id6 && !ID2.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
4665
5103
  if (ids2.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
4666
5104
  ids2.add(id6);
4667
5105
  const targetRaw = record(item.target);
4668
5106
  let target = { type: "entitlement", entitlementKey: "invalid" };
4669
5107
  if (targetRaw?.type === "tier") {
4670
- const tierId = text2(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
5108
+ const tierId = text3(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
4671
5109
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
4672
5110
  target = { type: "tier", tierId };
4673
5111
  } else if (targetRaw?.type === "entitlement") {
4674
- const entitlementKey = text2(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
5112
+ const entitlementKey = text3(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
4675
5113
  if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
4676
5114
  target = { type: "entitlement", entitlementKey };
4677
5115
  } else errors.push(`${path}.target.type must be tier or entitlement`);
4678
5116
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
4679
- const stripePriceId = text2(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
5117
+ const stripePriceId = text3(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
4680
5118
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
4681
5119
  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`);
4682
5120
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -4698,9 +5136,9 @@ function validateGiftOfferCatalog(input, tierIds) {
4698
5136
  redemptionLifetimeHours: lifetime,
4699
5137
  ...activeFrom !== void 0 ? { activeFrom } : {},
4700
5138
  ...activeUntil !== void 0 ? { activeUntil } : {},
4701
- title: text2(item.title, `${path}.title`, errors, 160),
4702
- description: text2(item.description, `${path}.description`, errors, 2e3),
4703
- ctaLabel: text2(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
5139
+ title: text3(item.title, `${path}.title`, errors, 160),
5140
+ description: text3(item.description, `${path}.description`, errors, 2e3),
5141
+ ctaLabel: text3(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
4704
5142
  });
4705
5143
  }
4706
5144
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -4918,22 +5356,22 @@ async function createPaymentQuote(input) {
4918
5356
  // src/membership-quote-projection.ts
4919
5357
  var MEMBERSHIP_QUOTE_NAMESPACE = "membershipQuoteProjections";
4920
5358
  var keys2 = ["appId", "chapterId", "environment", "runtime", "applicationId", "skuId"];
4921
- var fail4 = (code) => {
5359
+ var fail5 = (code) => {
4922
5360
  throw Object.assign(new Error(code), { code });
4923
5361
  };
4924
5362
  var load3 = async (db, ns, id6) => (await db.query({ [ns]: { $: { where: { id: id6 }, limit: 1 } } }))[ns]?.[0];
4925
5363
  async function restore2(row2, target, now, allowExpired = false) {
4926
- if (!row2 || keys2.some((key3) => row2[key3] !== target[key3]) || !Number.isSafeInteger(row2.revision) || Number(row2.revision) < 1 || typeof row2.requestId !== "string") return fail4("membership_quote_unavailable");
4927
- if (row2.status !== "ready") return fail4("membership_quote_pending");
5364
+ if (!row2 || keys2.some((key3) => row2[key3] !== target[key3]) || !Number.isSafeInteger(row2.revision) || Number(row2.revision) < 1 || typeof row2.requestId !== "string") return fail5("membership_quote_unavailable");
5365
+ if (row2.status !== "ready") return fail5("membership_quote_pending");
4928
5366
  const offer = await verifyIssuedMembershipOffer(row2.offer, { ...target, offerId: await membershipOfferIdForRequest({
4929
5367
  ...target,
4930
5368
  requestId: row2.requestId
4931
5369
  }), now, lifetimeMs: 6e4 });
4932
- if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return fail4("membership_quote_expired");
5370
+ if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return fail5("membership_quote_expired");
4933
5371
  const tier = row2.tier, quote = row2.quote;
4934
5372
  const offering = offer.contract.offering;
4935
5373
  if (!tier || tier.id !== target.skuId || tier.groupId !== target.chapterId || !tier.stripePriceId || tier.priceCents !== offering.basePriceCents || tier.currency !== offering.currency || tier.interval !== offering.interval || !quote) {
4936
- return fail4("membership_catalog_mismatch");
5374
+ return fail5("membership_catalog_mismatch");
4937
5375
  }
4938
5376
  const expected = await createPaymentQuote({
4939
5377
  ...target,
@@ -4944,7 +5382,7 @@ async function restore2(row2, target, now, allowExpired = false) {
4944
5382
  presentation: quote.presentation,
4945
5383
  founding: foundingOf(offer)
4946
5384
  });
4947
- if (quote.digest !== expected.digest) return fail4("membership_quote_conflict");
5385
+ if (quote.digest !== expected.digest) return fail5("membership_quote_conflict");
4948
5386
  return { tier, quote: expected, billingTerms: offer.contract.billingTerms, membershipOffer: offer, projectionRevision: Number(row2.revision) };
4949
5387
  }
4950
5388
  async function readMembershipQuote(db, target, now) {
@@ -4952,9 +5390,9 @@ async function readMembershipQuote(db, target, now) {
4952
5390
  }
4953
5391
  async function prepareMembershipQuote(db, input, issue) {
4954
5392
  const { applicationId: id6, now, app } = input;
4955
- if (app.id !== id6 || app.groupId !== input.chapterId || app.tierId !== input.skuId || app.stripeRuntime !== input.runtime || app.status !== "submitted" || app.canceled === true || app.giftOfferId || app.giftClaimId || app.namedSeatId || app.namedSeatClaimPending) return fail4("membership_quote_unavailable");
5393
+ if (app.id !== id6 || app.groupId !== input.chapterId || app.tierId !== input.skuId || app.stripeRuntime !== input.runtime || app.status !== "submitted" || app.canceled === true || app.giftOfferId || app.giftClaimId || app.namedSeatId || app.namedSeatClaimPending) return fail5("membership_quote_unavailable");
4956
5394
  let row2 = await load3(db, MEMBERSHIP_QUOTE_NAMESPACE, id6);
4957
- if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return fail4("membership_quote_conflict");
5395
+ if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return fail5("membership_quote_conflict");
4958
5396
  if (row2?.status === "ready") {
4959
5397
  try {
4960
5398
  return await restore2(row2, input, now);
@@ -4999,7 +5437,7 @@ async function prepareMembershipQuote(db, input, issue) {
4999
5437
  }
5000
5438
  if (row2?.status === "ready") return restore2(row2, input, now);
5001
5439
  if (!row2 || row2.status !== "pending" || keys2.some((key3) => row2[key3] !== input[key3]) || typeof row2.requestId !== "string") {
5002
- return fail4("membership_quote_pending");
5440
+ return fail5("membership_quote_pending");
5003
5441
  }
5004
5442
  const target = Object.fromEntries(keys2.map((key3) => [key3, input[key3]]));
5005
5443
  const offer = await verifyIssuedMembershipOffer(await issue({ ...target, requestId: row2.requestId }), {
@@ -5263,17 +5701,17 @@ async function readBoundedRequestText(req, maxBytes) {
5263
5701
  if (!reader) return "";
5264
5702
  const decoder = new TextDecoder();
5265
5703
  let bytes = 0;
5266
- let text6 = "";
5704
+ let text8 = "";
5267
5705
  for (; ; ) {
5268
5706
  const { done, value } = await reader.read();
5269
5707
  if (req.signal.aborted) return invalid2();
5270
5708
  if (done) {
5271
5709
  complete = true;
5272
- return text6 + decoder.decode();
5710
+ return text8 + decoder.decode();
5273
5711
  }
5274
5712
  bytes += value.byteLength;
5275
5713
  if (bytes > maxBytes) return json({ error: "request body too large" }, 413);
5276
- text6 += decoder.decode(value, { stream: true });
5714
+ text8 += decoder.decode(value, { stream: true });
5277
5715
  }
5278
5716
  } catch {
5279
5717
  return invalid2();
@@ -5626,37 +6064,61 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
5626
6064
  } catch {
5627
6065
  }
5628
6066
  }
5629
- async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
6067
+
6068
+ // src/submit-notifications.ts
6069
+ async function notifyAdminOfApplication(db, env, notice) {
5630
6070
  try {
5631
- const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
5632
- const group = Array.isArray(groups) ? groups[0] : void 0;
5633
- if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
5634
- const stringValue = (value) => typeof value === "string" ? value : "";
5635
- await sendTemplated(
5636
- {
5637
- db,
5638
- envName: env.ODLA_ENV,
5639
- debugEmail: env.ODLA_DEBUG_EMAIL,
5640
- sender: env.SEND_EMAIL,
5641
- from: env.EMAIL_FROM,
5642
- now: () => Date.now(),
5643
- newId: () => crypto.randomUUID()
6071
+ const { group, application: app } = notice;
6072
+ if (typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
6073
+ const { tierName: tierName2 } = await checkoutVariables(db, group, app);
6074
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
6075
+ group: emailGroupFrom(group),
6076
+ template: "adminNotification",
6077
+ to: group.notificationEmail,
6078
+ vars: { ...applicantVariables(app), tier: tierName2, tierName: tierName2, adminUrl: adminUrlFor(notice.origin), membersUrl: membersUrlFor(notice.origin) },
6079
+ dedupeKey: `apply:${String(app.id)}:admin`,
6080
+ applicationId: String(app.id)
6081
+ });
6082
+ } catch {
6083
+ }
6084
+ }
6085
+ var FREE_CONTINUATIONS = /* @__PURE__ */ new Set(["booking", "accepted", "approve"]);
6086
+ function shouldConfirmSubmit(state, group) {
6087
+ if (state !== null && FREE_CONTINUATIONS.has(state)) return true;
6088
+ const templates = group.emailTemplates;
6089
+ return templates?.paymentConfirmation?.enabled === false;
6090
+ }
6091
+ function submitNextStep(state) {
6092
+ switch (state) {
6093
+ case "accepted":
6094
+ return "Your membership is approved.";
6095
+ case "booking":
6096
+ return "Next, schedule your introduction call.";
6097
+ case "payment":
6098
+ case "paymentPending":
6099
+ case "gift":
6100
+ return "Next, complete your membership payment.";
6101
+ default:
6102
+ return "";
6103
+ }
6104
+ }
6105
+ async function notifySubmitConfirmed(db, env, notice) {
6106
+ try {
6107
+ const { group, application: app } = notice;
6108
+ if (typeof app.email !== "string" || !app.email || !shouldConfirmSubmit(notice.state, group)) return;
6109
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
6110
+ group: emailGroupFrom(group),
6111
+ template: "submitConfirmation",
6112
+ to: app.email,
6113
+ vars: {
6114
+ ...applicantVariables(app),
6115
+ ...await checkoutVariables(db, group, app),
6116
+ submitNextStep: submitNextStep(notice.state),
6117
+ membersUrl: membersUrlFor(notice.origin)
5644
6118
  },
5645
- {
5646
- group: emailGroupFrom(group),
5647
- template: "adminNotification",
5648
- to: group.notificationEmail,
5649
- vars: {
5650
- firstName: stringValue(fields.firstName),
5651
- lastName: stringValue(fields.lastName),
5652
- email: stringValue(fields.email),
5653
- phone: stringValue(fields.phone),
5654
- state: stringValue(fields.state)
5655
- },
5656
- dedupeKey: `apply:${applicationId}:admin`,
5657
- applicationId
5658
- }
5659
- );
6119
+ dedupeKey: `apply:${String(app.id)}:confirm`,
6120
+ applicationId: String(app.id)
6121
+ });
5660
6122
  } catch {
5661
6123
  }
5662
6124
  }
@@ -5854,17 +6316,17 @@ var handleMember = async (req, url, env, ctx) => {
5854
6316
  if (provisioningFailure.status === 409) await rejectUnprovisionedSignup(db, result.id, canonical3.status);
5855
6317
  return json(provisioningFailure.body, provisioningFailure.status);
5856
6318
  }
5857
- if (!result.duplicate && effectiveChapter.sends.adminNotification === "submit") {
5858
- await notifyAdminOfApplication(db, env, chapter.id, result.id, canonical3);
5859
- }
5860
6319
  const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
5861
6320
  const group = Array.isArray(groups) ? groups[0] : void 0;
5862
- const state = canonical3 && group ? await continueJoin(db, ctx, env, url.origin, canonical3, group) : null;
6321
+ const notice = group ? { group, application: canonical3, origin: url.origin } : null;
6322
+ if (notice && !result.duplicate && effectiveChapter.sends.adminNotification === "submit") await notifyAdminOfApplication(db, env, notice);
6323
+ const state = notice ? await continueJoin(db, ctx, env, url.origin, canonical3, notice.group) : null;
5863
6324
  if (!state) return json({
5864
6325
  error: "Application saved; its next step is temporarily unavailable. Retry this submission.",
5865
6326
  code: "application_continuation_unavailable",
5866
6327
  id: result.id
5867
6328
  }, 503);
6329
+ if (notice && !result.duplicate) await notifySubmitConfirmed(db, env, { ...notice, state });
5868
6330
  const final = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications?.[0];
5869
6331
  if (!final || final.groupId !== chapter.id || state === "accepted" && final.status !== "approved") {
5870
6332
  return json({ error: "application changed; reload its current state" }, 409);
@@ -5984,10 +6446,10 @@ function boundedString(value, path, errors, max, required = false) {
5984
6446
  errors.push(`${path} must be a string`);
5985
6447
  return void 0;
5986
6448
  }
5987
- const text6 = value.trim();
5988
- if (required && !text6) errors.push(`${path} is required`);
5989
- if (text6.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
5990
- return text6;
6449
+ const text8 = value.trim();
6450
+ if (required && !text8) errors.push(`${path} is required`);
6451
+ if (text8.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
6452
+ return text8;
5991
6453
  }
5992
6454
  function validateMembershipPageDocument(input, options = {}) {
5993
6455
  const errors = [];
@@ -6036,8 +6498,8 @@ function validateMembershipPageDocument(input, options = {}) {
6036
6498
  else {
6037
6499
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
6038
6500
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
6039
- const text6 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
6040
- return text6 ? [text6] : [];
6501
+ const text8 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
6502
+ return text8 ? [text8] : [];
6041
6503
  });
6042
6504
  }
6043
6505
  }
@@ -6131,7 +6593,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
6131
6593
  function unknownKeys(value, allowed2, path, errors) {
6132
6594
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
6133
6595
  }
6134
- function text3(value, path, errors, max = 65536) {
6596
+ function text4(value, path, errors, max = 65536) {
6135
6597
  if (typeof value !== "string" || !value.trim()) {
6136
6598
  errors.push(`${path} is required`);
6137
6599
  return "";
@@ -6154,11 +6616,11 @@ function tiersOf(value, errors) {
6154
6616
  return [];
6155
6617
  }
6156
6618
  unknownKeys(item, TIER_KEYS2, path, errors);
6157
- const id6 = text3(item.id, `${path}.id`, errors, 80);
6619
+ const id6 = text4(item.id, `${path}.id`, errors, 80);
6158
6620
  if (id6 && !TIER.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
6159
6621
  if (seen.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
6160
6622
  seen.add(id6);
6161
- const name = text3(item.name, `${path}.name`, errors, 160);
6623
+ const name = text4(item.name, `${path}.name`, errors, 160);
6162
6624
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
6163
6625
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
6164
6626
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -6176,7 +6638,7 @@ function tiersOf(value, errors) {
6176
6638
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
6177
6639
  errors.push(`${path}.interval must be year or month`);
6178
6640
  }
6179
- const blurb = item.blurb === void 0 ? void 0 : text3(item.blurb, `${path}.blurb`, errors, 4e3);
6641
+ const blurb = item.blurb === void 0 ? void 0 : text4(item.blurb, `${path}.blurb`, errors, 4e3);
6180
6642
  return [{
6181
6643
  id: id6,
6182
6644
  name,
@@ -6234,7 +6696,7 @@ function policyOf(value, errors, tiers) {
6234
6696
  const result = {};
6235
6697
  for (const key3 of allowed2) {
6236
6698
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
6237
- result[key3] = text3(input[key3], `document.policy.${key3}`, errors);
6699
+ result[key3] = text4(input[key3], `document.policy.${key3}`, errors);
6238
6700
  }
6239
6701
  const tierPolicies = {};
6240
6702
  if (input.tierPolicies !== void 0) {
@@ -6249,8 +6711,8 @@ function policyOf(value, errors, tiers) {
6249
6711
  }
6250
6712
  unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
6251
6713
  tierPolicies[id6] = {
6252
- refundPolicyText: text3(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
6253
- ...policy.trustCopy !== void 0 ? { trustCopy: text3(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
6714
+ refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
6715
+ ...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
6254
6716
  };
6255
6717
  }
6256
6718
  }
@@ -6290,9 +6752,9 @@ function validateSignupControlDocument(input) {
6290
6752
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
6291
6753
  const source = recordOf(root.source);
6292
6754
  const target = recordOf(root.target);
6293
- const appId = text3(source?.appId, "document.source.appId", errors, 63);
6294
- const chapterId = text3(target?.chapterId, "document.target.chapterId", errors, 63);
6295
- const environment = text3(target?.environment, "document.target.environment", errors, 63);
6755
+ const appId = text4(source?.appId, "document.source.appId", errors, 63);
6756
+ const chapterId = text4(target?.chapterId, "document.target.chapterId", errors, 63);
6757
+ const environment = text4(target?.environment, "document.target.environment", errors, 63);
6296
6758
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
6297
6759
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
6298
6760
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -7471,7 +7933,7 @@ async function deliverAdmissionGrants(options) {
7471
7933
  }
7472
7934
 
7473
7935
  // src/worker-routes-network-admission.ts
7474
- var PATH2 = "/api/admin/network/admissions";
7936
+ var PATH4 = "/api/admin/network/admissions";
7475
7937
  var RETRY_PATH = "/api/admin/network/admissions/retry";
7476
7938
  var REVOKE_PATH = "/api/admin/network/admissions/revoke";
7477
7939
  var MUTATION_ID2 = /^[A-Za-z0-9_-]{16,128}$/;
@@ -7551,11 +8013,11 @@ async function parseBody(req) {
7551
8013
  }
7552
8014
  }
7553
8015
  var handleAdminNetworkAdmissions = async (req, url, env, ctx) => {
7554
- if (![PATH2, RETRY_PATH, REVOKE_PATH].includes(url.pathname)) return null;
8016
+ if (![PATH4, RETRY_PATH, REVOKE_PATH].includes(url.pathname)) return null;
7555
8017
  const auth = await gate2(req, env, ctx);
7556
8018
  if ("response" in auth) return auth.response;
7557
8019
  const db = auth.db;
7558
- if (url.pathname === PATH2 && req.method === "GET") {
8020
+ if (url.pathname === PATH4 && req.method === "GET") {
7559
8021
  const targetId2 = url.searchParams.get("targetId") ?? "";
7560
8022
  const target2 = targetFor(ctx, targetId2);
7561
8023
  if (!target2) return reject6(404, "unknown_target", "admission target is not configured");
@@ -7656,6 +8118,41 @@ async function retryLeaderAdmissionDeliveries(env, ctx, limit = 100) {
7656
8118
 
7657
8119
  // src/worker-routes-schedule.ts
7658
8120
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
8121
+
8122
+ // src/booking-notifications.ts
8123
+ async function bookingConfirmationVariables(db, group, app, meeting) {
8124
+ const startAt = formatInstant(meeting.startAt, meeting.timezone);
8125
+ const meetUrl = meeting.meetUrl ?? "", htmlLink = meeting.htmlLink ?? "";
8126
+ return {
8127
+ ...applicantVariables(app),
8128
+ ...await checkoutVariables(db, group, app),
8129
+ startAt,
8130
+ endAt: formatInstant(meeting.endAt, meeting.timezone),
8131
+ timezone: meeting.timezone,
8132
+ meetUrl,
8133
+ htmlLink,
8134
+ // The placeholders the seeded template used before these variables existed.
8135
+ meetingTime: startAt,
8136
+ meetingLink: meetUrl || htmlLink
8137
+ };
8138
+ }
8139
+ async function notifyBookingConfirmed(db, env, input) {
8140
+ const app = input.application;
8141
+ if (typeof app.email !== "string" || !app.email) return;
8142
+ try {
8143
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
8144
+ group: emailGroupFrom(input.group),
8145
+ template: "prepEmail",
8146
+ to: app.email,
8147
+ vars: await bookingConfirmationVariables(db, input.group, app, input.meeting),
8148
+ dedupeKey: `prep:${String(app.id)}`,
8149
+ applicationId: String(app.id)
8150
+ });
8151
+ } catch {
8152
+ }
8153
+ }
8154
+
8155
+ // src/worker-routes-schedule.ts
7659
8156
  function errCode(err) {
7660
8157
  if (err && typeof err === "object") {
7661
8158
  const code = err.code;
@@ -7770,12 +8267,7 @@ async function bookSlot(req, env, ctx) {
7770
8267
  await db.transact([meetingOp, appOp]);
7771
8268
  const booked = { ...app, ...bookingAttrs };
7772
8269
  await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
7773
- if (typeof app.email === "string" && app.email) {
7774
- await sendTemplated(
7775
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL, now: () => Date.now(), newId: () => crypto.randomUUID() },
7776
- { group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
7777
- ).catch(() => void 0);
7778
- }
8270
+ await notifyBookingConfirmed(db, env, { group, application: app, meeting: { startAt, endAt, timezone: cfg.timezone, meetUrl, htmlLink } });
7779
8271
  return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
7780
8272
  }
7781
8273
  var handleSchedule = async (req, url, env, ctx) => {
@@ -7806,21 +8298,21 @@ import { stripeRequest as stripeRequest5 } from "@odla-ai/stripe";
7806
8298
  var row = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7807
8299
  var id2 = (value) => typeof value === "string" ? value : String(row(value).id ?? "");
7808
8300
  var providerId2 = (value, prefix) => new RegExp(`^${prefix}_[A-Za-z0-9]+$`).test(value);
7809
- function fail5(code = "refund_target_mismatch") {
8301
+ function fail6(code = "refund_target_mismatch") {
7810
8302
  throw Object.assign(new Error(code), { code });
7811
8303
  }
7812
8304
  async function refundSubscriptionPayment(secretKey, input) {
7813
- if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub")) fail5();
8305
+ if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub")) fail6();
7814
8306
  const call = async (method, path, params, key3) => {
7815
8307
  const result = await stripeRequest5({ secretKey }, method, path, params, key3 ? { idempotencyKey: key3 } : {});
7816
- if (!result.ok) fail5("stripe_error");
7817
- if (!result.body || typeof result.body !== "object" || Array.isArray(result.body)) fail5("stripe_invalid_response");
8308
+ if (!result.ok) fail6("stripe_error");
8309
+ if (!result.body || typeof result.body !== "object" || Array.isArray(result.body)) fail6("stripe_invalid_response");
7818
8310
  return result.body;
7819
8311
  };
7820
8312
  const sub = await call("GET", `/v1/subscriptions/${input.subscriptionId}`);
7821
- if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId) fail5();
8313
+ if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId) fail6();
7822
8314
  for (const [key3, value] of Object.entries(input.metadata ?? {})) {
7823
- if (row(sub.metadata)[key3] !== value) fail5();
8315
+ if (row(sub.metadata)[key3] !== value) fail6();
7824
8316
  }
7825
8317
  let cursor;
7826
8318
  let earliest;
@@ -7833,55 +8325,55 @@ async function refundSubscriptionPayment(secretKey, input) {
7833
8325
  limit: 100,
7834
8326
  ...cursor ? { starting_after: cursor } : {}
7835
8327
  });
7836
- if (!Array.isArray(list.data) || typeof list.has_more !== "boolean") fail5("stripe_invalid_response");
8328
+ if (!Array.isArray(list.data) || typeof list.has_more !== "boolean") fail6("stripe_invalid_response");
7837
8329
  for (const value of list.data) {
7838
8330
  const invoice2 = row(value);
7839
8331
  const subscription = invoice2.subscription ?? row(row(invoice2.parent).subscription_details).subscription;
7840
- if (!providerId2(id2(invoice2), "in") || seen.has(id2(invoice2)) || id2(subscription) !== input.subscriptionId || id2(invoice2.customer) !== input.customerId || invoice2.status !== "paid" || !Number.isSafeInteger(invoice2.created) || !Number.isSafeInteger(invoice2.amount_paid) || Number(invoice2.amount_paid) < 0) fail5();
8332
+ if (!providerId2(id2(invoice2), "in") || seen.has(id2(invoice2)) || id2(subscription) !== input.subscriptionId || id2(invoice2.customer) !== input.customerId || invoice2.status !== "paid" || !Number.isSafeInteger(invoice2.created) || !Number.isSafeInteger(invoice2.amount_paid) || Number(invoice2.amount_paid) < 0) fail6();
7841
8333
  seen.add(id2(invoice2));
7842
8334
  if (Number(invoice2.amount_paid) > 0 && (!earliest || Number(invoice2.created) < Number(earliest.created))) earliest = invoice2;
7843
8335
  }
7844
8336
  if (!list.has_more) break;
7845
- if (!list.data.length || page === 9) fail5("refund_history_incomplete");
8337
+ if (!list.data.length || page === 9) fail6("refund_history_incomplete");
7846
8338
  cursor = id2(list.data[list.data.length - 1]);
7847
8339
  }
7848
- if (!earliest) fail5("no_charge");
8340
+ if (!earliest) fail6("no_charge");
7849
8341
  const invoice = earliest;
7850
- if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents) fail5();
8342
+ if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents) fail6();
7851
8343
  let chargeId = id2(invoice.charge);
7852
8344
  let paymentIntentId = id2(invoice.payment_intent);
7853
8345
  if (!chargeId && !paymentIntentId) {
7854
8346
  const payments = await call("GET", "/v1/invoice_payments", { invoice: id2(invoice), status: "paid", limit: 100 });
7855
- if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1) fail5("refund_payment_ambiguous");
8347
+ if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1) fail6("refund_payment_ambiguous");
7856
8348
  const payment = row(payments.data[0]);
7857
- if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent") fail5();
8349
+ if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent") fail6();
7858
8350
  paymentIntentId = id2(row(payment.payment).payment_intent);
7859
8351
  }
7860
8352
  if (!chargeId) {
7861
- if (!providerId2(paymentIntentId, "pi")) fail5();
8353
+ if (!providerId2(paymentIntentId, "pi")) fail6();
7862
8354
  const payment = await call("GET", `/v1/payment_intents/${paymentIntentId}`);
7863
- if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded") fail5();
8355
+ if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded") fail6();
7864
8356
  chargeId = id2(payment.latest_charge);
7865
8357
  }
7866
- if (!providerId2(chargeId, "ch")) fail5();
8358
+ if (!providerId2(chargeId, "ch")) fail6();
7867
8359
  const charge = await call("GET", `/v1/charges/${chargeId}`);
7868
- if (id2(charge) !== chargeId || id2(charge.customer) !== input.customerId || charge.status !== "succeeded" || charge.invoice != null && id2(charge.invoice) !== id2(invoice) || paymentIntentId && id2(charge.payment_intent) !== paymentIntentId || !Number.isSafeInteger(charge.amount) || Number(charge.amount) <= 0 || !Number.isSafeInteger(charge.amount_refunded) || Number(charge.amount_refunded) < 0 || Number(charge.amount_refunded) > Number(charge.amount) || charge.currency !== invoice.currency || Number(charge.amount) !== Number(invoice.amount_paid)) fail5();
7869
- if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true)) fail5();
8360
+ if (id2(charge) !== chargeId || id2(charge.customer) !== input.customerId || charge.status !== "succeeded" || charge.invoice != null && id2(charge.invoice) !== id2(invoice) || paymentIntentId && id2(charge.payment_intent) !== paymentIntentId || !Number.isSafeInteger(charge.amount) || Number(charge.amount) <= 0 || !Number.isSafeInteger(charge.amount_refunded) || Number(charge.amount_refunded) < 0 || Number(charge.amount_refunded) > Number(charge.amount) || charge.currency !== invoice.currency || Number(charge.amount) !== Number(invoice.amount_paid)) fail6();
8361
+ if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true)) fail6();
7870
8362
  if (charge.amount_refunded !== charge.amount) {
7871
8363
  const refund = await call("POST", "/v1/refunds", {
7872
8364
  charge: chargeId,
7873
8365
  ...input.metadata ? { metadata: input.metadata } : {}
7874
8366
  }, `chapter-initial-refund:${input.subscriptionId}:${chargeId}`);
7875
- if (id2(refund.charge) !== chargeId || !["succeeded", "pending"].includes(String(refund.status)) || !Number.isSafeInteger(refund.amount) || Number(refund.amount) !== Number(charge.amount) - Number(charge.amount_refunded)) fail5("refund_not_confirmed");
8367
+ if (id2(refund.charge) !== chargeId || !["succeeded", "pending"].includes(String(refund.status)) || !Number.isSafeInteger(refund.amount) || Number(refund.amount) !== Number(charge.amount) - Number(charge.amount_refunded)) fail6("refund_not_confirmed");
7876
8368
  }
7877
8369
  let subscriptionCanceled = sub.status === "canceled";
7878
8370
  if (input.cancelSubscription !== false && !subscriptionCanceled) {
7879
8371
  const canceled = await call("DELETE", `/v1/subscriptions/${input.subscriptionId}`);
7880
- if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled") fail5("refund_cancel_incomplete");
8372
+ if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled") fail6("refund_cancel_incomplete");
7881
8373
  subscriptionCanceled = true;
7882
8374
  }
7883
8375
  const confirmed = await call("GET", `/v1/charges/${chargeId}`);
7884
- if (id2(confirmed) !== chargeId || id2(confirmed.customer) !== input.customerId || confirmed.amount !== charge.amount || confirmed.currency !== charge.currency || confirmed.amount_refunded !== charge.amount || confirmed.refunded !== true) fail5("refund_not_confirmed");
8376
+ if (id2(confirmed) !== chargeId || id2(confirmed.customer) !== input.customerId || confirmed.amount !== charge.amount || confirmed.currency !== charge.currency || confirmed.amount_refunded !== charge.amount || confirmed.refunded !== true) fail6("refund_not_confirmed");
7885
8377
  return { refundedCents: Number(charge.amount), subscriptionCanceled };
7886
8378
  }
7887
8379
 
@@ -7947,7 +8439,7 @@ async function stripeCall(sk, method, path, params, idempotencyKey) {
7947
8439
  idempotencyKey ? { idempotencyKey } : {}
7948
8440
  );
7949
8441
  }
7950
- function fail6(op, r) {
8442
+ function fail7(op, r) {
7951
8443
  const err = new Error(`stripe ${op} failed: ${r.status}`);
7952
8444
  err.code = "stripe_error";
7953
8445
  throw err;
@@ -7997,7 +8489,7 @@ function createStripeProvider(config) {
7997
8489
  { email: input.email, name: input.name, metadata: meta },
7998
8490
  `cus:${input.applicationId}`
7999
8491
  );
8000
- if (!cust.ok) fail6("customer create", cust);
8492
+ if (!cust.ok) fail7("customer create", cust);
8001
8493
  if (typeof cust.body.id !== "string" || !/^cus_[A-Za-z0-9]+$/.test(cust.body.id)) subscriptionContractMismatch();
8002
8494
  customerId = cust.body.id;
8003
8495
  }
@@ -8029,7 +8521,7 @@ function createStripeProvider(config) {
8029
8521
  // retry between create and the db write can't orphan a second one.
8030
8522
  `sub:${input.applicationId}${input.purchaseAttemptId ? `:${input.purchaseAttemptId}` : ""}`
8031
8523
  );
8032
- if (!sub.ok) fail6("subscription create", sub);
8524
+ if (!sub.ok) fail7("subscription create", sub);
8033
8525
  if (input.billingTerms?.discount?.duration === "once" && Array.isArray(sub.body.discounts) && sub.body.discounts.length === 0) {
8034
8526
  const subscriptionId = sub.body.id;
8035
8527
  if (typeof subscriptionId !== "string" || !/^sub_[A-Za-z0-9]+$/.test(subscriptionId)) subscriptionContractMismatch();
@@ -8039,14 +8531,14 @@ function createStripeProvider(config) {
8039
8531
  "expand[2]": "discounts",
8040
8532
  "expand[3]": "latest_invoice.discounts"
8041
8533
  });
8042
- if (!expanded.ok) fail6("subscription invoice evidence", expanded);
8534
+ if (!expanded.ok) fail7("subscription invoice evidence", expanded);
8043
8535
  if (expanded.body.id !== subscriptionId) subscriptionContractMismatch();
8044
8536
  sub = expanded;
8045
8537
  }
8046
8538
  verifySubscriptionResult(sub.body, input, customerId, meta);
8047
8539
  const invoice = sub.body.latest_invoice;
8048
8540
  const clientSecret2 = clientSecretOf(sub.body);
8049
- if (!clientSecret2) fail6("subscription confirmation-secret missing", sub);
8541
+ if (!clientSecret2) fail7("subscription confirmation-secret missing", sub);
8050
8542
  await tagSubscriptionPayment(
8051
8543
  sk,
8052
8544
  invoice,
@@ -8730,15 +9222,16 @@ async function readNamedSeatOffer(db, input, now) {
8730
9222
  const offering = membershipOfferingVersion(member.offeringSnapshot);
8731
9223
  const seat = member.namedSeatId ? await membershipLoad(db, "networkSeat", String(member.namedSeatId)) : void 0;
8732
9224
  if (seat) requireMembershipScope(seat, input);
9225
+ const purchaseEndsAt = seatPurchaseDeadline(offering, member);
8733
9226
  const offer = {
8734
9227
  membershipId: id6,
8735
9228
  amountCents: offering.seatPriceCents,
8736
9229
  currency: offering.currency,
8737
9230
  interval: offering.interval,
8738
9231
  termEndsAt: Number(member.termEndsAt),
8739
- purchaseEndsAt: Number(member.seatPurchaseEndsAt),
9232
+ purchaseEndsAt,
8740
9233
  autoRenew: member.autoRenew === true,
8741
- eligible: ["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) && member.refundedCents === 0 && now >= Number(member.initialTermStartedAt) && now < Number(member.termEndsAt) && now <= Number(member.seatPurchaseEndsAt) && (!seat || ["payment_pending", "payment_processing"].includes(String(seat.status))),
9234
+ eligible: ["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) && member.refundedCents === 0 && now >= Number(member.initialTermStartedAt) && now < Number(member.termEndsAt) && now <= purchaseEndsAt && (!seat || ["payment_pending", "payment_processing"].includes(String(seat.status))),
8742
9235
  ...seat ? { seat: {
8743
9236
  seatId: String(seat.id),
8744
9237
  status: String(seat.status),
@@ -8746,7 +9239,7 @@ async function readNamedSeatOffer(db, input, now) {
8746
9239
  recipientEmail: String(seat.recipientEmailNormalized)
8747
9240
  } } : {}
8748
9241
  };
8749
- return { ...offer, digest: await namedSeatOfferDigest(offer) };
9242
+ return { ...offer, digest: await namedSeatOfferDigest(offer), purchaseWindowed: offering.seatPurchaseWindowCalendarMonths !== void 0 };
8750
9243
  }
8751
9244
 
8752
9245
  // src/named-seat-request.ts
@@ -8870,6 +9363,58 @@ var handleNamedSeat = async (req, url, env, ctx) => {
8870
9363
  }
8871
9364
  };
8872
9365
 
9366
+ // src/named-seat-accepted-template.ts
9367
+ var SEAT_ACCEPTED_TEMPLATE = {
9368
+ enabled: true,
9369
+ subject: "{{recipientName}} accepted your {{chapterName}} seat",
9370
+ text: "Hi {{firstName}},\n\n{{recipientName}} has accepted the {{tierName}} seat you purchased at {{chapterName}}. They will complete their own application and any membership requirements from here; nothing more is needed from you.\n\nWarmly,\n{{chapterName}}"
9371
+ };
9372
+
9373
+ // src/named-seat-accepted-notice.ts
9374
+ var text5 = (value) => typeof value === "string" ? value : "";
9375
+ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9376
+ const purchaser = await membershipLoad(db, "applications", input.primaryApplicationId);
9377
+ if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text5(purchaser.email)) {
9378
+ return { sent: false, reason: "purchaser-missing" };
9379
+ }
9380
+ const group = await membershipLoad(db, "groups", ctx.chapter.id);
9381
+ if (!group) return { sent: false, reason: "group-missing" };
9382
+ const config = emailGroupFrom(group);
9383
+ config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
9384
+ const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
9385
+ return sendTemplated({
9386
+ db,
9387
+ envName: env.ODLA_ENV,
9388
+ sender: env.SEND_EMAIL,
9389
+ from: env.EMAIL_FROM,
9390
+ debugEmail: env.ODLA_DEBUG_EMAIL,
9391
+ now: () => Date.now(),
9392
+ newId: () => crypto.randomUUID()
9393
+ }, {
9394
+ group: config,
9395
+ template: "seatAccepted",
9396
+ to: text5(purchaser.email),
9397
+ dedupeKey: `named-seat-accepted:${input.seatId}`,
9398
+ applicationId: input.primaryApplicationId,
9399
+ vars: {
9400
+ firstName: text5(purchaser.firstName),
9401
+ purchaserName,
9402
+ recipientName: input.recipientName,
9403
+ tierName: await tierName(db, group, input.skuId),
9404
+ chapterName: ctx.chapter.name,
9405
+ membersUrl: `${input.origin}/members/`
9406
+ }
9407
+ });
9408
+ }
9409
+ async function tierName(db, group, skuId) {
9410
+ try {
9411
+ const tier = (await loadTiers(db, group)).find((candidate) => candidate.id === skuId);
9412
+ if (tier) return tier.name;
9413
+ } catch {
9414
+ }
9415
+ return skuId;
9416
+ }
9417
+
8873
9418
  // src/worker-routes-named-seat-recipient.ts
8874
9419
  var handleNamedSeatRecipient = async (req, url, env, ctx) => {
8875
9420
  if (url.pathname === "/api/named-seat/invitation") {
@@ -8987,6 +9532,13 @@ var handleNamedSeatRecipient = async (req, url, env, ctx) => {
8987
9532
  const current = await membershipLoad(db, "applications", body.applicationId), group = await membershipLoad(db, "groups", ctx.chapter.id);
8988
9533
  if (!current || current.namedSeatClaimPending !== false || !current.namedSeatAcceptedAt || !group) throw new Error("seat application reconciliation pending");
8989
9534
  if (current.clerkUserId !== user.userId || current.groupId !== ctx.chapter.id || current.tierId !== invitation.skuId || stripeRuntimeIdentity(current.stripeRuntime) !== runtime) throw new Error("seat application identity changed");
9535
+ await notifyNamedSeatAccepted(db, env, ctx, {
9536
+ seatId: body.seatId,
9537
+ primaryApplicationId: invitation.primaryApplicationId,
9538
+ recipientName: invitation.recipientName,
9539
+ skuId: invitation.skuId,
9540
+ origin: url.origin
9541
+ }).catch(() => console.warn("named_seat_accepted_notice_incomplete", { seatId: body.seatId }));
8990
9542
  await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) }).catch(() => void 0);
8991
9543
  const state = await continueJoin(db, ctx, env, url.origin, current, group);
8992
9544
  if (!state) throw new Error("seat Journey reconciliation pending");
@@ -9347,8 +9899,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
9347
9899
  };
9348
9900
 
9349
9901
  // src/crm-account-repair.ts
9350
- async function repairAccountPeoplePage(deps2, after) {
9351
- const users = (await deps2.db.query({ $users: { $: {
9902
+ async function repairAccountPeoplePage(deps, after) {
9903
+ const users = (await deps.db.query({ $users: { $: {
9352
9904
  ...after ? { where: { id: { $gt: after } } } : {},
9353
9905
  order: { id: "asc" },
9354
9906
  limit: 50
@@ -9357,7 +9909,7 @@ async function repairAccountPeoplePage(deps2, after) {
9357
9909
  const errors = [];
9358
9910
  for (const user of users) {
9359
9911
  try {
9360
- if (await syncAccountToCrm(deps2, String(user.id))) synced++;
9912
+ if (await syncAccountToCrm(deps, String(user.id))) synced++;
9361
9913
  } catch (error) {
9362
9914
  const message = error instanceof Error ? error.message : "";
9363
9915
  errors.push({ userId: String(user.id), code: /^crm_projection_identity_(ambiguous|archived|conflict)$/.test(message) ? message : "crm_projection_unavailable" });
@@ -9369,8 +9921,8 @@ async function repairAccountPeoplePage(deps2, after) {
9369
9921
  // src/crm-application-repair.ts
9370
9922
  import { getRecord as getRecord4, updateRecord as updateRecord5 } from "@odla-ai/crm";
9371
9923
  async function repairApplicationLink(db, chapter, recordId, actorId) {
9372
- const deps2 = { crm: chapter.crm, db };
9373
- const record4 = await getRecord4(deps2, recordId);
9924
+ const deps = { crm: chapter.crm, db };
9925
+ const record4 = await getRecord4(deps, recordId);
9374
9926
  if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
9375
9927
  if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
9376
9928
  const fields = record4.fields;
@@ -9388,7 +9940,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
9388
9940
  }
9389
9941
  const app = [...applications.values()][0];
9390
9942
  if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
9391
- await updateRecord5(deps2, {
9943
+ await updateRecord5(deps, {
9392
9944
  id: recordId,
9393
9945
  input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
9394
9946
  expectedUpdatedAt: record4.updatedAt,
@@ -10086,15 +10638,6 @@ async function loadGroup2(db, id6) {
10086
10638
  return groups?.[0] ?? null;
10087
10639
  }
10088
10640
  var str2 = (v, d = "") => typeof v === "string" ? v : d;
10089
- var notifyDeps = (db, env) => ({
10090
- db,
10091
- debugEmail: env.ODLA_DEBUG_EMAIL,
10092
- envName: env.ODLA_ENV,
10093
- sender: env.SEND_EMAIL,
10094
- from: env.EMAIL_FROM,
10095
- now: () => Date.now(),
10096
- newId: () => crypto.randomUUID()
10097
- });
10098
10641
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
10099
10642
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
10100
10643
  const got = await gate5(req, env, ctx);
@@ -10113,6 +10656,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10113
10656
  groupId: group.id,
10114
10657
  name: group.name,
10115
10658
  replyTo: str2(group.replyTo),
10659
+ fromName: str2(group.fromName),
10116
10660
  notificationEmail: str2(group.notificationEmail),
10117
10661
  debugEmail: str2(group.debugEmail),
10118
10662
  emailTemplates,
@@ -10139,11 +10683,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10139
10683
  for (const key3 of EMAIL_TEMPLATE_NAMES) {
10140
10684
  const t = templates[key3];
10141
10685
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
10142
- const text6 = typeof t?.text === "string" ? t.text : "";
10143
- if (!subject || !text6.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
10686
+ const text8 = typeof t?.text === "string" ? t.text : "";
10687
+ if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
10144
10688
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
10145
- if (text6.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
10146
- clean[key3] = { subject, text: text6, enabled: t?.enabled !== false };
10689
+ if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
10690
+ clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
10147
10691
  }
10148
10692
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10149
10693
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -10152,13 +10696,15 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10152
10696
  if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
10153
10697
  if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
10154
10698
  if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
10699
+ const fromName = str2(body.fromName).trim();
10700
+ if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
10155
10701
  const commitmentText = str2(body.commitmentText);
10156
10702
  const normsText = str2(body.normsText);
10157
10703
  if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
10158
10704
  const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
10159
10705
  await db.transact([
10160
10706
  { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
10161
- { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail } }
10707
+ { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
10162
10708
  ]);
10163
10709
  return json({ ok: true });
10164
10710
  };
@@ -10207,11 +10753,11 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
10207
10753
  }
10208
10754
  const group = await loadGroup2(db, ctx.chapter.id);
10209
10755
  if (!group) return json({ error: "not found" }, 404);
10210
- const res = await sendTemplated(notifyDeps(db, env), {
10756
+ const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
10211
10757
  group: emailGroupFrom(group),
10212
10758
  template,
10213
10759
  to: str2(group.notificationEmail),
10214
- vars: { firstName: "Sample", lastName: "Person", email: "sample@example.com", phone: "(555) 010-0100", state: "CA", adminUrl: `${url.origin}/admin/`, membersUrl: `${url.origin}/members/` },
10760
+ vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
10215
10761
  dedupeKey: `test:${template}:${Date.now()}`,
10216
10762
  force: true
10217
10763
  });
@@ -10238,7 +10784,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
10238
10784
  const app = data.applications?.[0] ?? null;
10239
10785
  if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
10240
10786
  const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
10241
- const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: `${url.origin}/admin/`, membersUrl: `${url.origin}/members/` } : null;
10787
+ const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
10242
10788
  const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
10243
10789
  let mailBody = typeof r.body === "string" ? r.body : null;
10244
10790
  if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
@@ -10262,6 +10808,28 @@ var handleAdminComms = async (req, url, env, ctx) => {
10262
10808
  import { getRecord as getRecord5, typeSummary } from "@odla-ai/crm";
10263
10809
  import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
10264
10810
 
10811
+ // src/network-edge.ts
10812
+ var EDGE_UNPAIRED = "edge_unpaired";
10813
+ function pairingCommand(leaderId, targetId, env) {
10814
+ const scope2 = env ? ` --env ${env}` : "";
10815
+ return `odla-ai chapter edge pair --leader ${leaderId} --follower ${targetId}${scope2}`;
10816
+ }
10817
+ function edgeUnpaired(leaderId, target, env) {
10818
+ return {
10819
+ code: EDGE_UNPAIRED,
10820
+ target: target.id,
10821
+ secretName: target.secretName,
10822
+ message: `edge to "${target.id}" is unpaired: leader vault slot "${target.secretName}" is empty`,
10823
+ hint: pairingCommand(leaderId, target.id, env)
10824
+ };
10825
+ }
10826
+ function unpairedResponse(unpaired) {
10827
+ return new Response(JSON.stringify({ error: unpaired.message, ...unpaired }), {
10828
+ status: 502,
10829
+ headers: { "content-type": "application/json" }
10830
+ });
10831
+ }
10832
+
10265
10833
  // src/worker-network-push.ts
10266
10834
  import { recordDeliveryAttempt } from "@odla-ai/crm";
10267
10835
  import { signFederatedRequest as signFederatedRequest3 } from "@odla-ai/db";
@@ -10274,15 +10842,15 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
10274
10842
  newId: () => crypto.randomUUID()
10275
10843
  };
10276
10844
  if (!secret) {
10277
- const error = `vault secret "${target.secretName}" is missing`;
10845
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
10278
10846
  await recordDeliveryAttempt(crmDeps3, {
10279
10847
  recordId: record4.id,
10280
10848
  targetId: target.id,
10281
10849
  status: "failed",
10282
10850
  payloadVersion: 2,
10283
- error
10851
+ error: unpaired.message
10284
10852
  });
10285
- return { id: target.id, name: target.name, ok: false, error };
10853
+ return { id: target.id, name: target.name, ok: false, error: unpaired.message, code: unpaired.code, hint: unpaired.hint };
10286
10854
  }
10287
10855
  let payload;
10288
10856
  try {
@@ -10423,17 +10991,20 @@ async function snapshotOne(db, env, ctx, target) {
10423
10991
  publicSite: { available: null }
10424
10992
  };
10425
10993
  const secret = await getVaultSecret(db, target.secretName);
10426
- if (!secret) return {
10427
- ...base,
10428
- available: false,
10429
- transport: {
10430
- kind: transportKind,
10431
- ...target.binding ? { binding: target.binding } : {},
10432
- reachable: null,
10433
- error: { code: "edge_secret_missing", message: "edge secret is missing" }
10434
- },
10435
- error: "edge secret is missing"
10436
- };
10994
+ if (!secret) {
10995
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
10996
+ return {
10997
+ ...base,
10998
+ available: false,
10999
+ transport: {
11000
+ kind: transportKind,
11001
+ ...target.binding ? { binding: target.binding } : {},
11002
+ reachable: null,
11003
+ error: unpaired
11004
+ },
11005
+ error: unpaired.message
11006
+ };
11007
+ }
10437
11008
  const destination = new URL("/api/network/snapshot", target.url);
10438
11009
  try {
10439
11010
  const transport = resolveNetworkTargetTransport(env, target);
@@ -10913,7 +11484,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10913
11484
  if (hasFields && Object.keys(fields).some((name) => !capabilities.edit?.fields.some((field) => field.id === name))) {
10914
11485
  return json({ error: "use the verified identity or application workflow for these fields", code: "field_forbidden" }, 403);
10915
11486
  }
10916
- const deps2 = { crm: ctx.chapter.crm, db };
11487
+ const deps = { crm: ctx.chapter.crm, db };
10917
11488
  if (hasFields && ctx.chapter.mode === "chapter" && record4.type === "person") {
10918
11489
  const updated2 = await updateChapterPerson({
10919
11490
  db,
@@ -10932,7 +11503,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10932
11503
  record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
10933
11504
  });
10934
11505
  }
10935
- const updated = hasFields ? await updateRecord6(deps2, {
11506
+ const updated = hasFields ? await updateRecord6(deps, {
10936
11507
  id: record4.id,
10937
11508
  input: fields,
10938
11509
  mutationId: activityId,
@@ -10943,7 +11514,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10943
11514
  authorId: `network:${verified.sender}`,
10944
11515
  meta: auditMeta
10945
11516
  }
10946
- }) : await setStage3(deps2, {
11517
+ }) : await setStage3(deps, {
10947
11518
  id: record4.id,
10948
11519
  to: input.stage,
10949
11520
  authorId: `network:${verified.sender}`,
@@ -11043,7 +11614,10 @@ function targetFor2(ctx, id6) {
11043
11614
  }
11044
11615
  async function readTargetRecords(db, env, ctx, target, query) {
11045
11616
  const secret = await getVaultSecret(db, target.secretName);
11046
- if (!secret) return { ok: false, error: "edge secret is missing" };
11617
+ if (!secret) {
11618
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
11619
+ return { ok: false, error: unpaired.message, unpaired };
11620
+ }
11047
11621
  const destination = new URL("/api/network/records", target.url);
11048
11622
  destination.search = query.toString();
11049
11623
  try {
@@ -11080,7 +11654,7 @@ async function mutateTargetRecord(db, env, ctx, target, input) {
11080
11654
  return json({ error: "stage transitions are not enabled for this target", code: "stage_forbidden" }, 403);
11081
11655
  }
11082
11656
  const secret = await getVaultSecret(db, target.secretName);
11083
- if (!secret) return json({ error: "edge secret is missing", code: "edge_unavailable" }, 502);
11657
+ if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
11084
11658
  const destination = new URL("/api/network/records", target.url);
11085
11659
  const body = JSON.stringify(input);
11086
11660
  try {
@@ -11162,7 +11736,9 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
11162
11736
  if (window.search) query.set("search", window.search);
11163
11737
  if (window.stage) query.set("stage", window.stage);
11164
11738
  const result = await readTargetRecords(got.db, env, ctx, target, query);
11165
- if (!result.ok) return json({ error: result.error }, result.status ?? 502);
11739
+ if (!result.ok) {
11740
+ return result.unpaired ? unpairedResponse(result.unpaired) : json({ error: result.error }, result.status ?? 502);
11741
+ }
11166
11742
  if ((window.stage || url.searchParams.has("sort")) && (!result.page.appliedQuery || result.page.appliedQuery.sort !== window.sortId || result.page.appliedQuery.stage !== window.stage)) {
11167
11743
  return json({ error: "follower did not apply the requested record query" }, 502);
11168
11744
  }
@@ -11418,7 +11994,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
11418
11994
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
11419
11995
  }
11420
11996
  const secret = await getVaultSecret(got.db, target.secretName);
11421
- if (!secret) return json({ error: "edge secret is missing" }, 502);
11997
+ if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
11422
11998
  const destination = new URL("/api/network/shared-notes", target.url);
11423
11999
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
11424
12000
  try {
@@ -11472,11 +12048,11 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
11472
12048
  if (raw2.approved !== true || raw2.isRealPerson !== true) errors.push("import must be approved and identify a real person");
11473
12049
  const sourceSystem = typeof raw2.sourceSystem === "string" ? raw2.sourceSystem.trim().toLowerCase() : "";
11474
12050
  const sourceRecordId = typeof raw2.sourceRecordId === "string" ? raw2.sourceRecordId.trim() : "";
11475
- const provenance = typeof raw2.provenance === "string" ? raw2.provenance.trim() : "";
12051
+ const provenance2 = typeof raw2.provenance === "string" ? raw2.provenance.trim() : "";
11476
12052
  const displayName = typeof raw2.displayName === "string" ? raw2.displayName.trim() : "";
11477
12053
  if (!TOKEN.test(sourceSystem) || /(?:^|[._:-])(fixture|test|development|dev)(?:$|[._:-])/.test(sourceSystem)) errors.push("sourceSystem must name an approved non-development external system");
11478
12054
  if (!sourceRecordId || sourceRecordId.length > 200) errors.push("sourceRecordId is required and bounded");
11479
- if (!provenance || provenance.length > 500) errors.push("provenance is required and bounded");
12055
+ if (!provenance2 || provenance2.length > 500) errors.push("provenance is required and bounded");
11480
12056
  if (!displayName || displayName.length > 200) errors.push("displayName is required and bounded");
11481
12057
  if (FIXTURE_MARKER.test(sourceRecordId) || FIXTURE_MARKER.test(displayName)) {
11482
12058
  errors.push("fixture, test, sample, and dummy rows cannot become network Persons");
@@ -11544,7 +12120,7 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
11544
12120
  sourceKind: "external_reimport",
11545
12121
  sourceSystem,
11546
12122
  sourceRecordId,
11547
- provenance,
12123
+ provenance: provenance2,
11548
12124
  approved: true,
11549
12125
  isRealPerson: true,
11550
12126
  displayName,
@@ -12249,7 +12825,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
12249
12825
  }
12250
12826
 
12251
12827
  // src/worker-routes-network-person-registry.ts
12252
- var PATH3 = "/api/admin/network-person-registry";
12828
+ var PATH5 = "/api/admin/network-person-registry";
12253
12829
  var MAX_BODY2 = 8 * 1024 * 1024;
12254
12830
  var MUTATION_ID4 = /^[A-Za-z0-9_-]{16,128}$/;
12255
12831
  var positiveRevision = (value) => {
@@ -12274,7 +12850,7 @@ function registryError(error) {
12274
12850
  return null;
12275
12851
  }
12276
12852
  var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
12277
- if (url.pathname !== PATH3) return null;
12853
+ if (url.pathname !== PATH5) return null;
12278
12854
  if (!ctx.chapter.networkPersonRegistry) return json({ error: "not found" }, 404);
12279
12855
  if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
12280
12856
  if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY2) {
@@ -12286,9 +12862,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
12286
12862
  if (req.method === "POST") {
12287
12863
  let body;
12288
12864
  try {
12289
- const text6 = await req.text();
12290
- if (new TextEncoder().encode(text6).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
12291
- const parsed = JSON.parse(text6);
12865
+ const text8 = await req.text();
12866
+ if (new TextEncoder().encode(text8).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
12867
+ const parsed = JSON.parse(text8);
12292
12868
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
12293
12869
  body = parsed;
12294
12870
  } catch {
@@ -12457,7 +13033,7 @@ import { signFederatedRequest as signFederatedRequest7, verifyFederatedRequest a
12457
13033
  // src/network-commercial-parity.ts
12458
13034
  var DIGEST = /^[0-9a-f]{64}$/;
12459
13035
  var MAX_RECORDS = 500;
12460
- var text4 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
13036
+ var text6 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
12461
13037
  function parityMessage(partition, kind, value) {
12462
13038
  return [
12463
13039
  "chapter-commercial-parity",
@@ -12489,18 +13065,18 @@ async function digestWithKey(key3, partition, kind, value) {
12489
13065
  async function buildChapterCommercialParitySnapshot(input) {
12490
13066
  const requestedMax = input.maxRecords ?? MAX_RECORDS;
12491
13067
  const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
12492
- const eligible = input.rows.filter((row2) => text4(row2.id) && (text4(row2.stripeCustomerId) || text4(row2.stripeSubscriptionId)));
13068
+ const eligible = input.rows.filter((row2) => text6(row2.id) && (text6(row2.stripeCustomerId) || text6(row2.stripeSubscriptionId)));
12493
13069
  const selected2 = eligible.slice(0, maxRecords);
12494
13070
  const key3 = await parityKey(input.secret);
12495
13071
  const records = await Promise.all(selected2.map(async (row2) => {
12496
- const applicationId = text4(row2.id);
12497
- const customerId = text4(row2.stripeCustomerId);
12498
- const subscriptionId = text4(row2.stripeSubscriptionId);
13072
+ const applicationId = text6(row2.id);
13073
+ const customerId = text6(row2.stripeCustomerId);
13074
+ const subscriptionId = text6(row2.stripeSubscriptionId);
12499
13075
  return {
12500
13076
  applicationDigest: await digestWithKey(key3, input.partition, "application", applicationId),
12501
13077
  customerDigest: customerId ? await digestWithKey(key3, input.partition, "customer", customerId) : null,
12502
13078
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.partition, "subscription", subscriptionId) : null,
12503
- status: text4(row2.status, 64),
13079
+ status: text6(row2.status, 64),
12504
13080
  canceled: row2.canceled === true
12505
13081
  };
12506
13082
  }));
@@ -12526,7 +13102,7 @@ function validSnapshot(snapshot, source) {
12526
13102
  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;
12527
13103
  const seen = /* @__PURE__ */ new Set();
12528
13104
  for (const record4 of records) {
12529
- if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text4(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
13105
+ if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text6(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
12530
13106
  const key3 = recordKey(record4);
12531
13107
  if (seen.has(key3)) return false;
12532
13108
  seen.add(key3);
@@ -12537,16 +13113,16 @@ async function reconcileCommercialParitySnapshot(input) {
12537
13113
  if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
12538
13114
  const sourceKey = networkCommercialSourceKey(input.source);
12539
13115
  const key3 = await parityKey(input.secret);
12540
- const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text4(row2.applicationId) && text4(row2.objectId)).map(async (row2) => {
12541
- const customerId = text4(row2.customerId);
12542
- const subscriptionId = text4(row2.subscriptionId) ?? text4(row2.objectId);
13116
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text6(row2.applicationId) && text6(row2.objectId)).map(async (row2) => {
13117
+ const customerId = text6(row2.customerId);
13118
+ const subscriptionId = text6(row2.subscriptionId) ?? text6(row2.objectId);
12543
13119
  return {
12544
13120
  applicationDigest: await digestWithKey(key3, input.snapshot.partition, "application", row2.applicationId),
12545
13121
  customerDigest: customerId ? await digestWithKey(key3, input.snapshot.partition, "customer", customerId) : null,
12546
13122
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.snapshot.partition, "subscription", subscriptionId) : null,
12547
13123
  status: null,
12548
13124
  canceled: false,
12549
- stripeStatus: text4(row2.status, 64),
13125
+ stripeStatus: text6(row2.status, 64),
12550
13126
  stripeCanceled: row2.status === "canceled"
12551
13127
  };
12552
13128
  }));
@@ -12672,7 +13248,7 @@ var handleNetworkCommercialParity = async (req, url, env, ctx) => {
12672
13248
  async function reconcileTarget(db, env, ctx, target, projection) {
12673
13249
  const base = { targetId: target.id, name: target.name, url: target.url };
12674
13250
  const secret = await getVaultSecret(db, target.secretName);
12675
- if (!secret) return { ...base, available: false, ok: false, error: { code: "edge_secret_missing" } };
13251
+ if (!secret) return { ...base, available: false, ok: false, error: edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV) };
12676
13252
  const destination = new URL(PARITY_PATH, target.url);
12677
13253
  try {
12678
13254
  const transport = resolveNetworkTargetTransport(env, target);
@@ -12828,11 +13404,23 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
12828
13404
  const event = await constructStripeEvent(body, signature, secret);
12829
13405
  const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
12830
13406
  if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
12831
- const result = await ingestNetworkCommercialFact(db, ctx.chapter.networkCommercialReadModel.sources, fact, Date.now(), await stripePayloadDigest(body));
13407
+ const result = await ingestNetworkCommercialFact(
13408
+ db,
13409
+ ctx.chapter.networkCommercialReadModel.sources,
13410
+ fact,
13411
+ Date.now(),
13412
+ await stripePayloadDigest(body),
13413
+ networkCommercialAlerts(ctx, WEBHOOK_PATH)
13414
+ );
12832
13415
  if (result.status === "collision") {
12833
13416
  console.error("network commercial event collision", JSON.stringify({ eventId: fact.eventId, eventType: fact.eventType }));
12834
13417
  }
12835
- return json({ ok: true, status: result.status, eventId: fact.eventId });
13418
+ return json({
13419
+ ok: true,
13420
+ status: result.status,
13421
+ eventId: fact.eventId,
13422
+ ...result.status === "replayed" && result.resolvedCollision ? { resolvedCollision: true } : {}
13423
+ });
12836
13424
  } catch (error) {
12837
13425
  if (error instanceof StripeError) return json({ error: error.message, code: error.code }, 400);
12838
13426
  throw error;
@@ -12856,18 +13444,39 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
12856
13444
  } catch {
12857
13445
  return json({ error: "invalid JSON body" }, 400);
12858
13446
  }
13447
+ const alerts = networkCommercialAlerts(ctx, ADMIN_PATH2);
12859
13448
  if (parsed.command === "recover-event") {
12860
13449
  try {
12861
13450
  return json(await recoverNetworkCommercialEvent(
12862
13451
  db,
12863
13452
  ctx.chapter.networkCommercialReadModel.sources,
12864
13453
  String(parsed.eventId ?? ""),
12865
- got.user.userId
13454
+ got.user.userId,
13455
+ Date.now(),
13456
+ alerts
12866
13457
  ));
12867
13458
  } catch (error) {
12868
13459
  return json({ error: error instanceof Error ? error.message : "event recovery failed" }, 409);
12869
13460
  }
12870
13461
  }
13462
+ if (parsed.command === "resolve") {
13463
+ if (typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) return json({ error: "resolve requires a valid mutationId" }, 400);
13464
+ const reason = typeof parsed.reason === "string" ? parsed.reason.trim() : "";
13465
+ if (!reason || reason.length > 500) return json({ error: "resolve requires a reason of at most 500 characters" }, 400);
13466
+ try {
13467
+ const result = await resolveNetworkCommercialCollision(
13468
+ db,
13469
+ ctx.chapter.networkCommercialReadModel.sources,
13470
+ { eventId: String(parsed.eventId ?? ""), mutationId: parsed.mutationId, actorUserId: got.user.userId, reason },
13471
+ Date.now(),
13472
+ alerts
13473
+ );
13474
+ return json({ resolved: true, eventId: result.eventId, reviewId: result.reviewId, ...safeProjection(result.projection) });
13475
+ } catch (error) {
13476
+ const code = error.code;
13477
+ return json({ error: error instanceof Error ? error.message : "resolve failed", code }, code === "invalid_event" ? 400 : 409);
13478
+ }
13479
+ }
12871
13480
  if (parsed.command === "reconcile-chapters") {
12872
13481
  const projection2 = await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources);
12873
13482
  return json(await reconcileChapterCommercialTargets(db, env, ctx, projection2));
@@ -12895,9 +13504,9 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
12895
13504
  }
12896
13505
  }
12897
13506
  if (parsed.command !== "rebuild" || typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) {
12898
- return json({ error: "rebuild, reconcile-stripe, or reconcile-chapters command is required; rebuild also requires a valid mutationId" }, 400);
13507
+ return json({ error: "rebuild, resolve, recover-event, reconcile-stripe, or reconcile-chapters command is required; rebuild and resolve also require a valid mutationId" }, 400);
12899
13508
  }
12900
- const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId);
13509
+ const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId, Date.now(), alerts);
12901
13510
  return json({ rebuilt: true, ...safeProjection(projection) });
12902
13511
  }
12903
13512
  return json(safeProjection(await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources)));
@@ -13007,7 +13616,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
13007
13616
  function unknownKeys3(value, allowed2, path, errors) {
13008
13617
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
13009
13618
  }
13010
- function text5(value, path, errors, max = MAX_TEXT2, required = true) {
13619
+ function text7(value, path, errors, max = MAX_TEXT2, required = true) {
13011
13620
  if (value === void 0 && !required) return void 0;
13012
13621
  if (typeof value !== "string" || required && !value.trim()) {
13013
13622
  errors.push(`${path} must be a non-empty string`);
@@ -13033,7 +13642,7 @@ function scopeValue(value, path, errors) {
13033
13642
  return { kind: raw2.kind };
13034
13643
  }
13035
13644
  if (raw2.kind === "chapter") {
13036
- const chapterId = text5(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
13645
+ const chapterId = text7(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
13037
13646
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
13038
13647
  return { kind: "chapter", chapterId };
13039
13648
  }
@@ -13047,22 +13656,22 @@ function resourceValue(value, path, errors) {
13047
13656
  return null;
13048
13657
  }
13049
13658
  unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
13050
- const id6 = text5(raw2.id, `${path}.id`, errors, 80) ?? "";
13659
+ const id6 = text7(raw2.id, `${path}.id`, errors, 80) ?? "";
13051
13660
  if (id6 && !ID4.test(id6)) errors.push(`${path}.id must be lowercase kebab-case`);
13052
- const kind = text5(raw2.kind, `${path}.kind`, errors) ?? "";
13661
+ const kind = text7(raw2.kind, `${path}.kind`, errors) ?? "";
13053
13662
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
13054
- const title = text5(raw2.title, `${path}.title`, errors, 240) ?? "";
13055
- const summary = text5(raw2.summary, `${path}.summary`, errors, 1e3, false);
13056
- const status2 = text5(raw2.status, `${path}.status`, errors) ?? "";
13663
+ const title = text7(raw2.title, `${path}.title`, errors, 240) ?? "";
13664
+ const summary = text7(raw2.summary, `${path}.summary`, errors, 1e3, false);
13665
+ const status2 = text7(raw2.status, `${path}.status`, errors) ?? "";
13057
13666
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
13058
13667
  const revision = integer4(raw2.revision, `${path}.revision`, errors);
13059
13668
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
13060
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text5(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
13669
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text7(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
13061
13670
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
13062
13671
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
13063
13672
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
13064
- const bodyMarkdown = text5(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
13065
- const url = text5(raw2.url, `${path}.url`, errors, 2048, false);
13673
+ const bodyMarkdown = text7(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
13674
+ const url = text7(raw2.url, `${path}.url`, errors, 2048, false);
13066
13675
  if (url) {
13067
13676
  try {
13068
13677
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -13074,9 +13683,9 @@ function resourceValue(value, path, errors) {
13074
13683
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
13075
13684
  if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
13076
13685
  const brandAsset = asset ? {
13077
- bookId: text5(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
13078
- assetId: text5(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
13079
- ...asset.filename === void 0 ? {} : { filename: text5(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
13686
+ bookId: text7(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
13687
+ assetId: text7(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
13688
+ ...asset.filename === void 0 ? {} : { filename: text7(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
13080
13689
  } : void 0;
13081
13690
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
13082
13691
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -13091,21 +13700,21 @@ function resourceValue(value, path, errors) {
13091
13700
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
13092
13701
  if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
13093
13702
  const promotedFrom = promotedRaw ? {
13094
- chapterId: text5(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
13095
- resourceId: text5(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
13703
+ chapterId: text7(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
13704
+ resourceId: text7(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
13096
13705
  resourceRevision: integer4(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
13097
13706
  documentRevision: integer4(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
13098
- promotedBy: text5(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
13707
+ promotedBy: text7(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
13099
13708
  promotedAt: integer4(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
13100
13709
  } : void 0;
13101
- const provenance = {
13102
- createdBy: text5(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
13710
+ const provenance2 = {
13711
+ createdBy: text7(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
13103
13712
  createdAt: integer4(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
13104
- updatedBy: text5(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
13713
+ updatedBy: text7(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
13105
13714
  updatedAt: integer4(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
13106
13715
  ...promotedFrom ? { promotedFrom } : {}
13107
13716
  };
13108
- return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance };
13717
+ return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance: provenance2 };
13109
13718
  }
13110
13719
  function validateBuilderLibraryDocument(value, options = {}) {
13111
13720
  const errors = [];
@@ -13120,7 +13729,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
13120
13729
  if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
13121
13730
  const sourceType = sourceRaw?.type;
13122
13731
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
13123
- const sourceAppId = text5(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
13732
+ const sourceAppId = text7(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
13124
13733
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
13125
13734
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
13126
13735
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -13128,8 +13737,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
13128
13737
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
13129
13738
  if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
13130
13739
  const target = targetRaw ? {
13131
- appId: text5(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
13132
- environment: text5(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
13740
+ appId: text7(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
13741
+ environment: text7(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
13133
13742
  } : void 0;
13134
13743
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
13135
13744
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -13763,7 +14372,11 @@ async function reconcileMembershipRenewal(db, input, now) {
13763
14372
  membershipUpdate("networkMembership", id6, {
13764
14373
  status: status2,
13765
14374
  graceEndsAt,
13766
- ...paid ? { termStartedAt: start, termEndsAt: end } : {},
14375
+ ...paid ? {
14376
+ termStartedAt: start,
14377
+ termEndsAt: end,
14378
+ ...membershipOfferingVersion(member.offeringSnapshot).seatPurchaseWindowCalendarMonths === void 0 ? { seatPurchaseEndsAt: end } : {}
14379
+ } : {},
13767
14380
  revision: Number(member.revision) + 1,
13768
14381
  updatedAt: now
13769
14382
  }),
@@ -13923,10 +14536,11 @@ async function reserveNamedMembershipSeat(db, input, now) {
13923
14536
  const id6 = (await membershipOfferIdForRequest({ ...input, requestId: "named_seat" })).replace(/^offer_/, "seat_");
13924
14537
  let seat = await membershipLoad(db, "networkSeat", id6);
13925
14538
  if (!seat) {
13926
- if (!["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) || Number(member.refundedCents) !== 0 || now < Number(member.initialTermStartedAt) || now >= Number(member.termEndsAt) || now > Number(member.seatPurchaseEndsAt) || !Number.isSafeInteger(member.seatPurchaseEndsAt) || member.namedSeatId !== void 0) {
14539
+ const offering = membershipOfferingVersion(member.offeringSnapshot), claimId = await recipientKey(input, email);
14540
+ const purchaseEndsAt = seatPurchaseDeadline(offering, member);
14541
+ if (!["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) || Number(member.refundedCents) !== 0 || now < Number(member.initialTermStartedAt) || now >= Number(member.termEndsAt) || now > purchaseEndsAt || !Number.isSafeInteger(purchaseEndsAt) || member.namedSeatId !== void 0) {
13927
14542
  membershipFail("named_seat_purchase_unavailable");
13928
14543
  }
13929
- const offering = membershipOfferingVersion(member.offeringSnapshot), claimId = await recipientKey(input, email);
13930
14544
  const attrs = {
13931
14545
  id: id6,
13932
14546
  ...membershipScope(input),
@@ -13945,7 +14559,7 @@ async function reserveNamedMembershipSeat(db, input, now) {
13945
14559
  status: "payment_pending",
13946
14560
  preparedAt: now,
13947
14561
  termEndsAt: member.termEndsAt,
13948
- seatPurchaseEndsAt: member.seatPurchaseEndsAt,
14562
+ seatPurchaseEndsAt: purchaseEndsAt,
13949
14563
  offeringSnapshot: offering,
13950
14564
  revision: 1,
13951
14565
  createdAt: now,
@@ -14147,7 +14761,7 @@ async function confirmNamedMembershipSeatPayment(db, input, now) {
14147
14761
  }
14148
14762
  if (seat.purchasedAt === void 0) {
14149
14763
  if (!["payment_processing", "payment_canceling"].includes(String(seat.status))) membershipFail("named_seat_payment_not_expected");
14150
- const eligible = seat.status !== "payment_canceling" && ["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) && Number(member.refundedCents) === 0 && paidAt <= Number(member.seatPurchaseEndsAt) && now < Number(member.termEndsAt);
14764
+ const eligible = seat.status !== "payment_canceling" && ["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) && Number(member.refundedCents) === 0 && paidAt <= seatPurchaseDeadline(membershipOfferingVersion(member.offeringSnapshot), member) && now < Number(member.termEndsAt);
14151
14765
  const status2 = eligible ? "renewal_pending" : "refund_pending", effectId = `${seat.id}:${eligible ? "attach-renewal" : "refund"}`;
14152
14766
  try {
14153
14767
  await db.transact([
@@ -15136,7 +15750,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
15136
15750
  };
15137
15751
  signal.addEventListener("abort", cancel, { once: true });
15138
15752
  let size = 0;
15139
- let text6 = "";
15753
+ let text8 = "";
15140
15754
  const decoder = new TextDecoder();
15141
15755
  try {
15142
15756
  while (true) {
@@ -15146,10 +15760,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
15146
15760
  if (done) break;
15147
15761
  size += value.byteLength;
15148
15762
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
15149
- text6 += decoder.decode(value, { stream: true });
15763
+ text8 += decoder.decode(value, { stream: true });
15150
15764
  }
15151
15765
  try {
15152
- return JSON.parse(text6 + decoder.decode());
15766
+ return JSON.parse(text8 + decoder.decode());
15153
15767
  } catch {
15154
15768
  throw new TypeError(`invalid ${label}`);
15155
15769
  }
@@ -15366,7 +15980,7 @@ async function requestNamedSeatReservation(options) {
15366
15980
  async function requestNamedSeatOffer(options) {
15367
15981
  const result = await requestAuthority(options, "seat-offer", { applicationId: options.applicationId, skuId: options.skuId });
15368
15982
  const offer = result.seat;
15369
- if (!offer || offer.membershipId !== await networkMembershipId(options) || !Number.isSafeInteger(offer.amountCents) || offer.amountCents <= 0 || !/^[a-z]{3}$/.test(offer.currency) || !["month", "year"].includes(offer.interval) || typeof offer.eligible !== "boolean" || typeof offer.autoRenew !== "boolean" || !Number.isSafeInteger(offer.termEndsAt) || !Number.isSafeInteger(offer.purchaseEndsAt) || offer.digest !== await namedSeatOfferDigest(offer)) throw new Error("invalid named seat offer");
15983
+ if (!offer || offer.membershipId !== await networkMembershipId(options) || !Number.isSafeInteger(offer.amountCents) || offer.amountCents <= 0 || !/^[a-z]{3}$/.test(offer.currency) || !["month", "year"].includes(offer.interval) || typeof offer.eligible !== "boolean" || typeof offer.autoRenew !== "boolean" || !Number.isSafeInteger(offer.termEndsAt) || !Number.isSafeInteger(offer.purchaseEndsAt) || offer.purchaseWindowed !== void 0 && typeof offer.purchaseWindowed !== "boolean" || offer.digest !== await namedSeatOfferDigest(offer)) throw new Error("invalid named seat offer");
15370
15984
  return offer;
15371
15985
  }
15372
15986
  async function requestNamedSeatInvitation(options) {
@@ -15811,6 +16425,7 @@ var BUILTIN_ROUTES = [
15811
16425
  handlePaymentRecovery,
15812
16426
  handleAdminNamedSeat,
15813
16427
  handleAdminMembershipSeats,
16428
+ handleAdminMembershipPolicyVersions,
15814
16429
  handleAdminMeetings,
15815
16430
  handleAdminScheduling,
15816
16431
  // Roster + identity