@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.
@@ -1,6 +1,6 @@
1
1
  export { APPLICATION_STAGES, AdminAccountMenuProps, AdminBuilderLibraryData, AdminChrome, AdminMemberContentData, AdminNote, AdminPage, AdminResource, AdminRouteState, AdminRouteTarget, AdminRouting, AdminSection, AdminSectionContext, AdminWorkspace, AdminWorkspaceFrameProps, AdminWorkspaceNavProps, AdmissionGrantsWorkspace, AvailabilitySectionOptions, BillingSectionOptions, BuilderLibraryAdmin, CandidateStagePanel, CandidateStagePanelProps, ChapterAdmin, ChapterAdminAuthAdapter, ChapterAdminAuthConfig, ChapterAdminAuthLoadContext, ChapterAdminProps, ChapterAdminUser, CollectionSectionOptions, DashboardSectionOptions, EmailSectionOptions, LeaderAdmissionAction, LeaderAdmissionActionProps, LeaderNetworkOverview, MeetingsSectionOptions, MemberContentAdmin, MembershipPolicyVersions, MembershipPolicyVersionsProps, NamedSeatRecordPanel, NamedSeatRecordPanelProps, NetworkRecordEditor, NetworkRecordsWorkspace, NetworkSeatBilling, NetworkShareActions, NetworkShareActionsProps, Panel, PaymentRecoveryQueue, PeopleSectionOptions, RecordActions, RecordActionsProps, RecordAdmissionPanel, RecordAdmissionPanelProps, ThemeWarning, adminFetch, adminRouteFromUrl, adminRouteHref, adminSectionFromUrl, adminSectionHref, admissionGrantsWorkspace, applicationLifecycleAdapter, availabilitySection, billingSection, builderLibrarySection, collectionSection, dashboardSection, defaultAdminSections, defaultAdminWorkspaces, emailSection, loadChapterAdminConfig, loadChapterAdminUser, meetingsSection, memberContentSection, networkRecordsWorkspace, peopleSection, useAdminResource } from './admin/index.js';
2
2
  export { C as ChapterCopyProvider, u as useChapterCopy } from '../copy-context-CaUXIxUI.js';
3
- export { ApiFn, BrandStyle, BrandStyleProps, GiftCodeRedeemResult, GiftCodeRedeemer, GiftCodeRedeemerCopy, GiftCodeRedeemerProps, GiftOfferCards, GiftOfferCardsMode, GiftOfferCardsProps, GiftPurchases, GiftPurchasesProps, JoinBookingSimulation, JoinConfig, JoinFlowState, JoinIsland, JoinIslandProps, JoinSimulation, JoinStepRenderContext, JoinSubmitRenderContext, JoinTier, JoinTierRenderContext, JourneyFrame, MEMBERSHIP_CARD_TOKEN_DEFAULTS, MemberContentArea, MemberContentAreaProps, MemberGifts, MemberProvisionalRenderContext, MemberRestart, MembersArea, MembersAreaProps, MembershipCardTokenName, MembershipCards, MembershipCardsMode, MembershipCardsProps, NAMED_SEAT_RECIPIENT_WORDS, NamedSeatPurchase, NamedSeatRecipientLine, PaymentPriceLines, PaymentStep, PaymentStepProps, PaymentStepSimulation, RescheduleProps, Rescheduler, Slot, SlotPicker, SlotPickerClasses, SlotPickerProps, SubResponse, TierPlaceholder, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, loadJoinResume, membershipApplicationHref, tierNeedsPayment, timeLabel, tzShort, useTierSelection } from './member/index.js';
3
+ export { ApiFn, BrandStyle, BrandStyleProps, CheckoutSeatEditor, CheckoutSeatEditorContext, GiftCodeRedeemResult, GiftCodeRedeemer, GiftCodeRedeemerCopy, GiftCodeRedeemerProps, GiftOfferCards, GiftOfferCardsMode, GiftOfferCardsProps, GiftPurchases, GiftPurchasesProps, JoinBookingSimulation, JoinConfig, JoinFlowState, JoinIsland, JoinIslandProps, JoinSimulation, JoinStepRenderContext, JoinSubmitRenderContext, JoinTier, JoinTierRenderContext, JourneyFrame, MEMBERSHIP_CARD_TOKEN_DEFAULTS, MemberContentArea, MemberContentAreaProps, MemberGifts, MemberProvisionalRenderContext, MemberRestart, MembersArea, MembersAreaProps, MembershipCardTokenName, MembershipCards, MembershipCardsMode, MembershipCardsProps, NAMED_SEAT_RECIPIENT_WORDS, NamedSeatPurchase, NamedSeatRecipientLine, PaymentPriceLines, PaymentStep, PaymentStepProps, PaymentStepSimulation, RescheduleProps, Rescheduler, Slot, SlotPicker, SlotPickerClasses, SlotPickerProps, SubResponse, TierPlaceholder, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, loadJoinResume, membershipApplicationHref, tierNeedsPayment, timeLabel, tzShort, useTierSelection } from './member/index.js';
4
4
  import 'preact';
