@odla-ai/chapter 0.55.0 → 0.56.1

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.
@@ -946,6 +946,8 @@ interface MembershipOfferPreview {
946
946
  renewalCents: number;
947
947
  discountDuration?: "first_term" | "while_active";
948
948
  initialTermEndsAt?: number;
949
+ /** Current additional-member price, including zero for an included member. */
950
+ seatPriceCents?: number;
949
951
  }
950
952
 
951
953
  /** Server-owned source identity; the browser never supplies an offer or its price. */
@@ -946,6 +946,8 @@ interface MembershipOfferPreview {
946
946
  renewalCents: number;
947
947
  discountDuration?: "first_term" | "while_active";
948
948
  initialTermEndsAt?: number;
949
+ /** Current additional-member price, including zero for an included member. */
950
+ seatPriceCents?: number;
949
951
  }
950
952
 
951
953
  /** Server-owned source identity; the browser never supplies an offer or its price. */
@@ -1713,7 +1713,7 @@ function findTier(tiers, tierId) {
1713
1713
  if (!tierId) return tiers.length === 1 ? tiers[0] : null;
1714
1714
  return tiers.find((tier) => tier.id === tierId) ?? null;
1715
1715
  }
1716
- function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
1716
+ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map(), seats = /* @__PURE__ */ new Map()) {
1717
1717
  return tiers.map((tier) => ({
1718
1718
  id: tier.id,
1719
1719
  name: tier.name,
@@ -1722,7 +1722,8 @@ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Ma
1722
1722
  interval: tier.interval ?? defaults.interval ?? "year",
1723
1723
  blurb: tier.blurb ?? "",
1724
1724
  free: tierIsFree(tier),
1725
- ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
1725
+ ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {},
1726
+ ...seats.has(tier.id) ? { seatOffer: seats.get(tier.id) } : {}
1726
1727
  }));
1727
1728
  }
1728
1729
 
@@ -1780,7 +1781,7 @@ async function frozenCheckoutVariables(db, app) {
1780
1781
  const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
1781
1782
  const offer = intent?.membershipOffer, contract = offer?.contract;
1782
1783
  const offering = contract?.offering, quote = intent?.paymentQuote;
1783
- const amount = contract?.dueTodayCents ?? intent?.priceCents;
1784
+ const amount = quote?.lineItems?.dueTodayCents ?? contract?.dueTodayCents ?? intent?.priceCents;
1784
1785
  const currency = offering?.currency ?? intent?.currency;
1785
1786
  let formatted = "";
1786
1787
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
@@ -2215,6 +2216,7 @@ async function previewMembershipCommercialOffer(db, input) {
2215
2216
  ...contract.founding && contract.discountPolicy ? { cohortLabel: contract.discountPolicy.cohortLabel } : {},
2216
2217
  dueTodayCents: contract.dueTodayCents,
2217
2218
  renewalCents: contract.renewalCents,
2219
+ ...offering.seatPriceCents !== void 0 ? { seatPriceCents: offering.seatPriceCents } : {},
2218
2220
  ...discount ? { discountDuration: discount.duration === "once" ? "first_term" : "while_active" } : {},
2219
2221
  ...contract.initialTermEndsAt !== void 0 ? { initialTermEndsAt: contract.initialTermEndsAt } : {}
2220
2222
  };
@@ -3186,6 +3188,48 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
3186
3188
  }
3187
3189
  }
3188
3190
 
