@odla-ai/chapter 0.42.4 → 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.
@@ -1037,10 +1037,10 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
1037
1037
  if (input[nameField] === void 0) input[nameField] = record4.name;
1038
1038
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
1039
1039
  }
1040
- async function upsertPerson(deps, opts) {
1040
+ async function upsertPerson(deps2, opts) {
1041
1041
  const email = opts.email.toLowerCase();
1042
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1043
- const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1042
+ const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1043
+ const { crm_record } = await deps2.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
1044
1044
  const existing = crm_record?.[0];
1045
1045
  if (existing && typeof existing.id === "string") {
1046
1046
  await updateRecord(crmDeps3, { id: existing.id, input: opts.input });
@@ -1049,24 +1049,24 @@ async function upsertPerson(deps, opts) {
1049
1049
  const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
1050
1050
  return { recordId: created.id };
1051
1051
  }
1052
- async function findSharedRecord(deps, record4, tag) {
1052
+ async function findSharedRecord(deps2, record4, tag) {
1053
1053
  const structured = await getRecordByOrigin(
1054
- { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1054
+ { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId },
1055
1055
  record4.sourceId,
1056
1056
  record4.sourceRecordId
1057
1057
  );
1058
1058
  if (structured) return structured;
1059
- const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1059
+ const mapped = await deps2.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1060
1060
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1061
1061
  if (typeof mappedId === "string") {
1062
- const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1062
+ const found = await deps2.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
1063
1063
  if (found.crm_record?.[0]) return found.crm_record[0];
1064
1064
  }
1065
- const def = deps.crm.type(record4.type);
1065
+ const def = deps2.crm.type(record4.type);
1066
1066
  const emailField = def.emailField;
1067
1067
  if (emailField && typeof record4.input[emailField] === "string") {
1068
1068
  const primaryEmail = record4.input[emailField].toLowerCase();
1069
- const found = await deps.db.query({
1069
+ const found = await deps2.db.query({
1070
1070
  crm_record: { $: { where: { type: record4.type, primaryEmail }, limit: 1 } }
1071
1071
  });
1072
1072
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1074,7 +1074,7 @@ async function findSharedRecord(deps, record4, tag) {
1074
1074
  const domain = record4.input.domain;
1075
1075
  const domainSlot = def.fields.domain?.slot;
1076
1076
  if (typeof domain === "string" && domainSlot) {
1077
- const found = await deps.db.query({
1077
+ const found = await deps2.db.query({
1078
1078
  crm_record: { $: { where: { type: record4.type, [domainSlot]: domain }, limit: 1 } }
1079
1079
  });
1080
1080
  if (found.crm_record?.[0]) return found.crm_record[0];
@@ -1082,14 +1082,14 @@ async function findSharedRecord(deps, record4, tag) {
1082
1082
  const nameField = def.nameField ?? "name";
1083
1083
  const name = record4.input[nameField];
1084
1084
  if (record4.type === "company" && typeof name === "string" && name.trim()) {
1085
- const found = await deps.db.query({
1085
+ const found = await deps2.db.query({
1086
1086
  crm_record: { $: { where: { type: record4.type, name: name.trim() }, limit: 1 } }
1087
1087
  });
1088
1088
  if (found.crm_record?.[0]) return found.crm_record[0];
1089
1089
  }
1090
1090
  return void 0;
1091
1091
  }
1092
- async function projectSharedRecord(deps, shared, options = {}) {
1092
+ async function projectSharedRecord(deps2, shared, options = {}) {
1093
1093
  const record4 = normalizeSharedRecord(shared, options.sourceId);
1094
1094
  if (!record4.type.trim() || !record4.sourceId.trim() || !record4.sourceRecordId.trim()) {
1095
1095
  throw new Error("type, source site id, and source record id must be non-empty");
@@ -1098,8 +1098,8 @@ async function projectSharedRecord(deps, shared, options = {}) {
1098
1098
  throw new Error("signed sender does not match payload source");
1099
1099
  }
1100
1100
  const tag = networkSourceTag(record4.type, record4.sourceRecordId, record4.version === 2 ? record4.sourceId : void 0);
1101
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1102
- const existing = await findSharedRecord(deps, record4, tag);
1101
+ const crmDeps3 = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1102
+ const existing = await findSharedRecord(deps2, record4, tag);
1103
1103
  let recordId;
1104
1104
  if (existing && typeof existing.id === "string") {
1105
1105
  await updateRecord(crmDeps3, { id: existing.id, input: record4.input });
@@ -1112,8 +1112,8 @@ async function projectSharedRecord(deps, shared, options = {}) {
1112
1112
  mutationId: `share-create:${tag}`
1113
1113
  });
1114
1114
  }
1115
- await deps.db.transact(
1116
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1115
+ await deps2.db.transact(
1116
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps2.now() } }],
1117
1117
  { mutationId: `share-map:${tag}:${recordId}` }
1118
1118
  );
1119
1119
  await upsertRecordOrigin(crmDeps3, {
@@ -1125,7 +1125,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
1125
1125
  });
1126
1126
  return { recordId };
1127
1127
  }
1128
- async function projectApplicant(deps, applicant) {
1128
+ async function projectApplicant(deps2, applicant) {
1129
1129
  const base = sharedPersonInput({
1130
1130
  email: applicant.email,
1131
1131
  firstName: applicant.firstName,
@@ -1136,11 +1136,11 @@ async function projectApplicant(deps, applicant) {
1136
1136
  });
1137
1137
  const mutationId = `apply:${applicant.applicationId}`;
1138
1138
  const extra = applicant.extra ?? {};
1139
- if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1139
+ if (Object.keys(extra).length === 0) return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1140
1140
  try {
1141
- return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
1141
+ return await upsertPerson(deps2, { email: applicant.email, input: { ...base, ...extra }, mutationId });
1142
1142
  } catch {
1143
- return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1143
+ return upsertPerson(deps2, { email: applicant.email, input: base, mutationId });
1144
1144
  }
1145
1145
  }
1146
1146
 
@@ -1178,12 +1178,12 @@ function billingColumns(app) {
1178
1178
  if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1179
1179
  return cols;
1180
1180
  }
1181
- async function syncApplicationToCrm(deps, opts) {
1181
+ async function syncApplicationToCrm(deps2, opts) {
1182
1182
  const emailKey = str(opts.app.email).toLowerCase();
1183
1183
  if (!emailKey) return null;
1184
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1185
- const input = personInputFromApp(deps.chapter, opts.app);
1186
- const { crm_record } = await deps.db.query({
1184
+ const recordDeps = { crm: deps2.crm, db: deps2.db, now: deps2.now, newId: deps2.newId };
1185
+ const input = personInputFromApp(deps2.chapter, opts.app);
1186
+ const { crm_record } = await deps2.db.query({
1187
1187
  crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1188
1188
  });
1189
1189
  const existing = crm_record?.[0] ?? null;
@@ -1199,12 +1199,12 @@ async function syncApplicationToCrm(deps, opts) {
1199
1199
  if (existing && stage && existing.stage !== stage) {
1200
1200
  await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1201
1201
  }
1202
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1202
+ await deps2.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1203
1203
  await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1204
1204
  return recordId;
1205
1205
  }
1206
- async function backfillCrm(deps) {
1207
- const data = await deps.db.query({
1206
+ async function backfillCrm(deps2) {
1207
+ const data = await deps2.db.query({
1208
1208
  applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } },
1209
1209
  $users: { $: { limit: 1e3 } }
1210
1210
  });
@@ -1216,7 +1216,7 @@ async function backfillCrm(deps) {
1216
1216
  if (!key || seen.has(key)) return;
1217
1217
  seen.add(key);
1218
1218
  try {
1219
- await syncApplicationToCrm(deps, { app, stage });
1219
+ await syncApplicationToCrm(deps2, { app, stage });
1220
1220
  synced += 1;
1221
1221
  } catch (err) {
1222
1222
  errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
@@ -1268,13 +1268,13 @@ function planDelivery(input) {
1268
1268
 
1269
1269
  // src/notify.ts
1270
1270
  var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1271
- async function sendTemplated(deps, input) {
1272
- const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1271
+ async function sendTemplated(deps2, input) {
1272
+ const { emailLog } = await deps2.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1273
1273
  const prior = Array.isArray(emailLog) ? emailLog : [];
1274
1274
  if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
1275
- const cloudflareReady = Boolean(deps.sender && deps.from);
1275
+ const cloudflareReady = Boolean(deps2.sender && deps2.from);
1276
1276
  const decision = planDelivery({
1277
- envName: deps.envName,
1277
+ envName: deps2.envName,
1278
1278
  group: input.group,
1279
1279
  template: input.template,
1280
1280
  to: input.to,
@@ -1285,10 +1285,10 @@ async function sendTemplated(deps, input) {
1285
1285
  if (!decision.deliver) return { sent: false, reason: decision.reason };
1286
1286
  let error;
1287
1287
  let messageId;
1288
- if (decision.transport === "cloudflare" && deps.sender && deps.from) {
1288
+ if (decision.transport === "cloudflare" && deps2.sender && deps2.from) {
1289
1289
  try {
1290
- const res = await deps.sender.send({
1291
- from: deps.from,
1290
+ const res = await deps2.sender.send({
1291
+ from: deps2.from,
1292
1292
  to: [decision.to],
1293
1293
  subject: decision.subject,
1294
1294
  text: decision.text,
@@ -1299,7 +1299,7 @@ async function sendTemplated(deps, input) {
1299
1299
  error = e instanceof Error ? e.message : String(e);
1300
1300
  }
1301
1301
  }
1302
- const id = deps.newId();
1302
+ const id = deps2.newId();
1303
1303
  const row = {
1304
1304
  id,
1305
1305
  groupId: input.group.id,
@@ -1310,12 +1310,12 @@ async function sendTemplated(deps, input) {
1310
1310
  transport: decision.transport,
1311
1311
  redirected: decision.redirected,
1312
1312
  dedupeKey: input.dedupeKey,
1313
- sentAt: deps.now(),
1313
+ sentAt: deps2.now(),
1314
1314
  ...input.applicationId ? { applicationId: input.applicationId } : {},
1315
1315
  ...messageId ? { messageId } : {},
1316
1316
  ...error ? { error } : {}
1317
1317
  };
1318
- await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1318
+ await deps2.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1319
1319
  return error ? { sent: false, reason: error } : { sent: true };
1320
1320
  }
1321
1321
  function emailGroupFrom(row) {
@@ -3802,6 +3802,7 @@ function findApplicationRef(obj) {
3802
3802
  const environment = candidates.map((metadata) => typeof metadata.odlaEnvironment === "string" ? metadata.odlaEnvironment : void 0).find(Boolean);
3803
3803
  const dataEnvironment = candidates.map((metadata) => typeof metadata.odlaDataEnvironment === "string" ? metadata.odlaDataEnvironment : void 0).find(Boolean);
3804
3804
  const runtime = candidates.map((metadata) => typeof metadata.odlaRuntime === "string" ? metadata.odlaRuntime : void 0).find(Boolean);
3805
+ const chapterId = candidates.map((metadata) => typeof metadata.chapterId === "string" ? metadata.chapterId : void 0).find(Boolean);
3805
3806
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
3806
3807
  return {
3807
3808
  ...applicationId ? { applicationId } : {},
@@ -3809,7 +3810,8 @@ function findApplicationRef(obj) {
3809
3810
  ...appId ? { appId } : {},
3810
3811
  ...environment ? { environment } : {},
3811
3812
  ...dataEnvironment ? { dataEnvironment } : {},
3812
- ...runtime ? { runtime } : {}
3813
+ ...runtime ? { runtime } : {},
3814
+ ...chapterId ? { chapterId } : {}
3813
3815
  };
3814
3816
  }
3815
3817
  function findGiftRef(obj) {
@@ -3825,13 +3827,15 @@ function findGiftRef(obj) {
3825
3827
  const environment = pickIdentity("odlaEnvironment");
3826
3828
  const dataEnvironment = pickIdentity("odlaDataEnvironment");
3827
3829
  const runtime = pickIdentity("odlaRuntime");
3830
+ const chapterId = pickIdentity("chapterId");
3828
3831
  return {
3829
3832
  ...giftIntentId ? { giftIntentId } : {},
3830
3833
  providerObjectId: subscriptionId ?? String(obj.id ?? ""),
3831
3834
  ...appId ? { appId } : {},
3832
3835
  ...environment ? { environment } : {},
3833
3836
  ...dataEnvironment ? { dataEnvironment } : {},
3834
- ...runtime ? { runtime } : {}
3837
+ ...runtime ? { runtime } : {},
3838
+ ...chapterId ? { chapterId } : {}
3835
3839
  };
3836
3840
  }
3837
3841
  function normalizeWebhookEvent(event) {
@@ -3879,8 +3883,83 @@ function canceledPatch() {
3879
3883
  return { canceled: true };
3880
3884
  }
3881
3885
 
3886
+ // src/payment-notifications.ts
3887
+ var groupFor = async (db, app) => {
3888
+ const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
3889
+ return result.groups?.[0];
3890
+ };
3891
+ var deps = (db, env) => ({
3892
+ db,
3893
+ envName: env.ODLA_ENV,
3894
+ sender: env.SEND_EMAIL,
3895
+ from: env.EMAIL_FROM,
3896
+ now: () => Date.now(),
3897
+ newId: () => crypto.randomUUID()
3898
+ });
3899
+ async function notifyPaymentConfirmed(db, env, eventId, app) {
3900
+ try {
3901
+ if (typeof app.email !== "string" || !app.email) return;
3902
+ const group = await groupFor(db, app);
3903
+ if (!group) return;
3904
+ await sendTemplated(deps(db, env), {
3905
+ group: emailGroupFrom(group),
3906
+ template: "paymentConfirmation",
3907
+ to: app.email,
3908
+ vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
3909
+ dedupeKey: `${eventId}:confirm`,
3910
+ applicationId: String(app.id)
3911
+ });
3912
+ } catch {
3913
+ }
3914
+ }
3915
+ async function notifyAdminOfPayment(db, env, eventId, app) {
3916
+ try {
3917
+ const group = await groupFor(db, app);
3918
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
3919
+ const text4 = (value) => typeof value === "string" ? value : "";
3920
+ await sendTemplated(deps(db, env), {
3921
+ group: emailGroupFrom(group),
3922
+ template: "adminNotification",
3923
+ to: group.notificationEmail,
3924
+ vars: {
3925
+ firstName: text4(app.firstName),
3926
+ lastName: text4(app.lastName),
3927
+ email: text4(app.email),
3928
+ phone: text4(app.phone),
3929
+ state: text4(app.state)
3930
+ },
3931
+ dedupeKey: `${eventId}:admin`,
3932
+ applicationId: String(app.id)
3933
+ });
3934
+ } catch {
3935
+ }
3936
+ }
3937
+
3882
3938
  // src/payments-stripe.ts
3883
3939
  import { stripeRequest, verifyStripeSignature as verifyStripeSignature2 } from "@odla-ai/stripe";
3940
+
3941
+ // src/stripe-runtime.ts
3942
+ function stripeWebhookSecretName(runtime) {
3943
+ if (!runtime) return "stripe_webhook_secret";
3944
+ if (!/^[a-z0-9]{2,12}$/.test(runtime) || runtime === "prod" || runtime === "production") {
3945
+ throw new Error("ODLA_RUNTIME is invalid");
3946
+ }
3947
+ return `stripe_webhook_secret__${runtime}`;
3948
+ }
3949
+ function stripeRuntimeIdentity(runtime) {
3950
+ return runtime || "default";
3951
+ }
3952
+ function stripePartitionMetadata(input) {
3953
+ return {
3954
+ odlaAppId: input.appId,
3955
+ odlaEnvironment: input.environment,
3956
+ odlaDataEnvironment: input.environment,
3957
+ odlaRuntime: stripeRuntimeIdentity(input.runtime),
3958
+ chapterId: input.chapterId
3959
+ };
3960
+ }
3961
+
3962
+ // src/payments-stripe.ts
3884
3963
  async function stripeCall(sk, method, path, params, idempotencyKey) {
3885
3964
  return stripeRequest(
3886
3965
  { secretKey: sk },
@@ -3919,10 +3998,7 @@ function createStripeProvider(config) {
3919
3998
  applicationId: input.applicationId,
3920
3999
  groupId: input.groupId,
3921
4000
  email: input.email,
3922
- odlaAppId: input.appId,
3923
- odlaEnvironment: input.environment,
3924
- odlaDataEnvironment: input.environment,
3925
- ...input.runtime ? { odlaRuntime: input.runtime } : {}
4001
+ ...stripePartitionMetadata(input)
3926
4002
  };
3927
4003
  let customerId = input.existingCustomerId;
3928
4004
  if (!customerId) {
@@ -3947,6 +4023,7 @@ function createStripeProvider(config) {
3947
4023
  "payment_settings[save_default_payment_method]": "on_subscription",
3948
4024
  "payment_settings[payment_method_types][0]": "card",
3949
4025
  "expand[0]": "latest_invoice.confirmation_secret",
4026
+ "expand[1]": "latest_invoice.payment_intent",
3950
4027
  metadata: meta
3951
4028
  },
3952
4029
  // Hardening: one subscription per application, so a client
@@ -3954,6 +4031,19 @@ function createStripeProvider(config) {
3954
4031
  `sub:${input.applicationId}`
3955
4032
  );
3956
4033
  if (!sub.ok) fail("subscription create", sub);
4034
+ const invoice = sub.body.latest_invoice;
4035
+ const paymentIntent = invoice?.payment_intent;
4036
+ const paymentIntentId = typeof paymentIntent === "string" ? paymentIntent : paymentIntent && typeof paymentIntent === "object" ? String(paymentIntent.id ?? "") : "";
4037
+ if (paymentIntentId) {
4038
+ const tagged = await stripeCall(
4039
+ sk,
4040
+ "POST",
4041
+ `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}`,
4042
+ { metadata: meta },
4043
+ `payment-partition:${input.applicationId}`
4044
+ );
4045
+ if (!tagged.ok) fail("payment intent metadata", tagged);
4046
+ }
3957
4047
  const clientSecret2 = clientSecretOf(sub.body);
3958
4048
  if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
3959
4049
  return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
@@ -4231,16 +4321,12 @@ var clientSecret = (value) => {
4231
4321
  const nested = confirmation?.client_secret ?? intent?.client_secret;
4232
4322
  return typeof nested === "string" && nested ? nested : null;
4233
4323
  };
4234
- async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId, runtime) {
4324
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId, runtime, appId = intent.chapterId) {
4235
4325
  const offer = intent.offerSnapshot;
4236
4326
  const metadata = {
4237
4327
  giftIntentId: intent.id,
4238
- chapterId: intent.chapterId,
4239
4328
  environment: intent.environment,
4240
- odlaAppId: intent.chapterId,
4241
- odlaEnvironment: intent.environment,
4242
- odlaDataEnvironment: intent.environment,
4243
- ...runtime ? { odlaRuntime: runtime } : {},
4329
+ ...stripePartitionMetadata({ appId, chapterId: intent.chapterId, environment: intent.environment, runtime }),
4244
4330
  offerId: intent.offerId
4245
4331
  };
4246
4332
  let customerId = existingCustomerId;
@@ -4257,10 +4343,20 @@ async function startGiftProviderCheckout(secretKey, intent, email, existingCusto
4257
4343
  "payment_settings[save_default_payment_method]": "on_subscription",
4258
4344
  "payment_settings[payment_method_types][0]": "card",
4259
4345
  "expand[0]": "latest_invoice.confirmation_secret",
4346
+ "expand[1]": "latest_invoice.payment_intent",
4260
4347
  metadata
4261
4348
  }, `gift-subscription:${intent.id}`);
4262
4349
  const secret2 = created2.ok ? clientSecret(created2.body) : null;
4263
4350
  if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
4351
+ const invoice = created2.body.latest_invoice;
4352
+ const paymentIntent = invoice?.payment_intent;
4353
+ const paymentIntentId = typeof paymentIntent === "string" ? paymentIntent : paymentIntent && typeof paymentIntent === "object" ? String(paymentIntent.id ?? "") : "";
4354
+ if (paymentIntentId) {
4355
+ const tagged = await stripeCall(secretKey, "POST", `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}`, {
4356
+ metadata
4357
+ }, `gift-payment-partition:${intent.id}`);
4358
+ if (!tagged.ok) throw new Error(`gift payment intent metadata failed (${tagged.status})`);
4359
+ }
4264
4360
  return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
4265
4361
  }
4266
4362
  const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
@@ -4319,7 +4415,8 @@ async function startGiftIntent(db, input) {
4319
4415
  stored,
4320
4416
  input.purchaserEmail,
4321
4417
  typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId,
4322
- input.runtime
4418
+ input.runtime,
4419
+ input.appId
4323
4420
  );
4324
4421
  await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
4325
4422
  status: "checkout_ready",
@@ -4338,15 +4435,6 @@ async function startGiftIntent(db, input) {
4338
4435
  };
4339
4436
  }
4340
4437
 
4341
- // src/stripe-runtime.ts
4342
- function stripeWebhookSecretName(runtime) {
4343
- if (!runtime) return "stripe_webhook_secret";
4344
- if (!/^[a-z0-9]{2,12}$/.test(runtime) || runtime === "prod" || runtime === "production") {
4345
- throw new Error("ODLA_RUNTIME is invalid");
4346
- }
4347
- return `stripe_webhook_secret__${runtime}`;
4348
- }
4349
-
4350
4438
  // src/payments-route-support.ts
4351
4439
  var paymentErrorCode = (error) => error && typeof error === "object" && typeof error.code === "string" ? error.code : "unknown";
4352
4440
  function paymentLineItems(group) {
@@ -4355,59 +4443,100 @@ function paymentLineItems(group) {
4355
4443
  return { standardCents: standard, discountCents: discount, dueTodayCents: standard - discount };
4356
4444
  }
4357
4445
 
4446
+ // src/member-system-contract.ts
4447
+ function classifyMemberStripeEvent(identity, target, hasProvenLegacyLocalLink) {
4448
+ const keys = ["appId", "environment", "dataEnvironment", "runtime", "chapterId"];
4449
+ const supplied = keys.filter((key) => identity[key] !== void 0);
4450
+ if (supplied.some((key) => identity[key] !== target[key])) return "ignore_sibling";
4451
+ if (supplied.length === keys.length) return "apply";
4452
+ return hasProvenLegacyLocalLink ? "apply_legacy" : "quarantine_unmatched";
4453
+ }
4454
+
4455
+ // src/stripe-webhook-partition.ts
4456
+ var firstRow2 = async (db, ns, q) => {
4457
+ const rows4 = (await db.query({ [ns]: { $: q } }))[ns];
4458
+ return Array.isArray(rows4) ? rows4[0] : void 0;
4459
+ };
4460
+ var stripeEventDigest = async (rawBody) => {
4461
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawBody));
4462
+ return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
4463
+ };
4464
+ var stripeEventIdentity = (event) => ({
4465
+ ...event.appId ? { appId: event.appId } : {},
4466
+ ...event.environment ? { environment: event.environment } : {},
4467
+ ...event.dataEnvironment ? { dataEnvironment: event.dataEnvironment } : {},
4468
+ ...event.runtime ? { runtime: event.runtime } : {},
4469
+ ...event.chapterId ? { chapterId: event.chapterId } : {}
4470
+ });
4471
+ var stripeEventTarget = (env, ctx) => ({
4472
+ appId: env.ODLA_APP_ID,
4473
+ environment: env.ODLA_ENV,
4474
+ dataEnvironment: env.ODLA_ENV,
4475
+ runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4476
+ chapterId: ctx.chapter.id
4477
+ });
4478
+ var receiptFor = (db, eventId) => firstRow2(db, "stripeEventReceipts", { where: { eventId }, limit: 1 });
4479
+ function reportStripePartition(level, eventId, event, disposition) {
4480
+ console[level]("chapter stripe partition", JSON.stringify({
4481
+ eventId,
4482
+ kind: event.kind,
4483
+ disposition,
4484
+ ...stripeEventIdentity(event)
4485
+ }));
4486
+ }
4487
+ async function storeStripeReceipt(db, input) {
4488
+ const existing = await receiptFor(db, input.eventId);
4489
+ if (existing) return existing.digest === input.digest ? "replayed" : "collision";
4490
+ const id = `stripe-event:${input.eventId}`;
4491
+ const write = await db.transact([{ t: "update", ns: "stripeEventReceipts", id, attrs: {
4492
+ id,
4493
+ eventId: input.eventId,
4494
+ digest: input.digest,
4495
+ disposition: input.disposition,
4496
+ kind: input.event.kind,
4497
+ matched: input.matched,
4498
+ ...stripeEventIdentity(input.event),
4499
+ receivedAt: Date.now()
4500
+ } }], { mutationId: `stripe-receipt:${input.eventId}` });
4501
+ if (!write.duplicate) return "stored";
4502
+ const raced = await receiptFor(db, input.eventId);
4503
+ return raced?.digest === input.digest ? "replayed" : "collision";
4504
+ }
4505
+ async function quarantineStripeEvent(db, eventId, digest, event) {
4506
+ const disposition = "quarantine_unmatched";
4507
+ const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
4508
+ if (stored === "collision") {
4509
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4510
+ return json({ ok: true, matched: false, collision: true });
4511
+ }
4512
+ reportStripePartition("warn", eventId, event, disposition);
4513
+ return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
4514
+ }
4515
+ async function replayedStripeReceipt(db, eventId, digest, event) {
4516
+ const existing = await receiptFor(db, eventId);
4517
+ if (!existing) return null;
4518
+ if (existing.digest !== digest) {
4519
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4520
+ return json({ ok: true, matched: false, collision: true });
4521
+ }
4522
+ return json({ ok: true, matched: existing.matched === true, replayed: true });
4523
+ }
4524
+
4358
4525
  // src/worker-routes-payments.ts
4359
- async function firstRow2(db, ns, q) {
4526
+ async function firstRow3(db, ns, q) {
4360
4527
  const res = await db.query({ [ns]: { $: q } });
4361
4528
  const rows4 = res[ns];
4362
4529
  return Array.isArray(rows4) ? rows4[0] : void 0;
4363
4530
  }
4364
4531
  async function findApplication(db, event) {
4365
4532
  if ("applicationId" in event && event.applicationId) {
4366
- return firstRow2(db, "applications", { where: { id: event.applicationId }, limit: 1 });
4533
+ return firstRow3(db, "applications", { where: { id: event.applicationId }, limit: 1 });
4367
4534
  }
4368
4535
  if ("customerId" in event && event.customerId) {
4369
- return firstRow2(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
4536
+ return firstRow3(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
4370
4537
  }
4371
4538
  return void 0;
4372
4539
  }
4373
- async function notifyPaymentConfirmed(db, env, eventId, app) {
4374
- try {
4375
- if (typeof app.email !== "string" || !app.email) return;
4376
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
4377
- if (!group) return;
4378
- await sendTemplated(
4379
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
4380
- {
4381
- group: emailGroupFrom(group),
4382
- template: "paymentConfirmation",
4383
- to: app.email,
4384
- vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
4385
- dedupeKey: `${eventId}:confirm`,
4386
- applicationId: String(app.id)
4387
- }
4388
- );
4389
- } catch {
4390
- }
4391
- }
4392
- async function notifyAdminOfPayment(db, env, eventId, app) {
4393
- try {
4394
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
4395
- if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
4396
- const s = (v) => typeof v === "string" ? v : "";
4397
- await sendTemplated(
4398
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
4399
- {
4400
- group: emailGroupFrom(group),
4401
- template: "adminNotification",
4402
- to: group.notificationEmail,
4403
- vars: { firstName: s(app.firstName), lastName: s(app.lastName), email: s(app.email), phone: s(app.phone), state: s(app.state) },
4404
- dedupeKey: `${eventId}:admin`,
4405
- applicationId: String(app.id)
4406
- }
4407
- );
4408
- } catch {
4409
- }
4410
- }
4411
4540
  function webhookPatch(event, status) {
4412
4541
  switch (event.kind) {
4413
4542
  case "first_payment":
@@ -4433,10 +4562,10 @@ async function startSubscription(req, env, ctx) {
4433
4562
  if (!applicationId) return json({ error: "applicationId required" }, 400);
4434
4563
  if (body.refundPolicyAck !== true) return json({ error: "refundPolicyAck required" }, 400);
4435
4564
  const db = ctx.makeDb(env);
4436
- const app = await firstRow2(db, "applications", { where: { id: applicationId }, limit: 1 });
4565
+ const app = await firstRow3(db, "applications", { where: { id: applicationId }, limit: 1 });
4437
4566
  if (!app) return json({ error: "not found" }, 404);
4438
4567
  if (app.status !== "submitted") return json({ error: "already processed" }, 409);
4439
- const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
4568
+ const group = await firstRow3(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
4440
4569
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
4441
4570
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
4442
4571
  if (typeof app.giftOfferId === "string") {
@@ -4448,6 +4577,7 @@ async function startSubscription(req, env, ctx) {
4448
4577
  try {
4449
4578
  const started = await startGiftIntent(db, {
4450
4579
  id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
4580
+ appId: env.ODLA_APP_ID,
4451
4581
  chapterId: ctx.chapter.id,
4452
4582
  environment: env.ODLA_ENV,
4453
4583
  offer,
@@ -4483,8 +4613,9 @@ async function startSubscription(req, env, ctx) {
4483
4613
  try {
4484
4614
  result = await provider.createSubscription({
4485
4615
  appId: env.ODLA_APP_ID,
4616
+ chapterId: ctx.chapter.id,
4486
4617
  environment: env.ODLA_ENV,
4487
- ...env.ODLA_RUNTIME ? { runtime: env.ODLA_RUNTIME } : {},
4618
+ runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4488
4619
  applicationId,
4489
4620
  groupId: String(group.id),
4490
4621
  email: String(app.email ?? ""),
@@ -4503,7 +4634,7 @@ async function startSubscription(req, env, ctx) {
4503
4634
  attrs: {
4504
4635
  stripeCustomerId: result.customerId,
4505
4636
  stripeSubscriptionId: result.subscriptionId,
4506
- ...env.ODLA_RUNTIME ? { stripeRuntime: env.ODLA_RUNTIME } : {},
4637
+ stripeRuntime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
4507
4638
  refundPolicyAckAt: Date.now()
4508
4639
  }
4509
4640
  }
@@ -4519,27 +4650,41 @@ async function ingestWebhook(req, env, ctx) {
4519
4650
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
4520
4651
  const { eventId, event } = ingest;
4521
4652
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
4522
- const identifiedEnvironment = event.appId !== void 0 || event.environment !== void 0 || event.dataEnvironment !== void 0 || event.runtime !== void 0;
4523
- const eventDataEnvironment = event.dataEnvironment ?? event.environment;
4524
- 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)) {
4525
- return json({ ok: true, matched: false });
4653
+ const digest = await stripeEventDigest(rawBody);
4654
+ const identity = stripeEventIdentity(event);
4655
+ const target = stripeEventTarget(env, ctx);
4656
+ const preliminary = classifyMemberStripeEvent(identity, target, false);
4657
+ if (preliminary === "ignore_sibling") {
4658
+ reportStripePartition("warn", eventId, event, preliminary);
4659
+ return json({ ok: true, matched: false, disposition: preliminary });
4526
4660
  }
4527
4661
  if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
4528
- const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
4529
- if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
4530
- return json({ ok: true, matched: false });
4531
- }
4532
- if (event.runtime === void 0 && typeof intent.runtime === "string" && intent.runtime !== env.ODLA_RUNTIME) {
4533
- return json({ ok: true, matched: false });
4662
+ const intent = await firstRow3(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
4663
+ if (intent && typeof intent.runtime === "string" && intent.runtime !== target.runtime) {
4664
+ reportStripePartition("warn", eventId, event, "ignore_sibling");
4665
+ return json({ ok: true, matched: false, disposition: "ignore_sibling" });
4666
+ }
4667
+ const replay2 = await replayedStripeReceipt(db, eventId, digest, event);
4668
+ if (replay2) return replay2;
4669
+ const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
4670
+ const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
4671
+ if (!localIntent || disposition2 === "quarantine_unmatched") {
4672
+ return quarantineStripeEvent(db, eventId, digest, event);
4534
4673
  }
4535
4674
  if (event.kind === "gift_paid") {
4536
- if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
4537
- if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
4675
+ if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
4676
+ return quarantineStripeEvent(db, eventId, digest, event);
4677
+ }
4538
4678
  try {
4539
4679
  const issued = await issueGiftClaim(db, intent, {
4540
4680
  eventId,
4541
4681
  objectId: event.providerObjectId
4542
4682
  }, Date.now());
4683
+ const stored3 = await storeStripeReceipt(db, { eventId, digest, event, disposition: disposition2, matched: true });
4684
+ if (stored3 === "collision") {
4685
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4686
+ return json({ ok: true, matched: false, collision: true });
4687
+ }
4543
4688
  return json({ ok: true, matched: true, claimId: issued.claimId });
4544
4689
  } catch {
4545
4690
  return json({ error: "gift issuance failed" }, 503);
@@ -4549,14 +4694,26 @@ async function ingestWebhook(req, env, ctx) {
4549
4694
  status: event.reason,
4550
4695
  updatedAt: Date.now()
4551
4696
  } }], { mutationId: `gift-revoked-intent:${eventId}` });
4552
- const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
4697
+ const claim = await firstRow3(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
4553
4698
  if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
4699
+ const stored2 = await storeStripeReceipt(db, { eventId, digest, event, disposition: disposition2, matched: Boolean(claim) });
4700
+ if (stored2 === "collision") {
4701
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4702
+ return json({ ok: true, matched: false, collision: true });
4703
+ }
4554
4704
  return json({ ok: true, matched: Boolean(claim) });
4555
4705
  }
4556
4706
  const app = await findApplication(db, event);
4557
- if (!app) return json({ ok: true, matched: false });
4558
- if (event.runtime === void 0 && typeof app.stripeRuntime === "string" && app.stripeRuntime !== env.ODLA_RUNTIME) {
4559
- return json({ ok: true, matched: false });
4707
+ if (app && typeof app.stripeRuntime === "string" && app.stripeRuntime !== target.runtime) {
4708
+ reportStripePartition("warn", eventId, event, "ignore_sibling");
4709
+ return json({ ok: true, matched: false, disposition: "ignore_sibling" });
4710
+ }
4711
+ const replay = await replayedStripeReceipt(db, eventId, digest, event);
4712
+ if (replay) return replay;
4713
+ const localApplication = Boolean(app && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
4714
+ const disposition = classifyMemberStripeEvent(identity, target, localApplication);
4715
+ if (!localApplication || disposition === "quarantine_unmatched") {
4716
+ return quarantineStripeEvent(db, eventId, digest, event);
4560
4717
  }
4561
4718
  const patch = webhookPatch(event, String(app.status ?? ""));
4562
4719
  let next = app;
@@ -4579,6 +4736,11 @@ async function ingestWebhook(req, env, ctx) {
4579
4736
  });
4580
4737
  }
4581
4738
  }
4739
+ const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: true });
4740
+ if (stored === "collision") {
4741
+ reportStripePartition("error", eventId, event, "quarantine_collision");
4742
+ return json({ ok: true, matched: false, collision: true });
4743
+ }
4582
4744
  return json({ ok: true });
4583
4745
  }
4584
4746
  var handlePayments = async (req, url, env, ctx) => {
@@ -4656,6 +4818,7 @@ var handleGifts = async (req, url, env, ctx) => {
4656
4818
  try {
4657
4819
  return json(await startGiftIntent(db, {
4658
4820
  id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
4821
+ appId: env.ODLA_APP_ID,
4659
4822
  chapterId: ctx.chapter.id,
4660
4823
  environment: env.ODLA_ENV,
4661
4824
  offer,
@@ -5405,7 +5568,19 @@ var handleAdminRefund = async (req, url, env, ctx) => {
5405
5568
  const succeeded = (charges.body.data ?? []).filter((c) => c.status === "succeeded" && c.refunded !== true);
5406
5569
  const firstCharge = succeeded[succeeded.length - 1];
5407
5570
  if (!firstCharge) return json({ error: "no paid charge to refund" }, 409);
5408
- const refund = await stripeCall(sk, "POST", "/v1/refunds", { charge: String(firstCharge.id) });
5571
+ const refund = await stripeCall(sk, "POST", "/v1/refunds", {
5572
+ charge: String(firstCharge.id),
5573
+ metadata: {
5574
+ ...stripePartitionMetadata({
5575
+ appId: env.ODLA_APP_ID,
5576
+ chapterId: ctx.chapter.id,
5577
+ environment: env.ODLA_ENV,
5578
+ runtime: env.ODLA_RUNTIME
5579
+ }),
5580
+ applicationId: String(app.id),
5581
+ groupId: String(app.groupId ?? ctx.chapter.id)
5582
+ }
5583
+ }, `refund:${String(app.id)}:${String(firstCharge.id)}`);
5409
5584
  if (!refund.ok) return json({ error: "refund failed upstream" }, 502);
5410
5585
  let subscriptionCanceled = false;
5411
5586
  if (ctx.chapter.operations.refund.cancelSubscription && subscriptionId) {
@@ -6076,8 +6251,8 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6076
6251
  }
6077
6252
  };
6078
6253
  try {
6079
- const deps = { crm: ctx.chapter.crm, db };
6080
- const updated = hasFields ? await updateRecord3(deps, {
6254
+ const deps2 = { crm: ctx.chapter.crm, db };
6255
+ const updated = hasFields ? await updateRecord3(deps2, {
6081
6256
  id: record4.id,
6082
6257
  input: fields,
6083
6258
  mutationId: activityId,
@@ -6088,7 +6263,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
6088
6263
  authorId: `network:${verified.sender}`,
6089
6264
  meta: auditMeta
6090
6265
  }
6091
- }) : await setStage2(deps, {
6266
+ }) : await setStage2(deps2, {
6092
6267
  id: record4.id,
6093
6268
  to: input.stage,
6094
6269
  authorId: `network:${verified.sender}`,