@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.
@@ -190,11 +190,11 @@ async function clerkVerifiedPrimaryEmail(db, userId) {
190
190
 
191
191
  // src/crm-person-projection.ts
192
192
  var import_crm = require("@odla-ai/crm");
193
- async function ensureProjectedPerson(deps2, input, options = {}) {
193
+ async function ensureProjectedPerson(deps, input, options = {}) {
194
194
  const email = String(input.email ?? "").trim().toLowerCase();
195
195
  if (!email) throw new Error("crm_projection_email_required");
196
196
  const find = async () => {
197
- const records = (await deps2.db.query({ crm_record: { $: {
197
+ const records = (await deps.db.query({ crm_record: { $: {
198
198
  where: { type: "person", primaryEmail: email },
199
199
  limit: 2
200
200
  } } })).crm_record ?? [];
@@ -207,7 +207,7 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
207
207
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`chapter:person:v1:${email}`)));
208
208
  const id6 = `person-${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
209
209
  try {
210
- const created = await (0, import_crm.createRecord)({ ...deps2, db: deps2.db }, {
210
+ const created = await (0, import_crm.createRecord)({ ...deps, db: deps.db }, {
211
211
  id: id6,
212
212
  type: "person",
213
213
  input: { ...input, email },
@@ -223,14 +223,14 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
223
223
  }
224
224
 
225
225
  // src/crm-account-projection.ts
226
- async function syncAccountToCrm(deps2, userId) {
227
- const user = (await deps2.db.query({ $users: { $: { where: { id: userId }, limit: 1 } } })).$users?.[0];
226
+ async function syncAccountToCrm(deps, userId) {
227
+ const user = (await deps.db.query({ $users: { $: { where: { id: userId }, limit: 1 } } })).$users?.[0];
228
228
  if (!user || user.deleted === true || typeof user.email !== "string" || !user.email.trim()) return null;
229
- const linked = (await deps2.db.query({ crm_record: { $: { where: { type: "person", clerkUserId: userId }, limit: 2 } } })).crm_record ?? [];
229
+ const linked = (await deps.db.query({ crm_record: { $: { where: { type: "person", clerkUserId: userId }, limit: 2 } } })).crm_record ?? [];
230
230
  if (linked.length > 1) throw new Error("crm_projection_identity_ambiguous");
231
231
  if (linked.length === 1) return String(linked[0].id);
232
232
  const email = user.email.trim().toLowerCase();
233
- const peers = (await deps2.db.query({ $users: { $: { where: { email }, limit: 2 } } })).$users ?? [];
233
+ const peers = (await deps.db.query({ $users: { $: { where: { email }, limit: 2 } } })).$users ?? [];
234
234
  if (peers.length !== 1 || peers[0]?.id !== userId) throw new Error("crm_projection_identity_ambiguous");
235
235
  const userGuard = {
236
236
  ns: "$users",
@@ -239,14 +239,14 @@ async function syncAccountToCrm(deps2, userId) {
239
239
  equals: { email: user.email, ...user.deleted !== void 0 ? { deleted: user.deleted } : {} },
240
240
  ...user.deleted === void 0 ? { absent: ["deleted"] } : {}
241
241
  };
242
- const { record: record4 } = await ensureProjectedPerson(deps2, {
242
+ const { record: record4 } = await ensureProjectedPerson(deps, {
243
243
  name: typeof user.name === "string" && user.name.trim() ? user.name : email,
244
244
  email
245
245
  }, { stage: null, guards: [userGuard], emailConsent: { state: "unsubscribed", source: "account-no-marketing-consent" } });
246
246
  if (record4.clerkUserId === userId) return String(record4.id);
247
247
  if (record4.clerkUserId != null) throw new Error("crm_projection_identity_conflict");
248
248
  try {
249
- await deps2.db.transact([{ t: "update", ns: "crm_record", id: String(record4.id), attrs: { clerkUserId: userId } }], {
249
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: String(record4.id), attrs: { clerkUserId: userId } }], {
250
250
  guards: [userGuard, {
251
251
  ns: "crm_record",
252
252
  id: String(record4.id),
@@ -262,12 +262,37 @@ async function syncAccountToCrm(deps2, userId) {
262
262
  });
263
263
  } catch (error) {
264
264
  if (error.code !== "transact_guard_failed") throw error;
265
- const current = (await deps2.db.query({ crm_record: { $: { where: { id: record4.id }, limit: 1 } } })).crm_record?.[0];
265
+ const current = (await deps.db.query({ crm_record: { $: { where: { id: record4.id }, limit: 1 } } })).crm_record?.[0];
266
266
  if (current?.clerkUserId !== userId) throw error;
267
267
  }
268
268
  return String(record4.id);
269
269
  }
270
270
 
271
+ // src/worker-alerts.ts
272
+ var consoleErrorRecorder = (error, report) => {
273
+ console.error("chapter_alert", JSON.stringify({ message: error.message, ...report }));
274
+ };
275
+ function reportChapterAlert(ctx, error, report) {
276
+ (ctx?.recordError ?? consoleErrorRecorder)(error, report);
277
+ }
278
+ var ACTIVATION_LIMIT = 50;
279
+ function networkCommercialAlerts(ctx, route) {
280
+ return {
281
+ exceptionsActivated(activated, activeCount) {
282
+ if (activated.length === 0) return;
283
+ reportChapterAlert(ctx, new Error("network commercial exception activated"), {
284
+ code: "network_commercial_exception_activated",
285
+ route,
286
+ attributes: {
287
+ "odla.commercial.active_exceptions": activeCount,
288
+ "odla.commercial.activated_exceptions": activated.length
289
+ },
290
+ artifacts: { exceptions: activated.slice(0, ACTIVATION_LIMIT).map(({ eventId, kind }) => ({ eventId, kind })) }
291
+ });
292
+ }
293
+ };
294
+ }
295
+
271
296
  // src/worker-context.ts
272
297
  var json = (body, status2 = 200) => new Response(JSON.stringify(body), { status: status2, headers: { "content-type": "application/json" } });
273
298
  function createWorkerContext(options) {
@@ -377,6 +402,7 @@ function createWorkerContext(options) {
377
402
  requirePaymentQuote: options.requirePaymentQuote === true,
378
403
  membershipAuthority: options.membershipAuthority,
379
404
  membershipAuthorityOperator: options.membershipAuthorityOperator,
405
+ recordError: options.recordError ?? consoleErrorRecorder,
380
406
  getPublicConfig,
381
407
  verifyUser,
382
408
  makeDb,
@@ -870,6 +896,17 @@ function projectNetworkCommercialEvents(input, sources) {
870
896
  };
871
897
  }
872
898
 
899
+ // src/network-commercial-fact-identity.ts
900
+ var stable = (value) => JSON.stringify(value, function(_key, item) {
901
+ return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
902
+ });
903
+ function sameNetworkCommercialFact(stored, incoming) {
904
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) return false;
905
+ const comparable = { ...incoming };
906
+ if (!stored.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
907
+ return stable(comparable) === stable(stored);
908
+ }
909
+
873
910
  // src/network-commercial-store.ts
874
911
  var eventRowId = (eventId) => `network-commercial-event:${eventId}`;
875
912
  var projectionId = (kind, sourceKey, providerId3) => `network-commercial:${kind}:${sourceKey}:${providerId3}`;
@@ -881,6 +918,7 @@ var materializedNamespaces = [
881
918
  "networkCommercialRefunds",
882
919
  "networkCommercialSeats"
883
920
  ];
921
+ var RESOLUTION_ATTRS = ["resolvedDigest", "reviewedDigest", "resolvedBy", "reviewId"];
884
922
  async function rows(db, ns) {
885
923
  const all = [];
886
924
  const pageSize = 500;
@@ -930,13 +968,14 @@ function projectionOps(projection, now) {
930
968
  }
931
969
  })));
932
970
  }
933
- async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now()) {
971
+ async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now(), alerts) {
934
972
  const projection = await loadNetworkCommercialProjection(db, sources);
935
973
  const [existing, ...materialized] = await Promise.all([
936
974
  rows(db, "networkCommercialExceptions"),
937
975
  ...materializedNamespaces.map((ns) => rows(db, ns))
938
976
  ]);
939
977
  const activeIds = new Set(projection.exceptions.map((item) => exceptionId(item.eventId, item.kind)));
978
+ const wasActive = new Set(existing.filter((row2) => row2.status === "active").map((row2) => String(row2.id)));
940
979
  const projectedIds = new Set(projectionOps(projection, now).map((op) => String(op.id)));
941
980
  const ops = [
942
981
  ...projectionOps(projection, now),
@@ -962,27 +1001,80 @@ async function rebuildNetworkCommercialProjection(db, sources, mutationId, now =
962
1001
  }
963
1002
  }))
964
1003
  ];
965
- if (ops.length > 0) await db.transact(ops, { mutationId });
1004
+ if (ops.length === 0) return projection;
1005
+ const write = await db.transact(ops, { mutationId });
1006
+ if (!write.duplicate) {
1007
+ alerts?.exceptionsActivated(
1008
+ projection.exceptions.filter((item) => !wasActive.has(exceptionId(item.eventId, item.kind))),
1009
+ projection.exceptions.length
1010
+ );
1011
+ }
966
1012
  return projection;
967
1013
  }
968
- async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(), legacyDigest) {
969
- const found = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
970
- const existing = found.networkCommercialEvents?.[0];
971
- if (existing) {
972
- const projection2 = await loadNetworkCommercialProjection(db, sources);
973
- if (existing.digest === fact.digest || existing.digest === legacyDigest) return { status: "replayed", projection: projection2 };
974
- const id7 = exceptionId(fact.eventId, "collision");
975
- await db.transact([{ t: "update", ns: "networkCommercialExceptions", id: id7, attrs: {
976
- id: id7,
1014
+ async function collide(db, sources, stored, fact, now, legacyDigest, alerts) {
1015
+ const id6 = exceptionId(fact.eventId, "collision");
1016
+ const exception = (await db.query({ networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } } })).networkCommercialExceptions?.[0];
1017
+ const exact = stored.digest === fact.digest || stored.digest === legacyDigest;
1018
+ const canonical3 = !exact && sameNetworkCommercialFact(stored.fact, fact);
1019
+ if (exact || canonical3) {
1020
+ const recorded = exception?.incomingFact;
1021
+ const resolvable = canonical3 && exception?.status === "active" && (recorded === void 0 || sameNetworkCommercialFact(stored.fact, recorded));
1022
+ if (!resolvable) return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
1023
+ const reviewId = crypto.randomUUID();
1024
+ await db.transact([
1025
+ { t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
1026
+ id: reviewId,
1027
+ eventId: fact.eventId,
1028
+ actorUserId: "system",
1029
+ reviewedAt: now,
1030
+ previousDigest: String(stored.digest),
1031
+ providerDigest: fact.digest,
1032
+ scope: "network",
1033
+ previousState: exception,
1034
+ action: "canonical_replay",
1035
+ reason: "a resend matched the retained canonical fact"
1036
+ } },
1037
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
1038
+ status: "resolved",
1039
+ resolvedDigest: stored.digest,
1040
+ reviewedDigest: fact.digest,
1041
+ resolvedBy: "system",
1042
+ reviewId,
1043
+ updatedAt: now
1044
+ } }
1045
+ ], { mutationId: `network-commercial-resolve:${fact.eventId}:${fact.digest}`, guards: [
1046
+ { ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
1047
+ ] });
1048
+ const projection2 = await rebuildNetworkCommercialProjection(db, sources, `network-commercial-resolve-rebuild:${fact.eventId}:${fact.digest}`, now, alerts);
1049
+ return { status: "replayed", resolvedCollision: true, projection: projection2 };
1050
+ }
1051
+ if (exception?.status === "resolved" && exception.reviewedDigest === fact.digest) {
1052
+ return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
1053
+ }
1054
+ const stale = RESOLUTION_ATTRS.filter((attr3) => exception !== void 0 && Object.hasOwn(exception, attr3));
1055
+ await db.transact([
1056
+ ...stale.length ? [{ t: "retract", ns: "networkCommercialExceptions", id: id6, attrs: stale }] : [],
1057
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
1058
+ id: id6,
977
1059
  eventId: fact.eventId,
978
1060
  kind: "collision",
979
1061
  status: "active",
980
1062
  incomingDigest: fact.digest,
1063
+ incomingFact: fact,
981
1064
  detail: { eventType: fact.eventType, objectId: fact.objectId },
982
1065
  updatedAt: now
983
- } }]);
984
- return { status: "collision", projection: await loadNetworkCommercialProjection(db, sources) };
1066
+ } }
1067
+ ]);
1068
+ const projection = await loadNetworkCommercialProjection(db, sources);
1069
+ if (exception?.status !== "active") {
1070
+ alerts?.exceptionsActivated([{ eventId: fact.eventId, kind: "collision", detail: { eventType: fact.eventType, objectId: fact.objectId } }], projection.exceptions.length);
985
1071
  }
1072
+ return { status: "collision", projection };
1073
+ }
1074
+ async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(), legacyDigest, alerts) {
1075
+ const found = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
1076
+ const existing = found.networkCommercialEvents?.[0];
1077
+ if (existing) return collide(db, sources, existing, fact, now, legacyDigest, alerts);
986
1078
  const id6 = eventRowId(fact.eventId);
987
1079
  let lostRace = false;
988
1080
  let duplicate = false;
@@ -1008,25 +1100,15 @@ async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(),
1008
1100
  if (duplicate || lostRace) {
1009
1101
  const raced = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
1010
1102
  const row2 = raced.networkCommercialEvents?.[0];
1011
- const projection2 = await loadNetworkCommercialProjection(db, sources);
1012
- if (row2?.digest === fact.digest || row2?.digest === legacyDigest) return { status: "replayed", projection: projection2 };
1013
- const id7 = exceptionId(fact.eventId, "collision");
1014
- await db.transact([{ t: "update", ns: "networkCommercialExceptions", id: id7, attrs: {
1015
- id: id7,
1016
- eventId: fact.eventId,
1017
- kind: "collision",
1018
- status: "active",
1019
- incomingDigest: fact.digest,
1020
- detail: { eventType: fact.eventType, objectId: fact.objectId },
1021
- updatedAt: now
1022
- } }]);
1023
- return { status: "collision", projection: await loadNetworkCommercialProjection(db, sources) };
1103
+ if (!row2) throw new Error("network commercial event vanished after a lost race");
1104
+ return collide(db, sources, row2, fact, now, legacyDigest, alerts);
1024
1105
  }
1025
1106
  const projection = await rebuildNetworkCommercialProjection(
1026
1107
  db,
1027
1108
  sources,
1028
1109
  `network-commercial-project:${fact.eventId}:${fact.digest}`,
1029
- now
1110
+ now,
1111
+ alerts
1030
1112
  );
1031
1113
  return { status: "stored", projection };
1032
1114
  }
@@ -1041,10 +1123,7 @@ async function readStripeRecoveryEvent(db, eventId) {
1041
1123
  const rawBody = JSON.stringify(response.body);
1042
1124
  return { event: response.body, rawBody, digest: await stripeEventDigest(rawBody) };
1043
1125
  }
1044
- var stable = (value) => JSON.stringify(value, function(_key, item) {
1045
- return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
1046
- });
1047
- async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now()) {
1126
+ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
1048
1127
  const eventRowId2 = `network-commercial-event:${eventId}`, exceptionId2 = `network-commercial-exception:${eventId}:collision`;
1049
1128
  const rows5 = await db.query({
1050
1129
  networkCommercialEvents: { $: { where: { id: eventRowId2 }, limit: 1 } },
@@ -1054,9 +1133,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1054
1133
  if (!stored) throw new Error("stored event not found");
1055
1134
  const provider = await readStripeRecoveryEvent(db, eventId);
1056
1135
  const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
1057
- const comparable = { ...fact };
1058
- if (!stored.fact?.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
1059
- if (stable(comparable) !== stable(stored.fact)) throw new Error("Stripe facts differ; collision requires investigation");
1136
+ if (!sameNetworkCommercialFact(stored.fact, fact)) throw new Error("Stripe facts differ; collision requires investigation");
1060
1137
  const id6 = crypto.randomUUID();
1061
1138
  await db.transact([
1062
1139
  { t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
@@ -1064,6 +1141,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1064
1141
  eventId,
1065
1142
  actorUserId,
1066
1143
  reviewedAt: now,
1144
+ action: "recover",
1067
1145
  previousDigest: stored.digest,
1068
1146
  providerDigest: provider.digest,
1069
1147
  scope: "network",
@@ -1074,7 +1152,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1074
1152
  t: "update",
1075
1153
  ns: "networkCommercialExceptions",
1076
1154
  id: exceptionId2,
1077
- attrs: { status: "resolved", resolvedDigest: provider.digest, updatedAt: now }
1155
+ attrs: { status: "resolved", resolvedDigest: provider.digest, resolvedBy: actorUserId, reviewId: id6, updatedAt: now }
1078
1156
  }] : []
1079
1157
  ], { mutationId: `payment-recovery:${id6}`, guards: [
1080
1158
  { ns: "networkCommercialEvents", id: eventRowId2, exists: true, equals: { digest: stored.digest } },
@@ -1085,7 +1163,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
1085
1163
  ...exception ? { equals: { status: exception.status, updatedAt: exception.updatedAt } } : {}
1086
1164
  }
1087
1165
  ] });
1088
- await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now);
1166
+ await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now, alerts);
1089
1167
  return { recovered: true, eventId, reviewId: id6 };
1090
1168
  }
1091
1169
 
@@ -1242,12 +1320,12 @@ function applicationCrmFields(crm, app) {
1242
1320
  }
1243
1321
 
1244
1322
  // src/applicant-projection.ts
1245
- async function upsertPerson(deps2, opts) {
1323
+ async function upsertPerson(deps, opts) {
1246
1324
  const email = opts.email.toLowerCase();
1247
- const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1248
- const { record: record4, created } = await ensureProjectedPerson(deps2, { ...opts.input, email });
1325
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1326
+ const { record: record4, created } = await ensureProjectedPerson(deps, { ...opts.input, email });
1249
1327
  if (!created) await (0, import_crm2.updateRecord)(crmDeps3, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
1250
- const initial = deps2.crm.initialStage("person");
1328
+ const initial = deps.crm.initialStage("person");
1251
1329
  if (!created && record4.stage == null && initial) {
1252
1330
  await (0, import_crm2.setStage)(crmDeps3, {
1253
1331
  id: String(record4.id),
@@ -1259,7 +1337,7 @@ async function upsertPerson(deps2, opts) {
1259
1337
  }
1260
1338
  return { recordId: String(record4.id) };
1261
1339
  }
1262
- async function projectApplicant(deps2, applicant) {
1340
+ async function projectApplicant(deps, applicant) {
1263
1341
  const base = sharedPersonInput({
1264
1342
  email: applicant.email,
1265
1343
  firstName: applicant.firstName,
@@ -1268,18 +1346,18 @@ async function projectApplicant(deps2, applicant) {
1268
1346
  linkedin: applicant.linkedin,
1269
1347
  hubRecordId: applicant.applicationId
1270
1348
  });
1271
- Object.assign(base, applicationCrmFields(deps2.crm, applicant));
1349
+ Object.assign(base, applicationCrmFields(deps.crm, applicant));
1272
1350
  const mutationId = `apply:${applicant.applicationId}`;
1273
1351
  const extra = applicant.extra ?? {};
1274
- if (Object.keys(extra).length === 0) return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1352
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1275
1353
  try {
1276
- return await upsertPerson(deps2, { email: applicant.email, input: {
1354
+ return await upsertPerson(deps, { email: applicant.email, input: {
1277
1355
  ...base,
1278
1356
  ...extra,
1279
- ...applicationCrmFields(deps2.crm, applicant)
1357
+ ...applicationCrmFields(deps.crm, applicant)
1280
1358
  }, mutationId });
1281
1359
  } catch {
1282
- return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1360
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1283
1361
  }
1284
1362
  }
1285
1363
 
@@ -1377,24 +1455,24 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
1377
1455
  if (input[nameField] === void 0) input[nameField] = record4.name;
1378
1456
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
1379
1457
  }
1380
- async function findSharedRecord(deps2, record4, tag) {
1458
+ async function findSharedRecord(deps, record4, tag) {
1381
1459
  const structured = await (0, import_crm3.getRecordByOrigin)(
1382
- { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId },
1460
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1383
1461
  record4.sourceId,
1384
1462
  record4.sourceRecordId
1385
1463
  );
1386
1464
  if (structured) return structured;
1387
- const mapped = await deps2.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1465
+ const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1388
1466
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1389
1467
  if (typeof mappedId === "string") {
1390
- const found = await deps2.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1468
+ const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1391
1469
  if (found.crm_record?.[0]) return found.crm_record[0];
1392
1470
  }
1393
- const def = deps2.crm.type(record4.type);
1471
+ const def = deps.crm.type(record4.type);
1394
1472
  const emailField = def.emailField;
1395
1473
  if (emailField && typeof record4.input[emailField] === "string") {
1396
1474
  const primaryEmail = record4.input[emailField].toLowerCase();
1397
- const found = await deps2.db.query({
1475
+ const found = await deps.db.query({
1398
1476
  crm_record: { $: { where: { type: record4.type, primaryEmail }, limit: 1 } }
1399
1477
  });
1400
1478
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1402,7 +1480,7 @@ async function findSharedRecord(deps2, record4, tag) {
1402
1480
  const domain = record4.input.domain;
1403
1481
  const domainSlot = def.fields.domain?.slot;
1404
1482
  if (typeof domain === "string" && domainSlot) {
1405
- const found = await deps2.db.query({
1483
+ const found = await deps.db.query({
1406
1484
  crm_record: { $: { where: { type: record4.type, [domainSlot]: domain }, limit: 1 } }
1407
1485
  });
1408
1486
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1410,14 +1488,14 @@ async function findSharedRecord(deps2, record4, tag) {
1410
1488
  const nameField = def.nameField ?? "name";
1411
1489
  const name = record4.input[nameField];
1412
1490
  if (record4.type === "company" && typeof name === "string" && name.trim()) {
1413
- const found = await deps2.db.query({
1491
+ const found = await deps.db.query({
1414
1492
  crm_record: { $: { where: { type: record4.type, name: name.trim() }, limit: 1 } }
1415
1493
  });
1416
1494
  if (found.crm_record?.[0]) return found.crm_record[0];
1417
1495
  }
1418
1496
  return void 0;
1419
1497
  }
1420
- async function projectSharedRecord(deps2, shared, options = {}) {
1498
+ async function projectSharedRecord(deps, shared, options = {}) {
1421
1499
  const record4 = normalizeSharedRecord(shared, options.sourceId);
1422
1500
  if (!record4.type.trim() || !record4.sourceId.trim() || !record4.sourceRecordId.trim()) {
1423
1501
  throw new Error("type, source site id, and source record id must be non-empty");
@@ -1426,14 +1504,14 @@ async function projectSharedRecord(deps2, shared, options = {}) {
1426
1504
  throw new Error("signed sender does not match payload source");
1427
1505
  }
1428
1506
  const tag = networkSourceTag(record4.type, record4.sourceRecordId, record4.version === 2 ? record4.sourceId : void 0);
1429
- const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1430
- const existing = await findSharedRecord(deps2, record4, tag);
1507
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1508
+ const existing = await findSharedRecord(deps, record4, tag);
1431
1509
  let recordId;
1432
1510
  if (existing && typeof existing.id === "string") {
1433
1511
  await (0, import_crm3.updateRecord)(crmDeps3, { id: existing.id, input: record4.input });
1434
1512
  recordId = existing.id;
1435
1513
  } else if (record4.type === "person" && typeof record4.input.email === "string") {
1436
- const projected = await ensureProjectedPerson(deps2, record4.input);
1514
+ const projected = await ensureProjectedPerson(deps, record4.input);
1437
1515
  recordId = String(projected.record.id);
1438
1516
  if (!projected.created) await (0, import_crm3.updateRecord)(crmDeps3, { id: recordId, input: record4.input });
1439
1517
  } else {
@@ -1444,8 +1522,8 @@ async function projectSharedRecord(deps2, shared, options = {}) {
1444
1522
  mutationId: `share-create:${tag}`
1445
1523
  });
1446
1524
  }
1447
- await deps2.db.transact(
1448
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps2.now() } }],
1525
+ await deps.db.transact(
1526
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1449
1527
  { mutationId: `share-map:${tag}:${recordId}` }
1450
1528
  );
1451
1529
  await (0, import_crm3.upsertRecordOrigin)(crmDeps3, {
@@ -1493,21 +1571,21 @@ function billingColumns(app) {
1493
1571
  if (billingStatus !== "canceled" && billingStatus !== "refunded" && Number.isFinite(renewal) && renewal > 0) cols.renewalAt = renewal;
1494
1572
  return cols;
1495
1573
  }
1496
- async function syncApplicationToCrm(deps2, opts) {
1574
+ async function syncApplicationToCrm(deps, opts) {
1497
1575
  if (!opts.app.id) throw new Error("crm_application_id_required");
1498
1576
  const emailKey = str(opts.app.email).toLowerCase();
1499
1577
  if (!emailKey) return null;
1500
- const recordDeps = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1501
- const input = personInputFromApp(deps2.chapter, opts.app);
1578
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1579
+ const input = personInputFromApp(deps.chapter, opts.app);
1502
1580
  const stage = opts.stage || void 0;
1503
- const { record: existing, created } = await ensureProjectedPerson(deps2, input, { stage });
1581
+ const { record: existing, created } = await ensureProjectedPerson(deps, input, { stage });
1504
1582
  const recordId = String(existing.id);
1505
1583
  if (!created) await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
1506
1584
  if (!created && stage && existing.stage !== stage) {
1507
1585
  await (0, import_crm4.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
1508
1586
  }
1509
1587
  const billing = billingColumns(opts.app);
1510
- await deps2.db.transact([
1588
+ await deps.db.transact([
1511
1589
  { t: "update", ns: "crm_record", id: recordId, attrs: billing },
1512
1590
  // Optional typed dates are removed with a retract, never an invalid null.
1513
1591
  ...!("renewalAt" in billing) ? [{ t: "retract", ns: "crm_record", id: recordId, attrs: ["renewalAt"] }] : []
@@ -1515,8 +1593,8 @@ async function syncApplicationToCrm(deps2, opts) {
1515
1593
  await (0, import_crm4.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1516
1594
  return recordId;
1517
1595
  }
1518
- async function backfillCrm(deps2) {
1519
- const data = await deps2.db.query({
1596
+ async function backfillCrm(deps) {
1597
+ const data = await deps.db.query({
1520
1598
  applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } }
1521
1599
  });
1522
1600
  const seen = /* @__PURE__ */ new Set();
@@ -1527,7 +1605,7 @@ async function backfillCrm(deps2) {
1527
1605
  if (!key3 || seen.has(key3)) return;
1528
1606
  seen.add(key3);
1529
1607
  try {
1530
- await syncApplicationToCrm(deps2, { app, stage });
1608
+ await syncApplicationToCrm(deps, { app, stage });
1531
1609
  synced += 1;
1532
1610
  } catch (err) {
1533
1611
  errors.push({ email: key3, error: err instanceof Error ? err.message : String(err) });
@@ -1535,10 +1613,10 @@ async function backfillCrm(deps2) {
1535
1613
  };
1536
1614
  for (const a of data.applications ?? []) await run(a, str(a.status));
1537
1615
  for (let offset = 0; ; offset += 200) {
1538
- const users = (await deps2.db.query({ $users: { $: { order: { id: "asc" }, limit: 200, offset } } })).$users ?? [];
1616
+ const users = (await deps.db.query({ $users: { $: { order: { id: "asc" }, limit: 200, offset } } })).$users ?? [];
1539
1617
  for (const u of users) {
1540
1618
  try {
1541
- if (await syncAccountToCrm(deps2, String(u.id))) synced += 1;
1619
+ if (await syncAccountToCrm(deps, String(u.id))) synced += 1;
1542
1620
  } catch (err) {
1543
1621
  errors.push({ email: str(u.email), error: err instanceof Error ? err.message : String(err) });
1544
1622
  }
@@ -1578,26 +1656,32 @@ function planDelivery(input) {
1578
1656
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1579
1657
  const to = redirect ? input.group.debugEmail : input.to;
1580
1658
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1581
- const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
1659
+ const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
1582
1660
 
1583
1661
  ` + render(tpl.text, vars) : render(tpl.text, vars);
1584
- return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
1662
+ return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
1585
1663
  }
1586
1664
 
1587
1665
  // src/notify.ts
1588
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1589
- async function sendTemplated(deps2, input) {
1590
- const { emailLog } = await deps2.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1666
+ var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1667
+ function fromAddress(configured, name) {
1668
+ const display = name?.trim();
1669
+ if (!display) return configured;
1670
+ const angled = configured.match(/<([^>]+)>/);
1671
+ return { name: display, email: (angled ? angled[1] : configured).trim() };
1672
+ }
1673
+ async function sendTemplated(deps, input) {
1674
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1591
1675
  const prior = Array.isArray(emailLog) ? emailLog : [];
1592
1676
  if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
1593
1677
  if (prior.length > 1 || prior.length === 1 && prior[0].deliveryState !== "retry_ready") {
1594
1678
  return { sent: false, reason: "delivery-uncertain" };
1595
1679
  }
1596
- const cloudflareReady = Boolean(deps2.sender && deps2.from);
1597
- const override = deps2.debugEmail?.trim();
1598
- const group = deps2.envName !== "prod" && deps2.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1680
+ const cloudflareReady = Boolean(deps.sender && deps.from);
1681
+ const override = deps.debugEmail?.trim();
1682
+ const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
1599
1683
  const decision = planDelivery({
1600
- envName: deps2.envName,
1684
+ envName: deps.envName,
1601
1685
  group,
1602
1686
  template: input.template,
1603
1687
  to: input.to,
@@ -1622,14 +1706,14 @@ async function sendTemplated(deps2, input) {
1622
1706
  transport: decision.transport,
1623
1707
  redirected: decision.redirected,
1624
1708
  dedupeKey: input.dedupeKey,
1625
- sentAt: deps2.now(),
1709
+ sentAt: deps.now(),
1626
1710
  deliveryState: decision.transport === "log-only" ? "sent" : "sending",
1627
1711
  deliveryAttempt: attempt,
1628
1712
  error: decision.transport === "log-only" ? "" : "delivery-pending",
1629
1713
  ...input.applicationId ? { applicationId: input.applicationId } : {}
1630
1714
  };
1631
1715
  try {
1632
- const claim = await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1716
+ const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
1633
1717
  mutationId: `email-claim:${id6}:${attempt}`,
1634
1718
  guards: [previous ? {
1635
1719
  ns: "emailLog",
@@ -1647,8 +1731,8 @@ async function sendTemplated(deps2, input) {
1647
1731
  if (decision.transport === "log-only") return { sent: true };
1648
1732
  let messageId;
1649
1733
  try {
1650
- const response = await deps2.sender.send({
1651
- from: deps2.from,
1734
+ const response = await deps.sender.send({
1735
+ from: fromAddress(deps.from, input.group.fromName),
1652
1736
  to: [decision.to],
1653
1737
  subject: String(row2.subject),
1654
1738
  text: String(row2.body),
@@ -1657,12 +1741,12 @@ async function sendTemplated(deps2, input) {
1657
1741
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
1658
1742
  messageId = response.messageId;
1659
1743
  } catch {
1660
- await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "uncertain", error: "delivery-uncertain" } }], {
1744
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "uncertain", error: "delivery-uncertain" } }], {
1661
1745
  guards: [{ ns: "emailLog", id: id6, exists: true, equals: { deliveryState: "sending", deliveryAttempt: attempt } }]
1662
1746
  });
1663
1747
  return { sent: false, reason: "delivery-uncertain" };
1664
1748
  }
1665
- await deps2.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "sent", error: "", messageId, sentAt: deps2.now() } }], {
1749
+ await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "sent", error: "", messageId, sentAt: deps.now() } }], {
1666
1750
  mutationId: `email-confirmed:${id6}:${attempt}`,
1667
1751
  guards: [{
1668
1752
  ns: "emailLog",
@@ -1681,6 +1765,7 @@ function emailGroupFrom(row2) {
1681
1765
  name: String(row2.name ?? ""),
1682
1766
  replyTo: str3(row2.replyTo) ?? "",
1683
1767
  debugEmail: str3(row2.debugEmail),
1768
+ fromName: str3(row2.fromName),
1684
1769
  refundPolicyText: str3(row2.refundPolicyText),
1685
1770
  commitmentText: str3(row2.commitmentText),
1686
1771
  normsText: str3(row2.normsText),
@@ -1702,6 +1787,11 @@ function canApprove(status2, p) {
1702
1787
  }
1703
1788
 
1704
1789
  // src/membership-policy.ts
1790
+ function seatPurchaseDeadline(offering, member) {
1791
+ if (offering.seatPurchaseWindowCalendarMonths === void 0) return Number(member.termEndsAt);
1792
+ if (Number.isSafeInteger(member.seatPurchaseEndsAt)) return Number(member.seatPurchaseEndsAt);
1793
+ return addMembershipCalendarMonths(Number(member.initialTermStartedAt ?? member.termStartedAt), offering.seatPurchaseWindowCalendarMonths);
1794
+ }
1705
1795
  var fail2 = (field) => {
1706
1796
  throw new TypeError(`invalid membership policy: ${field}`);
1707
1797
  };
@@ -1758,7 +1848,7 @@ function membershipOfferingVersion(value) {
1758
1848
  autoRenew: true,
1759
1849
  paymentGraceDays: integer2(input.paymentGraceDays, 0, 60),
1760
1850
  seatPriceCents: integer2(input.seatPriceCents, 1, Number.MAX_SAFE_INTEGER),
1761
- seatPurchaseWindowCalendarMonths: integer2(input.seatPurchaseWindowCalendarMonths, 1, 120),
1851
+ ...input.seatPurchaseWindowCalendarMonths === void 0 || input.seatPurchaseWindowCalendarMonths === null ? {} : { seatPurchaseWindowCalendarMonths: integer2(input.seatPurchaseWindowCalendarMonths, 1, 120) },
1762
1852
  requiredDisclosures: ids(input.requiredDisclosures)
1763
1853
  };
1764
1854
  }
@@ -2149,7 +2239,7 @@ async function confirmMembershipPayment(db, input, now) {
2149
2239
  initialTermEndsAt: endsAt,
2150
2240
  termStartedAt: paidAt,
2151
2241
  termEndsAt: endsAt,
2152
- seatPurchaseEndsAt: addMembershipCalendarMonths(Number(paidAt), offering.seatPurchaseWindowCalendarMonths),
2242
+ seatPurchaseEndsAt: seatPurchaseDeadline(offering, { initialTermStartedAt: paidAt, termEndsAt: endsAt }),
2153
2243
  autoRenew: true,
2154
2244
  refundedCents: 0,
2155
2245
  revision: 1,
@@ -2806,24 +2896,22 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
2806
2896
  }
2807
2897
  }
2808
2898
 
2809
- // src/payment-notifications.ts
2810
- var groupFor = async (db, app) => {
2811
- const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
2812
- return result.groups?.[0];
2813
- };
2814
- var deps = (db, env) => ({
2815
- db,
2816
- envName: env.ODLA_ENV,
2817
- sender: env.SEND_EMAIL,
2818
- from: env.EMAIL_FROM,
2819
- debugEmail: env.ODLA_DEBUG_EMAIL,
2820
- now: () => Date.now(),
2821
- newId: () => crypto.randomUUID()
2822
- });
2823
- async function paymentConfirmationVariables(db, app) {
2899
+ // src/notification-variables.ts
2900
+ function lifecycleNotifyDeps(db, env) {
2901
+ return {
2902
+ db,
2903
+ envName: env.ODLA_ENV,
2904
+ sender: env.SEND_EMAIL,
2905
+ from: env.EMAIL_FROM,
2906
+ debugEmail: env.ODLA_DEBUG_EMAIL,
2907
+ now: () => Date.now(),
2908
+ newId: () => crypto.randomUUID()
2909
+ };
2910
+ }
2911
+ async function frozenCheckoutVariables(db, app) {
2824
2912
  const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
2825
2913
  const offer = intent?.membershipOffer, contract = offer?.contract;
2826
- const offering = contract?.offering;
2914
+ const offering = contract?.offering, quote = intent?.paymentQuote;
2827
2915
  const amount = contract?.dueTodayCents ?? intent?.priceCents;
2828
2916
  const currency = offering?.currency ?? intent?.currency;
2829
2917
  let formatted = "";
@@ -2831,9 +2919,75 @@ async function paymentConfirmationVariables(db, app) {
2831
2919
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
2832
2920
  }
2833
2921
  return {
2834
- firstName: typeof app.firstName === "string" ? app.firstName : "",
2835
2922
  paymentAmount: formatted,
2836
2923
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
2924
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
2925
+ };
2926
+ }
2927
+ async function catalogTierName(db, group, app) {
2928
+ try {
2929
+ return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
2930
+ } catch {
2931
+ return "";
2932
+ }
2933
+ }
2934
+ async function checkoutVariables(db, group, app) {
2935
+ const frozen = await frozenCheckoutVariables(db, app);
2936
+ return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
2937
+ }
2938
+ function formatInstant(at, timeZone) {
2939
+ return new Intl.DateTimeFormat("en-US", {
2940
+ weekday: "long",
2941
+ month: "long",
2942
+ day: "numeric",
2943
+ year: "numeric",
2944
+ hour: "numeric",
2945
+ minute: "2-digit",
2946
+ timeZoneName: "short",
2947
+ timeZone
2948
+ }).format(new Date(at));
2949
+ }
2950
+ var adminUrlFor = (origin) => `${origin}/admin/`;
2951
+ var membersUrlFor = (origin) => `${origin}/members/`;
2952
+ function applicantVariables(app) {
2953
+ const text8 = (v) => typeof v === "string" ? v : "";
2954
+ return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
2955
+ }
2956
+ function sampleVariables(origin, timeZone, now = Date.now()) {
2957
+ const startAt = formatInstant(now + 864e5, timeZone);
2958
+ return {
2959
+ firstName: "Sample",
2960
+ lastName: "Person",
2961
+ email: "sample@example.com",
2962
+ phone: "(555) 010-0100",
2963
+ state: "CA",
2964
+ tier: "Membership",
2965
+ tierName: "Membership",
2966
+ paymentAmount: "$100.00",
2967
+ paymentAmountSummary: " of $100.00",
2968
+ paymentNextStep: "Next, schedule your introduction call.",
2969
+ submitNextStep: "Next, schedule your introduction call.",
2970
+ startAt,
2971
+ endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
2972
+ timezone: timeZone,
2973
+ meetUrl: "https://meet.google.com/sample",
2974
+ htmlLink: "https://calendar.google.com/event?eid=sample",
2975
+ meetingTime: startAt,
2976
+ meetingLink: "https://meet.google.com/sample",
2977
+ adminUrl: adminUrlFor(origin),
2978
+ membersUrl: membersUrlFor(origin)
2979
+ };
2980
+ }
2981
+
2982
+ // src/payment-notifications.ts
2983
+ var groupFor = async (db, app) => {
2984
+ const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
2985
+ return result.groups?.[0];
2986
+ };
2987
+ async function paymentConfirmationVariables(db, app) {
2988
+ return {
2989
+ firstName: typeof app.firstName === "string" ? app.firstName : "",
2990
+ ...await frozenCheckoutVariables(db, app),
2837
2991
  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."
2838
2992
  };
2839
2993
  }
@@ -2841,7 +2995,7 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
2841
2995
  if (typeof app.email !== "string" || !app.email) throw new Error("payment notice recipient missing");
2842
2996
  const group = await groupFor(db, app);
2843
2997
  if (!group) throw new Error("payment notice configuration missing");
2844
- const result = await sendTemplated(deps(db, env), {
2998
+ const result = await sendTemplated(lifecycleNotifyDeps(db, env), {
2845
2999
  group: emailGroupFrom(group),
2846
3000
  template: "paymentConfirmation",
2847
3001
  to: app.email,
@@ -2851,21 +3005,20 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
2851
3005
  });
2852
3006
  if (!result.sent && !["disabled", "template-missing"].includes(result.reason ?? "")) throw new Error("payment notice reconciliation pending");
2853
3007
  }
2854
- async function notifyAdminOfPayment(db, env, eventId, app) {
3008
+ async function notifyAdminOfPayment(db, env, eventId, app, origin = "") {
2855
3009
  try {
2856
3010
  const group = await groupFor(db, app);
2857
3011
  if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
2858
- const text6 = (value) => typeof value === "string" ? value : "";
2859
- await sendTemplated(deps(db, env), {
3012
+ const { tierName: tierName2 } = await frozenCheckoutVariables(db, app);
3013
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
2860
3014
  group: emailGroupFrom(group),
2861
3015
  template: "adminNotification",
2862
3016
  to: group.notificationEmail,
2863
3017
  vars: {
2864
- firstName: text6(app.firstName),
2865
- lastName: text6(app.lastName),
2866
- email: text6(app.email),
2867
- phone: text6(app.phone),
2868
- state: text6(app.state)
3018
+ ...applicantVariables(app),
3019
+ tier: tierName2,
3020
+ tierName: tierName2,
3021
+ ...origin ? { adminUrl: adminUrlFor(origin), membersUrl: membersUrlFor(origin) } : {}
2869
3022
  },
2870
3023
  dedupeKey: `${eventId}:admin`,
2871
3024
  applicationId: String(app.id)
@@ -3185,6 +3338,26 @@ function reportStripePartition(level, eventId, event, disposition) {
3185
3338
  ...stripeEventIdentity(event)
3186
3339
  }));
3187
3340
  }
3341
+ var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
3342
+ var QUEUE_LIMIT = 500;
3343
+ var QUEUE_IDS = 50;
3344
+ async function alertStripeQuarantine(db, ctx, eventId, disposition) {
3345
+ const pages = await Promise.all(QUARANTINE_DISPOSITIONS.map(async (value) => (await db.query({ stripeEventReceipts: { $: { where: { disposition: value }, limit: QUEUE_LIMIT } } })).stripeEventReceipts ?? []));
3346
+ const queued = pages.flat();
3347
+ reportChapterAlert(ctx, new Error("stripe webhook quarantined"), {
3348
+ code: "stripe_webhook_quarantined",
3349
+ attributes: {
3350
+ "odla.stripe.disposition": disposition,
3351
+ "odla.stripe.quarantined_count": queued.length,
3352
+ "odla.stripe.queue_truncated": pages.some((page) => page.length >= QUEUE_LIMIT)
3353
+ },
3354
+ artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
3355
+ });
3356
+ }
3357
+ async function reportStripeCollision(db, ctx, eventId, event) {
3358
+ reportStripePartition("error", eventId, event, "quarantine_collision");
3359
+ await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
3360
+ }
3188
3361
  async function storeStripeReceipt(db, input) {
3189
3362
  const existing = await receiptFor(db, input.eventId);
3190
3363
  if (existing?.retryReady === true && existing.reviewedDigest === input.digest) {
@@ -3217,17 +3390,18 @@ async function storeStripeReceipt(db, input) {
3217
3390
  const raced = await receiptFor(db, input.eventId);
3218
3391
  return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
3219
3392
  }
3220
- async function quarantineStripeEvent(db, eventId, digest, event) {
3393
+ async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
3221
3394
  const disposition = "quarantine_unmatched";
3222
3395
  const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
3223
3396
  if (stored === "collision") {
3224
- reportStripePartition("error", eventId, event, "quarantine_collision");
3397
+ await reportStripeCollision(db, ctx, eventId, event);
3225
3398
  return json({ ok: true, matched: false, collision: true });
3226
3399
  }
3227
3400
  reportStripePartition("warn", eventId, event, disposition);
3401
+ if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
3228
3402
  return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
3229
3403
  }
3230
- async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
3404
+ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
3231
3405
  const existing = await receiptFor(db, eventId);
3232
3406
  if (!existing) return null;
3233
3407
  if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
@@ -3237,7 +3411,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
3237
3411
  collisionDigest: digest,
3238
3412
  retryReady: false
3239
3413
  } }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
3240
- reportStripePartition("error", eventId, event, "quarantine_collision");
3414
+ await reportStripeCollision(db, ctx, eventId, event);
3241
3415
  return json({ ok: true, matched: false, collision: true });
3242
3416
  }
3243
3417
  return json({ ok: true, matched: existing.matched === true, replayed: true });
@@ -3268,16 +3442,16 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3268
3442
  reportStripePartition("warn", eventId, event, "ignore_sibling");
3269
3443
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3270
3444
  }
3271
- const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
3445
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3272
3446
  if (replay2) return replay2;
3273
3447
  const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
3274
3448
  const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
3275
3449
  if (!localIntent || disposition2 === "quarantine_unmatched") {
3276
- return quarantineStripeEvent(db, eventId, digest, event);
3450
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3277
3451
  }
3278
3452
  if (event.kind === "gift_paid") {
3279
3453
  if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
3280
- return quarantineStripeEvent(db, eventId, digest, event);
3454
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3281
3455
  }
3282
3456
  try {
3283
3457
  const issued = await issueGiftClaim(db, intent, {
@@ -3286,7 +3460,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3286
3460
  }, Date.now());
3287
3461
  const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
3288
3462
  if (stored3 === "collision") {
3289
- reportStripePartition("error", eventId, event, "quarantine_collision");
3463
+ await reportStripeCollision(db, ctx, eventId, event);
3290
3464
  return json({ ok: true, matched: false, collision: true });
3291
3465
  }
3292
3466
  return json({ ok: true, matched: true, claimId: issued.claimId });
@@ -3302,7 +3476,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3302
3476
  if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3303
3477
  const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
3304
3478
  if (stored2 === "collision") {
3305
- reportStripePartition("error", eventId, event, "quarantine_collision");
3479
+ await reportStripeCollision(db, ctx, eventId, event);
3306
3480
  return json({ ok: true, matched: false, collision: true });
3307
3481
  }
3308
3482
  return json({ ok: true, matched: Boolean(claim) });
@@ -3312,12 +3486,12 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3312
3486
  reportStripePartition("warn", eventId, event, "ignore_sibling");
3313
3487
  return json({ ok: true, matched: false, disposition: "ignore_sibling" });
3314
3488
  }
3315
- const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
3489
+ const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
3316
3490
  if (replay) return replay;
3317
3491
  const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
3318
3492
  const disposition = classifyMemberStripeEvent(identity, target, localApplication);
3319
3493
  if (!localApplication || disposition === "quarantine_unmatched") {
3320
- return quarantineStripeEvent(db, eventId, digest, event);
3494
+ return quarantineStripeEvent(db, eventId, digest, event, ctx);
3321
3495
  }
3322
3496
  if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
3323
3497
  await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
@@ -3334,7 +3508,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3334
3508
  if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
3335
3509
  const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3336
3510
  if (stored2 === "collision") {
3337
- reportStripePartition("error", eventId, event, "quarantine_collision");
3511
+ await reportStripeCollision(db, ctx, eventId, event);
3338
3512
  return json({ ok: true, matched: false, collision: true });
3339
3513
  }
3340
3514
  return json({ ok: true, seatId: event.seatId });
@@ -3358,7 +3532,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3358
3532
  if (intent) {
3359
3533
  const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
3360
3534
  if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
3361
- if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event);
3535
+ if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
3362
3536
  if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
3363
3537
  }
3364
3538
  if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
@@ -3409,7 +3583,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3409
3583
  } catch {
3410
3584
  return json({ error: "payment notice reconciliation pending" }, 503);
3411
3585
  }
3412
- if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3586
+ if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app, input.origin);
3413
3587
  if (typeof next.interviewWaivedAt === "number") {
3414
3588
  const approval = await approveApplication({
3415
3589
  db,
@@ -3424,15 +3598,132 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3424
3598
  }
3425
3599
  const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
3426
3600
  if (stored === "collision") {
3427
- reportStripePartition("error", eventId, event, "quarantine_collision");
3601
+ await reportStripeCollision(db, ctx, eventId, event);
3428
3602
  return json({ ok: true, matched: false, collision: true });
3429
3603
  }
3430
3604
  return json({ ok: true });
3431
3605
  }
3432
3606
 
3607
+ // src/network-commercial-resolve.ts
3608
+ var EVENT_ID = /^evt_[A-Za-z0-9_]{1,150}$/;
3609
+ var fail3 = (message, code) => Object.assign(new Error(message), { code });
3610
+ async function resolveNetworkCommercialCollision(db, sources, input, now = Date.now(), alerts) {
3611
+ if (!EVENT_ID.test(input.eventId)) throw fail3("invalid event reference", "invalid_event");
3612
+ const id6 = exceptionId(input.eventId, "collision"), reviewId = `resolve:${input.mutationId}`;
3613
+ const found = await db.query({
3614
+ networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } },
3615
+ networkCommercialEvents: { $: { where: { eventId: input.eventId }, limit: 1 } }
3616
+ });
3617
+ const exception = found.networkCommercialExceptions?.[0], stored = found.networkCommercialEvents?.[0];
3618
+ if (exception?.status === "resolved" && exception.reviewId === reviewId) {
3619
+ return { eventId: input.eventId, reviewId, projection: await loadNetworkCommercialProjection(db, sources) };
3620
+ }
3621
+ if (!exception || exception.status !== "active") throw fail3("no active collision for this event", "collision_not_active");
3622
+ if (!stored) throw fail3("stored event not found", "event_not_found");
3623
+ const dismissed = typeof exception.incomingDigest === "string" ? exception.incomingDigest : void 0;
3624
+ const write = await db.transact([
3625
+ { t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
3626
+ id: reviewId,
3627
+ eventId: input.eventId,
3628
+ actorUserId: input.actorUserId,
3629
+ reviewedAt: now,
3630
+ previousDigest: String(stored.digest),
3631
+ providerDigest: dismissed ?? "",
3632
+ scope: "network",
3633
+ previousState: exception,
3634
+ action: "resolve",
3635
+ reason: input.reason
3636
+ } },
3637
+ { t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
3638
+ status: "resolved",
3639
+ resolvedDigest: stored.digest,
3640
+ ...dismissed ? { reviewedDigest: dismissed } : {},
3641
+ resolvedBy: input.actorUserId,
3642
+ reviewId,
3643
+ updatedAt: now
3644
+ } }
3645
+ ], { mutationId: input.mutationId, guards: [
3646
+ { ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
3647
+ ] });
3648
+ if (write.duplicate) throw fail3("mutationId was already used for another change", "mutation_reused");
3649
+ const projection = await rebuildNetworkCommercialProjection(db, sources, `${input.mutationId}:rebuild`, now, alerts);
3650
+ return { eventId: input.eventId, reviewId, projection };
3651
+ }
3652
+ async function retryNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
3653
+ if (!EVENT_ID.test(eventId)) throw fail3("invalid event reference", "invalid_event");
3654
+ const active = (await loadNetworkCommercialProjection(db, sources)).exceptions.filter((item) => item.eventId === eventId);
3655
+ if (active.length === 0) throw fail3("event has no active exception", "exception_not_active");
3656
+ const provider = await readStripeRecoveryEvent(db, eventId);
3657
+ const stored = (await db.query({ networkCommercialEvents: { $: { where: { eventId }, limit: 1 } } })).networkCommercialEvents?.[0];
3658
+ const reviewId = crypto.randomUUID();
3659
+ await db.transact([{ t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
3660
+ id: reviewId,
3661
+ eventId,
3662
+ actorUserId,
3663
+ reviewedAt: now,
3664
+ previousDigest: String(stored?.digest ?? ""),
3665
+ providerDigest: provider.digest,
3666
+ scope: "network",
3667
+ previousState: { exceptions: active },
3668
+ action: "retry"
3669
+ } }], { mutationId: `payment-recovery:${reviewId}` });
3670
+ const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
3671
+ const result = await ingestNetworkCommercialFact(db, sources, fact, now, await stripePayloadDigest(provider.rawBody), alerts);
3672
+ return {
3673
+ eventId,
3674
+ reviewId,
3675
+ status: result.status,
3676
+ resolvedCollision: result.status === "replayed" && result.resolvedCollision === true,
3677
+ exceptions: result.projection.exceptions.length
3678
+ };
3679
+ }
3680
+
3681
+ // src/worker-routes-network-commercial-recovery.ts
3682
+ var PATH = "/api/admin/payment-recovery";
3683
+ var handleNetworkCommercialRecovery = async (req, url, env, ctx) => {
3684
+ const model = ctx.chapter.networkCommercialReadModel;
3685
+ if (url.pathname !== PATH || !model) return null;
3686
+ if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
3687
+ const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3688
+ if (!user) return json({ error: "unauthorized" }, 401);
3689
+ if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "forbidden" }, 403);
3690
+ const db = raw2;
3691
+ if (req.method === "GET") {
3692
+ const { exceptions } = await loadNetworkCommercialProjection(db, model.sources);
3693
+ return json({
3694
+ scope: "network",
3695
+ count: exceptions.length,
3696
+ truncated: false,
3697
+ rows: exceptions.map((item) => ({
3698
+ eventId: item.eventId,
3699
+ kind: item.detail.eventType,
3700
+ disposition: item.kind,
3701
+ sourceKey: item.sourceKey ?? null,
3702
+ retryReady: /^evt_/.test(item.eventId)
3703
+ }))
3704
+ });
3705
+ }
3706
+ let eventId;
3707
+ try {
3708
+ eventId = String((await req.json()).eventId ?? "");
3709
+ } catch {
3710
+ return json({ error: "eventId required" }, 400);
3711
+ }
3712
+ try {
3713
+ const retried = await retryNetworkCommercialEvent(db, model.sources, eventId, user.userId, Date.now(), networkCommercialAlerts(ctx, PATH));
3714
+ return json({ ok: true, ...retried });
3715
+ } catch (error) {
3716
+ const code = error.code;
3717
+ if (code === "invalid_event") return json({ error: "invalid event reference" }, 400);
3718
+ if (code === "exception_not_active") return json({ error: "event has no active exception" }, 409);
3719
+ return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
3720
+ }
3721
+ };
3722
+
3433
3723
  // src/worker-routes-payment-recovery.ts
3434
3724
  var handlePaymentRecovery = async (req, url, env, ctx) => {
3435
- if (url.pathname !== "/api/admin/payment-recovery" || ctx.chapter.mode !== "chapter") return null;
3725
+ if (url.pathname !== "/api/admin/payment-recovery") return null;
3726
+ if (ctx.chapter.mode !== "chapter") return handleNetworkCommercialRecovery(req, url, env, ctx);
3436
3727
  if (!["GET", "POST"].includes(req.method)) return json({ error: "method not allowed" }, 405);
3437
3728
  const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3438
3729
  if (!user) return json({ error: "unauthorized" }, 401);
@@ -3494,10 +3785,10 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
3494
3785
  };
3495
3786
 
3496
3787
  // src/worker-routes-admin-named-seat.ts
3497
- var PATH = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund)?$/;
3788
+ var PATH2 = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund)?$/;
3498
3789
  var REFUNDABLE = /* @__PURE__ */ new Set(["renewal_pending", "pending_acceptance", "pending_approval", "pending_primary", "active"]);
