@odla-ai/chapter 0.55.0 → 0.56.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.
package/dist/ui/index.js CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  timeLabel,
30
30
  tzShort,
31
31
  useTierSelection
32
- } from "../chunk-VEOGTSVX.js";
32
+ } from "../chunk-IQZWCG42.js";
33
33
  import {
34
34
  APPLICATION_STAGES,
35
35
  AdminNote,
@@ -262,6 +262,10 @@ interface JoinTier {
262
262
  blurb: string;
263
263
  /** True when the tier costs nothing, so the flow skips the payment step. */
264
264
  free: boolean;
265
+ /** Current configured additional-member price; zero means included. Checkout confirms the offer. */
266
+ seatOffer?: {
267
+ amountCents: number;
268
+ };
265
269
  /** Present when the network authority currently qualifies applicants as founding members at a reduced rate. */
266
270
  founding?: {
267
271
  cohortLabel: string;
@@ -278,7 +282,7 @@ interface JoinTier {
278
282
  * is an unstyled placeholder, not a design.
279
283
  */
280
284
  interface JoinTierRenderContext {
281
- /** Offered tiers, in display order. */
285
+ /** Offered tiers, with the selected tier first and the others in catalog order. */
282
286
  tiers: readonly JoinTier[];
283
287
  /** The chosen tier, or null while nothing is chosen. */
284
288
  selected: JoinTier | null;
@@ -29,7 +29,7 @@ import {
29
29
  timeLabel,
30
30
  tzShort,
31
31
  useTierSelection
32
- } from "../../chunk-VEOGTSVX.js";
32
+ } from "../../chunk-IQZWCG42.js";
33
33
  import {
34
34
  BrandStyle,
35
35
  ChapterCopyProvider,
@@ -1762,7 +1762,7 @@ function findTier(tiers, tierId) {
1762
1762
  if (!tierId) return tiers.length === 1 ? tiers[0] : null;
1763
1763
  return tiers.find((tier) => tier.id === tierId) ?? null;
1764
1764
  }
1765
- function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map()) {
1765
+ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Map(), seats = /* @__PURE__ */ new Map()) {
1766
1766
  return tiers.map((tier) => ({
1767
1767
  id: tier.id,
1768
1768
  name: tier.name,
@@ -1771,7 +1771,8 @@ function joinConfigTiers(tiers, defaults = {}, founding = /* @__PURE__ */ new Ma
1771
1771
  interval: tier.interval ?? defaults.interval ?? "year",
1772
1772
  blurb: tier.blurb ?? "",
1773
1773
  free: tierIsFree(tier),
1774
- ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {}
1774
+ ...founding.has(tier.id) ? { founding: founding.get(tier.id) } : {},
1775
+ ...seats.has(tier.id) ? { seatOffer: seats.get(tier.id) } : {}
1775
1776
  }));
1776
1777
  }
1777
1778
 
@@ -1829,7 +1830,7 @@ async function frozenCheckoutVariables(db, app) {
1829
1830
  const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
1830
1831
  const offer = intent?.membershipOffer, contract = offer?.contract;
1831
1832
  const offering = contract?.offering, quote = intent?.paymentQuote;
1832
- const amount = contract?.dueTodayCents ?? intent?.priceCents;
1833
+ const amount = quote?.lineItems?.dueTodayCents ?? contract?.dueTodayCents ?? intent?.priceCents;
1833
1834
  const currency = offering?.currency ?? intent?.currency;
1834
1835
  let formatted = "";
1835
1836
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
@@ -2264,6 +2265,7 @@ async function previewMembershipCommercialOffer(db, input) {
2264
2265
  ...contract.founding && contract.discountPolicy ? { cohortLabel: contract.discountPolicy.cohortLabel } : {},
2265
2266
  dueTodayCents: contract.dueTodayCents,
2266
2267
  renewalCents: contract.renewalCents,
2268
+ ...offering.seatPriceCents !== void 0 ? { seatPriceCents: offering.seatPriceCents } : {},
2267
2269
  ...discount ? { discountDuration: discount.duration === "once" ? "first_term" : "while_active" } : {},
2268
2270
  ...contract.initialTermEndsAt !== void 0 ? { initialTermEndsAt: contract.initialTermEndsAt } : {}
2269
2271
  };
@@ -3235,6 +3237,48 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
3235
3237
  }
3236
3238
  }
3237
3239
 
3240
+ // src/payment-receipt.ts
3241
+ async function retainPaymentReceipt(db, intent, rawBody) {
3242
+ const quote = intent.paymentQuote;
3243
+ if (!quote || intent.paymentReceipt) return;
3244
+ const event2 = JSON.parse(rawBody), invoice = event2.data?.object;
3245
+ const paidAt = invoice?.status_transitions?.paid_at ?? event2.created;
3246
+ if (!/^in_[A-Za-z0-9_]+$/.test(invoice?.id ?? "") || !Number.isSafeInteger(paidAt) || paidAt <= 0) return;
3247
+ const receipt = {
3248
+ invoiceId: invoice.id,
3249
+ paidAt: paidAt * 1e3,
3250
+ currency: quote.currency,
3251
+ tierName: quote.tierName,
3252
+ merchant: quote.merchantDisclosureText,
3253
+ membershipCents: quote.lineItems.dueTodayCents - (quote.lineItems.namedSeatCents ?? 0),
3254
+ totalCents: quote.lineItems.dueTodayCents,
3255
+ ...quote.namedSeat ? { namedSeat: { recipientName: quote.namedSeat.recipientName, amountCents: quote.lineItems.namedSeatCents } } : {}
3256
+ };
3257
+ await db.transact([{ t: "update", ns: "subscriptionCheckoutIntents", id: String(intent.id), attrs: { paymentReceipt: receipt } }], {
3258
+ mutationId: `payment-receipt:${invoice.id}`,
3259
+ guards: [{
3260
+ ns: "subscriptionCheckoutIntents",
3261
+ id: String(intent.id),
3262
+ exists: true,
3263
+ equals: { providerSubscriptionId: intent.providerSubscriptionId, providerCustomerId: intent.providerCustomerId },
3264
+ absent: ["paymentReceipt"]
3265
+ }]
3266
+ });
3267
+ }
3268
+ function paymentReceiptText(receipt, chapterName) {
3269
+ const money = (cents) => new Intl.NumberFormat("en-US", { style: "currency", currency: receipt.currency }).format(cents / 100);
3270
+ return [
3271
+ chapterName,
3272
+ "Payment receipt",
3273
+ `Invoice: ${receipt.invoiceId}`,
3274
+ `Paid: ${new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(receipt.paidAt)}`,
3275
+ receipt.merchant,
3276
+ `${receipt.tierName}: ${money(receipt.membershipCents)}`,
3277
+ ...receipt.namedSeat ? [`Additional member \u2014 ${receipt.namedSeat.recipientName}: ${receipt.namedSeat.amountCents === 0 ? "Included \u2014 " : ""}${money(receipt.namedSeat.amountCents)}`] : [],
3278
+ `Total paid: ${money(receipt.totalCents)}`
3279
+ ].filter(Boolean).join("\n");
3280
+ }
3281
+
3238
3282
  // src/payment-notifications.ts
3239
3283
  var groupFor = async (db, app) => {
3240
3284
  const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
@@ -3797,6 +3841,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
3797
3841
  } catch {
3798
3842
  return json({ error: "network membership payment reconciliation pending" }, 503);
3799
3843
  }
3844
+ if (intent) await retainPaymentReceipt(db, intent, rawBody);
3800
3845
  }
3801
3846
  }
3802
3847
  const invoiceEvent = event2.kind === "first_payment" || event2.kind === "renewal" || event2.kind === "renewal_failed";
@@ -5583,7 +5628,7 @@ async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
5583
5628
  }
5584
5629
 
5585
5630
  // src/join-founding-preview.ts
5586
- async function foundingPreviews(tiers, env, ctx) {
5631
+ async function foundingPreviews(tiers, env, ctx, seats = /* @__PURE__ */ new Map()) {
5587
5632
  const result = /* @__PURE__ */ new Map();
5588
5633
  const preview = ctx.membershipAuthority?.previewOffer;
5589
5634
  if (!preview) return result;
@@ -5591,6 +5636,10 @@ async function foundingPreviews(tiers, env, ctx) {
5591
5636
  await Promise.all(tiers.filter((tier) => !tierIsFree(tier) && tier.stripePriceId).map(async (tier) => {
5592
5637
  try {
5593
5638
  const offer = await preview({ ...scope2, skuId: tier.id }, env);
5639
+ if (offer.skuId !== tier.id || offer.basePriceCents !== tier.priceCents) return;
5640
+ if (ctx.membershipAuthority?.namedSeats && Number.isSafeInteger(offer.seatPriceCents) && Number(offer.seatPriceCents) >= 0) {
5641
+ seats.set(tier.id, { amountCents: Number(offer.seatPriceCents) });
5642
+ }
5594
5643
  if (!offer.founding || !offer.cohortLabel || offer.basePriceCents !== tier.priceCents || offer.dueTodayCents >= offer.basePriceCents) return;
5595
5644
  result.set(tier.id, {
5596
5645
  cohortLabel: offer.cohortLabel,
@@ -7167,11 +7216,12 @@ var handleMember = async (req, url, env, ctx) => {
7167
7216
  group,
7168
7217
  Boolean(stripeKey)
7169
7218
  );
7170
- const founding = await foundingPreviews(offered, env, ctx);
7219
+ const seats = /* @__PURE__ */ new Map();
7220
+ const founding = await foundingPreviews(offered, env, ctx, seats);
7171
7221
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
7172
7222
  const application = controlledApplication(chapter.application, activeControl?.document);
7173
7223
  return json({
7174
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered, { currency: typeof group.currency === "string" ? group.currency : void 0, interval: group.interval === "month" ? "month" : "year" }, founding), application.conditions),
7224
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered, { currency: typeof group.currency === "string" ? group.currency : void 0, interval: group.interval === "month" ? "month" : "year" }, founding, seats), application.conditions),
7175
7225
  fieldContract: chapterFlowFieldContract(chapter, "join", application),
7176
7226
  copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
7177
7227
  ...activeControl ? { signupControl: {
@@ -9954,6 +10004,58 @@ async function startSubscription(req, env, ctx) {
9954
10004
  return json({ clientSecret: result.clientSecret, publishableKey: group.stripePublishableKey ?? null, lineItems: quote.lineItems });
9955
10005
  }
9956
10006
 
10007
+ // src/worker-payment-receipt.ts
10008
+ var json2 = (value, status2 = 200) => Response.json(value, { status: status2, headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" } });
10009
+ var escape = (text8) => text8.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
10010
+ async function paymentReceiptRoute(req, url, env, ctx) {
10011
+ const id7 = url.searchParams.get("application");
10012
+ if (!id7 || id7.length > 160) return json2({ error: "application required" }, 400);
10013
+ if (req.method === "POST" && req.headers.has("origin") && req.headers.get("origin") !== url.origin) return json2({ error: "forbidden" }, 403);
10014
+ const db = ctx.makeDb(env);
10015
+ const rows5 = await db.query({ applications: { $: { where: { id: id7 }, limit: 1 } }, subscriptionCheckoutIntents: { $: { where: { id: id7 }, limit: 1 } } });
10016
+ const app = rows5.applications?.[0], intent = rows5.subscriptionCheckoutIntents?.[0];
10017
+ 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);
10018
+ const receipt = intent.paymentReceipt;
10019
+ if (!receipt) return json2({ pending: true }, 202);
10020
+ const text8 = paymentReceiptText(receipt, ctx.chapter.name);
10021
+ if (req.method === "POST") {
10022
+ if (!env.SEND_EMAIL || !env.EMAIL_FROM) return json2({ error: "Email is unavailable. You can print or save your receipt." }, 503);
10023
+ const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
10024
+ if (!group) return json2({ error: "Receipt email is unavailable." }, 503);
10025
+ const config = emailGroupFrom(group);
10026
+ const debugEmail = (env.ODLA_DEBUG_EMAIL ?? config.debugEmail)?.trim();
10027
+ if (env.ODLA_ENV !== "prod" && (!debugEmail || !/^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(debugEmail))) {
10028
+ return json2({ error: "A test inbox is required to email receipts in this environment." }, 503);
10029
+ }
10030
+ const result = await sendTemplated(lifecycleNotifyDeps(db, env), {
10031
+ group: { ...config, emailTemplates: {
10032
+ ...config.emailTemplates,
10033
+ paymentReceipt: { enabled: true, subject: "Your {{chapterName}} payment receipt", text: "{{receiptText}}" }
10034
+ } },
10035
+ template: "paymentReceipt",
10036
+ to: String(intent.purchaserEmailNormalized),
10037
+ applicationId: id7,
10038
+ vars: { chapterName: ctx.chapter.name, receiptText: text8 },
10039
+ dedupeKey: `requested-payment-receipt:${receipt.invoiceId}`,
10040
+ event: { label: "Payment receipt", trigger: "Purchaser requests their receipt", audience: "Purchaser", variables: ["chapterName", "receiptText"] }
10041
+ });
10042
+ 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);
10043
+ }
10044
+ if (url.searchParams.get("format") !== "html") return json2({ receipt, chapterName: ctx.chapter.name });
10045
+ const nonce = crypto.randomUUID();
10046
+ return new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Payment receipt</title>
10047
+ <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>
10048
+ <h1>Payment receipt</h1><pre>${escape(text8)}</pre><button id="print">Print / save PDF</button>
10049
+ <script nonce="${nonce}">document.getElementById('print').addEventListener('click',()=>window.print())</script></html>`, {
10050
+ headers: {
10051
+ "content-type": "text/html; charset=utf-8",
10052
+ "cache-control": "no-store",
10053
+ "referrer-policy": "no-referrer",
10054
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; frame-ancestors 'none'; base-uri 'none'`
10055
+ }
10056
+ });
10057
+ }
10058
+
9957
10059
  // src/worker-routes-payments.ts
9958
10060
  async function ingestWebhook(req, env, ctx) {
9959
10061
  const db = ctx.makeDb(env);
@@ -9966,6 +10068,7 @@ async function ingestWebhook(req, env, ctx) {
9966
10068
  }
9967
10069
  var handlePayments = async (req, url, env, ctx) => {
9968
10070
  if (ctx.chapter.mode !== "chapter") return null;
10071
+ if (["GET", "POST"].includes(req.method) && url.pathname === "/api/payments/receipt") return paymentReceiptRoute(req, url, env, ctx);
9969
10072
  if (req.method === "GET" && url.pathname === "/api/payments/quote") return paymentQuoteRoute(url, env, ctx);
9970
10073
  if (req.method === "POST" && url.pathname === "/api/payments/quote") return paymentQuoteRoute(url, env, ctx, true, req);
9971
10074
  if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
@@ -16865,7 +16968,7 @@ async function requestMembershipCommercialOffer(options) {
16865
16968
  async function requestMembershipOfferPreview(options) {
16866
16969
  const result = await requestAuthority(options, "offer-preview", { skuId: options.skuId });
16867
16970
  const preview = result.preview;
16868
- 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");
16971
+ 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");
16869
16972
  return preview;
16870
16973
  }
16871
16974
  async function requestMembershipCheckoutBinding(options) {