5
5
  import '@odla-ai/crm';
6
6
  import '@odla-ai/crm/ui';
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-NK267YQZ.js";
33
33
  import {
34
34
  APPLICATION_STAGES,
35
35
  AdminNote,
@@ -209,6 +209,8 @@ interface PaymentPriceLines {
209
209
  dueTodayCents: number;
210
210
  /** Included in dueTodayCents; zero means the selected SKU includes the additional member. */
211
211
  namedSeatCents?: number;
212
+ /** True when an included additional member is available but no recipient was selected. Display Pick later. */
213
+ namedSeatPickLater?: boolean;
212
214
  founding?: {
213
215
  cohortLabel: string;
214
216
  };
@@ -262,6 +264,10 @@ interface JoinTier {
262
264
  blurb: string;
263
265
  /** True when the tier costs nothing, so the flow skips the payment step. */
264
266
  free: boolean;
267
+ /** Current configured additional-member price; zero means included. Checkout confirms the offer. */
268
+ seatOffer?: {
269
+ amountCents: number;
270
+ };
265
271
  /** Present when the network authority currently qualifies applicants as founding members at a reduced rate. */
266
272
  founding?: {
267
273
  cohortLabel: string;
@@ -278,7 +284,7 @@ interface JoinTier {
278
284
  * is an unstyled placeholder, not a design.
279
285
  */
280
286
  interface JoinTierRenderContext {
281
- /** Offered tiers, in display order. */
287
+ /** Offered tiers, with the selected tier first and the others in catalog order. */
282
288
  tiers: readonly JoinTier[];
283
289
  /** The chosen tier, or null while nothing is chosen. */
284
290
  selected: JoinTier | null;
@@ -370,6 +376,30 @@ interface ChapterFlowFieldContract {
370
376
  fields: readonly ChapterFlowField[];
371
377
  }
372
378
 
379
+ /** The optional recipient selected before the applicant consents to payment. */
380
+ interface CheckoutNamedSeatSelection {
381
+ recipientName: string;
382
+ recipientEmail: string;
383
+ }
384
+
385
+ /** Reopens the host's original recipient fields before payment begins. */
386
+ interface CheckoutSeatEditorContext {
387
+ /** Server-saved selection; null means the applicant has not chosen a recipient. */
388
+ selection: CheckoutNamedSeatSelection | null;
389
+ /** The current authority-owned additional-member price and billing interval. */
390
+ offer: {
391
+ amountCents: number;
392
+ currency: string;
393
+ interval: string;
394
+ };
395
+ /** Validate and save a recipient, or null to remove them, then return to checkout. */
396
+ save: (selection: CheckoutNamedSeatSelection | null) => Promise<void>;
397
+ /** Return to checkout without changing its saved selection. */
398
+ cancel: () => void;
399
+ }
400
+ /** Host-owned recipient fields reused from the application instead of a second checkout editor. */
401
+ type CheckoutSeatEditor = (context: CheckoutSeatEditorContext) => ComponentChildren;
402
+
373
403
  /** The public join config (the shape `GET /api/join-config` returns). */
374
404
  interface JoinConfig {
375
405
  id: string;
@@ -422,6 +452,8 @@ type JoinFlowState = {
422
452
  /** Canonical flow state supplied to a site's per-step heading render slot. */
423
453
  interface JoinStepRenderContext {
424
454
  state: JoinFlowState;
455
+ /** True while returning to the application's family-member fields; the saved application stays on its payment step. */
456
+ editingNamedSeat?: boolean;
425
457
  invitation?: JoinInvitation;
426
458
  applicationId?: string;
427
459
  }
@@ -480,6 +512,10 @@ interface JoinIslandProps {
480
512
  recipientName: string;
481
513
  recipientEmail: string;
482
514
  };
515
+ /** Reuse the application's recipient fields for edits. Checkout automatically quotes initialNamedSeat and shows a read-only review. */
516
+ renderNamedSeatEditor?: CheckoutSeatEditor;
517
+ /** Heading for the read-only recipient review. Defaults to Additional member. */
518
+ namedSeatLabel?: string;
483
519
  appearance?: Record<string, unknown>;
484
520
  fonts?: readonly Record<string, unknown>[];
485
521
  /** Render the server-owned price lines; `lines.founding` says the difference is a policy-backed founding discount,
@@ -707,4 +743,4 @@ declare function JourneyFrame({ children }: {
707
743
  children: ComponentChildren;
708
744
  }): preact.JSX.Element;
709
745
 
710
- export { type ApiFn, BrandStyle, type BrandStyleProps, type GiftCodeRedeemResult, GiftCodeRedeemer, type GiftCodeRedeemerCopy, type GiftCodeRedeemerProps, GiftOfferCards, type GiftOfferCardsMode, type GiftOfferCardsProps, GiftPurchases, type GiftPurchasesProps, type JoinBookingSimulation, type JoinConfig, type JoinFlowState, JoinIsland, type JoinIslandProps, type JoinSimulation, type JoinStepRenderContext, type JoinSubmitRenderContext, type JoinTier, type JoinTierRenderContext, JourneyFrame, MEMBERSHIP_CARD_TOKEN_DEFAULTS, MemberContentArea, type MemberContentAreaProps, MemberGifts, type MemberProvisionalRenderContext, MemberRestart, MembersArea, type MembersAreaProps, type MembershipCardTokenName, MembershipCards, type MembershipCardsMode, type MembershipCardsProps, NAMED_SEAT_RECIPIENT_WORDS, NamedSeatPurchase, NamedSeatRecipientLine, type PaymentPriceLines, PaymentStep, type PaymentStepProps, type PaymentStepSimulation, type RescheduleProps, Rescheduler, type Slot, SlotPicker, type SlotPickerClasses, type SlotPickerProps, type SubResponse, TierPlaceholder, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, loadJoinResume, membershipApplicationHref, tierNeedsPayment, timeLabel, tzShort, useTierSelection };
746
+ export { type ApiFn, BrandStyle, type BrandStyleProps, type CheckoutSeatEditor, type CheckoutSeatEditorContext, type GiftCodeRedeemResult, GiftCodeRedeemer, type GiftCodeRedeemerCopy, type GiftCodeRedeemerProps, GiftOfferCards, type GiftOfferCardsMode, type GiftOfferCardsProps, GiftPurchases, type GiftPurchasesProps, type JoinBookingSimulation, type JoinConfig, type JoinFlowState, JoinIsland, type JoinIslandProps, type JoinSimulation, type JoinStepRenderContext, type JoinSubmitRenderContext, type JoinTier, type JoinTierRenderContext, JourneyFrame, MEMBERSHIP_CARD_TOKEN_DEFAULTS, MemberContentArea, type MemberContentAreaProps, MemberGifts, type MemberProvisionalRenderContext, MemberRestart, MembersArea, type MembersAreaProps, type MembershipCardTokenName, MembershipCards, type MembershipCardsMode, type MembershipCardsProps, NAMED_SEAT_RECIPIENT_WORDS, NamedSeatPurchase, NamedSeatRecipientLine, type PaymentPriceLines, PaymentStep, type PaymentStepProps, type PaymentStepSimulation, type RescheduleProps, Rescheduler, type Slot, SlotPicker, type SlotPickerClasses, type SlotPickerProps, type SubResponse, TierPlaceholder, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, loadJoinResume, membershipApplicationHref, tierNeedsPayment, timeLabel, tzShort, useTierSelection };
@@ -29,7 +29,7 @@ import {
29
29
  timeLabel,
30
30
  tzShort,
31
31
  useTierSelection
32
- } from "../../chunk-VEOGTSVX.js";
32
+ } from "../../chunk-NK267YQZ.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) {