3191
+ // src/payment-receipt.ts
3192
+ async function retainPaymentReceipt(db, intent, rawBody) {
3193
+ const quote = intent.paymentQuote;
3194
+ if (!quote || intent.paymentReceipt) return;
3195
+ const event2 = JSON.parse(rawBody), invoice = event2.data?.object;
3196
+ const paidAt = invoice?.status_transitions?.paid_at ?? event2.created;
3197
+ if (!/^in_[A-Za-z0-9_]+$/.test(invoice?.id ?? "") || !Number.isSafeInteger(paidAt) || paidAt <= 0) return;
3198
+ const receipt = {
3199
+ invoiceId: invoice.id,
3200
+ paidAt: paidAt * 1e3,
3201
+ currency: quote.currency,
3202
+ tierName: quote.tierName,
3203
+ merchant: quote.merchantDisclosureText,
3204
+ membershipCents: quote.lineItems.dueTodayCents - (quote.lineItems.namedSeatCents ?? 0),
3205
+ totalCents: quote.lineItems.dueTodayCents,
3206
+ ...quote.namedSeat ? { namedSeat: { recipientName: quote.namedSeat.recipientName, amountCents: quote.lineItems.namedSeatCents } } : {}
3207
+ };
3208
+ await db.transact([{ t: "update", ns: "subscriptionCheckoutIntents", id: String(intent.id), attrs: { paymentReceipt: receipt } }], {
3209
+ mutationId: `payment-receipt:${invoice.id}`,
3210
+ guards: [{
3211
+ ns: "subscriptionCheckoutIntents",
3212
+ id: String(intent.id),
3213
+ exists: true,
3214
+ equals: { providerSubscriptionId: intent.providerSubscriptionId, providerCustomerId: intent.providerCustomerId },
3215
+ absent: ["paymentReceipt"]
3216
+ }]
3217
+ });
3218
+ }
3219
+ function paymentReceiptText(receipt, chapterName) {
3220
+ const money = (cents) => new Intl.NumberFormat("en-US", { style: "currency", currency: receipt.currency }).format(cents / 100);
3221
+ return [
3222
+ chapterName,
3223
+ "Payment receipt",
3224
+ `Invoice: ${receipt.invoiceId}`,
3225
+ `Paid: ${new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(receipt.paidAt)}`,
3226
+ receipt.merchant,
3227
+ `${receipt.tierName}: ${money(receipt.membershipCents)}`,
3228
+ ...receipt.namedSeat ? [`Additional member \u2014 ${receipt.namedSeat.recipientName}: ${receipt.namedSeat.amountCents === 0 ? "Included \u2014 " : ""}${money(receipt.namedSeat.amountCents)}`] : [],
3229
+ `Total paid: ${money(receipt.totalCents)}`
3230
+ ].filter(Boolean).join("\n");
3231
+ }
3232
+
3189
3233
  // src/payment-notifications.ts
3190
3234
  var groupFor = async (db, app) => {
3191
3235
  const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
@@ -3748,6 +3792,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3748
3792
  } catch {
3749
3793
  return json({ error: "network membership payment reconciliation pending" }, 503);
3750
3794
  }
3795
+ if (intent) await retainPaymentReceipt(db, intent, rawBody);
3751
3796
  }
3752
3797
  }
3753
3798
  const invoiceEvent = event2.kind === "first_payment" || event2.kind === "renewal" || event2.kind === "renewal_failed";
@@ -5534,7 +5579,7 @@ async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
5534
5579
  }
5535
5580
 
5536
5581
  // src/join-founding-preview.ts