3499
3790
  var handleAdminNamedSeat = async (req, url, env, ctx) => {
3500
- const match = url.pathname.match(PATH);
3791
+ const match = url.pathname.match(PATH2);
3501
3792
  if (!match) return null;
3502
3793
  const seats = ctx.membershipAuthority?.namedSeats;
3503
3794
  if (ctx.chapter.mode !== "chapter" || !seats) return json({ error: "named seats are not configured" }, 404);
@@ -3550,42 +3841,42 @@ async function closeRecipientApplication(db, env, ctx, seatId) {
3550
3841
 
3551
3842
  // src/membership-seat-refund-stripe.ts
3552
3843
  var import_stripe6 = require("@odla-ai/stripe");
3553
- var fail3 = (code = "named_seat_refund_mismatch") => {
3844
+ var fail4 = (code = "named_seat_refund_mismatch") => {
3554
3845
  throw Object.assign(new Error(code), { code });
3555
3846
  };
3556
3847
  async function refundNamedSeatStripePayment(secretKey, seat) {
3557
- if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail3();
3848
+ if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail4();
3558
3849
  const call = async (method, path, params, idempotencyKey) => {
3559
3850
  const result = await (0, import_stripe6.stripeRequest)({ secretKey }, method, path, params, idempotencyKey ? { idempotencyKey } : {});
3560
- if (!result.ok || !result.body) return fail3("named_seat_refund_provider_unavailable");
3851
+ if (!result.ok || !result.body) return fail4("named_seat_refund_provider_unavailable");
3561
3852
  return result.body;
3562
3853
  };
3563
3854
  const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId, membershipId: seat.primaryMembershipId, seatId: seat.id };
3564
3855
  const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(seat.providerPaymentIntentId)}`);
3565
- 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();
3856
+ 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();
3566
3857
  const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
3567
- 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();
3858
+ 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();
3568
3859
  const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
3569
- if (items.length > 1) fail3();
3860
+ if (items.length > 1) fail4();
3570
3861
  if (items.length && sub.status !== "canceled") {
3571
3862
  const item = items[0];
3572
- if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail3();
3863
+ if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail4();
3573
3864
  const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
3574
- if (removed.id !== item.id || removed.deleted !== true) fail3("named_seat_renewal_removal_unconfirmed");
3865
+ if (removed.id !== item.id || removed.deleted !== true) fail4("named_seat_renewal_removal_unconfirmed");
3575
3866
  }
3576
3867
  const list = await call("GET", "/v1/refunds", { payment_intent: seat.providerPaymentIntentId, limit: 100 });
3577
- if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail3("named_seat_refund_history_incomplete");
3868
+ if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail4("named_seat_refund_history_incomplete");
3578
3869
  let refundedCents = 0, pendingCents = 0;
3579
3870
  const refundIds = [];
3580
3871
  for (const refund of list.data) {
3581
- 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();
3872
+ 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();
3582
3873
  if (refund.status === "succeeded") {
3583
3874
  refundedCents += refund.amount;
3584
3875
  refundIds.push(refund.id);
3585
3876
  } else if (refund.status === "pending" || refund.status === "requires_action") pendingCents += refund.amount;
3586
3877
  }
3587
- if (refundedCents + pendingCents > seat.amountCents) fail3();
3588
- if (pendingCents) fail3("named_seat_refund_pending");
3878
+ if (refundedCents + pendingCents > seat.amountCents) fail4();
3879
+ if (pendingCents) fail4("named_seat_refund_pending");
3589
3880
  if (refundedCents < seat.amountCents) {
3590
3881
  const remaining = seat.amountCents - refundedCents;
3591
3882
  const refund = await call("POST", "/v1/refunds", {
@@ -3594,8 +3885,8 @@ async function refundNamedSeatStripePayment(secretKey, seat) {
3594
3885
  reason: "requested_by_customer",
3595
3886
  metadata
3596
3887
  }, `named-seat-refund:${seat.id}:${remaining}`);
3597
- if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail3();
3598
- if (refund.status !== "succeeded") fail3("named_seat_refund_pending");
3888
+ if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail4();
3889
+ if (refund.status !== "succeeded") fail4("named_seat_refund_pending");
3599
3890
  refundedCents += remaining;
3600
3891
  refundIds.push(refund.id);
3601
3892
  }
@@ -3684,9 +3975,37 @@ async function refundNamedSeatByOperator(db, input, stripeSecretKey, now) {
3684
3975
  return { seatId: String(seat.id), status: "refunded", refundedCents: Number(refunded.refundedCents), subscriptionCanceled: false };
3685
3976
  }
3686
3977
 
3978
+ // src/membership-seat-window.ts
3979
+ async function liftSeatPurchaseWindow(db, scope2, now, limit = 100) {
3980
+ membershipClock(now);
3981
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError("invalid lift limit");
3982
+ const state = await loadMembershipAuthorityState(db, scope2);
3983
+ const rows5 = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
3984
+ let lifted = 0;
3985
+ for (const member of rows5.slice(0, limit)) {
3986
+ if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) throw new Error("membership lift scope mismatch");
3987
+ const offering = membershipOfferingVersion(member.offeringSnapshot);
3988
+ if (offering.seatPurchaseWindowCalendarMonths === void 0) continue;
3989
+ const { seatPurchaseWindowCalendarMonths: _window, ...windowless } = offering;
3990
+ await db.transact([membershipUpdate("networkMembership", String(member.id), {
3991
+ offeringSnapshot: windowless,
3992
+ seatPurchaseEndsAt: Number(member.termEndsAt),
3993
+ revision: Number(member.revision) + 1,
3994
+ updatedAt: now
3995
+ })], { mutationId: `seat-window-lift:${member.id}:${member.revision}`, guards: [
3996
+ { ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision } },
3997
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } }
3998
+ ] });
3999
+ lifted++;
4000
+ }
4001
+ return { lifted, more: rows5.length > limit };
4002
+ }
4003
+
3687
4004
  // src/worker-routes-admin-membership-seats.ts
3688
4005
  var LIST = "/api/admin/membership-seats";
4006
+ var LIFT = "/api/admin/membership-seats/lift-purchase-window";
3689
4007
  var ONE = /^\/api\/admin\/membership-seats\/([A-Za-z0-9][A-Za-z0-9_.:-]{2,159})\/refund$/;
4008
+ var SCOPE = /^[a-z0-9][a-z0-9-]{1,62}$/;
3690
4009
  var PUBLIC = [
3691
4010
  "id",
3692
4011
  "appId",
@@ -3711,11 +4030,11 @@ var PUBLIC = [
3711
4030
  ];
3712
4031
  var project = (row2) => Object.fromEntries(PUBLIC.filter((key3) => row2[key3] !== void 0).map((key3) => [key3, row2[key3]]));
3713
4032
  var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3714
- const one = url.pathname.match(ONE);
3715
- if (url.pathname !== LIST && !one) return null;
4033
+ const one = url.pathname.match(ONE), lift = url.pathname === LIFT;
4034
+ if (url.pathname !== LIST && !one && !lift) return null;
3716
4035
  const operator = ctx.membershipAuthorityOperator;
3717
4036
  if (!operator) return json({ error: "not found" }, 404);
3718
- if (req.method !== (one ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
4037
+ if (req.method !== (one || lift ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
3719
4038
  const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
3720
4039
  if (!user) return json({ error: "unauthorized" }, 401);
3721
4040
  if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
@@ -3725,6 +4044,24 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3725
4044
  } catch {
3726
4045
  return json({ error: "the membership authority is not hosted by this deployment" }, 404);
3727
4046
  }
4047
+ if (lift) {
4048
+ let body;
4049
+ try {
4050
+ body = await req.json();
4051
+ } catch {
4052
+ return json({ error: "chapterId and runtime are required" }, 400);
4053
+ }
4054
+ const appId = body.appId ?? body.chapterId;
4055
+ 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)) {
4056
+ return json({ error: "chapterId and runtime are required" }, 400);
4057
+ }
4058
+ try {
4059
+ return json({ ok: true, ...await liftSeatPurchaseWindow(db, { appId, chapterId: body.chapterId, environment: env.ODLA_ENV, runtime: body.runtime }, Date.now()) });
4060
+ } catch (error) {
4061
+ const code = error.code;
4062
+ return json({ error: "purchase window lift is pending; retry to confirm", ...code ? { code } : {} }, code ? 409 : 503);
4063
+ }
4064
+ }
3728
4065
  if (!one) {
3729
4066
  const rows5 = (await db.query({ networkSeat: { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 201 } } })).networkSeat ?? [];
3730
4067
  return json({ seats: rows5.slice(0, 200).map(project), truncated: rows5.length > 200 });
@@ -3754,6 +4091,107 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
3754
4091
  }
3755
4092
  };
3756
4093
 
4094
+ // src/membership-policy-versions.ts
4095
+ var LIMIT = 200;
4096
+ var text2 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
4097
+ var instant = (value) => {
4098
+ const at = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
4099
+ return Number.isSafeInteger(at) && at >= 0 ? at : void 0;
4100
+ };
4101
+ function provenance(row2) {
4102
+ const source = text2(row2.source), actor = text2(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
4103
+ return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
4104
+ }
4105
+ var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
4106
+ function stateOf(item, all, now, window, rivals) {
4107
+ if (item.status !== "published") return { state: item.status };
4108
+ const mine = window(item);
4109
+ if (now < mine.from) return { state: "scheduled" };
4110
+ const closed = mine.until !== void 0 && mine.until <= now;
4111
+ 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));
4112
+ if (!closed && !higher.some((w) => live(w, now))) return { state: "active" };
4113
+ const dates = higher.map((w) => Math.max(w.from, mine.from));
4114
+ if (closed) dates.push(mine.until);
4115
+ return { state: "superseded", supersededAt: Math.min(...dates) };
4116
+ }
4117
+ 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";
4118
+ var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
4119
+ var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
4120
+ function normalize(rows5, collection, parse, invalid2) {
4121
+ const out = [];
4122
+ for (const row2 of rows5) {
4123
+ try {
4124
+ out.push({ value: parse(row2.snapshot), row: row2 });
4125
+ } catch (error) {
4126
+ invalid2.push({ collection, id: String(row2.id ?? ""), error: error instanceof Error ? error.message : String(error) });
4127
+ }
4128
+ }
4129
+ return out;
4130
+ }
4131
+ async function listMembershipPolicyVersions(db, input) {
4132
+ const where = input.chapterId ? { chapterId: input.chapterId } : {};
4133
+ const rows5 = await db.query({
4134
+ membershipOfferingVersion: { $: { where, limit: LIMIT } },
4135
+ discountPolicyVersion: { $: { where, limit: LIMIT } }
4136
+ });
4137
+ const offeringRows = rows5.membershipOfferingVersion ?? [], policyRows = rows5.discountPolicyVersion ?? [];
4138
+ const invalid2 = [];
4139
+ const offerings = normalize(offeringRows, "membershipOfferingVersion", membershipOfferingVersion, invalid2);
4140
+ const policies = normalize(policyRows, "discountPolicyVersion", membershipDiscountPolicyVersion, invalid2);
4141
+ const offeringValues = offerings.map((item) => item.value), policyValues = policies.map((item) => item.value);
4142
+ return {
4143
+ now: input.now,
4144
+ chapterId: input.chapterId ?? null,
4145
+ offerings: offerings.map(({ value, row: row2 }) => ({
4146
+ ...value,
4147
+ ...provenance(row2),
4148
+ ...stateOf(
4149
+ value,
4150
+ offeringValues,
4151
+ input.now,
4152
+ (v) => ({ from: v.effectiveFrom, ...v.effectiveUntil !== void 0 ? { until: v.effectiveUntil } : {} }),
4153
+ (a, b) => a.chapterId === b.chapterId && a.baseSkuId === b.baseSkuId
4154
+ )
4155
+ })).sort((a, b) => byChapter(a, b) || a.baseSkuId.localeCompare(b.baseSkuId) || b.version - a.version),
4156
+ discountPolicies: policies.map(({ value, row: row2 }) => ({
4157
+ ...value,
4158
+ ...provenance(row2),
4159
+ mechanism: mechanism(value),
4160
+ ...stateOf(
4161
+ value,
4162
+ policyValues,
4163
+ input.now,
4164
+ (v) => ({ from: v.qualifyingStartsAt, until: v.qualifyingEndsAt }),
4165
+ (a, b) => a.chapterId === b.chapterId && sharesSku(a, b)
4166
+ )
4167
+ })).sort((a, b) => byChapter(a, b) || b.version - a.version),
4168
+ invalid: invalid2,
4169
+ truncated: offeringRows.length >= LIMIT || policyRows.length >= LIMIT
4170
+ };
4171
+ }
4172
+
4173
+ // src/worker-routes-admin-membership-policy.ts
4174
+ var PATH3 = "/api/admin/membership-policy-versions";
4175
+ var SCOPE2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
4176
+ var handleAdminMembershipPolicyVersions = async (req, url, env, ctx) => {
4177
+ if (url.pathname !== PATH3) return null;
4178
+ const operator = ctx.membershipAuthorityOperator;
4179
+ if (!operator) return json({ error: "not found" }, 404);
4180
+ if (req.method !== "GET") return json({ error: "method not allowed" }, 405);
4181
+ const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
4182
+ if (!user) return json({ error: "unauthorized" }, 401);
4183
+ if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
4184
+ const chapterId = url.searchParams.get("chapterId");
4185
+ if (chapterId !== null && !SCOPE2.test(chapterId)) return json({ error: "chapterId is invalid" }, 400);
4186
+ let db;
4187
+ try {
4188
+ db = operator.makeDb(env);
4189
+ } catch {
4190
+ return json({ error: "the membership authority is not hosted by this deployment" }, 404);
4191
+ }
4192
+ return json(await listMembershipPolicyVersions(db, { chapterId, now: Date.now() }));
4193
+ };
4194
+
3757
4195
  // src/worker-routes-membership-cancel.ts
3758
4196
  var handleMembershipCancellation = async (req, url, env, ctx) => {
3759
4197
  const match = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/cancel-renewal$/);
@@ -4606,8 +5044,8 @@ var APPLICATION_LABELS = {
4606
5044
  state: "State"
4607
5045
  };
4608
5046
  function humanize(id6) {
4609
- const text6 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
4610
- return text6 ? `${text6[0].toUpperCase()}${text6.slice(1)}` : id6;
5047
+ const text8 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
5048
+ return text8 ? `${text8[0].toUpperCase()}${text8.slice(1)}` : id6;
4611
5049
  }
4612
5050
  function applicationDefs(chapter) {
4613
5051
  try {
@@ -4674,7 +5112,7 @@ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
4674
5112
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
4675
5113
  var CURRENCY = /^[a-z]{3}$/;
4676
5114
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
4677
- var text2 = (value, path, errors, max) => {
5115
+ var text3 = (value, path, errors, max) => {
4678
5116
  if (typeof value !== "string" || !value.trim()) {
4679
5117
  errors.push(`${path} is required`);
4680
5118
  return "";
@@ -4702,23 +5140,23 @@ function validateGiftOfferCatalog(input, tierIds) {
4702
5140
  }
4703
5141
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
4704
5142
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
4705
- const id6 = text2(item.id, `${path}.id`, errors, 80);
5143
+ const id6 = text3(item.id, `${path}.id`, errors, 80);
4706
5144
  if (id6 && !ID2.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
4707
5145
  if (ids2.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
4708
5146
  ids2.add(id6);
4709
5147
  const targetRaw = record(item.target);
4710
5148
  let target = { type: "entitlement", entitlementKey: "invalid" };
4711
5149
  if (targetRaw?.type === "tier") {
4712
- const tierId = text2(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
5150
+ const tierId = text3(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
4713
5151
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
4714
5152
  target = { type: "tier", tierId };
4715
5153
  } else if (targetRaw?.type === "entitlement") {
4716
- const entitlementKey = text2(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
5154
+ const entitlementKey = text3(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
4717
5155
  if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
4718
5156
  target = { type: "entitlement", entitlementKey };
4719
5157
  } else errors.push(`${path}.target.type must be tier or entitlement`);
4720
5158
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
4721
- const stripePriceId = text2(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
5159
+ const stripePriceId = text3(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
4722
5160
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
4723
5161
  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`);
4724
5162
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -4740,9 +5178,9 @@ function validateGiftOfferCatalog(input, tierIds) {
4740
5178
  redemptionLifetimeHours: lifetime,
4741
5179
  ...activeFrom !== void 0 ? { activeFrom } : {},
4742
5180
  ...activeUntil !== void 0 ? { activeUntil } : {},
4743
- title: text2(item.title, `${path}.title`, errors, 160),
4744
- description: text2(item.description, `${path}.description`, errors, 2e3),
4745
- ctaLabel: text2(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
5181
+ title: text3(item.title, `${path}.title`, errors, 160),
5182
+ description: text3(item.description, `${path}.description`, errors, 2e3),
5183
+ ctaLabel: text3(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
4746
5184
  });
4747
5185
  }
4748
5186
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -4960,22 +5398,22 @@ async function createPaymentQuote(input) {
4960
5398
  // src/membership-quote-projection.ts
4961
5399
  var MEMBERSHIP_QUOTE_NAMESPACE = "membershipQuoteProjections";
4962
5400
  var keys2 = ["appId", "chapterId", "environment", "runtime", "applicationId", "skuId"];
4963
- var fail4 = (code) => {
5401
+ var fail5 = (code) => {
4964
5402
  throw Object.assign(new Error(code), { code });
4965
5403
  };
4966
5404
  var load3 = async (db, ns, id6) => (await db.query({ [ns]: { $: { where: { id: id6 }, limit: 1 } } }))[ns]?.[0];
4967
5405
  async function restore2(row2, target, now, allowExpired = false) {
4968
- 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");
4969
- if (row2.status !== "ready") return fail4("membership_quote_pending");
5406
+ 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");
5407
+ if (row2.status !== "ready") return fail5("membership_quote_pending");
4970
5408
  const offer = await verifyIssuedMembershipOffer(row2.offer, { ...target, offerId: await membershipOfferIdForRequest({
4971
5409
  ...target,
4972
5410
  requestId: row2.requestId
4973
5411
  }), now, lifetimeMs: 6e4 });
4974
- if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return fail4("membership_quote_expired");
5412
+ if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return fail5("membership_quote_expired");
4975
5413
  const tier = row2.tier, quote = row2.quote;
4976
5414
  const offering = offer.contract.offering;
4977
5415
  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) {
4978
- return fail4("membership_catalog_mismatch");
5416
+ return fail5("membership_catalog_mismatch");
4979
5417
  }
4980
5418
  const expected = await createPaymentQuote({
4981
5419
  ...target,
@@ -4986,7 +5424,7 @@ async function restore2(row2, target, now, allowExpired = false) {
4986
5424
  presentation: quote.presentation,
4987
5425
  founding: foundingOf(offer)
4988
5426
  });
4989
- if (quote.digest !== expected.digest) return fail4("membership_quote_conflict");
5427
+ if (quote.digest !== expected.digest) return fail5("membership_quote_conflict");
4990
5428
  return { tier, quote: expected, billingTerms: offer.contract.billingTerms, membershipOffer: offer, projectionRevision: Number(row2.revision) };
4991
5429
  }
4992
5430
  async function readMembershipQuote(db, target, now) {
@@ -4994,9 +5432,9 @@ async function readMembershipQuote(db, target, now) {
4994
5432
  }
4995
5433
  async function prepareMembershipQuote(db, input, issue) {
4996
5434
  const { applicationId: id6, now, app } = input;
4997
- 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");
5435
+ 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");
4998
5436
  let row2 = await load3(db, MEMBERSHIP_QUOTE_NAMESPACE, id6);
4999
- if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return fail4("membership_quote_conflict");
5437
+ if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return fail5("membership_quote_conflict");
5000
5438
  if (row2?.status === "ready") {
5001
5439
  try {
5002
5440
  return await restore2(row2, input, now);
@@ -5041,7 +5479,7 @@ async function prepareMembershipQuote(db, input, issue) {
5041
5479
  }
5042
5480
  if (row2?.status === "ready") return restore2(row2, input, now);
5043
5481
  if (!row2 || row2.status !== "pending" || keys2.some((key3) => row2[key3] !== input[key3]) || typeof row2.requestId !== "string") {
5044
- return fail4("membership_quote_pending");
5482
+ return fail5("membership_quote_pending");
5045
5483
  }
5046
5484
  const target = Object.fromEntries(keys2.map((key3) => [key3, input[key3]]));
5047
5485
  const offer = await verifyIssuedMembershipOffer(await issue({ ...target, requestId: row2.requestId }), {
@@ -5305,17 +5743,17 @@ async function readBoundedRequestText(req, maxBytes) {
5305
5743
  if (!reader) return "";
5306
5744
  const decoder = new TextDecoder();
5307
5745
  let bytes = 0;
5308
- let text6 = "";
5746
+ let text8 = "";
5309
5747
  for (; ; ) {
5310
5748
  const { done, value } = await reader.read();
5311
5749
  if (req.signal.aborted) return invalid2();
5312
5750
  if (done) {
5313
5751
  complete = true;
5314
- return text6 + decoder.decode();
5752
+ return text8 + decoder.decode();
5315
5753
  }
5316
5754
  bytes += value.byteLength;
5317
5755
  if (bytes > maxBytes) return json({ error: "request body too large" }, 413);
5318
- text6 += decoder.decode(value, { stream: true });
5756
+ text8 += decoder.decode(value, { stream: true });
5319
5757
  }
5320
5758
  } catch {
5321
5759
  return invalid2();
@@ -5668,37 +6106,61 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
5668
6106
  } catch {
5669
6107
  }
5670
6108
  }
5671
- async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
6109
+
6110
+ // src/submit-notifications.ts
6111
+ async function notifyAdminOfApplication(db, env, notice) {
5672
6112
  try {
5673
- const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
5674
- const group = Array.isArray(groups) ? groups[0] : void 0;
5675
- if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
5676
- const stringValue = (value) => typeof value === "string" ? value : "";
5677
- await sendTemplated(
5678
- {
5679
- db,
5680
- envName: env.ODLA_ENV,
5681
- debugEmail: env.ODLA_DEBUG_EMAIL,
5682
- sender: env.SEND_EMAIL,
5683
- from: env.EMAIL_FROM,
5684
- now: () => Date.now(),
5685
- newId: () => crypto.randomUUID()
6113
+ const { group, application: app } = notice;
6114
+ if (typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
6115
+ const { tierName: tierName2 } = await checkoutVariables(db, group, app);
6116
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
6117
+ group: emailGroupFrom(group),
6118
+ template: "adminNotification",
6119
+ to: group.notificationEmail,
6120
+ vars: { ...applicantVariables(app), tier: tierName2, tierName: tierName2, adminUrl: adminUrlFor(notice.origin), membersUrl: membersUrlFor(notice.origin) },
6121
+ dedupeKey: `apply:${String(app.id)}:admin`,
6122
+ applicationId: String(app.id)
6123
+ });
6124
+ } catch {
6125
+ }
6126
+ }
6127
+ var FREE_CONTINUATIONS = /* @__PURE__ */ new Set(["booking", "accepted", "approve"]);
6128
+ function shouldConfirmSubmit(state, group) {
6129
+ if (state !== null && FREE_CONTINUATIONS.has(state)) return true;
6130
+ const templates = group.emailTemplates;
6131
+ return templates?.paymentConfirmation?.enabled === false;
6132
+ }
6133
+ function submitNextStep(state) {
6134
+ switch (state) {
6135
+ case "accepted":
6136
+ return "Your membership is approved.";
6137
+ case "booking":
6138
+ return "Next, schedule your introduction call.";
6139
+ case "payment":
6140
+ case "paymentPending":
6141
+ case "gift":
6142
+ return "Next, complete your membership payment.";
6143
+ default:
6144
+ return "";
6145
+ }
6146
+ }
6147
+ async function notifySubmitConfirmed(db, env, notice) {
6148
+ try {
6149
+ const { group, application: app } = notice;
6150
+ if (typeof app.email !== "string" || !app.email || !shouldConfirmSubmit(notice.state, group)) return;
6151
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
6152
+ group: emailGroupFrom(group),
6153
+ template: "submitConfirmation",
6154
+ to: app.email,
6155
+ vars: {
6156
+ ...applicantVariables(app),
6157
+ ...await checkoutVariables(db, group, app),
6158
+ submitNextStep: submitNextStep(notice.state),
6159
+ membersUrl: membersUrlFor(notice.origin)
5686
6160
  },
5687
- {
5688
- group: emailGroupFrom(group),
5689
- template: "adminNotification",
5690
- to: group.notificationEmail,
5691
- vars: {
5692
- firstName: stringValue(fields.firstName),
5693
- lastName: stringValue(fields.lastName),
5694
- email: stringValue(fields.email),
5695
- phone: stringValue(fields.phone),
5696
- state: stringValue(fields.state)
5697
- },
5698
- dedupeKey: `apply:${applicationId}:admin`,
5699
- applicationId
5700
- }
5701
- );
6161
+ dedupeKey: `apply:${String(app.id)}:confirm`,
6162
+ applicationId: String(app.id)
6163
+ });
5702
6164
  } catch {
5703
6165
  }
5704
6166
  }
@@ -5896,17 +6358,17 @@ var handleMember = async (req, url, env, ctx) => {
5896
6358
  if (provisioningFailure.status === 409) await rejectUnprovisionedSignup(db, result.id, canonical3.status);
5897
6359
  return json(provisioningFailure.body, provisioningFailure.status);
5898
6360
  }
5899
- if (!result.duplicate && effectiveChapter.sends.adminNotification === "submit") {
5900
- await notifyAdminOfApplication(db, env, chapter.id, result.id, canonical3);
5901
- }
5902
6361
  const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
5903
6362
  const group = Array.isArray(groups) ? groups[0] : void 0;
5904
- const state = canonical3 && group ? await continueJoin(db, ctx, env, url.origin, canonical3, group) : null;
6363
+ const notice = group ? { group, application: canonical3, origin: url.origin } : null;
6364
+ if (notice && !result.duplicate && effectiveChapter.sends.adminNotification === "submit") await notifyAdminOfApplication(db, env, notice);
6365
+ const state = notice ? await continueJoin(db, ctx, env, url.origin, canonical3, notice.group) : null;
5905
6366
  if (!state) return json({
5906
6367
  error: "Application saved; its next step is temporarily unavailable. Retry this submission.",
5907
6368
  code: "application_continuation_unavailable",
5908
6369
  id: result.id
5909
6370
  }, 503);
6371
+ if (notice && !result.duplicate) await notifySubmitConfirmed(db, env, { ...notice, state });
5910
6372
  const final = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications?.[0];
5911
6373
  if (!final || final.groupId !== chapter.id || state === "accepted" && final.status !== "approved") {
5912
6374
  return json({ error: "application changed; reload its current state" }, 409);
@@ -6026,10 +6488,10 @@ function boundedString(value, path, errors, max, required = false) {
6026
6488
  errors.push(`${path} must be a string`);
6027
6489
  return void 0;
6028
6490
  }
6029
- const text6 = value.trim();
6030
- if (required && !text6) errors.push(`${path} is required`);
6031
- if (text6.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
6032
- return text6;
6491
+ const text8 = value.trim();
6492
+ if (required && !text8) errors.push(`${path} is required`);
6493
+ if (text8.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
6494
+ return text8;
6033
6495
  }
6034
6496
  function validateMembershipPageDocument(input, options = {}) {
6035
6497
  const errors = [];
@@ -6078,8 +6540,8 @@ function validateMembershipPageDocument(input, options = {}) {
6078
6540
  else {
6079
6541
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
6080
6542
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
6081
- const text6 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
6082
- return text6 ? [text6] : [];
6543
+ const text8 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
6544
+ return text8 ? [text8] : [];
6083
6545
  });
6084
6546
  }
6085
6547
  }
@@ -6173,7 +6635,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
6173
6635
  function unknownKeys(value, allowed2, path, errors) {
6174
6636
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
6175
6637
  }
6176
- function text3(value, path, errors, max = 65536) {
6638
+ function text4(value, path, errors, max = 65536) {
6177
6639
  if (typeof value !== "string" || !value.trim()) {
6178
6640
  errors.push(`${path} is required`);
6179
6641
  return "";
@@ -6196,11 +6658,11 @@ function tiersOf(value, errors) {
6196
6658
  return [];
6197
6659
  }
6198
6660
  unknownKeys(item, TIER_KEYS2, path, errors);
6199
- const id6 = text3(item.id, `${path}.id`, errors, 80);
6661
+ const id6 = text4(item.id, `${path}.id`, errors, 80);
6200
6662
  if (id6 && !TIER.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
6201
6663
  if (seen.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
6202
6664
  seen.add(id6);
6203
- const name = text3(item.name, `${path}.name`, errors, 160);
6665
+ const name = text4(item.name, `${path}.name`, errors, 160);
6204
6666
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
6205
6667
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
6206
6668
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -6218,7 +6680,7 @@ function tiersOf(value, errors) {
6218
6680
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
6219
6681
  errors.push(`${path}.interval must be year or month`);
6220
6682
  }
6221
- const blurb = item.blurb === void 0 ? void 0 : text3(item.blurb, `${path}.blurb`, errors, 4e3);
6683
+ const blurb = item.blurb === void 0 ? void 0 : text4(item.blurb, `${path}.blurb`, errors, 4e3);
6222
6684
  return [{
6223
6685
  id: id6,
6224
6686
  name,
@@ -6276,7 +6738,7 @@ function policyOf(value, errors, tiers) {
6276
6738
  const result = {};
6277
6739
  for (const key3 of allowed2) {
6278
6740
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
6279
- result[key3] = text3(input[key3], `document.policy.${key3}`, errors);
6741
+ result[key3] = text4(input[key3], `document.policy.${key3}`, errors);
6280
6742
  }
6281
6743
  const tierPolicies = {};
6282
6744
  if (input.tierPolicies !== void 0) {
@@ -6291,8 +6753,8 @@ function policyOf(value, errors, tiers) {
6291
6753
  }
6292
6754
  unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
6293
6755
  tierPolicies[id6] = {
6294
- refundPolicyText: text3(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
6295
- ...policy.trustCopy !== void 0 ? { trustCopy: text3(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
6756
+ refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
6757
+ ...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
6296
6758
  };
6297
6759
  }
6298
6760
  }
@@ -6332,9 +6794,9 @@ function validateSignupControlDocument(input) {
6332
6794
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
6333
6795
  const source = recordOf(root.source);
6334
6796
  const target = recordOf(root.target);
6335
- const appId = text3(source?.appId, "document.source.appId", errors, 63);
6336
- const chapterId = text3(target?.chapterId, "document.target.chapterId", errors, 63);
6337
- const environment = text3(target?.environment, "document.target.environment", errors, 63);
6797
+ const appId = text4(source?.appId, "document.source.appId", errors, 63);
6798
+ const chapterId = text4(target?.chapterId, "document.target.chapterId", errors, 63);
6799
+ const environment = text4(target?.environment, "document.target.environment", errors, 63);
6338
6800
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
6339
6801
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
6340
6802
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -7513,7 +7975,7 @@ async function deliverAdmissionGrants(options) {
7513
7975
  }
7514
7976
 
7515
7977
  // src/worker-routes-network-admission.ts
7516
- var PATH2 = "/api/admin/network/admissions";
7978
+ var PATH4 = "/api/admin/network/admissions";
7517
7979
  var RETRY_PATH = "/api/admin/network/admissions/retry";
7518
7980
  var REVOKE_PATH = "/api/admin/network/admissions/revoke";
7519
7981
  var MUTATION_ID2 = /^[A-Za-z0-9_-]{16,128}$/;
@@ -7593,11 +8055,11 @@ async function parseBody(req) {
7593
8055
  }
7594
8056
  }
7595
8057
  var handleAdminNetworkAdmissions = async (req, url, env, ctx) => {
7596
- if (![PATH2, RETRY_PATH, REVOKE_PATH].includes(url.pathname)) return null;
8058
+ if (![PATH4, RETRY_PATH, REVOKE_PATH].includes(url.pathname)) return null;
7597
8059
  const auth = await gate2(req, env, ctx);
7598
8060
  if ("response" in auth) return auth.response;
7599
8061
  const db = auth.db;
7600
- if (url.pathname === PATH2 && req.method === "GET") {
8062
+ if (url.pathname === PATH4 && req.method === "GET") {
7601
8063
  const targetId2 = url.searchParams.get("targetId") ?? "";
7602
8064
  const target2 = targetFor(ctx, targetId2);
7603
8065
  if (!target2) return reject6(404, "unknown_target", "admission target is not configured");
@@ -7698,6 +8160,41 @@ async function retryLeaderAdmissionDeliveries(env, ctx, limit = 100) {
7698
8160
 
7699
8161
  // src/worker-routes-schedule.ts
7700
8162
  var import_calendar = require("@odla-ai/calendar");
8163
+
8164
+ // src/booking-notifications.ts
8165
+ async function bookingConfirmationVariables(db, group, app, meeting) {
8166
+ const startAt = formatInstant(meeting.startAt, meeting.timezone);
8167
+ const meetUrl = meeting.meetUrl ?? "", htmlLink = meeting.htmlLink ?? "";
8168
+ return {
8169
+ ...applicantVariables(app),
8170
+ ...await checkoutVariables(db, group, app),
8171
+ startAt,
8172
+ endAt: formatInstant(meeting.endAt, meeting.timezone),
8173
+ timezone: meeting.timezone,
8174
+ meetUrl,
8175
+ htmlLink,
8176
+ // The placeholders the seeded template used before these variables existed.
8177
+ meetingTime: startAt,
8178
+ meetingLink: meetUrl || htmlLink
8179
+ };
8180
+ }
8181
+ async function notifyBookingConfirmed(db, env, input) {
8182
+ const app = input.application;
8183
+ if (typeof app.email !== "string" || !app.email) return;
8184
+ try {
8185
+ await sendTemplated(lifecycleNotifyDeps(db, env), {
8186
+ group: emailGroupFrom(input.group),
8187
+ template: "prepEmail",
8188
+ to: app.email,
8189
+ vars: await bookingConfirmationVariables(db, input.group, app, input.meeting),
8190
+ dedupeKey: `prep:${String(app.id)}`,
8191
+ applicationId: String(app.id)
8192
+ });
8193
+ } catch {
8194
+ }
8195
+ }
8196
+
8197
+ // src/worker-routes-schedule.ts
7701
8198
  function errCode(err) {
7702
8199
  if (err && typeof err === "object") {
7703
8200
  const code = err.code;
@@ -7812,12 +8309,7 @@ async function bookSlot(req, env, ctx) {
7812
8309
  await db.transact([meetingOp, appOp]);
7813
8310
  const booked = { ...app, ...bookingAttrs };
7814
8311
  await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
7815
- if (typeof app.email === "string" && app.email) {
7816
- await sendTemplated(
7817
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL, now: () => Date.now(), newId: () => crypto.randomUUID() },
7818
- { group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
7819
- ).catch(() => void 0);
7820
- }
8312
+ await notifyBookingConfirmed(db, env, { group, application: app, meeting: { startAt, endAt, timezone: cfg.timezone, meetUrl, htmlLink } });
7821
8313
  return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
7822
8314
  }
7823
8315
  var handleSchedule = async (req, url, env, ctx) => {
@@ -7848,21 +8340,21 @@ var import_stripe7 = require("@odla-ai/stripe");
7848
8340
  var row = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
7849
8341
  var id2 = (value) => typeof value === "string" ? value : String(row(value).id ?? "");
7850
8342
  var providerId2 = (value, prefix) => new RegExp(`^${prefix}_[A-Za-z0-9]+$`).test(value);
7851
- function fail5(code = "refund_target_mismatch") {
8343
+ function fail6(code = "refund_target_mismatch") {
7852
8344
  throw Object.assign(new Error(code), { code });
7853
8345
  }
7854
8346
  async function refundSubscriptionPayment(secretKey, input) {
7855
- if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub")) fail5();
8347
+ if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub")) fail6();
7856
8348
  const call = async (method, path, params, key3) => {
7857
8349
  const result = await (0, import_stripe7.stripeRequest)({ secretKey }, method, path, params, key3 ? { idempotencyKey: key3 } : {});
7858
- if (!result.ok) fail5("stripe_error");
7859
- if (!result.body || typeof result.body !== "object" || Array.isArray(result.body)) fail5("stripe_invalid_response");
8350
+ if (!result.ok) fail6("stripe_error");
8351
+ if (!result.body || typeof result.body !== "object" || Array.isArray(result.body)) fail6("stripe_invalid_response");
7860
8352
  return result.body;
7861
8353
  };
7862
8354
  const sub = await call("GET", `/v1/subscriptions/${input.subscriptionId}`);
7863
- if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId) fail5();
8355
+ if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId) fail6();
7864
8356
  for (const [key3, value] of Object.entries(input.metadata ?? {})) {
7865
- if (row(sub.metadata)[key3] !== value) fail5();
8357
+ if (row(sub.metadata)[key3] !== value) fail6();
7866
8358
  }
7867
8359
  let cursor;
7868
8360
  let earliest;
@@ -7875,55 +8367,55 @@ async function refundSubscriptionPayment(secretKey, input) {
7875
8367
  limit: 100,
7876
8368
  ...cursor ? { starting_after: cursor } : {}
7877
8369
  });
7878
- if (!Array.isArray(list.data) || typeof list.has_more !== "boolean") fail5("stripe_invalid_response");
8370
+ if (!Array.isArray(list.data) || typeof list.has_more !== "boolean") fail6("stripe_invalid_response");
7879
8371
  for (const value of list.data) {
7880
8372
  const invoice2 = row(value);
7881
8373
  const subscription = invoice2.subscription ?? row(row(invoice2.parent).subscription_details).subscription;
7882
- 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();
8374
+ 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();
7883
8375
  seen.add(id2(invoice2));
7884
8376
  if (Number(invoice2.amount_paid) > 0 && (!earliest || Number(invoice2.created) < Number(earliest.created))) earliest = invoice2;
7885
8377
  }
7886
8378
  if (!list.has_more) break;
7887
- if (!list.data.length || page === 9) fail5("refund_history_incomplete");
8379
+ if (!list.data.length || page === 9) fail6("refund_history_incomplete");
7888
8380
  cursor = id2(list.data[list.data.length - 1]);
7889
8381
  }
7890
- if (!earliest) fail5("no_charge");
8382
+ if (!earliest) fail6("no_charge");
7891
8383
  const invoice = earliest;
7892
- if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents) fail5();
8384
+ if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents) fail6();
7893
8385
  let chargeId = id2(invoice.charge);
7894
8386
  let paymentIntentId = id2(invoice.payment_intent);
7895
8387
  if (!chargeId && !paymentIntentId) {
7896
8388
  const payments = await call("GET", "/v1/invoice_payments", { invoice: id2(invoice), status: "paid", limit: 100 });
7897
- if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1) fail5("refund_payment_ambiguous");
8389
+ if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1) fail6("refund_payment_ambiguous");
7898
8390
  const payment = row(payments.data[0]);
7899
- if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent") fail5();
8391
+ if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent") fail6();
7900
8392
  paymentIntentId = id2(row(payment.payment).payment_intent);
7901
8393
  }
7902
8394
  if (!chargeId) {
7903
- if (!providerId2(paymentIntentId, "pi")) fail5();
8395
+ if (!providerId2(paymentIntentId, "pi")) fail6();
7904
8396
  const payment = await call("GET", `/v1/payment_intents/${paymentIntentId}`);
7905
- if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded") fail5();
8397
+ if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded") fail6();
7906
8398
  chargeId = id2(payment.latest_charge);
7907
8399
  }
7908
- if (!providerId2(chargeId, "ch")) fail5();
8400
+ if (!providerId2(chargeId, "ch")) fail6();
7909
8401
  const charge = await call("GET", `/v1/charges/${chargeId}`);
7910
- 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();
7911
- if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true)) fail5();
8402
+ 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();
8403
+ if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true)) fail6();
7912
8404
  if (charge.amount_refunded !== charge.amount) {
7913
8405
  const refund = await call("POST", "/v1/refunds", {
7914
8406
  charge: chargeId,
7915
8407
  ...input.metadata ? { metadata: input.metadata } : {}
7916
8408
  }, `chapter-initial-refund:${input.subscriptionId}:${chargeId}`);
7917
- 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");
8409
+ 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");
7918
8410
  }
7919
8411
  let subscriptionCanceled = sub.status === "canceled";
7920
8412
  if (input.cancelSubscription !== false && !subscriptionCanceled) {
7921
8413
  const canceled = await call("DELETE", `/v1/subscriptions/${input.subscriptionId}`);
7922
- if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled") fail5("refund_cancel_incomplete");
8414
+ if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled") fail6("refund_cancel_incomplete");
7923
8415
  subscriptionCanceled = true;
7924
8416
  }
7925
8417
  const confirmed = await call("GET", `/v1/charges/${chargeId}`);
7926
- 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");
8418
+ 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");
7927
8419
  return { refundedCents: Number(charge.amount), subscriptionCanceled };
7928
8420
  }
7929
8421
 
@@ -7989,7 +8481,7 @@ async function stripeCall(sk, method, path, params, idempotencyKey) {
7989
8481
  idempotencyKey ? { idempotencyKey } : {}
7990
8482
  );
7991
8483
  }
7992
- function fail6(op, r) {
8484
+ function fail7(op, r) {
7993
8485
  const err = new Error(`stripe ${op} failed: ${r.status}`);
7994
8486
  err.code = "stripe_error";
7995
8487
  throw err;
@@ -8039,7 +8531,7 @@ function createStripeProvider(config) {
8039
8531
  { email: input.email, name: input.name, metadata: meta },
8040
8532
  `cus:${input.applicationId}`
8041
8533
  );
8042
- if (!cust.ok) fail6("customer create", cust);
8534
+ if (!cust.ok) fail7("customer create", cust);
8043
8535
  if (typeof cust.body.id !== "string" || !/^cus_[A-Za-z0-9]+$/.test(cust.body.id)) subscriptionContractMismatch();
8044
8536
  customerId = cust.body.id;
8045
8537
  }
@@ -8071,7 +8563,7 @@ function createStripeProvider(config) {
8071
8563
  // retry between create and the db write can't orphan a second one.
8072
8564
  `sub:${input.applicationId}${input.purchaseAttemptId ? `:${input.purchaseAttemptId}` : ""}`
8073
8565
  );
8074
- if (!sub.ok) fail6("subscription create", sub);
8566
+ if (!sub.ok) fail7("subscription create", sub);
8075
8567
  if (input.billingTerms?.discount?.duration === "once" && Array.isArray(sub.body.discounts) && sub.body.discounts.length === 0) {
8076
8568
  const subscriptionId = sub.body.id;
8077
8569
  if (typeof subscriptionId !== "string" || !/^sub_[A-Za-z0-9]+$/.test(subscriptionId)) subscriptionContractMismatch();
@@ -8081,14 +8573,14 @@ function createStripeProvider(config) {
8081
8573
  "expand[2]": "discounts",
8082
8574
  "expand[3]": "latest_invoice.discounts"
8083
8575
  });
8084
- if (!expanded.ok) fail6("subscription invoice evidence", expanded);
8576
+ if (!expanded.ok) fail7("subscription invoice evidence", expanded);
8085
8577
  if (expanded.body.id !== subscriptionId) subscriptionContractMismatch();
8086
8578
  sub = expanded;
8087
8579
  }
8088
8580
  verifySubscriptionResult(sub.body, input, customerId, meta);
8089
8581
  const invoice = sub.body.latest_invoice;
8090
8582
  const clientSecret2 = clientSecretOf(sub.body);
8091
- if (!clientSecret2) fail6("subscription confirmation-secret missing", sub);
8583
+ if (!clientSecret2) fail7("subscription confirmation-secret missing", sub);
8092
8584
  await tagSubscriptionPayment(
8093
8585
  sk,
8094
8586
  invoice,
@@ -8772,15 +9264,16 @@ async function readNamedSeatOffer(db, input, now) {
8772
9264
  const offering = membershipOfferingVersion(member.offeringSnapshot);
8773
9265
  const seat = member.namedSeatId ? await membershipLoad(db, "networkSeat", String(member.namedSeatId)) : void 0;
8774
9266
  if (seat) requireMembershipScope(seat, input);
9267
+ const purchaseEndsAt = seatPurchaseDeadline(offering, member);
8775
9268
  const offer = {
8776
9269
  membershipId: id6,
8777
9270
  amountCents: offering.seatPriceCents,
8778
9271
  currency: offering.currency,
8779
9272
  interval: offering.interval,
8780
9273
  termEndsAt: Number(member.termEndsAt),
8781
- purchaseEndsAt: Number(member.seatPurchaseEndsAt),
9274
+ purchaseEndsAt,
8782
9275
  autoRenew: member.autoRenew === true,
8783
- 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))),
9276
+ 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))),
8784
9277
  ...seat ? { seat: {
8785
9278
  seatId: String(seat.id),
8786
9279
  status: String(seat.status),
@@ -8788,7 +9281,7 @@ async function readNamedSeatOffer(db, input, now) {
8788
9281
  recipientEmail: String(seat.recipientEmailNormalized)
8789
9282
  } } : {}
8790
9283
  };
8791
- return { ...offer, digest: await namedSeatOfferDigest(offer) };
9284
+ return { ...offer, digest: await namedSeatOfferDigest(offer), purchaseWindowed: offering.seatPurchaseWindowCalendarMonths !== void 0 };
8792
9285
  }
8793
9286
 
8794
9287
  // src/named-seat-request.ts
@@ -8912,6 +9405,58 @@ var handleNamedSeat = async (req, url, env, ctx) => {
8912
9405
  }
8913
9406
  };
8914
9407
 
9408
+ // src/named-seat-accepted-template.ts
9409
+ var SEAT_ACCEPTED_TEMPLATE = {
9410
+ enabled: true,
9411
+ subject: "{{recipientName}} accepted your {{chapterName}} seat",
9412
+ 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}}"
9413
+ };
9414
+
9415
+ // src/named-seat-accepted-notice.ts
9416
+ var text5 = (value) => typeof value === "string" ? value : "";
9417
+ async function notifyNamedSeatAccepted(db, env, ctx, input) {
9418
+ const purchaser = await membershipLoad(db, "applications", input.primaryApplicationId);
9419
+ if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text5(purchaser.email)) {
9420
+ return { sent: false, reason: "purchaser-missing" };
9421
+ }
9422
+ const group = await membershipLoad(db, "groups", ctx.chapter.id);
9423
+ if (!group) return { sent: false, reason: "group-missing" };
9424
+ const config = emailGroupFrom(group);
9425
+ config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
9426
+ const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
9427
+ return sendTemplated({
9428
+ db,
9429
+ envName: env.ODLA_ENV,
9430
+ sender: env.SEND_EMAIL,
9431
+ from: env.EMAIL_FROM,
9432
+ debugEmail: env.ODLA_DEBUG_EMAIL,
9433
+ now: () => Date.now(),
9434
+ newId: () => crypto.randomUUID()
9435
+ }, {
9436
+ group: config,
9437
+ template: "seatAccepted",
9438
+ to: text5(purchaser.email),
9439
+ dedupeKey: `named-seat-accepted:${input.seatId}`,
9440
+ applicationId: input.primaryApplicationId,
9441
+ vars: {
9442
+ firstName: text5(purchaser.firstName),
9443
+ purchaserName,
9444
+ recipientName: input.recipientName,
9445
+ tierName: await tierName(db, group, input.skuId),
9446
+ chapterName: ctx.chapter.name,
9447
+ membersUrl: `${input.origin}/members/`
9448
+ }
9449
+ });
9450
+ }
9451
+ async function tierName(db, group, skuId) {
9452
+ try {
9453
+ const tier = (await loadTiers(db, group)).find((candidate) => candidate.id === skuId);
9454
+ if (tier) return tier.name;
9455
+ } catch {
9456
+ }
9457
+ return skuId;
9458
+ }
9459
+
8915
9460
  // src/worker-routes-named-seat-recipient.ts
8916
9461
  var handleNamedSeatRecipient = async (req, url, env, ctx) => {
8917
9462
  if (url.pathname === "/api/named-seat/invitation") {
@@ -9029,6 +9574,13 @@ var handleNamedSeatRecipient = async (req, url, env, ctx) => {
9029
9574
  const current = await membershipLoad(db, "applications", body.applicationId), group = await membershipLoad(db, "groups", ctx.chapter.id);
9030
9575
  if (!current || current.namedSeatClaimPending !== false || !current.namedSeatAcceptedAt || !group) throw new Error("seat application reconciliation pending");
9031
9576
  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");
9577
+ await notifyNamedSeatAccepted(db, env, ctx, {
9578
+ seatId: body.seatId,
9579
+ primaryApplicationId: invitation.primaryApplicationId,
9580
+ recipientName: invitation.recipientName,
9581
+ skuId: invitation.skuId,
9582
+ origin: url.origin
9583
+ }).catch(() => console.warn("named_seat_accepted_notice_incomplete", { seatId: body.seatId }));
9032
9584
  await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) }).catch(() => void 0);
9033
9585
  const state = await continueJoin(db, ctx, env, url.origin, current, group);
9034
9586
  if (!state) throw new Error("seat Journey reconciliation pending");
@@ -9389,8 +9941,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
9389
9941
  };
9390
9942
 
9391
9943
  // src/crm-account-repair.ts
9392
- async function repairAccountPeoplePage(deps2, after) {
9393
- const users = (await deps2.db.query({ $users: { $: {
9944
+ async function repairAccountPeoplePage(deps, after) {
9945
+ const users = (await deps.db.query({ $users: { $: {
9394
9946
  ...after ? { where: { id: { $gt: after } } } : {},
9395
9947
  order: { id: "asc" },
9396
9948
  limit: 50
@@ -9399,7 +9951,7 @@ async function repairAccountPeoplePage(deps2, after) {
9399
9951
  const errors = [];
9400
9952
  for (const user of users) {
9401
9953
  try {
9402
- if (await syncAccountToCrm(deps2, String(user.id))) synced++;
9954
+ if (await syncAccountToCrm(deps, String(user.id))) synced++;
9403
9955
  } catch (error) {
9404
9956
  const message = error instanceof Error ? error.message : "";
9405
9957
  errors.push({ userId: String(user.id), code: /^crm_projection_identity_(ambiguous|archived|conflict)$/.test(message) ? message : "crm_projection_unavailable" });
@@ -9411,8 +9963,8 @@ async function repairAccountPeoplePage(deps2, after) {
9411
9963
  // src/crm-application-repair.ts
9412
9964
  var import_crm11 = require("@odla-ai/crm");
9413
9965
  async function repairApplicationLink(db, chapter, recordId, actorId) {
9414
- const deps2 = { crm: chapter.crm, db };
9415
- const record4 = await (0, import_crm11.getRecord)(deps2, recordId);
9966
+ const deps = { crm: chapter.crm, db };
9967
+ const record4 = await (0, import_crm11.getRecord)(deps, recordId);
9416
9968
  if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
9417
9969
  if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
9418
9970
  const fields = record4.fields;
@@ -9430,7 +9982,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
9430
9982
  }
9431
9983
  const app = [...applications.values()][0];
9432
9984
  if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
9433
- await (0, import_crm11.updateRecord)(deps2, {
9985
+ await (0, import_crm11.updateRecord)(deps, {
9434
9986
  id: recordId,
9435
9987
  input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
9436
9988
  expectedUpdatedAt: record4.updatedAt,
@@ -10128,15 +10680,6 @@ async function loadGroup2(db, id6) {
10128
10680
  return groups?.[0] ?? null;
10129
10681
  }
10130
10682
  var str2 = (v, d = "") => typeof v === "string" ? v : d;
10131
- var notifyDeps = (db, env) => ({
10132
- db,
10133
- debugEmail: env.ODLA_DEBUG_EMAIL,
10134
- envName: env.ODLA_ENV,
10135
- sender: env.SEND_EMAIL,
10136
- from: env.EMAIL_FROM,
10137
- now: () => Date.now(),
10138
- newId: () => crypto.randomUUID()
10139
- });
10140
10683
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
10141
10684
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
10142
10685
  const got = await gate5(req, env, ctx);
@@ -10155,6 +10698,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10155
10698
  groupId: group.id,
10156
10699
  name: group.name,
10157
10700
  replyTo: str2(group.replyTo),
10701
+ fromName: str2(group.fromName),
10158
10702
  notificationEmail: str2(group.notificationEmail),
10159
10703
  debugEmail: str2(group.debugEmail),
10160
10704
  emailTemplates,
@@ -10181,11 +10725,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10181
10725
  for (const key3 of EMAIL_TEMPLATE_NAMES) {
10182
10726
  const t = templates[key3];
10183
10727
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
10184
- const text6 = typeof t?.text === "string" ? t.text : "";
10185
- if (!subject || !text6.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
10728
+ const text8 = typeof t?.text === "string" ? t.text : "";
10729
+ if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
10186
10730
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
10187
- if (text6.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
10188
- clean[key3] = { subject, text: text6, enabled: t?.enabled !== false };
10731
+ if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
10732
+ clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
10189
10733
  }
10190
10734
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10191
10735
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -10194,13 +10738,15 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
10194
10738
  if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
10195
10739
  if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
10196
10740
  if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
10741
+ const fromName = str2(body.fromName).trim();
10742
+ 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);
10197
10743
  const commitmentText = str2(body.commitmentText);
10198
10744
  const normsText = str2(body.normsText);
10199
10745
  if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
10200
10746
  const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
10201
10747
  await db.transact([
10202
10748
  { t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
10203
- { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail } }
10749
+ { t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
10204
10750
  ]);
10205
10751
  return json({ ok: true });
10206
10752
  };
@@ -10249,11 +10795,11 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
10249
10795
  }
10250
10796
  const group = await loadGroup2(db, ctx.chapter.id);
10251
10797
  if (!group) return json({ error: "not found" }, 404);
10252
- const res = await sendTemplated(notifyDeps(db, env), {
10798
+ const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
10253
10799
  group: emailGroupFrom(group),
10254
10800
  template,
10255
10801
  to: str2(group.notificationEmail),
10256
- vars: { firstName: "Sample", lastName: "Person", email: "sample@example.com", phone: "(555) 010-0100", state: "CA", adminUrl: `${url.origin}/admin/`, membersUrl: `${url.origin}/members/` },
10802
+ vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
10257
10803
  dedupeKey: `test:${template}:${Date.now()}`,
10258
10804
  force: true
10259
10805
  });
@@ -10280,7 +10826,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
10280
10826
  const app = data.applications?.[0] ?? null;
10281
10827
  if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
10282
10828
  const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
10283
- 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;
10829
+ 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;
10284
10830
  const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
10285
10831
  let mailBody = typeof r.body === "string" ? r.body : null;
10286
10832
  if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
@@ -10304,6 +10850,28 @@ var handleAdminComms = async (req, url, env, ctx) => {
10304
10850
  var import_crm13 = require("@odla-ai/crm");
10305
10851
  var import_db9 = require("@odla-ai/db");
10306
10852
 
10853
+ // src/network-edge.ts
10854
+ var EDGE_UNPAIRED = "edge_unpaired";
10855
+ function pairingCommand(leaderId, targetId, env) {
10856
+ const scope2 = env ? ` --env ${env}` : "";
10857
+ return `odla-ai chapter edge pair --leader ${leaderId} --follower ${targetId}${scope2}`;
10858
+ }
10859
+ function edgeUnpaired(leaderId, target, env) {
10860
+ return {
10861
+ code: EDGE_UNPAIRED,
10862
+ target: target.id,
10863
+ secretName: target.secretName,
10864
+ message: `edge to "${target.id}" is unpaired: leader vault slot "${target.secretName}" is empty`,
10865
+ hint: pairingCommand(leaderId, target.id, env)
10866
+ };
10867
+ }
10868
+ function unpairedResponse(unpaired) {
10869
+ return new Response(JSON.stringify({ error: unpaired.message, ...unpaired }), {
10870
+ status: 502,
10871
+ headers: { "content-type": "application/json" }
10872
+ });
10873
+ }
10874
+
10307
10875
  // src/worker-network-push.ts
10308
10876
  var import_crm12 = require("@odla-ai/crm");
10309
10877
  var import_db8 = require("@odla-ai/db");
@@ -10316,15 +10884,15 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
10316
10884
  newId: () => crypto.randomUUID()
10317
10885
  };
10318
10886
  if (!secret) {
10319
- const error = `vault secret "${target.secretName}" is missing`;
10887
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
10320
10888
  await (0, import_crm12.recordDeliveryAttempt)(crmDeps3, {
10321
10889
  recordId: record4.id,
10322
10890
  targetId: target.id,
10323
10891
  status: "failed",
10324
10892
  payloadVersion: 2,
10325
- error
10893
+ error: unpaired.message
10326
10894
  });
10327
- return { id: target.id, name: target.name, ok: false, error };
10895
+ return { id: target.id, name: target.name, ok: false, error: unpaired.message, code: unpaired.code, hint: unpaired.hint };
10328
10896
  }
10329
10897
  let payload;
10330
10898
  try {
@@ -10465,17 +11033,20 @@ async function snapshotOne(db, env, ctx, target) {
10465
11033
  publicSite: { available: null }
10466
11034
  };
10467
11035
  const secret = await getVaultSecret(db, target.secretName);
10468
- if (!secret) return {
10469
- ...base,
10470
- available: false,
10471
- transport: {
10472
- kind: transportKind,
10473
- ...target.binding ? { binding: target.binding } : {},
10474
- reachable: null,
10475
- error: { code: "edge_secret_missing", message: "edge secret is missing" }
10476
- },
10477
- error: "edge secret is missing"
10478
- };
11036
+ if (!secret) {
11037
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
11038
+ return {
11039
+ ...base,
11040
+ available: false,
11041
+ transport: {
11042
+ kind: transportKind,
11043
+ ...target.binding ? { binding: target.binding } : {},
11044
+ reachable: null,
11045
+ error: unpaired
11046
+ },
11047
+ error: unpaired.message
11048
+ };
11049
+ }
10479
11050
  const destination = new URL("/api/network/snapshot", target.url);
10480
11051
  try {
10481
11052
  const transport = resolveNetworkTargetTransport(env, target);
@@ -10950,7 +11521,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10950
11521
  if (hasFields && Object.keys(fields).some((name) => !capabilities.edit?.fields.some((field) => field.id === name))) {
10951
11522
  return json({ error: "use the verified identity or application workflow for these fields", code: "field_forbidden" }, 403);
10952
11523
  }
10953
- const deps2 = { crm: ctx.chapter.crm, db };
11524
+ const deps = { crm: ctx.chapter.crm, db };
10954
11525
  if (hasFields && ctx.chapter.mode === "chapter" && record4.type === "person") {
10955
11526
  const updated2 = await updateChapterPerson({
10956
11527
  db,
@@ -10969,7 +11540,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10969
11540
  record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
10970
11541
  });
10971
11542
  }
10972
- const updated = hasFields ? await (0, import_crm17.updateRecord)(deps2, {
11543
+ const updated = hasFields ? await (0, import_crm17.updateRecord)(deps, {
10973
11544
  id: record4.id,
10974
11545
  input: fields,
10975
11546
  mutationId: activityId,
@@ -10980,7 +11551,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
10980
11551
  authorId: `network:${verified.sender}`,
10981
11552
  meta: auditMeta
10982
11553
  }
10983
- }) : await (0, import_crm17.setStage)(deps2, {
11554
+ }) : await (0, import_crm17.setStage)(deps, {
10984
11555
  id: record4.id,
10985
11556
  to: input.stage,
10986
11557
  authorId: `network:${verified.sender}`,
@@ -11080,7 +11651,10 @@ function targetFor2(ctx, id6) {
11080
11651
  }
11081
11652
  async function readTargetRecords(db, env, ctx, target, query) {
11082
11653
  const secret = await getVaultSecret(db, target.secretName);
11083
- if (!secret) return { ok: false, error: "edge secret is missing" };
11654
+ if (!secret) {
11655
+ const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
11656
+ return { ok: false, error: unpaired.message, unpaired };
11657
+ }
11084
11658
  const destination = new URL("/api/network/records", target.url);
11085
11659
  destination.search = query.toString();
11086
11660
  try {
@@ -11117,7 +11691,7 @@ async function mutateTargetRecord(db, env, ctx, target, input) {
11117
11691
  return json({ error: "stage transitions are not enabled for this target", code: "stage_forbidden" }, 403);
11118
11692
  }
11119
11693
  const secret = await getVaultSecret(db, target.secretName);
11120
- if (!secret) return json({ error: "edge secret is missing", code: "edge_unavailable" }, 502);
11694
+ if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
11121
11695
  const destination = new URL("/api/network/records", target.url);
11122
11696
  const body = JSON.stringify(input);
11123
11697
  try {
@@ -11199,7 +11773,9 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
11199
11773
  if (window.search) query.set("search", window.search);
11200
11774
  if (window.stage) query.set("stage", window.stage);
11201
11775
  const result = await readTargetRecords(got.db, env, ctx, target, query);
11202
- if (!result.ok) return json({ error: result.error }, result.status ?? 502);
11776
+ if (!result.ok) {
11777
+ return result.unpaired ? unpairedResponse(result.unpaired) : json({ error: result.error }, result.status ?? 502);
11778
+ }
11203
11779
  if ((window.stage || url.searchParams.has("sort")) && (!result.page.appliedQuery || result.page.appliedQuery.sort !== window.sortId || result.page.appliedQuery.stage !== window.stage)) {
11204
11780
  return json({ error: "follower did not apply the requested record query" }, 502);
11205
11781
  }
@@ -11451,7 +12027,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
11451
12027
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
11452
12028
  }
11453
12029
  const secret = await getVaultSecret(got.db, target.secretName);
11454
- if (!secret) return json({ error: "edge secret is missing" }, 502);
12030
+ if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
11455
12031
  const destination = new URL("/api/network/shared-notes", target.url);
11456
12032
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
11457
12033
  try {
@@ -11505,11 +12081,11 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
11505
12081
  if (raw2.approved !== true || raw2.isRealPerson !== true) errors.push("import must be approved and identify a real person");
11506
12082
  const sourceSystem = typeof raw2.sourceSystem === "string" ? raw2.sourceSystem.trim().toLowerCase() : "";
11507
12083
  const sourceRecordId = typeof raw2.sourceRecordId === "string" ? raw2.sourceRecordId.trim() : "";
11508
- const provenance = typeof raw2.provenance === "string" ? raw2.provenance.trim() : "";
12084
+ const provenance2 = typeof raw2.provenance === "string" ? raw2.provenance.trim() : "";
11509
12085
  const displayName = typeof raw2.displayName === "string" ? raw2.displayName.trim() : "";
11510
12086
  if (!TOKEN.test(sourceSystem) || /(?:^|[._:-])(fixture|test|development|dev)(?:$|[._:-])/.test(sourceSystem)) errors.push("sourceSystem must name an approved non-development external system");
11511
12087
  if (!sourceRecordId || sourceRecordId.length > 200) errors.push("sourceRecordId is required and bounded");
11512
- if (!provenance || provenance.length > 500) errors.push("provenance is required and bounded");
12088
+ if (!provenance2 || provenance2.length > 500) errors.push("provenance is required and bounded");
11513
12089
  if (!displayName || displayName.length > 200) errors.push("displayName is required and bounded");
11514
12090
  if (FIXTURE_MARKER.test(sourceRecordId) || FIXTURE_MARKER.test(displayName)) {
11515
12091
  errors.push("fixture, test, sample, and dummy rows cannot become network Persons");
@@ -11577,7 +12153,7 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
11577
12153
  sourceKind: "external_reimport",
11578
12154
  sourceSystem,
11579
12155
  sourceRecordId,
11580
- provenance,
12156
+ provenance: provenance2,
11581
12157
  approved: true,
11582
12158
  isRealPerson: true,
11583
12159
  displayName,
@@ -12282,7 +12858,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
12282
12858
  }
12283
12859
 
12284
12860
  // src/worker-routes-network-person-registry.ts
12285
- var PATH3 = "/api/admin/network-person-registry";
12861
+ var PATH5 = "/api/admin/network-person-registry";
12286
12862
  var MAX_BODY2 = 8 * 1024 * 1024;
12287
12863
  var MUTATION_ID4 = /^[A-Za-z0-9_-]{16,128}$/;
12288
12864
  var positiveRevision = (value) => {
@@ -12307,7 +12883,7 @@ function registryError(error) {
12307
12883
  return null;
12308
12884
  }
12309
12885
  var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
12310
- if (url.pathname !== PATH3) return null;
12886
+ if (url.pathname !== PATH5) return null;
12311
12887
  if (!ctx.chapter.networkPersonRegistry) return json({ error: "not found" }, 404);
12312
12888
  if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
12313
12889
  if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY2) {
@@ -12319,9 +12895,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
12319
12895
  if (req.method === "POST") {
12320
12896
  let body;
12321
12897
  try {
12322
- const text6 = await req.text();
12323
- if (new TextEncoder().encode(text6).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
12324
- const parsed = JSON.parse(text6);
12898
+ const text8 = await req.text();
12899
+ if (new TextEncoder().encode(text8).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
12900
+ const parsed = JSON.parse(text8);
12325
12901
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
12326
12902
  body = parsed;
12327
12903
  } catch {
@@ -12490,7 +13066,7 @@ var import_db13 = require("@odla-ai/db");
12490
13066
  // src/network-commercial-parity.ts
12491
13067
  var DIGEST = /^[0-9a-f]{64}$/;
12492
13068
  var MAX_RECORDS = 500;
12493
- var text4 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
13069
+ var text6 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
12494
13070
  function parityMessage(partition, kind, value) {
12495
13071
  return [
12496
13072
  "chapter-commercial-parity",
@@ -12522,18 +13098,18 @@ async function digestWithKey(key3, partition, kind, value) {
12522
13098
  async function buildChapterCommercialParitySnapshot(input) {
12523
13099
  const requestedMax = input.maxRecords ?? MAX_RECORDS;
12524
13100
  const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
12525
- const eligible = input.rows.filter((row2) => text4(row2.id) && (text4(row2.stripeCustomerId) || text4(row2.stripeSubscriptionId)));
13101
+ const eligible = input.rows.filter((row2) => text6(row2.id) && (text6(row2.stripeCustomerId) || text6(row2.stripeSubscriptionId)));
12526
13102
  const selected2 = eligible.slice(0, maxRecords);
12527
13103
  const key3 = await parityKey(input.secret);
12528
13104
  const records = await Promise.all(selected2.map(async (row2) => {
12529
- const applicationId = text4(row2.id);
12530
- const customerId = text4(row2.stripeCustomerId);
12531
- const subscriptionId = text4(row2.stripeSubscriptionId);
13105
+ const applicationId = text6(row2.id);
13106
+ const customerId = text6(row2.stripeCustomerId);
13107
+ const subscriptionId = text6(row2.stripeSubscriptionId);
12532
13108
  return {
12533
13109
  applicationDigest: await digestWithKey(key3, input.partition, "application", applicationId),
12534
13110
  customerDigest: customerId ? await digestWithKey(key3, input.partition, "customer", customerId) : null,
12535
13111
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.partition, "subscription", subscriptionId) : null,
12536
- status: text4(row2.status, 64),
13112
+ status: text6(row2.status, 64),
12537
13113
  canceled: row2.canceled === true
12538
13114
  };
12539
13115
  }));
@@ -12559,7 +13135,7 @@ function validSnapshot(snapshot, source) {
12559
13135
  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;
12560
13136
  const seen = /* @__PURE__ */ new Set();
12561
13137
  for (const record4 of records) {
12562
- 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;
13138
+ 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;
12563
13139
  const key3 = recordKey(record4);
12564
13140
  if (seen.has(key3)) return false;
12565
13141
  seen.add(key3);
@@ -12570,16 +13146,16 @@ async function reconcileCommercialParitySnapshot(input) {
12570
13146
  if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
12571
13147
  const sourceKey = networkCommercialSourceKey(input.source);
12572
13148
  const key3 = await parityKey(input.secret);
12573
- const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text4(row2.applicationId) && text4(row2.objectId)).map(async (row2) => {
12574
- const customerId = text4(row2.customerId);
12575
- const subscriptionId = text4(row2.subscriptionId) ?? text4(row2.objectId);
13149
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text6(row2.applicationId) && text6(row2.objectId)).map(async (row2) => {
13150
+ const customerId = text6(row2.customerId);
13151
+ const subscriptionId = text6(row2.subscriptionId) ?? text6(row2.objectId);
12576
13152
  return {
12577
13153
  applicationDigest: await digestWithKey(key3, input.snapshot.partition, "application", row2.applicationId),
12578
13154
  customerDigest: customerId ? await digestWithKey(key3, input.snapshot.partition, "customer", customerId) : null,
12579
13155
  subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.snapshot.partition, "subscription", subscriptionId) : null,
12580
13156
  status: null,
12581
13157
  canceled: false,
12582
- stripeStatus: text4(row2.status, 64),
13158
+ stripeStatus: text6(row2.status, 64),
12583
13159
  stripeCanceled: row2.status === "canceled"
12584
13160
  };
12585
13161
  }));
@@ -12705,7 +13281,7 @@ var handleNetworkCommercialParity = async (req, url, env, ctx) => {
12705
13281
  async function reconcileTarget(db, env, ctx, target, projection) {
12706
13282
  const base = { targetId: target.id, name: target.name, url: target.url };
12707
13283
  const secret = await getVaultSecret(db, target.secretName);
12708
- if (!secret) return { ...base, available: false, ok: false, error: { code: "edge_secret_missing" } };
13284
+ if (!secret) return { ...base, available: false, ok: false, error: edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV) };
12709
13285
  const destination = new URL(PARITY_PATH, target.url);
12710
13286
  try {
12711
13287
  const transport = resolveNetworkTargetTransport(env, target);
@@ -12861,11 +13437,23 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
12861
13437
  const event = await (0, import_stripe11.constructStripeEvent)(body, signature, secret);
12862
13438
  const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
12863
13439
  if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
12864
- const result = await ingestNetworkCommercialFact(db, ctx.chapter.networkCommercialReadModel.sources, fact, Date.now(), await stripePayloadDigest(body));
13440
+ const result = await ingestNetworkCommercialFact(
13441
+ db,
13442
+ ctx.chapter.networkCommercialReadModel.sources,
13443
+ fact,
13444
+ Date.now(),
13445
+ await stripePayloadDigest(body),
13446
+ networkCommercialAlerts(ctx, WEBHOOK_PATH)
13447
+ );
12865
13448
  if (result.status === "collision") {
12866
13449
  console.error("network commercial event collision", JSON.stringify({ eventId: fact.eventId, eventType: fact.eventType }));
12867
13450
  }
12868
- return json({ ok: true, status: result.status, eventId: fact.eventId });
13451
+ return json({
13452
+ ok: true,
13453
+ status: result.status,
13454
+ eventId: fact.eventId,
13455
+ ...result.status === "replayed" && result.resolvedCollision ? { resolvedCollision: true } : {}
13456
+ });
12869
13457
  } catch (error) {
12870
13458
  if (error instanceof import_stripe11.StripeError) return json({ error: error.message, code: error.code }, 400);
12871
13459
  throw error;
@@ -12889,18 +13477,39 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
12889
13477
  } catch {
12890
13478
  return json({ error: "invalid JSON body" }, 400);
12891
13479
  }
13480
+ const alerts = networkCommercialAlerts(ctx, ADMIN_PATH2);
12892
13481
  if (parsed.command === "recover-event") {
12893
13482
  try {
12894
13483
  return json(await recoverNetworkCommercialEvent(
12895
13484
  db,
12896
13485
  ctx.chapter.networkCommercialReadModel.sources,
12897
13486
  String(parsed.eventId ?? ""),
12898
- got.user.userId
13487
+ got.user.userId,
13488
+ Date.now(),
13489
+ alerts
12899
13490
  ));
12900
13491
  } catch (error) {
12901
13492
  return json({ error: error instanceof Error ? error.message : "event recovery failed" }, 409);
12902
13493
  }
12903
13494
  }
13495
+ if (parsed.command === "resolve") {
13496
+ if (typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) return json({ error: "resolve requires a valid mutationId" }, 400);
13497
+ const reason = typeof parsed.reason === "string" ? parsed.reason.trim() : "";
13498
+ if (!reason || reason.length > 500) return json({ error: "resolve requires a reason of at most 500 characters" }, 400);
13499
+ try {
13500
+ const result = await resolveNetworkCommercialCollision(
13501
+ db,
13502
+ ctx.chapter.networkCommercialReadModel.sources,
13503
+ { eventId: String(parsed.eventId ?? ""), mutationId: parsed.mutationId, actorUserId: got.user.userId, reason },
13504
+ Date.now(),
13505
+ alerts
13506
+ );
13507
+ return json({ resolved: true, eventId: result.eventId, reviewId: result.reviewId, ...safeProjection(result.projection) });
13508
+ } catch (error) {
13509
+ const code = error.code;
13510
+ return json({ error: error instanceof Error ? error.message : "resolve failed", code }, code === "invalid_event" ? 400 : 409);
13511
+ }
13512
+ }
12904
13513
  if (parsed.command === "reconcile-chapters") {
12905
13514
  const projection2 = await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources);
12906
13515
  return json(await reconcileChapterCommercialTargets(db, env, ctx, projection2));
@@ -12928,9 +13537,9 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
12928
13537
  }
12929
13538
  }
12930
13539
  if (parsed.command !== "rebuild" || typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) {
12931
- return json({ error: "rebuild, reconcile-stripe, or reconcile-chapters command is required; rebuild also requires a valid mutationId" }, 400);
13540
+ return json({ error: "rebuild, resolve, recover-event, reconcile-stripe, or reconcile-chapters command is required; rebuild and resolve also require a valid mutationId" }, 400);
12932
13541
  }
12933
- const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId);
13542
+ const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId, Date.now(), alerts);
12934
13543
  return json({ rebuilt: true, ...safeProjection(projection) });
12935
13544
  }
12936
13545
  return json(safeProjection(await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources)));
@@ -13040,7 +13649,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
13040
13649
  function unknownKeys3(value, allowed2, path, errors) {
13041
13650
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
13042
13651
  }
13043
- function text5(value, path, errors, max = MAX_TEXT2, required = true) {
13652
+ function text7(value, path, errors, max = MAX_TEXT2, required = true) {
13044
13653
  if (value === void 0 && !required) return void 0;
13045
13654
  if (typeof value !== "string" || required && !value.trim()) {
13046
13655
  errors.push(`${path} must be a non-empty string`);
@@ -13066,7 +13675,7 @@ function scopeValue(value, path, errors) {
13066
13675
  return { kind: raw2.kind };
13067
13676
  }
13068
13677
  if (raw2.kind === "chapter") {
13069
- const chapterId = text5(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
13678
+ const chapterId = text7(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
13070
13679
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
13071
13680
  return { kind: "chapter", chapterId };
13072
13681
  }
@@ -13080,22 +13689,22 @@ function resourceValue(value, path, errors) {
13080
13689
  return null;
13081
13690
  }
13082
13691
  unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
13083
- const id6 = text5(raw2.id, `${path}.id`, errors, 80) ?? "";
13692
+ const id6 = text7(raw2.id, `${path}.id`, errors, 80) ?? "";
13084
13693
  if (id6 && !ID4.test(id6)) errors.push(`${path}.id must be lowercase kebab-case`);
13085
- const kind = text5(raw2.kind, `${path}.kind`, errors) ?? "";
13694
+ const kind = text7(raw2.kind, `${path}.kind`, errors) ?? "";
13086
13695
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
13087
- const title = text5(raw2.title, `${path}.title`, errors, 240) ?? "";
13088
- const summary = text5(raw2.summary, `${path}.summary`, errors, 1e3, false);
13089
- const status2 = text5(raw2.status, `${path}.status`, errors) ?? "";
13696
+ const title = text7(raw2.title, `${path}.title`, errors, 240) ?? "";
13697
+ const summary = text7(raw2.summary, `${path}.summary`, errors, 1e3, false);
13698
+ const status2 = text7(raw2.status, `${path}.status`, errors) ?? "";
13090
13699
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
13091
13700
  const revision = integer4(raw2.revision, `${path}.revision`, errors);
13092
13701
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
13093
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text5(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
13702
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text7(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
13094
13703
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
13095
13704
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
13096
13705
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
13097
- const bodyMarkdown = text5(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
13098
- const url = text5(raw2.url, `${path}.url`, errors, 2048, false);
13706
+ const bodyMarkdown = text7(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
13707
+ const url = text7(raw2.url, `${path}.url`, errors, 2048, false);
13099
13708
  if (url) {
13100
13709
  try {
13101
13710
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -13107,9 +13716,9 @@ function resourceValue(value, path, errors) {
13107
13716
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
13108
13717
  if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
13109
13718
  const brandAsset = asset ? {
13110
- bookId: text5(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
13111
- assetId: text5(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
13112
- ...asset.filename === void 0 ? {} : { filename: text5(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
13719
+ bookId: text7(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
13720
+ assetId: text7(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
13721
+ ...asset.filename === void 0 ? {} : { filename: text7(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
13113
13722
  } : void 0;
13114
13723
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
13115
13724
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -13124,21 +13733,21 @@ function resourceValue(value, path, errors) {
13124
13733
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
13125
13734
  if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
13126
13735
  const promotedFrom = promotedRaw ? {
13127
- chapterId: text5(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
13128
- resourceId: text5(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
13736
+ chapterId: text7(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
13737
+ resourceId: text7(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
13129
13738
  resourceRevision: integer4(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
13130
13739
  documentRevision: integer4(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
13131
- promotedBy: text5(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
13740
+ promotedBy: text7(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
13132
13741
  promotedAt: integer4(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
13133
13742
  } : void 0;
13134
- const provenance = {
13135
- createdBy: text5(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
13743
+ const provenance2 = {
13744
+ createdBy: text7(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
13136
13745
  createdAt: integer4(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
13137
- updatedBy: text5(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
13746
+ updatedBy: text7(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
13138
13747
  updatedAt: integer4(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
13139
13748
  ...promotedFrom ? { promotedFrom } : {}
13140
13749
  };
13141
- return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance };
13750
+ return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance: provenance2 };
13142
13751
  }
13143
13752
  function validateBuilderLibraryDocument(value, options = {}) {
13144
13753
  const errors = [];
@@ -13153,7 +13762,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
13153
13762
  if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
13154
13763
  const sourceType = sourceRaw?.type;
13155
13764
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
13156
- const sourceAppId = text5(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
13765
+ const sourceAppId = text7(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
13157
13766
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
13158
13767
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
13159
13768
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -13161,8 +13770,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
13161
13770
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
13162
13771
  if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
13163
13772
  const target = targetRaw ? {
13164
- appId: text5(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
13165
- environment: text5(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
13773
+ appId: text7(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
13774
+ environment: text7(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
13166
13775
  } : void 0;
13167
13776
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
13168
13777
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -13796,7 +14405,11 @@ async function reconcileMembershipRenewal(db, input, now) {
13796
14405
  membershipUpdate("networkMembership", id6, {
13797
14406
  status: status2,
13798
14407
  graceEndsAt,
13799
- ...paid ? { termStartedAt: start, termEndsAt: end } : {},
14408
+ ...paid ? {
14409
+ termStartedAt: start,
14410
+ termEndsAt: end,
14411
+ ...membershipOfferingVersion(member.offeringSnapshot).seatPurchaseWindowCalendarMonths === void 0 ? { seatPurchaseEndsAt: end } : {}
14412
+ } : {},
13800
14413
  revision: Number(member.revision) + 1,
13801
14414
  updatedAt: now
13802
14415
  }),
@@ -13956,10 +14569,11 @@ async function reserveNamedMembershipSeat(db, input, now) {
13956
14569
  const id6 = (await membershipOfferIdForRequest({ ...input, requestId: "named_seat" })).replace(/^offer_/, "seat_");
13957
14570
  let seat = await membershipLoad(db, "networkSeat", id6);
13958
14571
  if (!seat) {
13959
- 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) {
14572
+ const offering = membershipOfferingVersion(member.offeringSnapshot), claimId = await recipientKey(input, email);
14573
+ const purchaseEndsAt = seatPurchaseDeadline(offering, member);
14574
+ 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) {
13960
14575
  membershipFail("named_seat_purchase_unavailable");
13961
14576
  }
13962
- const offering = membershipOfferingVersion(member.offeringSnapshot), claimId = await recipientKey(input, email);
13963
14577
  const attrs = {
13964
14578
  id: id6,
13965
14579
  ...membershipScope(input),
@@ -13978,7 +14592,7 @@ async function reserveNamedMembershipSeat(db, input, now) {
13978
14592
  status: "payment_pending",
13979
14593
  preparedAt: now,
13980
14594
  termEndsAt: member.termEndsAt,
13981
- seatPurchaseEndsAt: member.seatPurchaseEndsAt,
14595
+ seatPurchaseEndsAt: purchaseEndsAt,
13982
14596
  offeringSnapshot: offering,
13983
14597
  revision: 1,
13984
14598
  createdAt: now,
@@ -14180,7 +14794,7 @@ async function confirmNamedMembershipSeatPayment(db, input, now) {
14180
14794
  }
14181
14795
  if (seat.purchasedAt === void 0) {
14182
14796
  if (!["payment_processing", "payment_canceling"].includes(String(seat.status))) membershipFail("named_seat_payment_not_expected");
14183
- 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);
14797
+ 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);
14184
14798
  const status2 = eligible ? "renewal_pending" : "refund_pending", effectId = `${seat.id}:${eligible ? "attach-renewal" : "refund"}`;
14185
14799
  try {
14186
14800
  await db.transact([
@@ -15169,7 +15783,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
15169
15783
  };
15170
15784
  signal.addEventListener("abort", cancel, { once: true });
15171
15785
  let size = 0;
15172
- let text6 = "";
15786
+ let text8 = "";
15173
15787
  const decoder = new TextDecoder();
15174
15788
  try {
15175
15789
  while (true) {
@@ -15179,10 +15793,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
15179
15793
  if (done) break;
15180
15794
  size += value.byteLength;
15181
15795
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
15182
- text6 += decoder.decode(value, { stream: true });
15796
+ text8 += decoder.decode(value, { stream: true });
15183
15797
  }
15184
15798
  try {
15185
- return JSON.parse(text6 + decoder.decode());
15799
+ return JSON.parse(text8 + decoder.decode());
15186
15800
  } catch {
15187
15801
  throw new TypeError(`invalid ${label}`);
15188
15802
  }
@@ -15399,7 +16013,7 @@ async function requestNamedSeatReservation(options) {
15399
16013
  async function requestNamedSeatOffer(options) {
15400
16014
  const result = await requestAuthority(options, "seat-offer", { applicationId: options.applicationId, skuId: options.skuId });
15401
16015
  const offer = result.seat;
15402
- 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");
16016
+ 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");
15403
16017
  return offer;
15404
16018
  }
15405
16019
  async function requestNamedSeatInvitation(options) {
@@ -15844,6 +16458,7 @@ var BUILTIN_ROUTES = [
15844
16458
  handlePaymentRecovery,
15845
16459
  handleAdminNamedSeat,
15846
16460
  handleAdminMembershipSeats,
16461
+ handleAdminMembershipPolicyVersions,
15847
16462
  handleAdminMeetings,
15848
16463
  handleAdminScheduling,
15849
16464
  // Roster + identity