@odla-ai/chapter 0.42.3 → 0.43.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.
@@ -1067,10 +1067,10 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
1067
1067
  if (input[nameField] === void 0) input[nameField] = record4.name;
1068
1068
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
1069
1069
  }
1070
- async function upsertPerson(deps, opts) {
1070
+ async function upsertPerson(deps2, opts) {
1071
1071
  const email = opts.email.toLowerCase();
1072
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1073
- const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1072
+ const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1073
+ const { crm_record } = await deps2.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1074
1074
  const existing = crm_record?.[0];
1075
1075
  if (existing && typeof existing.id === "string") {
1076
1076
  await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: opts.input });
@@ -1079,24 +1079,24 @@ async function upsertPerson(deps, opts) {
1079
1079
  const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
1080
1080
  return { recordId: created.id };
1081
1081
  }
1082
- async function findSharedRecord(deps, record4, tag) {
1082
+ async function findSharedRecord(deps2, record4, tag) {
1083
1083
  const structured = await (0, import_crm2.getRecordByOrigin)(
1084
- { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1084
+ { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId },
1085
1085
  record4.sourceId,
1086
1086
  record4.sourceRecordId
1087
1087
  );
1088
1088
  if (structured) return structured;
1089
- const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1089
+ const mapped = await deps2.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1090
1090
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1091
1091
  if (typeof mappedId === "string") {
1092
- const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1092
+ const found = await deps2.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1093
1093
  if (found.crm_record?.[0]) return found.crm_record[0];
1094
1094
  }
1095
- const def = deps.crm.type(record4.type);
1095
+ const def = deps2.crm.type(record4.type);
1096
1096
  const emailField = def.emailField;
1097
1097
  if (emailField && typeof record4.input[emailField] === "string") {
1098
1098
  const primaryEmail = record4.input[emailField].toLowerCase();
1099
- const found = await deps.db.query({
1099
+ const found = await deps2.db.query({
1100
1100
  crm_record: { $: { where: { type: record4.type, primaryEmail }, limit: 1 } }
1101
1101
  });
1102
1102
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1104,7 +1104,7 @@ async function findSharedRecord(deps, record4, tag) {
1104
1104
  const domain = record4.input.domain;
1105
1105
  const domainSlot = def.fields.domain?.slot;
1106
1106
  if (typeof domain === "string" && domainSlot) {
1107
- const found = await deps.db.query({
1107
+ const found = await deps2.db.query({
1108
1108
  crm_record: { $: { where: { type: record4.type, [domainSlot]: domain }, limit: 1 } }
1109
1109
  });
1110
1110
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1112,14 +1112,14 @@ async function findSharedRecord(deps, record4, tag) {
1112
1112
  const nameField = def.nameField ?? "name";
1113
1113
  const name = record4.input[nameField];
1114
1114
  if (record4.type === "company" && typeof name === "string" && name.trim()) {
1115
- const found = await deps.db.query({
1115
+ const found = await deps2.db.query({
1116
1116
  crm_record: { $: { where: { type: record4.type, name: name.trim() }, limit: 1 } }
1117
1117
  });
1118
1118
  if (found.crm_record?.[0]) return found.crm_record[0];
1119
1119
  }
1120
1120
  return void 0;
1121
1121
  }
1122
- async function projectSharedRecord(deps, shared, options = {}) {
1122
+ async function projectSharedRecord(deps2, shared, options = {}) {
1123
1123
  const record4 = normalizeSharedRecord(shared, options.sourceId);
1124
1124
  if (!record4.type.trim() || !record4.sourceId.trim() || !record4.sourceRecordId.trim()) {
1125
1125
  throw new Error("type, source site id, and source record id must be non-empty");
@@ -1128,8 +1128,8 @@ async function projectSharedRecord(deps, shared, options = {}) {
1128
1128
  throw new Error("signed sender does not match payload source");
1129
1129
  }
1130
1130
  const tag = networkSourceTag(record4.type, record4.sourceRecordId, record4.version === 2 ? record4.sourceId : void 0);
1131
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1132
- const existing = await findSharedRecord(deps, record4, tag);
1131
+ const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1132
+ const existing = await findSharedRecord(deps2, record4, tag);
1133
1133
  let recordId;
1134
1134
  if (existing && typeof existing.id === "string") {
1135
1135
  await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record4.input });
@@ -1142,8 +1142,8 @@ async function projectSharedRecord(deps, shared, options = {}) {
1142
1142
  mutationId: `share-create:${tag}`
1143
1143
  });
1144
1144
  }
1145
- await deps.db.transact(
1146
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1145
+ await deps2.db.transact(
1146
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps2.now() } }],
1147
1147
  { mutationId: `share-map:${tag}:${recordId}` }
1148
1148
  );
1149
1149
  await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
@@ -1155,7 +1155,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
1155
1155
  });
1156
1156
  return { recordId };
1157
1157
  }
1158
- async function projectApplicant(deps, applicant) {
1158
+ async function projectApplicant(deps2, applicant) {
1159
1159
  const base = sharedPersonInput({
1160
1160
  email: applicant.email,
1161
1161
  firstName: applicant.firstName,
@@ -1166,11 +1166,11 @@ async function projectApplicant(deps, applicant) {
1166
1166
  });
1167
1167
  const mutationId = `apply:${applicant.applicationId}`;
1168
1168
  const extra = applicant.extra ?? {};
1169
- if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1169
+ if (Object.keys(extra).length === 0) return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1170
1170
  try {
1171
- return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
1171
+ return await upsertPerson(deps2, { email: applicant.email, input: { ...base, ...extra }, mutationId });
1172
1172
  } catch {
1173
- return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1173
+ return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1174
1174
  }
1175
1175
  }
1176
1176
 
@@ -1208,12 +1208,12 @@ function billingColumns(app) {
1208
1208
  if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1209
1209
  return cols;
1210
1210
  }
1211
- async function syncApplicationToCrm(deps, opts) {
1211
+ async function syncApplicationToCrm(deps2, opts) {
1212
1212
  const emailKey = str(opts.app.email).toLowerCase();
1213
1213
  if (!emailKey) return null;
1214
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1215
- const input = personInputFromApp(deps.chapter, opts.app);
1216
- const { crm_record } = await deps.db.query({
1214
+ const recordDeps = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1215
+ const input = personInputFromApp(deps2.chapter, opts.app);
1216
+ const { crm_record } = await deps2.db.query({
1217
1217
  crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1218
1218
  });
1219
1219
  const existing = crm_record?.[0] ?? null;
@@ -1229,12 +1229,12 @@ async function syncApplicationToCrm(deps, opts) {
1229
1229
  if (existing && stage && existing.stage !== stage) {
1230
1230
  await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1231
1231
  }
1232
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1232
+ await deps2.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1233
1233
  await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1234
1234
  return recordId;
1235
1235
  }
1236
- async function backfillCrm(deps) {
1237
- const data = await deps.db.query({
1236
+ async function backfillCrm(deps2) {
1237
+ const data = await deps2.db.query({
1238
1238
  applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } },
1239
1239
  $users: { $: { limit: 1e3 } }
1240
1240
  });
@@ -1246,7 +1246,7 @@ async function backfillCrm(deps) {
1246
1246
  if (!key || seen.has(key)) return;
1247
1247
  seen.add(key);
1248
1248
  try {
1249
- await syncApplicationToCrm(deps, { app, stage });
1249
+ await syncApplicationToCrm(deps2, { app, stage });
1250
1250
  synced += 1;
1251
1251
  } catch (err) {
1252
1252
  errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
@@ -1298,13 +1298,13 @@ function planDelivery(input) {
1298
1298
 
1299
1299
  // src/notify.ts
1300
1300
  var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1301
- async function sendTemplated(deps, input) {
1302
- const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1301
+ async function sendTemplated(deps2, input) {
1302
+ const { emailLog } = await deps2.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1303
1303
  const prior = Array.isArray(emailLog) ? emailLog : [];
1304
1304
  if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
1305
- const cloudflareReady = Boolean(deps.sender && deps.from);
1305
+ const cloudflareReady = Boolean(deps2.sender && deps2.from);
1306
1306
  const decision = planDelivery({
1307
- envName: deps.envName,
1307
+ envName: deps2.envName,
1308
1308
  group: input.group,
1309
1309
  template: input.template,
1310
1310
  to: input.to,
@@ -1315,10 +1315,10 @@ async function sendTemplated(deps, input) {
1315
1315
  if (!decision.deliver) return { sent: false, reason: decision.reason };
1316
1316
  let error;
1317
1317
  let messageId;
1318
- if (decision.transport === "cloudflare" && deps.sender && deps.from) {
1318
+ if (decision.transport === "cloudflare" && deps2.sender && deps2.from) {
1319
1319
  try {
1320
- const res = await deps.sender.send({
1321
- from: deps.from,
1320
+ const res = await deps2.sender.send({
1321
+ from: deps2.from,
1322
1322
  to: [decision.to],
1323
1323
  subject: decision.subject,
1324
1324
  text: decision.text,
@@ -1329,7 +1329,7 @@ async function sendTemplated(deps, input) {
1329
1329
  error = e instanceof Error ? e.message : String(e);
1330
1330
  }
1331
1331
  }
1332
- const id = deps.newId();
1332
+ const id = deps2.newId();
1333
1333
  const row = {
1334
1334
  id,
1335
1335
  groupId: input.group.id,
@@ -1340,12 +1340,12 @@ async function sendTemplated(deps, input) {
1340
1340
  transport: decision.transport,
1341
1341
  redirected: decision.redirected,
1342
1342
  dedupeKey: input.dedupeKey,
1343
- sentAt: deps.now(),
1343
+ sentAt: deps2.now(),
1344
1344
  ...input.applicationId ? { applicationId: input.applicationId } : {},
1345
1345
  ...messageId ? { messageId } : {},
1346
1346
  ...error ? { error } : {}
1347
1347
  };
1348
- await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1348
+ await deps2.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1349
1349
  return error ? { sent: false, reason: error } : { sent: true };
1350
1350
  }
1351
1351
  function emailGroupFrom(row) {
@@ -3832,6 +3832,7 @@ function findApplicationRef(obj) {
3832
3832
  const environment = candidates.map((metadata) => typeof metadata.odlaEnvironment === "string" ? metadata.odlaEnvironment : void 0).find(Boolean);
3833
3833
  const dataEnvironment = candidates.map((metadata) => typeof metadata.odlaDataEnvironment === "string" ? metadata.odlaDataEnvironment : void 0).find(Boolean);
3834
3834
  const runtime = candidates.map((metadata) => typeof metadata.odlaRuntime === "string" ? metadata.odlaRuntime : void 0).find(Boolean);
3835
+ const chapterId = candidates.map((metadata) => typeof metadata.chapterId === "string" ? metadata.chapterId : void 0).find(Boolean);
3835
3836
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
3836
3837
  return {
3837
3838
  ...applicationId ? { applicationId } : {},
@@ -3839,7 +3840,8 @@ function findApplicationRef(obj) {
3839
3840
  ...appId ? { appId } : {},
3840
3841
  ...environment ? { environment } : {},
3841
3842
  ...dataEnvironment ? { dataEnvironment } : {},
3842
- ...runtime ? { runtime } : {}
3843
+ ...runtime ? { runtime } : {},
3844
+ ...chapterId ? { chapterId } : {}
3843
3845
  };
3844
3846
  }
3845
3847
  function findGiftRef(obj) {
@@ -3855,13 +3857,15 @@ function findGiftRef(obj) {
3855
3857
  const environment = pickIdentity("odlaEnvironment");
3856
3858
  const dataEnvironment = pickIdentity("odlaDataEnvironment");
3857
3859
  const runtime = pickIdentity("odlaRuntime");
3860
+ const chapterId = pickIdentity("chapterId");
3858
3861
  return {
3859
3862
  ...giftIntentId ? { giftIntentId } : {},
3860
3863
  providerObjectId: subscriptionId ?? String(obj.id ?? ""),
3861
3864
  ...appId ? { appId } : {},
3862
3865
  ...environment ? { environment } : {},
3863
3866
  ...dataEnvironment ? { dataEnvironment } : {},
3864
- ...runtime ? { runtime } : {}
3867
+ ...runtime ? { runtime } : {},
3868
+ ...chapterId ? { chapterId } : {}
3865
3869
  };
3866
3870
  }
3867
3871
  function normalizeWebhookEvent(event) {
@@ -3909,8 +3913,83 @@ function canceledPatch() {
3909
3913
  return { canceled: true };
3910
3914
  }
3911
3915
 
3916
+ // src/payment-notifications.ts
3917
+ var groupFor = async (db, app) => {
3918
+ const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
3919
+ return result.groups?.[0];
3920
+ };
3921
+ var deps = (db, env) => ({
3922
+ db,
3923
+ envName: env.ODLA_ENV,
3924
+ sender: env.SEND_EMAIL,
3925
+ from: env.EMAIL_FROM,
3926
+ now: () => Date.now(),
3927
+ newId: () => crypto.randomUUID()
3928
+ });
3929
+ async function notifyPaymentConfirmed(db, env, eventId, app) {
3930
+ try {
3931
+ if (typeof app.email !== "string" || !app.email) return;
3932
+ const group = await groupFor(db, app);
3933
+ if (!group) return;
3934
+ await sendTemplated(deps(db, env), {
3935
+ group: emailGroupFrom(group),
3936
+ template: "paymentConfirmation",
3937
+ to: app.email,
3938
+ vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
3939
+ dedupeKey: `${eventId}:confirm`,
3940
+ applicationId: String(app.id)
3941
+ });
3942
+ } catch {
3943
+ }
3944
+ }
3945
+ async function notifyAdminOfPayment(db, env, eventId, app) {
3946
+ try {
3947
+ const group = await groupFor(db, app);
3948
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
3949
+ const text4 = (value) => typeof value === "string" ? value : "";
3950
+ await sendTemplated(deps(db, env), {
3951
+ group: emailGroupFrom(group),
3952
+ template: "adminNotification",
3953
+ to: group.notificationEmail,
3954
+ vars: {
3955
+ firstName: text4(app.firstName),
3956
+ lastName: text4(app.lastName),
3957
+ email: text4(app.email),
3958
+ phone: text4(app.phone),
3959
+ state: text4(app.state)
3960
+ },
3961
+ dedupeKey: `${eventId}:admin`,
3962
+ applicationId: String(app.id)
3963
+ });
3964
+ } catch {
3965
+ }
3966
+ }
3967
+
3912
3968
  // src/payments-stripe.ts
3913
3969
  var import_stripe2 = require("@odla-ai/stripe");
3970
+
3971
+ // src/stripe-runtime.ts
3972
+ function stripeWebhookSecretName(runtime) {
3973
+ if (!runtime) return "stripe_webhook_secret";
3974
+ if (!/^[a-z0-9]{2,12}$/.test(runtime) || runtime === "prod" || runtime === "production") {
3975
+ throw new Error("ODLA_RUNTIME is invalid");
3976
+ }
3977
+ return `stripe_webhook_secret__${runtime}`;
3978
+ }
3979
+ function stripeRuntimeIdentity(runtime) {
3980
+ return runtime || "default";
3981
+ }
3982
+ function stripePartitionMetadata(input) {
3983
+ return {
3984
+ odlaAppId: input.appId,
3985
+ odlaEnvironment: input.environment,
3986
+ odlaDataEnvironment: input.environment,
3987
+ odlaRuntime: stripeRuntimeIdentity(input.runtime),
3988
+ chapterId: input.chapterId
3989
+ };
3990
+ }
3991
+
3992
+ // src/payments-stripe.ts
3914
3993
  async function stripeCall(sk, method, path, params, idempotencyKey) {
3915
3994
  return (0, import_stripe2.stripeRequest)(
3916
3995
  { secretKey: sk },
@@ -3949,10 +4028,7 @@ function createStripeProvider(config) {
3949
4028
  applicationId: input.applicationId,
3950
4029
  groupId: input.groupId,
3951
4030
  email: input.email,
3952
- odlaAppId: input.appId,
3953
- odlaEnvironment: input.environment,
3954
- odlaDataEnvironment: input.environment,
3955
- ...input.runtime ? { odlaRuntime: input.runtime } : {}
4031
+ ...stripePartitionMetadata(input)
3956
4032
  };
3957
4033
  let customerId = input.existingCustomerId;
3958
4034
  if (!customerId) {
@@ -3977,6 +4053,7 @@ function createStripeProvider(config) {
3977
4053
  "payment_settings[save_default_payment_method]": "on_subscription",
3978
4054
  "payment_settings[payment_method_types][0]": "card",
3979
4055
  "expand[0]": "latest_invoice.confirmation_secret",
4056
+ "expand[1]": "latest_invoice.payment_intent",
3980
4057
  metadata: meta
3981
4058
  },
3982
4059
  // Hardening: one subscription per application, so a client
@@ -3984,6 +4061,19 @@ function createStripeProvider(config) {
3984
4061
  `sub:${input.applicationId}`
3985
4062
  );
3986
4063
  if (!sub.ok) fail("subscription create", sub);
4064
+ const invoice = sub.body.latest_invoice;
4065
+ const paymentIntent = invoice?.payment_intent;
4066
+ const paymentIntentId = typeof paymentIntent === "string" ? paymentIntent : paymentIntent && typeof paymentIntent === "object" ? String(paymentIntent.id ?? "") : "";
4067
+ if (paymentIntentId) {
4068
+ const tagged = await stripeCall(
4069
+ sk,
4070
+ "POST",
4071
+ `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}`,
4072
+ { metadata: meta },
4073
+ `payment-partition:${input.applicationId}`
4074
+ );
4075
+ if (!tagged.ok) fail("payment intent metadata", tagged);
4076
+ }
3987
4077
  const clientSecret2 = clientSecretOf(sub.body);
3988
4078
  if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
3989
4079
  return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
@@ -4261,16 +4351,12 @@ var clientSecret = (value) => {
4261
4351
  const nested = confirmation?.client_secret ?? intent?.client_secret;
4262
4352
  return typeof nested === "string" && nested ? nested : null;
4263
4353
  };
4264
- async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId, runtime) {
4354
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId, runtime, appId = intent.chapterId) {
4265
4355
  const offer = intent.offerSnapshot;
4266
4356
  const metadata = {
4267
4357
  giftIntentId: intent.id,
4268
- chapterId: intent.chapterId,
4269
4358
  environment: intent.environment,
4270
- odlaAppId: intent.chapterId,
4271
- odlaEnvironment: intent.environment,
4272
- odlaDataEnvironment: intent.environment,
4273
- ...runtime ? { odlaRuntime: runtime } : {},
4359
+ ...stripePartitionMetadata({ appId, chapterId: intent.chapterId, environment: intent.environment, runtime }),
4274
4360
  offerId: intent.offerId
4275
4361
  };
4276
4362
  let customerId = existingCustomerId;
@@ -4287,10 +4373,20 @@ async function startGiftProviderCheckout(secretKey, intent, email, existingCusto
4287
4373
  "payment_settings[save_default_payment_method]": "on_subscription",
4288
4374
  "payment_settings[payment_method_types][0]": "card",
4289
4375
  "expand[0]": "latest_invoice.confirmation_secret",
4376
+ "expand[1]": "latest_invoice.payment_intent",
4290
4377
  metadata
4291
4378
  }, `gift-subscription:${intent.id}`);
4292
4379
  const secret2 = created2.ok ? clientSecret(created2.body) : null;
4293
4380
  if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
4381
+ const invoice = created2.body.latest_invoice;
4382
+ const paymentIntent = invoice?.payment_intent;
4383
+ const paymentIntentId = typeof paymentIntent === "string" ? paymentIntent : paymentIntent && typeof paymentIntent === "object" ? String(paymentIntent.id ?? "") : "";
4384
+ if (paymentIntentId) {
4385
+ const tagged = await stripeCall(secretKey, "POST", `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}`, {
4386
+ metadata
4387
+ }, `gift-payment-partition:${intent.id}`);
4388
+ if (!tagged.ok) throw new Error(`gift payment intent metadata failed (${tagged.status})`);
4389
+ }
4294
4390
  return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
4295
4391
  }
4296
4392
  const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
@@ -4349,7 +4445,8 @@ async function startGiftIntent(db, input) {
4349
4445
  stored,
4350
4446
  input.purchaserEmail,
4351
4447
  typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId,
4352
- input.runtime
4448
+ input.runtime,
4449
+ input.appId
4353
4450
  );
4354
4451
  await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
4355
4452
  status: "checkout_ready",
@@ -4368,15 +4465,6 @@ async function startGiftIntent(db, input) {
4368
4465
  };
4369
4466
  }
4370
4467
 
4371
- // src/stripe-runtime.ts
4372
- function stripeWebhookSecretName(runtime) {
4373
- if (!runtime) return "stripe_webhook_secret";
4374
- if (!/^[a-z0-9]{2,12}$/.test(runtime) || runtime === "prod" || runtime === "production") {
4375
- throw new Error("ODLA_RUNTIME is invalid");
4376
- }
4377
- return `stripe_webhook_secret__${runtime}`;
4378
- }
4379
-
4380
4468
  // src/payments-route-support.ts
4381
4469
  var paymentErrorCode = (error) => error && typeof error === "object" && typeof error.code === "string" ? error.code : "unknown";
4382
4470
  function paymentLineItems(group) {
@@ -4385,59 +4473,100 @@ function paymentLineItems(group) {
4385
4473
  return { standardCents: standard, discountCents: discount, dueTodayCents: standard - discount };
4386
4474
  }
4387
4475
 
4476
+ // src/member-system-contract.ts
4477
+ function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
4478
+ const keys = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
4479
+ const supplied = keys.filter((key) => identity[key] !== void 0);
4480
+ if (supplied.some((key) => identity[key] !== target[key])) return "ignore_sibling";
4481
+ if (supplied.length === keys.length) return "apply";
4482
+ return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
4483
+ }
4484
+
4485
+ // src/stripe-webhook-partition.ts
4486
+ var firstRow2 = async (db, ns, q) => {
4487
+ const rows4 = (await db.query({ [ns]: { $: q } }))[ns];
4488
+ return Array.isArray(rows4) ? rows4[0] : void 0;
4489
+ };
4490
+ var stripeEventDigest = async (rawBody) => {
4491
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawBody));
4492
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4493
+ };
4494
+ var stripeEventIdentity = (event) => ({
4495
+ ...event.appId ? { appId: event.appId } : {},
4496
+ ...event.environment ? { environment: event.environment } : {},
4497
+ ...event.dataEnvironment ? { dataEnvironment: event.dataEnvironment } : {},
4498
+ ...event.runtime ? { runtime: event.runtime } : {},
4499
+ ...event.chapterId ? { chapterId: event.chapterId } : {}
4500
+ });
4501
+ var stripeEventTarget = (env, ctx) => ({
4502
+ appId: env.ODLA_APP_ID,
4503
+ environment: env.ODLA_ENV,
4504
+ dataEnvironment: env.ODLA_ENV,
4505
+ runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4506
+ chapterId: ctx.chapter.id
4507
+ });
4508
+ var receiptFor = (db, eventId) => firstRow2(db, "stripeEventReceipts", { where: { eventId }, limit: 1 });
4509
+ function reportStripePartition(level, eventId, event, disposition) {
4510
+ console[level]("chapter stripe partition", JSON.stringify({
4511
+ eventId,
4512
+ kind: event.kind,
4513
+ disposition,
4514
+ ...stripeEventIdentity(event)
4515
+ }));
4516
+ }
4517
+ async function storeStripeReceipt(db, input) {
4518
+ const existing = await receiptFor(db, input.eventId);
4519
+ if (existing) return existing.digest === input.digest ? "replayed" : "collision";
4520
+ const id = `stripe-event:${input.eventId}`;
4521
+ const write = await db.transact([{ t: "update", ns: "stripeEventReceipts", id, attrs: {
4522
+ id,
4523
+ eventId: input.eventId,
4524
+ digest: input.digest,
4525
+ disposition: input.disposition,
4526
+ kind: input.event.kind,
4527
+ matched: input.matched,
4528
+ ...stripeEventIdentity(input.event),
4529
+ receivedAt: Date.now()
4530
+ } }], { mutationId: `stripe-receipt:${input.eventId}` });
4531
+ if (!write.duplicate) return "stored";
4532
+ const raced = await receiptFor(db, input.eventId);
4533
+ return raced?.digest === input.digest ? "replayed" : "collision";
4534
+ }
4535
+ async function quarantineStripeEvent(db, eventId, digest, event) {
4536
+ const disposition = "quarantine_unmatched";
4537
+ const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
4538
+ if (stored === "collision") {
4539
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4540
+ return json({ ok: true, matched: false, collision: true });
4541
+ }
4542
+ reportStripePartition("warn", eventId, event, disposition);
4543
+ return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
4544
+ }
4545
+ async function replayedStripeReceipt(db, eventId, digest, event) {
4546
+ const existing = await receiptFor(db, eventId);
4547
+ if (!existing) return null;
4548
+ if (existing.digest !== digest) {
4549
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4550
+ return json({ ok: true, matched: false, collision: true });
4551
+ }
4552
+ return json({ ok: true, matched: existing.matched === true, replayed: true });
4553
+ }
4554
+
4388
4555
  // src/worker-routes-payments.ts
4389
- async function firstRow2(db, ns, q) {
4556
+ async function firstRow3(db, ns, q) {
4390
4557
  const res = await db.query({ [ns]: { $: q } });
4391
4558
  const rows4 = res[ns];
4392
4559
  return Array.isArray(rows4) ? rows4[0] : void 0;
4393
4560
  }
4394
4561
  async function findApplication(db, event) {
4395
4562
  if ("applicationId" in event && event.applicationId) {
4396
- return firstRow2(db, "applications", { where: { id: event.applicationId }, limit: 1 });
4563
+ return firstRow3(db, "applications", { where: { id: event.applicationId }, limit: 1 });
4397
4564
  }
4398
4565
  if ("customerId" in event && event.customerId) {
4399
- return firstRow2(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
4566
+ return firstRow3(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
4400
4567
  }
4401
4568
  return void 0;
4402
4569
  }
4403
- async function notifyPaymentConfirmed(db, env, eventId, app) {
4404
- try {
4405
- if (typeof app.email !== "string" || !app.email) return;
4406
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
4407
- if (!group) return;
4408
- await sendTemplated(
4409
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
4410
- {
4411
- group: emailGroupFrom(group),
4412
- template: "paymentConfirmation",
4413
- to: app.email,
4414
- vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
4415
- dedupeKey: `${eventId}:confirm`,
4416
- applicationId: String(app.id)
4417
- }
4418
- );
4419
- } catch {
4420
- }
4421
- }
4422
- async function notifyAdminOfPayment(db, env, eventId, app) {
4423
- try {
4424
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
4425
- if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
4426
- const s = (v) => typeof v === "string" ? v : "";
4427
- await sendTemplated(
4428
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
4429
- {
4430
- group: emailGroupFrom(group),
4431
- template: "adminNotification",
4432
- to: group.notificationEmail,
4433
- vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
4434
- dedupeKey: `${eventId}:admin`,
4435
- applicationId: String(app.id)
4436
- }
4437
- );
4438
- } catch {
4439
- }
4440
- }
4441
4570
  function webhookPatch(event, status) {
4442
4571
  switch (event.kind) {
4443
4572
  case "first_payment":
@@ -4463,10 +4592,10 @@ async function startSubscription(req, env, ctx) {
4463
4592
  if (!applicationId) return json({ error: "applicationId required" }, 400);
4464
4593
  if (body.refundPolicyAck !== true) return json({ error: "refundPolicyAck required" }, 400);
4465
4594
  const db = ctx.makeDb(env);
4466
- const app = await firstRow2(db, "applications", { where: { id: applicationId }, limit: 1 });
4595
+ const app = await firstRow3(db, "applications", { where: { id: applicationId }, limit: 1 });
4467
4596
  if (!app) return json({ error: "not found" }, 404);
4468
4597
  if (app.status !== "submitted") return json({ error: "already processed" }, 409);
4469
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
4598
+ const group = await firstRow3(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
4470
4599
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
4471
4600
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
4472
4601
  if (typeof app.giftOfferId === "string") {
@@ -4478,6 +4607,7 @@ async function startSubscription(req, env, ctx) {
4478
4607
  try {
4479
4608
  const started = await startGiftIntent(db, {
4480
4609
  id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
4610
+ appId: env.ODLA_APP_ID,
4481
4611
  chapterId: ctx.chapter.id,
4482
4612
  environment: env.ODLA_ENV,
4483
4613
  offer,
@@ -4513,8 +4643,9 @@ async function startSubscription(req, env, ctx) {
4513
4643
  try {
4514
4644
  result = await provider.createSubscription({
4515
4645
  appId: env.ODLA_APP_ID,
4646
+ chapterId: ctx.chapter.id,
4516
4647
  environment: env.ODLA_ENV,
4517
- ...env.ODLA_RUNTIME ? { runtime: env.ODLA_RUNTIME } : {},
4648
+ runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4518
4649
  applicationId,
4519
4650
  groupId: String(group.id),
4520
4651
  email: String(app.email ?? ""),
@@ -4533,7 +4664,7 @@ async function startSubscription(req, env, ctx) {
4533
4664
  attrs: {
4534
4665
  stripeCustomerId: result.customerId,
4535
4666
  stripeSubscriptionId: result.subscriptionId,
4536
- ...env.ODLA_RUNTIME ? { stripeRuntime: env.ODLA_RUNTIME } : {},
4667
+ stripeRuntime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4537
4668
  refundPolicyAckAt: Date.now()
4538
4669
  }
4539
4670
  }
@@ -4549,27 +4680,41 @@ async function ingestWebhook(req, env, ctx) {
4549
4680
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
4550
4681
  const { eventId, event } = ingest;
4551
4682
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
4552
- const identifiedEnvironment = event.appId !== void 0 || event.environment !== void 0 || event.dataEnvironment !== void 0 || event.runtime !== void 0;
4553
- const eventDataEnvironment = event.dataEnvironment ?? event.environment;
4554
- if (env.ODLA_APP_ID && identifiedEnvironment && (event.appId !== env.ODLA_APP_ID || eventDataEnvironment !== env.ODLA_ENV || event.runtime !== void 0 && event.runtime !== env.ODLA_RUNTIME)) {
4555
- return json({ ok: true, matched: false });
4683
+ const digest = await stripeEventDigest(rawBody);
4684
+ const identity = stripeEventIdentity(event);
4685
+ const target = stripeEventTarget(env, ctx);
4686
+ const preliminary = classifyMemberStripeEvent(identity, target, false);
4687
+ if (preliminary === "ignore_sibling") {
4688
+ reportStripePartition("warn", eventId, event, preliminary);
4689
+ return json({ ok: true, matched: false, disposition: preliminary });
4556
4690
  }
4557
4691
  if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
4558
- const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
4559
- if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
4560
- return json({ ok: true, matched: false });
4561
- }
4562
- if (event.runtime === void 0 && typeof intent.runtime === "string" && intent.runtime !== env.ODLA_RUNTIME) {
4563
- return json({ ok: true, matched: false });
4692
+ const intent = await firstRow3(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
4693
+ if (intent && typeof intent.runtime === "string" && intent.runtime !== target.runtime) {
4694
+ reportStripePartition("warn", eventId, event, "ignore_sibling");
4695
+ return json({ ok: true, matched: false, disposition: "ignore_sibling" });
4696
+ }
4697
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event);
4698
+ if (replay2) return replay2;
4699
+ const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
4700
+ const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
4701
+ if (!localIntent || disposition2 === "quarantine_unmatched") {
4702
+ return quarantineStripeEvent(db, eventId, digest, event);
4564
4703
  }
4565
4704
  if (event.kind === "gift_paid") {
4566
- if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
4567
- if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
4705
+ if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
4706
+ return quarantineStripeEvent(db, eventId, digest, event);
4707
+ }
4568
4708
  try {
4569
4709
  const issued = await issueGiftClaim(db, intent, {
4570
4710
  eventId,
4571
4711
  objectId: event.providerObjectId
4572
4712
  }, Date.now());
4713
+ const stored3 = await storeStripeReceipt(db, { eventId, digest, event, disposition: disposition2, matched: true });
4714
+ if (stored3 === "collision") {
4715
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4716
+ return json({ ok: true, matched: false, collision: true });
4717
+ }
4573
4718
  return json({ ok: true, matched: true, claimId: issued.claimId });
4574
4719
  } catch {
4575
4720
  return json({ error: "gift issuance failed" }, 503);
@@ -4579,14 +4724,26 @@ async function ingestWebhook(req, env, ctx) {
4579
4724
  status: event.reason,
4580
4725
  updatedAt: Date.now()
4581
4726
  } }], { mutationId: `gift-revoked-intent:${eventId}` });
4582
- const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
4727
+ const claim = await firstRow3(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
4583
4728
  if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
4729
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, event, disposition: disposition2, matched: Boolean(claim) });
4730
+ if (stored2 === "collision") {
4731
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4732
+ return json({ ok: true, matched: false, collision: true });
4733
+ }
4584
4734
  return json({ ok: true, matched: Boolean(claim) });
4585
4735
  }
4586
4736
  const app = await findApplication(db, event);
4587
- if (!app) return json({ ok: true, matched: false });
4588
- if (event.runtime === void 0 && typeof app.stripeRuntime === "string" && app.stripeRuntime !== env.ODLA_RUNTIME) {
4589
- return json({ ok: true, matched: false });
4737
+ if (app && typeof app.stripeRuntime === "string" && app.stripeRuntime !== target.runtime) {
4738
+ reportStripePartition("warn", eventId, event, "ignore_sibling");
4739
+ return json({ ok: true, matched: false, disposition: "ignore_sibling" });
4740
+ }
4741
+ const replay = await replayedStripeReceipt(db, eventId, digest, event);
4742
+ if (replay) return replay;
4743
+ const localApplication = Boolean(app && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
4744
+ const disposition = classifyMemberStripeEvent(identity, target, localApplication);
4745
+ if (!localApplication || disposition === "quarantine_unmatched") {
4746
+ return quarantineStripeEvent(db, eventId, digest, event);
4590
4747
  }
4591
4748
  const patch = webhookPatch(event, String(app.status ?? ""));
4592
4749
  let next = app;
@@ -4609,6 +4766,11 @@ async function ingestWebhook(req, env, ctx) {
4609
4766
  });
4610
4767
  }
4611
4768
  }
4769
+ const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: true });
4770
+ if (stored === "collision") {
4771
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4772
+ return json({ ok: true, matched: false, collision: true });
4773
+ }
4612
4774
  return json({ ok: true });
4613
4775
  }
4614
4776
  var handlePayments = async (req, url, env, ctx) => {
@@ -4686,6 +4848,7 @@ var handleGifts = async (req, url, env, ctx) => {
4686
4848
  try {
4687
4849
  return json(await startGiftIntent(db, {
4688
4850
  id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
4851
+ appId: env.ODLA_APP_ID,
4689
4852
  chapterId: ctx.chapter.id,
4690
4853
  environment: env.ODLA_ENV,
4691
4854
  offer,
@@ -5435,7 +5598,19 @@ var handleAdminRefund = async (req, url, env, ctx) => {
5435
5598
  const succeeded = (charges.body.data ?? []).filter((c) => c.status === "succeeded" && c.refunded !== true);
5436
5599
  const firstCharge = succeeded[succeeded.length - 1];
5437
5600
  if (!firstCharge) return json({ error: "no paid charge to refund" }, 409);
5438
- const refund = await stripeCall(sk, "POST", "/v1/refunds", { charge: String(firstCharge.id) });
5601
+ const refund = await stripeCall(sk, "POST", "/v1/refunds", {
5602
+ charge: String(firstCharge.id),
5603
+ metadata: {
5604
+ ...stripePartitionMetadata({
5605
+ appId: env.ODLA_APP_ID,
5606
+ chapterId: ctx.chapter.id,
5607
+ environment: env.ODLA_ENV,
5608
+ runtime: env.ODLA_RUNTIME
5609
+ }),
5610
+ applicationId: String(app.id),
5611
+ groupId: String(app.groupId ?? ctx.chapter.id)
5612
+ }
5613
+ }, `refund:${String(app.id)}:${String(firstCharge.id)}`);
5439
5614
  if (!refund.ok) return json({ error: "refund failed upstream" }, 502);
5440
5615
  let subscriptionCanceled = false;
5441
5616
  if (ctx.chapter.operations.refund.cancelSubscription && subscriptionId) {
@@ -6106,8 +6281,8 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6106
6281
  }
6107
6282
  };
6108
6283
  try {
6109
- const deps = { crm: ctx.chapter.crm, db };
6110
- const updated = hasFields ? await (0, import_crm9.updateRecord)(deps, {
6284
+ const deps2 = { crm: ctx.chapter.crm, db };
6285
+ const updated = hasFields ? await (0, import_crm9.updateRecord)(deps2, {
6111
6286
  id: record4.id,
6112
6287
  input: fields,
6113
6288
  mutationId: activityId,
@@ -6118,7 +6293,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6118
6293
  authorId: `network:${verified.sender}`,
6119
6294
  meta: auditMeta
6120
6295
  }
6121
- }) : await (0, import_crm9.setStage)(deps, {
6296
+ }) : await (0, import_crm9.setStage)(deps2, {
6122
6297
  id: record4.id,
6123
6298
  to: input.stage,
6124
6299
  authorId: `network:${verified.sender}`,