5537
- async function foundingPreviews(tiers, env, ctx) {
5582
+ async function foundingPreviews(tiers, env, ctx, seats = /* @__PURE__ */ new Map()) {
5538
5583
  const result = /* @__PURE__ */ new Map();
5539
5584
  const preview = ctx.membershipAuthority?.previewOffer;
5540
5585
  if (!preview) return result;
@@ -5542,6 +5587,10 @@ async function foundingPreviews(tiers, env, ctx) {
5542
5587
  await Promise.all(tiers.filter((tier) => !tierIsFree(tier) && tier.stripePriceId).map(async (tier) => {
5543
5588
  try {
5544
5589
  const offer = await preview({ ...scope2, skuId: tier.id }, env);
5590
+ if (offer.skuId !== tier.id || offer.basePriceCents !== tier.priceCents) return;
5591
+ if (ctx.membershipAuthority?.namedSeats && Number.isSafeInteger(offer.seatPriceCents) && Number(offer.seatPriceCents) >= 0) {
5592
+ seats.set(tier.id, { amountCents: Number(offer.seatPriceCents) });
5593
+ }
5545
5594
  if (!offer.founding || !offer.cohortLabel || offer.basePriceCents !== tier.priceCents || offer.dueTodayCents >= offer.basePriceCents) return;
5546
5595
  result.set(tier.id, {
5547
5596
  cohortLabel: offer.cohortLabel,
@@ -7118,11 +7167,12 @@ var handleMember = async (req, url, env, ctx) => {
7118
7167
  group,
7119
7168
  Boolean(stripeKey)
7120
7169
  );
7121
- const founding = await foundingPreviews(offered, env, ctx);
7170
+ const seats = /* @__PURE__ */ new Map();
7171
+ const founding = await foundingPreviews(offered, env, ctx, seats);
7122
7172
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
7123
7173
  const application = controlledApplication(chapter.application, activeControl?.document);
7124
7174
  return json({
7125
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered, { currency: typeof group.currency === "string" ? group.currency : void 0, interval: group.interval === "month" ? "month" : "year" }, founding), application.conditions),
7175
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered, { currency: typeof group.currency === "string" ? group.currency : void 0, interval: group.interval === "month" ? "month" : "year" }, founding, seats), application.conditions),
7126
7176
  fieldContract: chapterFlowFieldContract(chapter, "join", application),
7127
7177
  copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
7128
7178
  ...activeControl ? { signupControl: {
@@ -9905,6 +9955,58 @@ async function startSubscription(req, env, ctx) {
9905
9955
  return json({ clientSecret: result.clientSecret, publishableKey: group.stripePublishableKey ?? null, lineItems: quote.lineItems });
9906
9956
  }
9907
9957
 
9958
+ // src/worker-payment-receipt.ts
9959
+ var json2 = (value, status2 = 200) => Response.json(value, { status: status2, headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" } });
9960
+ var escape = (text8) => text8.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
9961
+ async function paymentReceiptRoute(req, url, env, ctx) {
9962
+ const id7 = url.searchParams.get("application");
9963
+ if (!id7 || id7.length > 160) return json2({ error: "application required" }, 400);
9964
+ if (req.method === "POST" && req.headers.has("origin") && req.headers.get("origin") !== url.origin) return json2({ error: "forbidden" }, 403);
9965
+ const db = ctx.makeDb(env);
9966
+ const rows5 = await db.query({ applications: { $: { where: { id: id7 }, limit: 1 } }, subscriptionCheckoutIntents: { $: { where: { id: id7 }, limit: 1 } } });
9967
+ const app = rows5.applications?.[0], intent = rows5.subscriptionCheckoutIntents?.[0];
9968
+ if (!app || app.groupId !== ctx.chapter.id || !intent || intent.applicationId !== id7 || intent.appId !== env.ODLA_APP_ID || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV || intent.runtime !== stripeRuntimeIdentity(env.ODLA_RUNTIME) || app.stripeRuntime !== intent.runtime || app.stripeSubscriptionId !== intent.providerSubscriptionId) return json2({ error: "receipt not found" }, 404);
9969
+ const receipt = intent.paymentReceipt;
9970
+ if (!receipt) return json2({ pending: true }, 202);
9971
+ const text8 = paymentReceiptText(receipt, ctx.chapter.name);
9972
+ if (req.method === "POST") {
9973
+ if (!env.SEND_EMAIL || !env.EMAIL_FROM) return json2({ error: "Email is unavailable. You can print or save your receipt." }, 503);
9974
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
9975
+ if (!group) return json2({ error: "Receipt email is unavailable." }, 503);
9976
+ const config = emailGroupFrom(group);
9977
+ const debugEmail = (env.ODLA_DEBUG_EMAIL ?? config.debugEmail)?.trim();
9978
+ if (env.ODLA_ENV !== "prod" && (!debugEmail || !/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(debugEmail))) {
9979
+ return json2({ error: "A test inbox is required to email receipts in this environment." }, 503);
9980
+ }
9981
+ const result = await sendTemplated(lifecycleNotifyDeps(db, env), {
9982
+ group: { ...config, emailTemplates: {
9983
+ ...config.emailTemplates,
9984
+ paymentReceipt: { enabled: true, subject: "Your {{chapterName}} payment receipt", text: "{{receiptText}}" }
9985
+ } },
9986
+ template: "paymentReceipt",
9987
+ to: String(intent.purchaserEmailNormalized),
9988
+ applicationId: id7,
9989
+ vars: { chapterName: ctx.chapter.name, receiptText: text8 },
9990
+ dedupeKey: `requested-payment-receipt:${receipt.invoiceId}`,
9991
+ event: { label: "Payment receipt", trigger: "Purchaser requests their receipt", audience: "Purchaser", variables: ["chapterName", "receiptText"] }
9992
+ });
9993
+ return result.sent ? json2({ sent: true, preview: env.ODLA_ENV !== "prod" }) : json2({ error: "We could not confirm email delivery. You can print or save your receipt." }, 503);
9994
+ }
9995
+ if (url.searchParams.get("format") !== "html") return json2({ receipt, chapterName: ctx.chapter.name });
9996
+ const nonce = crypto.randomUUID();
9997
+ return new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Payment receipt</title>
9998
+ <style>body{font:16px/1.6 system-ui;max-width:680px;margin:48px auto;padding:24px;color:#17252d}pre{font:inherit;white-space:pre-wrap}button{font:inherit;padding:10px 16px}@media print{button{display:none}body{margin:0}}</style>
9999
+ <h1>Payment receipt</h1><pre>${escape(text8)}</pre><button id="print">Print / save PDF</button>
10000
+ <script nonce="${nonce}">document.getElementById('print').addEventListener('click',()=>window.print())</script></html>`, {
10001
+ headers: {
10002
+ "content-type": "text/html; charset=utf-8",
10003
+ "cache-control": "no-store",
10004
+ "referrer-policy": "no-referrer",
10005
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; frame-ancestors 'none'; base-uri 'none'`
10006
+ }
10007
+ });
10008
+ }
10009
+
9908
10010
  // src/worker-routes-payments.ts
9909
10011
  async function ingestWebhook(req, env, ctx) {
9910
10012
  const db = ctx.makeDb(env);
@@ -9917,6 +10019,7 @@ async function ingestWebhook(req, env, ctx) {
9917
10019
  }
9918
10020
  var handlePayments = async (req, url, env, ctx) => {
9919
10021
  if (ctx.chapter.mode !== "chapter") return null;
10022
+ if (["GET", "POST"].includes(req.method) && url.pathname === "/api/payments/receipt") return paymentReceiptRoute(req, url, env, ctx);
9920
10023
  if (req.method === "GET" && url.pathname === "/api/payments/quote") return paymentQuoteRoute(url, env, ctx);
9921
10024
  if (req.method === "POST" && url.pathname === "/api/payments/quote") return paymentQuoteRoute(url, env, ctx, true, req);
9922
10025
  if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
@@ -16825,7 +16928,7 @@ async function requestMembershipCommercialOffer(options) {
16825
16928
  async function requestMembershipOfferPreview(options) {
16826
16929
  const result = await requestAuthority(options, "offer-preview", { skuId: options.skuId });
16827
16930
  const preview = result.preview;
16828
- if (!preview || preview.skuId !== options.skuId || typeof preview.founding !== "boolean" || !Number.isSafeInteger(preview.basePriceCents) || !Number.isSafeInteger(preview.dueTodayCents) || preview.dueTodayCents < 0 || preview.dueTodayCents > preview.basePriceCents || !Number.isSafeInteger(preview.renewalCents) || !/^[a-z]{3}$/.test(preview.currency) || !["month", "year"].includes(preview.interval) || preview.cohortLabel !== void 0 && typeof preview.cohortLabel !== "string" || preview.discountDuration !== void 0 && !["first_term", "while_active"].includes(preview.discountDuration)) throw new Error("invalid offer preview");
16931
+ if (!preview || preview.skuId !== options.skuId || typeof preview.founding !== "boolean" || !Number.isSafeInteger(preview.basePriceCents) || !Number.isSafeInteger(preview.dueTodayCents) || preview.dueTodayCents < 0 || preview.dueTodayCents > preview.basePriceCents || !Number.isSafeInteger(preview.renewalCents) || !/^[a-z]{3}$/.test(preview.currency) || !["month", "year"].includes(preview.interval) || preview.cohortLabel !== void 0 && typeof preview.cohortLabel !== "string" || preview.seatPriceCents !== void 0 && (!Number.isSafeInteger(preview.seatPriceCents) || preview.seatPriceCents < 0) || preview.discountDuration !== void 0 && !["first_term", "while_active"].includes(preview.discountDuration)) throw new Error("invalid offer preview");
16829
16932
  return preview;
16830
16933
  }
16831
16934
  async function requestMembershipCheckoutBinding(options) {