diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index b0663154..1f388297 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -28,7 +28,11 @@ export default defineConfig({ }, }, }), - coursebuilder({ configFile: './coursebuilder.config.ts' }), + // CodeTV owns /api/coursebuilder/* in src/pages/api/coursebuilder/[...coursebuilder].ts + coursebuilder({ + configFile: './coursebuilder.config.ts', + injectEndpoints: false, + }), expressiveCode({ themes: ['night-owl'] }), mdx(), react(), diff --git a/apps/website/coursebuilder.config.ts b/apps/website/coursebuilder.config.ts index fbd23e12..075ff266 100644 --- a/apps/website/coursebuilder.config.ts +++ b/apps/website/coursebuilder.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from '@coursebuilder/astro'; import { userSchema } from '@coursebuilder/core/schemas'; -import { getStripeProvider } from './src/coursebuilder/stripe-provider'; +import { createInlineCommerceEvents } from './src/coursebuilder/fulfillment'; +import { + getCommerceBaseUrl, + getStripeProvider, +} from './src/coursebuilder/stripe-provider'; import { getCourseBuilderUserForClerkUser } from './src/coursebuilder/users'; import { getCourseBuilderAdapter } from './src/db'; @@ -16,15 +20,19 @@ export default defineConfig(async (context) => { return courseBuilderUser ? userSchema.parse(courseBuilderUser) : null; }; + const baseUrl = getCommerceBaseUrl(); + return { - baseUrl: - process.env.COURSEBUILDER_URL ?? - process.env.PUBLIC_SITE_URL ?? - process.env.URL ?? - 'http://localhost:4321', + baseUrl, basePath: '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/api/coursebuilder', adapter: getCourseBuilderAdapter(), providers: stripeProvider ? [stripeProvider] : [], + // Webhook fulfillment runs in-process; see src/coursebuilder/fulfillment.ts. + inngest: createInlineCommerceEvents({ + getAdapter: getCourseBuilderAdapter, + getPaymentProvider: () => stripeProvider, + siteRootUrl: baseUrl, + }), getCurrentUser, callbacks: { session: async (request) => ({ diff --git a/apps/website/package.json b/apps/website/package.json index 45a75e6f..db682056 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -7,6 +7,7 @@ "start": "astro dev", "build": "astro build", "preview": "astro preview", + "test": "node --test 'src/**/*.test.ts'", "astro": "astro", "db:generate": "dotenv drizzle-kit generate", "db:push": "dotenv drizzle-kit push", diff --git a/apps/website/src/components/workshops/purchase-processing.tsx b/apps/website/src/components/workshops/purchase-processing.tsx new file mode 100644 index 00000000..a7f37d1a --- /dev/null +++ b/apps/website/src/components/workshops/purchase-processing.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; + +import { + getPollStep, + type CheckoutStatus, + type PollPhase, +} from '../../coursebuilder/purchase-polling'; + +type Status = CheckoutStatus & { workshopPath?: string | null }; + +type PurchaseProcessingProps = { + sessionId: string; + initialStatus: Status; + supportEmail: string; +}; + +/** + * Purchase Processing screen, mirroring the AI Hero / Code with Antonio + * thanks-page pattern: reassure the buyer right away, poll until the purchase + * is verified in the database, then show the welcome state. After about two + * minutes, show the shared "payment succeeded, setup delayed" message. + * + * TODO: invoice, Ticket Transfer and team distribution actions unlock here + * once verified. Port ai-hero/apps/ai-hero/src/app/(commerce)/thanks/purchase/page.tsx. + */ +export function PurchaseProcessing({ + sessionId, + initialStatus, + supportEmail, +}: PurchaseProcessingProps) { + const [status, setStatus] = useState(initialStatus); + const [phase, setPhase] = useState('processing'); + + useEffect(() => { + if (status.status !== 'processing') return; + + let cancelled = false; + let timeoutId: ReturnType | undefined; + const startedAt = Date.now(); + + const schedule = (attempt: number) => { + const step = getPollStep(Date.now() - startedAt, attempt); + setPhase(step.phase); + + if (step.phase === 'failed') { + // One last request so the server logs that this buyer saw the fallback. + void fetch( + `/api/commerce/checkout-status?${new URLSearchParams({ + session_id: sessionId, + attempt: 'gave-up', + })}`, + { cache: 'no-store' }, + ).catch(() => undefined); + return; + } + + timeoutId = setTimeout(() => poll(attempt + 1), step.delayMs); + }; + + const poll = async (attempt: number) => { + try { + const response = await fetch( + `/api/commerce/checkout-status?${new URLSearchParams({ + session_id: sessionId, + attempt: String(attempt), + })}`, + { cache: 'no-store' }, + ); + const next = (await response.json().catch(() => null)) as Status | null; + if (cancelled) return; + + if (next && next.status !== 'processing') { + setStatus(next); + return; + } + } catch { + if (cancelled) return; + } + + schedule(attempt); + }; + + schedule(0); + + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [sessionId, status.status]); + + if (status.status === 'ready') { + return ( +
+

Purchase confirmed

+

You’re in!

+

+ Your Workshop Ticket + {status.productName ? ( + <> + {' '} + for {status.productName} + + ) : null}{' '} + is confirmed and linked to your CodeTV account. +

+ {status.purchaseStatus === 'Restricted' && ( +

+ You bought this ticket with regional pricing, so it’s tied to the + country you bought it from. +

+ )} +

+ We’ll share everything you need before the cohort starts. Questions? + Email {supportEmail}. +

+ {status.workshopPath && ( + + Back to the workshop + + )} +
+ ); + } + + if (status.status === 'not-paid') { + return ( +
+

Your payment didn’t go through

+

+ Stripe didn’t complete this checkout, so you haven’t been charged. + Head back to the workshop page to try again, or email{' '} + {supportEmail}. +

+
+ ); + } + + if (status.status === 'error') { + return ( +
+

We couldn’t find that checkout

+

+ {status.message} If you were charged, email{' '} + {supportEmail} and we’ll sort + it out. +

+
+ ); + } + + if (phase === 'failed') { + return ( +
+

Payment successful

+

Your payment went through

+

+ We’re having a temporary problem setting up your ticket. You don’t + need to pay again. +

+

+ If you haven’t received a confirmation email after 20–30 minutes, + email {supportEmail} and we’ll + help as soon as possible. +

+
+ ); + } + + return ( +
+

Payment received

+

Thanks for your purchase!

+

+ {phase === 'slow' + ? 'Still setting up your ticket…' + : 'Finalizing your ticket…'} +

+ {phase === 'slow' && ( +

+ This usually takes a few seconds. You can leave this tab open while we + finish. +

+ )} +
+ ); +} diff --git a/apps/website/src/components/workshops/workshop-pricing.tsx b/apps/website/src/components/workshops/workshop-pricing.tsx new file mode 100644 index 00000000..7bf042f6 --- /dev/null +++ b/apps/website/src/components/workshops/workshop-pricing.tsx @@ -0,0 +1,252 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { EnrollmentState } from '../../coursebuilder/enrollment'; + +/** + * Pricing block for a Workshop Ticket. + * + * Prices come from CourseBuilder's `prices-formatted` action, which applies the + * default (early bird) coupon, a `?code=` coupon, and PPP. The browser sends + * only the site coupon id and whether the buyer opted into PPP; the server + * decides the country and the merchant coupon for both pricing and checkout + * (see src/coursebuilder/checkout-coupons.ts). + * + * TODO: team seats (quantity > 1) and the Team Claim Link redeem flow. Port + * ai-hero/apps/ai-hero/src/app/(content)/cohorts/[slug]/_components/cohort-pricing-widget-container.tsx. + */ + +type MerchantCoupon = { + id: string; + type: string; + percentageDiscount?: number | string | null; + amountDiscount?: number | null; + country?: string; + merchantAccountId?: string; + status?: number; +}; + +type FormattedPrice = { + unitPrice: number; + fullPrice: number; + calculatedPrice: number; + appliedMerchantCoupon?: MerchantCoupon; + appliedDiscountType?: 'ppp' | 'bulk' | 'fixed' | 'percentage' | 'none'; + appliedCouponLabel?: string; + availableCoupons?: Array; + usedCouponId?: string; +}; + +export type WorkshopPricingProps = { + productId: string; + listPrice: number | null; + enrollment: { + state: EnrollmentState; + opensAt: string | null; + closesAt: string | null; + }; + couponId: string | null; + canCheckout: boolean; + hasTicket: boolean; + checkoutNotice: string | null; + returnUrl: string; + /** Dev-only PPP override, forwarded from `?ppp_country=`. */ + devCountry: string | null; +}; + +const usd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); + +// `dateStyle`/`timeStyle` can't be combined with `timeZoneName`. +const dateFormat = new Intl.DateTimeFormat('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: 'America/Los_Angeles', + timeZoneName: 'short', +}); + +const notices: Record = { + 'sign-in-required': 'Sign in to buy a ticket.', + 'enrollment-closed': 'Ticket sales for this cohort are closed.', + 'already-purchased': 'You already have a ticket for this cohort.', + error: + 'We couldn’t start checkout. Please try again, or email info@codetv.dev.', +}; + +function percentOff(coupon?: MerchantCoupon) { + const value = Number(coupon?.percentageDiscount ?? 0); + return Math.round(value * 100); +} + +export function WorkshopPricing({ + productId, + listPrice, + enrollment, + couponId, + canCheckout, + hasTicket, + checkoutNotice, + returnUrl, + devCountry, +}: WorkshopPricingProps) { + const [price, setPrice] = useState(null); + const [ppp, setPpp] = useState(false); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + const endpoint = new URL( + '/api/coursebuilder/prices-formatted', + window.location.origin, + ); + if (devCountry) endpoint.searchParams.set('ppp_country', devCountry); + + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId, + ...(couponId && { couponId }), + ppp, + }), + }) + .then(async (response) => { + if (!response.ok) + throw new Error(`prices-formatted ${response.status}`); + return (await response.json()) as FormattedPrice; + }) + .then((formatted) => { + if (cancelled) return; + setPrice(formatted); + setFailed(false); + }) + .catch((error) => { + if (cancelled) return; + console.error('workshop-pricing.failed', error); + setFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [productId, couponId, ppp, devCountry]); + + const pppOffer = useMemo( + () => + price?.availableCoupons?.find( + (coupon): coupon is MerchantCoupon => coupon?.type === 'ppp', + ) ?? null, + [price], + ); + + const pppApplied = price?.appliedMerchantCoupon?.type === 'ppp'; + const discounted = + price && price.calculatedPrice < price.fullPrice ? price : null; + + const checkoutAction = useMemo(() => { + const params = new URLSearchParams({ productId, cancelUrl: returnUrl }); + if (couponId) params.set('usedCouponId', couponId); + if (pppApplied) params.set('ppp', '1'); + if (devCountry) params.set('ppp_country', devCountry); + return `/api/coursebuilder/checkout/stripe?${params.toString()}`; + }, [productId, returnUrl, couponId, pppApplied, devCountry]); + + const opensAt = enrollment.opensAt ? new Date(enrollment.opensAt) : null; + const closesAt = enrollment.closesAt ? new Date(enrollment.closesAt) : null; + const notice = checkoutNotice ? notices[checkoutNotice] : null; + + return ( +
+ {notice && ( +

+ {notice} +

+ )} + +

+ {discounted ? ( + <> + + {usd.format(discounted.calculatedPrice)} + {' '} + {usd.format(discounted.fullPrice)} + + ) : ( + + {price + ? usd.format(price.calculatedPrice) + : listPrice !== null + ? usd.format(listPrice) + : '—'} + + )} +

+ + {discounted && price?.appliedMerchantCoupon && ( +

+ {pppApplied + ? `Regional pricing: ${percentOff(price.appliedMerchantCoupon)}% off` + : (price.appliedCouponLabel ?? + (percentOff(price.appliedMerchantCoupon) > 0 + ? `${percentOff(price.appliedMerchantCoupon)}% off applied` + : 'Discount applied'))} +

+ )} + + {failed && ( +

+ We couldn’t load the latest price. Refresh to try again. +

+ )} + + {enrollment.state === 'open' && + !hasTicket && + (pppOffer || pppApplied) && ( + + )} + + {hasTicket ? ( +

You have a ticket for this cohort.

+ ) : enrollment.state === 'not-yet-open' ? ( +

+ Ticket sales open + {opensAt ? ` ${dateFormat.format(opensAt)}` : ' soon'}. +

+ ) : enrollment.state === 'closed' ? ( +

Ticket sales for this cohort are closed.

+ ) : canCheckout ? ( +
+ +
+ ) : null} + + {enrollment.state === 'open' && closesAt && !hasTicket && ( +

Sales close {dateFormat.format(closesAt)}.

+ )} +
+ ); +} diff --git a/apps/website/src/coursebuilder/checkout-coupons.test.ts b/apps/website/src/coursebuilder/checkout-coupons.test.ts new file mode 100644 index 00000000..5b8c37cd --- /dev/null +++ b/apps/website/src/coursebuilder/checkout-coupons.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + decideCheckoutCoupon, + getValidSiteCoupon, +} from './checkout-coupons.ts'; + +const productId = 'product-ticket'; +const now = new Date('2026-10-20T00:00:00.000Z'); + +const merchantCoupons = { + ppp60: { id: 'mc_ppp_60', type: 'ppp', percentageDiscount: 0.6 }, + special40: { id: 'mc_special_40', type: 'special', percentageDiscount: 0.4 }, + special50: { id: 'mc_special_50', type: 'special', percentageDiscount: 0.5 }, + special25: { id: 'mc_special_25', type: 'special', percentageDiscount: 0.25 }, +}; + +type SiteCoupon = { + id: string; + status: number; + maxUses: number; + usedCount: number; + expires: Date | null; + percentageDiscount: string; + restrictedToProductId: string | null; + merchantCoupon: { id: string; percentageDiscount: number }; +}; + +function siteCoupon(overrides: Partial = {}): SiteCoupon { + return { + id: 'coupon_member', + status: 1, + maxUses: -1, + usedCount: 0, + expires: null, + percentageDiscount: '0.5', + restrictedToProductId: productId, + merchantCoupon: merchantCoupons.special50, + ...overrides, + }; +} + +function adapter({ + coupons = {} as Record, + defaultCoupon = null as null | { + merchant: { id: string; percentageDiscount: number }; + }, +} = {}) { + return { + getMerchantCouponForTypeAndPercent: async ({ + type, + percentageDiscount, + }: { + type: string; + percentageDiscount: number; + }) => + type === 'ppp' && percentageDiscount === 0.6 + ? merchantCoupons.ppp60 + : null, + couponForIdOrCode: async ({ couponId }: { couponId?: string | null }) => + (couponId && coupons[couponId]) || null, + getDefaultCoupon: async () => + defaultCoupon + ? { + defaultMerchantCoupon: defaultCoupon.merchant, + defaultCoupon: { id: 'coupon_early_bird' }, + } + : null, + } as any; +} + +describe('decideCheckoutCoupon', () => { + it('applies PPP only when the buyer opted in and the trusted country qualifies', async () => { + // Poland qualifies for 60% off in CourseBuilder's PPP table. + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'PL', + requestedPpp: true, + adapter: adapter(), + }); + + assert.deepEqual(decision, { + country: 'PL', + couponId: 'mc_ppp_60', + reason: 'ppp', + }); + }); + + it('never auto-applies PPP: no opt-in means the country is pinned to US', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'IN', + requestedPpp: false, + adapter: adapter(), + }); + + assert.deepEqual(decision, { country: 'US', reason: 'none' }); + }); + + it('ignores a PPP opt-in from a country without PPP', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'US', + requestedPpp: true, + adapter: adapter(), + }); + + assert.equal(decision.couponId, undefined); + assert.equal(decision.country, 'US'); + }); + + it('uses the early bird default coupon when there is no code', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'US', + requestedPpp: false, + adapter: adapter({ + defaultCoupon: { merchant: merchantCoupons.special40 }, + }), + }); + + assert.deepEqual(decision, { + country: 'US', + couponId: 'mc_special_40', + usedCouponId: 'coupon_early_bird', + reason: 'default-coupon', + }); + }); + + it('uses a ?code= coupon when it beats the default', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'US', + requestedPpp: false, + usedCouponId: 'coupon_member', + adapter: adapter({ + coupons: { coupon_member: siteCoupon() }, + defaultCoupon: { merchant: merchantCoupons.special40 }, + }), + now, + }); + + assert.equal(decision.couponId, 'mc_special_50'); + assert.equal(decision.usedCouponId, 'coupon_member'); + assert.equal(decision.reason, 'site-coupon'); + }); + + it('keeps the default when the code is worse', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'US', + requestedPpp: false, + usedCouponId: 'coupon_small', + adapter: adapter({ + coupons: { + coupon_small: siteCoupon({ + id: 'coupon_small', + percentageDiscount: '0.25', + merchantCoupon: merchantCoupons.special25, + }), + }, + defaultCoupon: { merchant: merchantCoupons.special40 }, + }), + now, + }); + + assert.equal(decision.couponId, 'mc_special_40'); + assert.equal(decision.reason, 'default-coupon'); + }); + + it('drops an unknown coupon id instead of trusting it', async () => { + const decision = await decideCheckoutCoupon({ + productId, + trustedCountry: 'US', + requestedPpp: false, + usedCouponId: 'mc_ppp_60', + adapter: adapter(), + now, + }); + + assert.deepEqual(decision, { country: 'US', reason: 'none' }); + }); +}); + +describe('getValidSiteCoupon', () => { + const check = (coupon: SiteCoupon) => + getValidSiteCoupon( + coupon.id, + productId, + adapter({ coupons: { [coupon.id]: coupon } }), + now, + ); + + it('accepts an active coupon for this product', async () => { + assert.equal((await check(siteCoupon()))?.id, 'coupon_member'); + }); + + it('rejects a coupon restricted to another product', async () => { + assert.equal( + await check(siteCoupon({ restrictedToProductId: 'product-other' })), + null, + ); + }); + + it('rejects an expired coupon', async () => { + assert.equal( + await check( + siteCoupon({ expires: new Date('2026-10-01T00:00:00.000Z') }), + ), + null, + ); + }); + + it('rejects a used-up coupon', async () => { + assert.equal(await check(siteCoupon({ maxUses: 5, usedCount: 5 })), null); + }); + + it('rejects an inactive coupon', async () => { + assert.equal(await check(siteCoupon({ status: 0 })), null); + }); + + it('rejects 100% Team Claim Link coupons (they redeem, not check out)', async () => { + assert.equal(await check(siteCoupon({ percentageDiscount: '1' })), null); + }); +}); diff --git a/apps/website/src/coursebuilder/checkout-coupons.ts b/apps/website/src/coursebuilder/checkout-coupons.ts new file mode 100644 index 00000000..f6cc32f9 --- /dev/null +++ b/apps/website/src/coursebuilder/checkout-coupons.ts @@ -0,0 +1,143 @@ +import { isAfter } from 'date-fns'; + +import type { CourseBuilderAdapter } from '@coursebuilder/core/adapters'; +import { getPPPDiscountPercent } from '@coursebuilder/core/pricing/parity-coupon'; + +/** + * Server-side coupon decisions for Workshop Ticket pricing and checkout. + * + * CourseBuilder core 1.2.1 trusts whatever merchant `couponId` reaches + * checkout and mints a Stripe promotion code from it, and it auto-applies PPP + * from the query country. So the browser never sends merchant coupon ids: + * it sends the site coupon it was given (`usedCouponId`, from `?code=`) and + * whether the buyer opted into PPP. Everything else is decided here. + * + * The adapter is passed in so `node --test` can exercise this without a DB. + */ + +export type CouponAdapter = Pick< + CourseBuilderAdapter, + | 'getMerchantCouponForTypeAndPercent' + | 'couponForIdOrCode' + | 'getDefaultCoupon' +>; + +/** Country pinned at checkout when the buyer did not opt into PPP. */ +export const NON_PPP_COUNTRY = 'US'; + +/** The PPP merchant coupon for a country, when that country qualifies. */ +export async function getPppMerchantCoupon( + country: string, + adapter: CouponAdapter, +) { + const percentageDiscount = getPPPDiscountPercent(country); + if (percentageDiscount <= 0) return null; + + return adapter.getMerchantCouponForTypeAndPercent({ + type: 'ppp', + percentageDiscount, + }); +} + +/** A site coupon from `?code=` that is usable for this product right now. */ +export async function getValidSiteCoupon( + couponId: string | null | undefined, + productId: string, + adapter: CouponAdapter, + now = new Date(), +) { + if (!couponId) return null; + + const coupon = await adapter.couponForIdOrCode({ couponId }); + if (!coupon || coupon.status !== 1) return null; + if ( + coupon.restrictedToProductId && + coupon.restrictedToProductId !== productId + ) + return null; + if (coupon.maxUses !== -1 && coupon.usedCount >= coupon.maxUses) return null; + if (coupon.expires && isAfter(now, coupon.expires)) return null; + // 100% bulk coupons are Team Claim Links, which redeem instead of checking out. + if (Number(coupon.percentageDiscount) >= 1) return null; + + return coupon; +} + +function discountOf( + coupon: { percentageDiscount?: unknown } | null | undefined, +) { + return Number(coupon?.percentageDiscount ?? 0); +} + +export type CheckoutCouponDecision = { + country: string; + couponId?: string; + usedCouponId?: string; + reason: 'ppp' | 'site-coupon' | 'default-coupon' | 'none'; +}; + +/** + * Decide the merchant coupon and country checkout should use. + * + * - PPP only when the buyer opted in and the trusted country qualifies. + * - Otherwise the better of the product's default coupon (early bird) and a + * valid `?code=` coupon, priced as a US buyer so PPP is never auto-applied. + */ +export async function decideCheckoutCoupon({ + productId, + trustedCountry, + requestedPpp, + usedCouponId, + adapter, + now, +}: { + productId: string; + trustedCountry: string; + requestedPpp: boolean; + usedCouponId?: string | null; + adapter: CouponAdapter; + now?: Date; +}): Promise { + if (requestedPpp) { + const pppCoupon = await getPppMerchantCoupon(trustedCountry, adapter); + if (pppCoupon) { + return { + country: trustedCountry, + couponId: pppCoupon.id, + reason: 'ppp', + }; + } + } + + const [siteCoupon, defaults] = await Promise.all([ + getValidSiteCoupon(usedCouponId, productId, adapter, now), + adapter.getDefaultCoupon([productId]), + ]); + + const siteMerchantCoupon = siteCoupon?.merchantCoupon ?? null; + const defaultMerchantCoupon = defaults?.defaultMerchantCoupon ?? null; + + if ( + siteCoupon && + siteMerchantCoupon && + discountOf(siteMerchantCoupon) >= discountOf(defaultMerchantCoupon) + ) { + return { + country: NON_PPP_COUNTRY, + couponId: siteMerchantCoupon.id, + usedCouponId: siteCoupon.id, + reason: 'site-coupon', + }; + } + + if (defaults?.defaultMerchantCoupon) { + return { + country: NON_PPP_COUNTRY, + couponId: defaults.defaultMerchantCoupon.id, + usedCouponId: defaults.defaultCoupon.id, + reason: 'default-coupon', + }; + } + + return { country: NON_PPP_COUNTRY, reason: 'none' }; +} diff --git a/apps/website/src/coursebuilder/checkout-redirects.test.ts b/apps/website/src/coursebuilder/checkout-redirects.test.ts new file mode 100644 index 00000000..a5a40149 --- /dev/null +++ b/apps/website/src/coursebuilder/checkout-redirects.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + getSafeReturnUrl, + getStripeCheckoutUrl, +} from './checkout-redirects.ts'; + +describe('getStripeCheckoutUrl', () => { + const stripe = 'https://checkout.stripe.com/c/pay/cs_test_a1b2c3#fid'; + + it('unwraps core’s verify-login redirect for cohort products', () => { + const location = `https://codetv.dev/subscribe/verify-login?${new URLSearchParams( + { + productId: 'product-ticket', + checkoutUrl: stripe, + }, + )}`; + + assert.equal(getStripeCheckoutUrl(location), stripe); + }); + + it('passes a direct Stripe redirect through', () => { + assert.equal(getStripeCheckoutUrl(stripe), stripe); + }); + + it('treats core’s error redirect to the site root as a failure', () => { + assert.equal(getStripeCheckoutUrl('https://codetv.dev'), null); + assert.equal( + getStripeCheckoutUrl( + 'https://codetv.dev/subscribe/verify-login?checkoutUrl=https%3A%2F%2Fcodetv.dev', + ), + null, + ); + }); + + it('refuses lookalike and non-https hosts', () => { + assert.equal( + getStripeCheckoutUrl('https://checkout.stripe.com.evil.dev/x'), + null, + ); + assert.equal(getStripeCheckoutUrl('http://checkout.stripe.com/x'), null); + assert.equal(getStripeCheckoutUrl(null), null); + assert.equal(getStripeCheckoutUrl('not a url'), null); + }); +}); + +describe('getSafeReturnUrl', () => { + const requestUrl = new URL( + 'https://deploy-preview-12--codetv.netlify.app/api/coursebuilder/checkout/stripe', + ); + + it('keeps same-site return URLs, including query strings', () => { + const url = getSafeReturnUrl( + 'https://deploy-preview-12--codetv.netlify.app/workshops/example-workshop?code=EXAMPLE10', + requestUrl, + [], + ); + + assert.equal(url.pathname, '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/workshops/example-workshop'); + assert.equal(url.searchParams.get('code'), 'EXAMPLE10'); + }); + + it('allows the configured commerce origin', () => { + const url = getSafeReturnUrl('https://codetv.dev/workshops/x', requestUrl, [ + 'https://codetv.dev', + ]); + + assert.equal(url.toString(), 'https://codetv.dev/workshops/x'); + }); + + it('falls back to the home page for other origins (no open redirect)', () => { + assert.equal( + getSafeReturnUrl('https://evil.example/phish', requestUrl, []).toString(), + 'https://deploy-preview-12--codetv.netlify.app/', + ); + assert.equal( + getSafeReturnUrl('//evil.example/phish', requestUrl, []).origin, + 'https://deploy-preview-12--codetv.netlify.app', + ); + }); + + it('resolves relative paths against the request', () => { + assert.equal( + getSafeReturnUrl('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/workshops/x', requestUrl, []).toString(), + 'https://deploy-preview-12--codetv.netlify.app/workshops/x', + ); + }); +}); diff --git a/apps/website/src/coursebuilder/checkout-redirects.ts b/apps/website/src/coursebuilder/checkout-redirects.ts new file mode 100644 index 00000000..f4bbfc91 --- /dev/null +++ b/apps/website/src/coursebuilder/checkout-redirects.ts @@ -0,0 +1,54 @@ +/** + * Redirect guards for the checkout route. Dependency-free for `node --test`. + */ + +/** + * Resolve where to send the buyer back to (cancel URL, error notices). + * Only same-site URLs are allowed; anything else falls back to the home page. + */ +export function getSafeReturnUrl( + value: string | null, + requestUrl: URL, + allowedOrigins: string[], +): URL { + const fallback = new URL('/', requestUrl); + if (!value) return fallback; + + try { + const url = new URL(value, requestUrl); + return new Set([requestUrl.origin, ...allowedOrigins]).has(url.origin) + ? url + : fallback; + } catch { + return fallback; + } +} + +/** + * Pull the Stripe Checkout URL out of core's checkout response. + * + * For `cohort`, `live` and `membership` products core 1.2.1 redirects to + * `${baseUrl}/subscribe/verify-login?checkoutUrl=`. CodeTV has + * already verified the Viewer, so it goes straight to Stripe. Anything that + * isn't an https checkout.stripe.com URL (including core's error redirect to + * the site root) returns null. + */ +export function getStripeCheckoutUrl(location: string | null): string | null { + if (!location) return null; + + try { + const url = new URL(location); + const candidate = url.pathname.endsWith('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/subscribe/verify-login') + ? url.searchParams.get('checkoutUrl') + : location; + if (!candidate) return null; + + const checkoutUrl = new URL(candidate); + return checkoutUrl.protocol === 'https:' && + checkoutUrl.hostname === 'checkout.stripe.com' + ? checkoutUrl.toString() + : null; + } catch { + return null; + } +} diff --git a/apps/website/src/coursebuilder/checkout-status.ts b/apps/website/src/coursebuilder/checkout-status.ts new file mode 100644 index 00000000..f222ad47 --- /dev/null +++ b/apps/website/src/coursebuilder/checkout-status.ts @@ -0,0 +1,88 @@ +import { courseBuilderAdapter } from '../db'; +import type { CheckoutStatus } from './purchase-polling'; +import { getStripeProvider } from './stripe-provider'; +import { getWorkshopPathForProduct } from './workshops'; + +export type CheckoutStatusWithLinks = CheckoutStatus & { + workshopPath?: string | null; +}; + +const CHECKOUT_SESSION_ID = /^cs_(test|live)_[A-Za-z0-9]+$/; + +export function isCheckoutSessionId(value: string | null | undefined) { + return Boolean(value && CHECKOUT_SESSION_ID.test(value)); +} + +/** + * Purchase Processing status for a Stripe Checkout Session. + * + * The verified purchase row is the source of truth. Until it exists, Stripe's + * own session state is used only to reassure the buyer (or to tell them the + * payment didn't go through). + */ +export async function getCheckoutStatus( + sessionId: string, +): Promise { + if (!isCheckoutSessionId(sessionId)) { + return { status: 'error', message: 'Unknown checkout session.' }; + } + + try { + return await lookupCheckoutStatus(sessionId); + } catch (error) { + // The buyer has just paid; keep them in Purchase Processing and retry. + console.error('checkout-status.lookup-failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return { status: 'processing', paymentStatus: null }; + } +} + +async function lookupCheckoutStatus( + sessionId: string, +): Promise { + const purchase = + await courseBuilderAdapter.getPurchaseByCheckoutSessionId(sessionId); + + if (purchase) { + const product = await courseBuilderAdapter.getProduct( + purchase.productId, + false, + ); + + return { + status: 'ready', + purchaseId: purchase.id, + productName: product?.name ?? null, + purchaseStatus: purchase.status, + workshopPath: await getWorkshopPathForProduct(purchase.productId), + }; + } + + const stripeProvider = getStripeProvider(); + if (!stripeProvider) { + return { status: 'processing', paymentStatus: null }; + } + + try { + const session = + await stripeProvider.options.paymentsAdapter.getCheckoutSession( + sessionId, + ); + + // `open` means the buyer never finished checkout; `complete` + `unpaid` + // is an async payment still settling, so keep reassuring. + if (session.status === 'expired' || session.status === 'open') { + return { status: 'not-paid', paymentStatus: session.payment_status }; + } + + return { status: 'processing', paymentStatus: session.payment_status }; + } catch (error) { + console.warn('checkout-status.stripe-lookup-failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return { status: 'processing', paymentStatus: null }; + } +} diff --git a/apps/website/src/coursebuilder/enrollment.test.ts b/apps/website/src/coursebuilder/enrollment.test.ts new file mode 100644 index 00000000..23d07454 --- /dev/null +++ b/apps/website/src/coursebuilder/enrollment.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { getEnrollmentWindow, pickCurrentTicket } from './enrollment.ts'; + +const opens = '2026-10-10T07:00:00.000Z'; +const dayOne = '2026-11-02T08:00:00.000Z'; + +describe('getEnrollmentWindow', () => { + it('is open between openEnrollment and closeEnrollment', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-10-20T00:00:00.000Z'), + productActive: true, + openEnrollment: opens, + closeEnrollment: dayOne, + }); + + assert.equal(window.state, 'open'); + assert.equal(window.closesAt?.toISOString(), dayOne); + }); + + it('is not yet open before openEnrollment', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-10-09T00:00:00.000Z'), + productActive: true, + openEnrollment: opens, + closeEnrollment: dayOne, + }); + + assert.equal(window.state, 'not-yet-open'); + assert.equal(window.reason, 'before-open'); + }); + + it('closes exactly at closeEnrollment (cohort day one)', () => { + const window = getEnrollmentWindow({ + now: new Date(dayOne), + productActive: true, + openEnrollment: opens, + closeEnrollment: dayOne, + }); + + assert.equal(window.state, 'closed'); + assert.equal(window.reason, 'after-close'); + }); + + it('falls back to the cohort start when the product has no closeEnrollment', () => { + const before = getEnrollmentWindow({ + now: new Date('2026-11-01T00:00:00.000Z'), + productActive: true, + cohortStartsAt: dayOne, + }); + const after = getEnrollmentWindow({ + now: new Date('2026-11-03T00:00:00.000Z'), + productActive: true, + cohortStartsAt: dayOne, + }); + + assert.equal(before.state, 'open'); + assert.equal(after.state, 'closed'); + assert.equal(after.closesAt?.toISOString(), dayOne); + }); + + it('prefers the product closeEnrollment over the cohort start', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-10-30T00:00:00.000Z'), + productActive: true, + closeEnrollment: '2026-10-29T00:00:00.000Z', + cohortStartsAt: dayOne, + }); + + assert.equal(window.state, 'closed'); + }); + + it('is closed when the product is inactive, even inside the window', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-10-20T00:00:00.000Z'), + productActive: false, + openEnrollment: opens, + closeEnrollment: dayOne, + }); + + assert.equal(window.state, 'closed'); + assert.equal(window.reason, 'product-inactive'); + }); + + it('is open with no window at all', () => { + const window = getEnrollmentWindow({ productActive: true }); + + assert.equal(window.state, 'open'); + assert.equal(window.opensAt, null); + assert.equal(window.closesAt, null); + }); + + it('treats an unparseable closeEnrollment as missing and uses the cohort start', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-11-03T00:00:00.000Z'), + productActive: true, + closeEnrollment: 'garbage', + cohortStartsAt: dayOne, + }); + + assert.equal(window.state, 'closed'); + }); + + it('ignores unparseable dates instead of closing sales', () => { + const window = getEnrollmentWindow({ + now: new Date('2026-10-20T00:00:00.000Z'), + productActive: true, + openEnrollment: 'not a date', + closeEnrollment: dayOne, + }); + + assert.equal(window.state, 'open'); + assert.equal(window.opensAt, null); + }); +}); + +describe('pickCurrentTicket', () => { + const now = new Date('2026-10-20T00:00:00.000Z'); + const candidate = ( + ticket: string, + cohortStartsAt: string, + openEnrollment?: string, + ) => ({ + ticket, + cohortStartsAt: new Date(cohortStartsAt), + enrollment: getEnrollmentWindow({ + now, + productActive: true, + openEnrollment, + cohortStartsAt, + }), + }); + + it('returns null with no candidates', () => { + assert.equal(pickCurrentTicket([]), null); + }); + + it('prefers an open ticket over upcoming and closed ones', () => { + const picked = pickCurrentTicket([ + candidate('past', '2026-09-01T00:00:00.000Z'), + candidate( + 'upcoming', + '2027-01-10T00:00:00.000Z', + '2026-12-01T00:00:00.000Z', + ), + candidate('open', '2026-11-02T00:00:00.000Z'), + ]); + + assert.equal(picked?.ticket, 'open'); + }); + + it('picks the soonest open cohort', () => { + const picked = pickCurrentTicket([ + candidate('later', '2026-12-02T00:00:00.000Z'), + candidate('sooner', '2026-11-02T00:00:00.000Z'), + ]); + + assert.equal(picked?.ticket, 'sooner'); + }); + + it('shows the most recent cohort when every ticket is closed', () => { + const picked = pickCurrentTicket([ + candidate('older', '2026-06-01T00:00:00.000Z'), + candidate('recent', '2026-09-01T00:00:00.000Z'), + ]); + + assert.equal(picked?.ticket, 'recent'); + }); +}); diff --git a/apps/website/src/coursebuilder/enrollment.ts b/apps/website/src/coursebuilder/enrollment.ts new file mode 100644 index 00000000..6c27fb90 --- /dev/null +++ b/apps/website/src/coursebuilder/enrollment.ts @@ -0,0 +1,105 @@ +/** + * Enrollment window for a Workshop Ticket. + * + * CourseBuilder stores the window on the product as `fields.openEnrollment` + * and `fields.closeEnrollment` (ISO strings). `createCohort` sets + * `closeEnrollment` to the Cohort start date, so ticket sales stop on day one. + * When a product has no `closeEnrollment`, the Cohort's `startsAt` is used. + * + * Keep this module dependency-free: it runs in Astro pages, React islands and + * `node --test`. + */ + +export type EnrollmentState = 'open' | 'not-yet-open' | 'closed'; + +export type EnrollmentWindowInput = { + now?: Date; + /** Product is active (status 1) and its `fields.state` is `published`. */ + productActive: boolean; + openEnrollment?: string | Date | null; + closeEnrollment?: string | Date | null; + cohortStartsAt?: string | Date | null; +}; + +export type EnrollmentWindow = { + state: EnrollmentState; + opensAt: Date | null; + closesAt: Date | null; + reason: 'product-inactive' | 'before-open' | 'after-close' | 'within-window'; +}; + +function toDate(value: string | Date | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function getEnrollmentWindow({ + now = new Date(), + productActive, + openEnrollment, + closeEnrollment, + cohortStartsAt, +}: EnrollmentWindowInput): EnrollmentWindow { + const opensAt = toDate(openEnrollment); + const closesAt = toDate(closeEnrollment) ?? toDate(cohortStartsAt); + + if (!productActive) { + return { state: 'closed', opensAt, closesAt, reason: 'product-inactive' }; + } + + if (opensAt && now.getTime() < opensAt.getTime()) { + return { state: 'not-yet-open', opensAt, closesAt, reason: 'before-open' }; + } + + if (closesAt && now.getTime() >= closesAt.getTime()) { + return { state: 'closed', opensAt, closesAt, reason: 'after-close' }; + } + + return { state: 'open', opensAt, closesAt, reason: 'within-window' }; +} + +export type TicketCandidate = { + ticket: T; + enrollment: EnrollmentWindow; + cohortStartsAt?: Date | null; +}; + +const stateRank: Record = { + open: 0, + 'not-yet-open': 1, + closed: 2, +}; + +/** + * Pick the Workshop Ticket a Workshop page should sell. + * + * Open beats not-yet-open beats closed. Among open or upcoming tickets, the + * soonest Cohort wins. Among closed tickets, the most recent Cohort wins so + * the page shows the last run. + */ +export function pickCurrentTicket( + candidates: TicketCandidate[], +): TicketCandidate | null { + const time = (candidate: TicketCandidate) => + candidate.cohortStartsAt?.getTime() ?? + candidate.enrollment.closesAt?.getTime() ?? + Number.POSITIVE_INFINITY; + + const sorted = [...candidates].sort((a, b) => { + const byState = + stateRank[a.enrollment.state] - stateRank[b.enrollment.state]; + if (byState !== 0) return byState; + + if (a.enrollment.state === 'closed') { + // Infinity (no date) sorts last for closed tickets too. + const aTime = Number.isFinite(time(a)) ? time(a) : -Infinity; + const bTime = Number.isFinite(time(b)) ? time(b) : -Infinity; + return bTime - aTime; + } + + return time(a) - time(b); + }); + + return sorted[0] ?? null; +} diff --git a/apps/website/src/coursebuilder/fulfillment.test.ts b/apps/website/src/coursebuilder/fulfillment.test.ts new file mode 100644 index 00000000..a29ef3ef --- /dev/null +++ b/apps/website/src/coursebuilder/fulfillment.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createInlineCommerceEvents } from './fulfillment.ts'; + +/** + * Runs CourseBuilder core's real `stripeCheckoutSessionComplete` handler + * through the inline runner, with a fake adapter and a fake Stripe session. + */ + +const checkoutSessionId = 'cs_test_a1b2c3'; + +function checkoutSessionCompletedEvent(mode: 'payment' | 'subscription') { + return { + id: 'evt_test_1', + created: 1_760_000_000, + type: 'checkout.session.completed', + data: { + object: { + id: checkoutSessionId, + object: 'checkout.session', + amount_subtotal: 50_000, + amount_total: 30_000, + created: 1_760_000_000, + currency: 'usd', + custom_fields: [], + customer: 'cus_test_1', + customer_details: { + address: { + city: null, + country: 'US', + line1: null, + line2: null, + postal_code: '97201', + state: null, + }, + email: 'viewer@example.com', + name: 'Test Viewer', + }, + livemode: false, + metadata: { productId: 'product-ticket', userId: 'user_1' }, + mode, + payment_intent: mode === 'payment' ? 'pi_test_1' : null, + subscription: mode === 'payment' ? null : 'sub_test_1', + payment_method_collection: 'always', + payment_status: 'paid', + phone_number_collection: { enabled: false }, + status: 'complete', + success_url: 'https://codetv.dev/thanks/purchase', + total_details: { + amount_discount: 20_000, + amount_shipping: 0, + amount_tax: 0, + }, + }, + }, + }; +} + +function fakeStripeCheckoutSession() { + return { + id: checkoutSessionId, + customer: { + id: 'cus_test_1', + email: 'viewer@example.com', + name: 'Test Viewer', + }, + line_items: { + data: [ + { + quantity: 1, + price: { + product: { id: 'prod_stripe_ticket', name: 'Workshop Ticket' }, + }, + discounts: [{ discount: { coupon: { id: 'stripe_coupon_40' } } }], + }, + ], + }, + payment_intent: { + id: 'pi_test_1', + latest_charge: { id: 'ch_test_1', amount: 30_000 }, + }, + metadata: { country: 'US', usedCouponId: 'coupon_early_bird' }, + amount_total: 30_000, + }; +} + +function setup() { + const calls: Record = {}; + const record = (name: string, value: unknown) => { + (calls[name] ??= []).push(value); + }; + const user = { + id: 'user_1', + email: 'viewer@example.com', + name: 'Test Viewer', + }; + + const adapter = { + getMerchantAccount: async () => ({ id: 'ctv-stripe-merchant-account' }), + getUserByEmail: async () => user, + getPurchaseForStripeCharge: async () => null, + getPurchasesForUser: async () => [], + findOrCreateUser: async (email: string) => { + record('findOrCreateUser', email); + return { user, isNewUser: false }; + }, + getMerchantProduct: async (identifier: string) => { + record('getMerchantProduct', identifier); + return { id: 'mproduct_1', productId: 'product-ticket' }; + }, + findOrCreateMerchantCustomer: async (options: unknown) => { + record('findOrCreateMerchantCustomer', options); + return { id: 'mcustomer_1' }; + }, + getProduct: async () => ({ + id: 'product-ticket', + name: 'Workshop Ticket', + type: 'cohort', + }), + createMerchantChargeAndPurchase: async ( + options: Record, + ) => { + record('createMerchantChargeAndPurchase', options); + return { id: 'purch_1', status: 'Valid', productId: 'product-ticket' }; + }, + }; + + const paymentProvider = { + options: { + paymentsAdapter: { + getCheckoutSession: async (id: string) => { + record('getCheckoutSession', id); + return fakeStripeCheckoutSession(); + }, + }, + }, + }; + + const events = createInlineCommerceEvents({ + getAdapter: () => adapter as any, + getPaymentProvider: () => paymentProvider as any, + siteRootUrl: 'https://codetv.dev', + }); + + return { events, calls }; +} + +describe('createInlineCommerceEvents', () => { + it('records the purchase inline when Stripe reports a completed checkout', async () => { + const { events, calls } = setup(); + + await events.send({ + name: 'stripe/checkout-session-completed', + data: { + txnId: 'txn_1', + stripeEvent: checkoutSessionCompletedEvent('payment'), + }, + }); + + assert.deepEqual(calls.getCheckoutSession, [checkoutSessionId]); + assert.deepEqual(calls.findOrCreateUser, ['viewer@example.com']); + assert.deepEqual(calls.getMerchantProduct, ['prod_stripe_ticket']); + + const [purchase] = calls.createMerchantChargeAndPurchase as Array< + Record + >; + assert.equal(purchase.userId, 'user_1'); + assert.equal(purchase.productId, 'product-ticket'); + assert.equal(purchase.stripeChargeId, 'ch_test_1'); + assert.equal(purchase.stripeChargeAmount, 30_000); + assert.equal(purchase.stripeCouponId, 'stripe_coupon_40'); + assert.equal(purchase.checkoutSessionId, checkoutSessionId); + assert.equal(purchase.usedCouponId, 'coupon_early_bird'); + assert.equal(purchase.quantity, 1); + }); + + it('ignores subscription checkouts, like the Inngest trigger does', async () => { + const { events, calls } = setup(); + + await events.send({ + name: 'stripe/checkout-session-completed', + data: { stripeEvent: checkoutSessionCompletedEvent('subscription') }, + }); + + assert.equal(calls.createMerchantChargeAndPurchase, undefined); + }); + + it('propagates fulfillment failures so the webhook fails and Stripe retries', async () => { + const { events } = setup(); + const failing = createInlineCommerceEvents({ + getAdapter: () => + ({ + getMerchantAccount: async () => { + throw new Error('database unavailable'); + }, + }) as any, + getPaymentProvider: () => ({ options: { paymentsAdapter: {} } }) as any, + siteRootUrl: 'https://codetv.dev', + }); + + await assert.rejects( + failing.send({ + name: 'stripe/checkout-session-completed', + data: { stripeEvent: checkoutSessionCompletedEvent('payment') }, + }), + /database unavailable/, + ); + // The happy-path runner is unaffected. + assert.ok(events); + }); + + it('logs events it does not handle instead of throwing', async () => { + const { events } = setup(); + + await events.send({ name: 'commerce/purchase-status-updated', data: {} }); + }); +}); diff --git a/apps/website/src/coursebuilder/fulfillment.ts b/apps/website/src/coursebuilder/fulfillment.ts new file mode 100644 index 00000000..39ab551e --- /dev/null +++ b/apps/website/src/coursebuilder/fulfillment.ts @@ -0,0 +1,123 @@ +import type { CourseBuilderConfig } from '@coursebuilder/core'; +import type { CourseBuilderAdapter } from '@coursebuilder/core/adapters'; +import { NEW_PURCHASE_CREATED_EVENT } from '@coursebuilder/core/inngest/commerce/event-new-purchase-created'; +import { + STRIPE_CHECKOUT_SESSION_COMPLETED_EVENT, + stripeCheckoutSessionComplete, +} from '@coursebuilder/core/inngest/stripe/event-checkout-session-completed'; +import type { PaymentsProviderConfig } from '@coursebuilder/core/types'; + +/** + * In-process stand-in for the Inngest client that CourseBuilder core expects + * at `options.inngest`. + * + * Core's Stripe webhook does not write purchases itself. It sends + * `stripe/checkout-session-completed` to Inngest, and an Inngest function + * (`stripeCheckoutSessionComplete`) records the MerchantCharge, MerchantSession + * and Purchase. CodeTV's Inngest app lives in `apps/workflows` and has no access + * to the CourseBuilder database, so for the first Cohort we run that same core + * handler inline, inside the webhook request. + * + * - If fulfillment throws, the webhook responds non-2xx and Stripe retries. + * - `createMerchantChargeAndPurchase` is idempotent on the Stripe charge, so a + * retried delivery returns the existing purchase. + * + * TODO: move to a real Inngest function when post-purchase work lands + * (emails, Discord role, Kit sync). Port + * `ai-hero/apps/ai-hero/src/inngest/functions/post-purchase-workflow.ts` and + * register core's `stripeCheckoutSessionComplete` with + * `createInngestMiddleware` instead of this runner. + */ + +type CommerceEvent = { + name: string; + data?: Record; + user?: unknown; +}; + +type InlineCommerceContext = { + getAdapter: () => CourseBuilderAdapter; + getPaymentProvider: () => PaymentsProviderConfig | null; + siteRootUrl: string; +}; + +function createInlineStep(dispatch: (event: CommerceEvent) => Promise) { + return { + async run(name: string, fn: () => Promise | T): Promise { + console.info('commerce.fulfillment.step', { step: name }); + return await fn(); + }, + async sendEvent(_id: string, payload: CommerceEvent | CommerceEvent[]) { + for (const event of Array.isArray(payload) ? payload : [payload]) { + await dispatch(event); + } + return { ids: [] }; + }, + }; +} + +export function createInlineCommerceEvents(context: InlineCommerceContext) { + async function dispatch(event: CommerceEvent): Promise { + switch (event.name) { + case STRIPE_CHECKOUT_SESSION_COMPLETED_EVENT: { + const mode = event.data?.stripeEvent?.data?.object?.mode; + // Mirrors the Inngest trigger: only one-time payments create purchases. + if (mode !== 'payment') { + console.info('commerce.fulfillment.skipped', { + event: event.name, + mode, + reason: 'not-a-one-time-payment', + }); + return; + } + + const paymentProvider = context.getPaymentProvider(); + if (!paymentProvider) { + throw new Error( + 'Stripe provider is not configured for CourseBuilder', + ); + } + + const result = (await stripeCheckoutSessionComplete.handler({ + event, + step: createInlineStep(dispatch), + db: context.getAdapter(), + siteRootUrl: context.siteRootUrl, + paymentProvider, + emailProvider: undefined, + getAuthConfig: () => ({}), + } as any)) as { txnId?: string; purchase?: { id: string } } | undefined; + + console.info('commerce.fulfillment.completed', { + txnId: result?.txnId, + purchaseId: result?.purchase?.id, + checkoutSessionId: event.data?.stripeEvent?.data?.object?.id, + }); + return; + } + case NEW_PURCHASE_CREATED_EVENT: + // TODO: post-purchase email, Discord role and Kit sync. Port + // ai-hero/apps/ai-hero/src/inngest/functions/post-purchase-workflow.ts. + console.info('commerce.purchase.created', { + txnId: event.data?.txnId, + purchaseId: event.data?.purchaseId, + checkoutSessionId: event.data?.checkoutSessionId, + productType: event.data?.productType, + }); + return; + default: + console.info('commerce.event.unhandled', { event: event.name }); + } + } + + const client = { + async send(payload: CommerceEvent | CommerceEvent[]) { + for (const event of Array.isArray(payload) ? payload : [payload]) { + await dispatch(event); + } + return { ids: [] }; + }, + }; + + return client as unknown as NonNullable; +} diff --git a/apps/website/src/coursebuilder/products.ts b/apps/website/src/coursebuilder/products.ts index 669b5a16..4df187e3 100644 --- a/apps/website/src/coursebuilder/products.ts +++ b/apps/website/src/coursebuilder/products.ts @@ -1,7 +1,16 @@ import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; import { courseBuilderAdapter, db } from '../db'; -import { merchantPrice, merchantProduct, prices } from '../db/schema'; +import { + contentResource, + contentResourceProduct, + merchantPrice, + merchantProduct, + prices, + products, +} from '../db/schema'; +import { ensureStripeMerchantAccount } from './merchant-account'; import { getStripeProvider } from './stripe-provider'; export type MerchantVerification = { @@ -106,6 +115,136 @@ function assertProductName(value: unknown): string { return name; } +const isoDate = z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: 'must be an ISO date', + }) + .transform((value) => new Date(value).toISOString()); + +/** + * Optional product fields. `type`, `slug`, `state`, `visibility` and + * `quantityAvailable` are what `cb product create|update` already sends. + * `openEnrollment`, `closeEnrollment` and `resourceId` are CodeTV additions + * for Workshop Tickets (send them with `--body` through curl or the SOP). + */ +const ProductOptionsSchema = z.object({ + type: z + .enum([ + 'self-paced', + 'cohort', + 'cohort-archive', + 'membership', + 'live', + 'source-code-access', + ]) + .optional(), + slug: z + .string() + .regex(/^[a-z0-9]+(?:[-~][a-z0-9]+)*$/, 'must be a lowercase URL slug') + .max(191) + .optional(), + state: z.enum(['draft', 'published', 'archived', 'deleted']).optional(), + visibility: z.enum(['public', 'private', 'unlisted']).optional(), + quantityAvailable: z.number().int().min(-1).optional(), + openEnrollment: isoDate.nullable().optional(), + closeEnrollment: isoDate.nullable().optional(), + resourceId: z.string().min(1).optional(), +}); + +type ProductOptions = z.infer; + +function parseProductOptions(input: unknown): ProductOptions { + const parsed = ProductOptionsSchema.safeParse(input ?? {}); + if (!parsed.success) { + throw new ProductServiceError( + 'Invalid product options', + 400, + 'INVALID_PRODUCT_OPTIONS', + z.treeifyError(parsed.error), + ); + } + return parsed.data; +} + +/** Merge field-level options into the product's `fields` JSON. */ +export async function applyProductFieldOptions( + productId: string, + options: ProductOptions, +) { + const patch: Record = {}; + for (const key of [ + 'slug', + 'state', + 'visibility', + 'openEnrollment', + 'closeEnrollment', + ] as const) { + if (options[key] !== undefined) patch[key] = options[key]; + } + + const columns: Partial = {}; + if (options.type) columns.type = options.type; + if (options.quantityAvailable !== undefined) { + columns.quantityAvailable = options.quantityAvailable; + } + + if (Object.keys(patch).length === 0 && Object.keys(columns).length === 0) { + return; + } + + const current = await db.query.products.findFirst({ + where: eq(products.id, productId), + }); + if (!current) { + throw new ProductServiceError( + 'Product not found', + 404, + 'PRODUCT_NOT_FOUND', + ); + } + + const fields = { ...((current.fields ?? {}) as Record) }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete fields[key]; + else fields[key] = value; + } + + await db + .update(products) + .set({ ...columns, fields }) + .where(eq(products.id, productId)); +} + +/** Link a product to a Cohort or Workshop so its page can sell it. */ +async function linkProductToResource(productId: string, resourceId: string) { + const resource = await db.query.contentResource.findFirst({ + where: eq(contentResource.id, resourceId), + }); + if (!resource) { + throw new ProductServiceError( + 'Resource not found', + 404, + 'RESOURCE_NOT_FOUND', + ); + } + + const existing = await db.query.contentResourceProduct.findFirst({ + where: and( + eq(contentResourceProduct.productId, productId), + eq(contentResourceProduct.resourceId, resourceId), + ), + }); + if (existing) return; + + await db.insert(contentResourceProduct).values({ + productId, + resourceId, + position: 0, + metadata: { addedBy: 'api/products' }, + }); +} + export async function verifyMerchantProduct( productId: string, ): Promise { @@ -189,24 +328,53 @@ export async function verifyMerchantProduct( }; } -export async function createVerifiedProduct(input: { - name?: unknown; - price?: unknown; -}) { +export async function createVerifiedProduct( + input: { + name?: unknown; + price?: unknown; + } & Record, +) { const name = assertProductName(input.name); const price = parseUsdPrice(input.price); + const options = parseProductOptions({ + type: input.type, + slug: input.slug, + state: input.state, + visibility: input.visibility, + quantityAvailable: input.quantityAvailable, + openEnrollment: input.openEnrollment, + closeEnrollment: input.closeEnrollment, + resourceId: input.resourceId, + }); let product: CourseBuilderProduct | null = null; try { + await ensureStripeMerchantAccount(); + product = (await courseBuilderAdapter.createProduct({ name, price: price.amount, - type: 'self-paced', - quantityAvailable: -1, - state: 'draft', - visibility: 'unlisted', + type: options.type ?? 'self-paced', + quantityAvailable: options.quantityAvailable ?? -1, + state: options.state ?? 'draft', + visibility: options.visibility ?? 'unlisted', + ...(options.openEnrollment && { + openEnrollment: options.openEnrollment, + }), + ...(options.closeEnrollment && { + closeEnrollment: options.closeEnrollment, + }), })) as CourseBuilderProduct; + if (options.slug) { + await applyProductFieldOptions(product.id, { slug: options.slug }); + } + if (options.resourceId) { + await linkProductToResource(product.id, options.resourceId); + } + product = ((await courseBuilderAdapter.getProduct(product.id)) ?? + product) as CourseBuilderProduct; + const merchantVerification = await verifyMerchantProduct(product.id); if (!merchantVerification.verified) { throw new ProductServiceError( @@ -240,23 +408,52 @@ export async function createVerifiedProduct(input: { } } -export async function updateVerifiedProductPatch(input: { - id: unknown; - name?: unknown; - price?: unknown; -}) { +export async function updateVerifiedProductPatch( + input: { + id: unknown; + name?: unknown; + price?: unknown; + } & Record, +) { const id = assertProductId(input.id); const hasName = input.name !== undefined; const hasPrice = input.price !== undefined; + const options = parseProductOptions({ + slug: input.slug, + state: input.state, + visibility: input.visibility, + quantityAvailable: input.quantityAvailable, + openEnrollment: input.openEnrollment, + closeEnrollment: input.closeEnrollment, + resourceId: input.resourceId, + }); + const { resourceId, ...fieldOptions } = options; + const hasOptions = Object.values(options).some( + (value) => value !== undefined, + ); - if (!hasName && !hasPrice) { + if (!hasName && !hasPrice && !hasOptions) { throw new ProductServiceError( - 'Provide at least one field to update: name or price', + 'Provide at least one field to update: name, price, slug, state, visibility, quantityAvailable, openEnrollment, closeEnrollment or resourceId', 400, 'NO_PRODUCT_UPDATES', ); } + if (!hasName && !hasPrice) { + await applyProductFieldOptions(id, fieldOptions); + if (resourceId) await linkProductToResource(id, resourceId); + const product = await courseBuilderAdapter.getProduct(id); + if (!product) { + throw new ProductServiceError( + 'Product not found', + 404, + 'PRODUCT_NOT_FOUND', + ); + } + return { product, merchantVerification: await verifyMerchantProduct(id) }; + } + const currentProduct = (await courseBuilderAdapter.getProduct( id, )) as CourseBuilderProduct | null; @@ -310,6 +507,14 @@ export async function updateVerifiedProductPatch(input: { )) as CourseBuilderProduct; } + if (hasOptions) { + await applyProductFieldOptions(id, fieldOptions); + if (resourceId) await linkProductToResource(id, resourceId); + product = (await courseBuilderAdapter.getProduct( + id, + )) as CourseBuilderProduct; + } + const merchantVerification = await verifyMerchantProduct(id); if (!merchantVerification.verified) { throw new ProductServiceError( diff --git a/apps/website/src/coursebuilder/purchase-polling.test.ts b/apps/website/src/coursebuilder/purchase-polling.test.ts new file mode 100644 index 00000000..361faa17 --- /dev/null +++ b/apps/website/src/coursebuilder/purchase-polling.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + GIVE_UP_AFTER_MS, + OPTIMISTIC_WINDOW_MS, + getPollStep, + isVerified, +} from './purchase-polling.ts'; + +describe('getPollStep', () => { + it('starts optimistic and polls after one second', () => { + assert.deepEqual(getPollStep(0, 0), { phase: 'processing', delayMs: 1000 }); + }); + + it('backs off by 250ms per attempt, capped at 3 seconds', () => { + assert.equal(getPollStep(5_000, 4).delayMs, 2000); + assert.equal(getPollStep(20_000, 8).delayMs, 3000); + assert.equal(getPollStep(50_000, 30).delayMs, 3000); + }); + + it('stays optimistic for the first minute', () => { + assert.equal(getPollStep(OPTIMISTIC_WINDOW_MS - 1, 20).phase, 'processing'); + }); + + it('escalates to "still setting up" after a minute but keeps polling', () => { + const step = getPollStep(OPTIMISTIC_WINDOW_MS, 25); + + assert.equal(step.phase, 'slow'); + assert.equal(step.delayMs, 3000); + }); + + it('gives up after about two minutes and stops polling', () => { + assert.deepEqual(getPollStep(GIVE_UP_AFTER_MS, 45), { + phase: 'failed', + delayMs: null, + }); + }); +}); + +describe('isVerified', () => { + it('is true only for a ready purchase', () => { + assert.equal( + isVerified({ + status: 'ready', + purchaseId: 'purch_1', + productName: 'Workshop Ticket', + purchaseStatus: 'Valid', + }), + true, + ); + assert.equal( + isVerified({ status: 'processing', paymentStatus: 'paid' }), + false, + ); + assert.equal(isVerified({ status: 'error', message: 'nope' }), false); + assert.equal(isVerified(null), false); + }); +}); diff --git a/apps/website/src/coursebuilder/purchase-polling.ts b/apps/website/src/coursebuilder/purchase-polling.ts new file mode 100644 index 00000000..03e67a1e --- /dev/null +++ b/apps/website/src/coursebuilder/purchase-polling.ts @@ -0,0 +1,53 @@ +/** + * Polling schedule for the Purchase Processing screen. + * + * CONTEXT.md: keep the buyer in an optimistic state for about 60 seconds, + * keep polling up to about 2 minutes, then show the support-oriented + * "payment succeeded, setup delayed" fallback. + * + * Keep this module dependency-free: it runs in a React island and `node --test`. + */ + +export const OPTIMISTIC_WINDOW_MS = 60_000; +export const GIVE_UP_AFTER_MS = 120_000; + +export type CheckoutStatus = + | { + status: 'ready'; + purchaseId: string; + productName: string | null; + purchaseStatus: string; + } + | { status: 'processing'; paymentStatus: string | null } + | { status: 'not-paid'; paymentStatus: string | null } + | { status: 'error'; message: string }; + +export type PollPhase = 'processing' | 'slow' | 'failed'; + +export type PollStep = + | { phase: 'processing' | 'slow'; delayMs: number } + | { phase: 'failed'; delayMs: null }; + +/** + * Decide what the screen shows and when to poll next. + * + * @param elapsedMs - Time since the screen started polling. + * @param attempt - Number of completed polls (0-based). + */ +export function getPollStep(elapsedMs: number, attempt: number): PollStep { + if (elapsedMs >= GIVE_UP_AFTER_MS) { + return { phase: 'failed', delayMs: null }; + } + + const delayMs = Math.min(1000 + attempt * 250, 3000); + + return { + phase: elapsedMs >= OPTIMISTIC_WINDOW_MS ? 'slow' : 'processing', + delayMs, + }; +} + +/** True when the screen should stop polling and show the verified state. */ +export function isVerified(status: CheckoutStatus | null) { + return status?.status === 'ready'; +} diff --git a/apps/website/src/coursebuilder/request-country.ts b/apps/website/src/coursebuilder/request-country.ts new file mode 100644 index 00000000..1d3b3241 --- /dev/null +++ b/apps/website/src/coursebuilder/request-country.ts @@ -0,0 +1,29 @@ +import type { APIContext, AstroGlobal } from 'astro'; + +/** + * Country used for PPP pricing and checkout. + * + * Never trust a country sent by the browser. On Netlify the edge geolocates + * the request (`locals.netlify.context.geo`). Locally there is no geo, so + * `astro dev` accepts `?ppp_country=XX` to exercise PPP by hand. + */ +export function getTrustedCountry( + context: Pick, +): string { + const locals = context.locals as { + netlify?: { context?: { geo?: { country?: { code?: string } } } }; + }; + const netlifyCountry = locals.netlify?.context?.geo?.country?.code; + const devCountry = import.meta.env.DEV + ? context.url.searchParams.get('ppp_country') + : null; + + const country = ( + netlifyCountry ?? + devCountry ?? + process.env.DEFAULT_COUNTRY ?? + 'US' + ).toUpperCase(); + + return /^[A-Z]{2}$/.test(country) ? country : 'US'; +} diff --git a/apps/website/src/coursebuilder/stripe-provider.ts b/apps/website/src/coursebuilder/stripe-provider.ts index 3fbaa02a..d56ca841 100644 --- a/apps/website/src/coursebuilder/stripe-provider.ts +++ b/apps/website/src/coursebuilder/stripe-provider.ts @@ -1,25 +1,81 @@ +import Stripe from 'stripe'; + import StripeProvider, { StripePaymentAdapter, } from '@coursebuilder/core/providers/stripe'; -const baseUrl = - process.env.COURSEBUILDER_URL ?? - process.env.PUBLIC_SITE_URL ?? - process.env.URL ?? - 'http://localhost:4321'; +/** + * Base URL for CourseBuilder commerce redirects (Stripe success/cancel URLs). + * + * On Netlify, `URL` is always the production domain. Deploy previews and branch + * deploys use `DEPLOY_PRIME_URL` so a test purchase returns to the preview. + */ +export function getCommerceBaseUrl() { + const netlifyPreviewUrl = + process.env.CONTEXT && process.env.CONTEXT !== 'production' + ? process.env.DEPLOY_PRIME_URL + : undefined; -export function getStripeProvider() { - const stripeToken = + return ( + process.env.COURSEBUILDER_URL ?? + netlifyPreviewUrl ?? + process.env.PUBLIC_SITE_URL ?? + process.env.URL ?? + 'http://localhost:4321' + ).replace(/\/$/, ''); +} + +function getStripeToken() { + return ( process.env.COURSEBUILDER_STRIPE_SECRET_TOKEN ?? process.env.COURSEBUILDER_STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_TOKEN ?? - process.env.STRIPE_SECRET_KEY; - const stripeWebhookSecret = + process.env.STRIPE_SECRET_KEY + ); +} + +/** True when CourseBuilder commerce is pointed at a live Stripe key. */ +export function isStripeLiveMode() { + return /^(sk|rk)_live_/.test(getStripeToken() ?? ''); +} + +function getStripeWebhookSecret() { + return ( process.env.COURSEBUILDER_STRIPE_WEBHOOK_SECRET ?? - process.env.STRIPE_WEBHOOK_SECRET; + process.env.STRIPE_WEBHOOK_SECRET + ); +} + +/** + * Verify a Stripe webhook signature against the CourseBuilder webhook secret. + * + * CourseBuilder core 1.2.1 calls its async `verifyWebhookSignature` without + * awaiting it, so a bad signature does not stop core from processing the + * event. The `/api/coursebuilder/webhook` route verifies here first. + */ +export function verifyStripeWebhookSignature( + rawBody: string, + signature: string | null, +): boolean { + const secret = getStripeWebhookSecret(); + if (!secret || !signature) return false; + + try { + Stripe.webhooks.constructEvent(rawBody, signature, secret); + return true; + } catch { + return false; + } +} + +export function getStripeProvider() { + const stripeToken = getStripeToken(); + const stripeWebhookSecret = getStripeWebhookSecret(); if (!stripeToken || !stripeWebhookSecret) return null; + const baseUrl = getCommerceBaseUrl(); + return StripeProvider({ errorRedirectUrl: baseUrl, baseSuccessUrl: baseUrl, diff --git a/apps/website/src/coursebuilder/workshop-not-found.test.ts b/apps/website/src/coursebuilder/workshop-not-found.test.ts new file mode 100644 index 00000000..75ad7466 --- /dev/null +++ b/apps/website/src/coursebuilder/workshop-not-found.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + readFile, + writeFile, + rm, + symlink, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { it } from 'node:test'; +import { dev } from 'astro'; + +it('rewrites an unknown workshop to the site 404 page with a 404 status', async () => { + // Exercise the real Astro pages and rewrite handler without database or Clerk + // credentials. Only external data and the surrounding layout are stubbed. + const root = await mkdtemp(join(tmpdir(), 'codetv-workshop-404-')); + async function fixture(path: string, content: string) { + const target = join(root, path); + await mkdir(join(target, '..'), { recursive: true }); + await writeFile(target, content); + } + let server: Awaited> | undefined; + try { + await symlink( + fileURLToPath(new URL('../../node_modules', import.meta.url)), + join(root, 'node_modules'), + 'dir', + ); + const workshopPage = await readFile( + new URL('../pages/workshops/[slug].astro', import.meta.url), + 'utf8', + ); + await fixture( + 'src/pages/workshops/[slug].astro', + workshopPage.replace( + "'@clerk/astro/components'", + "'../../coursebuilder/stubs'", + ), + ); + await fixture( + 'src/pages/404.astro', + await readFile(new URL('../pages/404.astro', import.meta.url), 'utf8'), + ); + await fixture( + 'src/layouts/default.astro', + '', + ); + await fixture( + 'src/components/design-system/block.astro', + '
', + ); + await fixture( + 'src/components/workshops/workshop-pricing.ts', + 'export const WorkshopPricing = () => null;', + ); + await fixture( + 'src/coursebuilder/stubs.ts', + 'export const SignInButton = () => null;', + ); + await fixture( + 'src/coursebuilder/users.ts', + 'export const getCourseBuilderUserForClerkUser = async () => { throw new Error("Unexpected user lookup for missing workshop"); };', + ); + await fixture( + 'src/coursebuilder/workshops.ts', + ` + export const getWorkshopForSale = async () => null; + export const getTicketPurchase = async () => { throw new Error('Unexpected ticket lookup'); }; + export const resolveCouponCode = async () => { throw new Error('Unexpected coupon lookup'); }; + `, + ); + server = await dev({ + root, + configFile: false, + output: 'server', + server: { host: '127.0.0.1', port: 0 }, + logLevel: 'silent', + }); + const response = await fetch( + `http://127.0.0.1:${server.address.port}/workshops/does-not-exist`, + ); + assert.equal(response.status, 404); + const html = await response.text(); + assert.match(html, /Page Not Found/); + assert.match(html, /The page you requested was not found/); + assert.match(html, /open an issue/); + assert.doesNotMatch(html, /Get a ticket/); + } finally { + await server?.stop(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/website/src/coursebuilder/workshops.ts b/apps/website/src/coursebuilder/workshops.ts new file mode 100644 index 00000000..4d7d5369 --- /dev/null +++ b/apps/website/src/coursebuilder/workshops.ts @@ -0,0 +1,288 @@ +import { and, asc, eq, inArray, isNull, or } from 'drizzle-orm'; + +import { courseBuilderAdapter, db } from '../db'; +import { + contentResource, + contentResourceResource, + products, + purchases, +} from '../db/schema'; +import { + getEnrollmentWindow, + pickCurrentTicket, + type EnrollmentWindow, +} from './enrollment'; + +type Fields = Record; + +export type WorkshopTicket = { + productId: string; + productName: string; + productType: string; + /** USD dollars, from the local Price row. The pricing island formats PPP/coupons. */ + unitAmount: number | null; + cohort: { + id: string; + title: string | null; + startsAt: Date | null; + endsAt: Date | null; + } | null; + enrollment: EnrollmentWindow; +}; + +export type WorkshopForSale = { + id: string; + slug: string; + title: string; + description: string | null; + body: string | null; + outline: Array<{ id: string; type: string; title: string }>; + ticket: WorkshopTicket | null; +}; + +const ACCESS_STATUSES = ['Valid', 'Restricted']; + +function toDate(value: unknown): Date | null { + if (!value) return null; + const date = new Date(value as string); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isProductActive(product: { status: number; fields: unknown }) { + const fields = (product.fields ?? {}) as Fields; + return product.status === 1 && (fields.state ?? 'published') === 'published'; +} + +/** A Workshop is sellable when published and either public or unlisted. */ +function isWorkshopVisible(fields: Fields) { + return ( + fields.state === 'published' && + (fields.visibility === 'public' || fields.visibility === 'unlisted') + ); +} + +type ProductRow = typeof products.$inferSelect & { + price?: { unitAmount: string | number } | null; +}; + +function toTicket( + product: ProductRow, + cohort: typeof contentResource.$inferSelect | null, + now: Date, +): WorkshopTicket { + const productFields = (product.fields ?? {}) as Fields; + const cohortFields = (cohort?.fields ?? {}) as Fields; + const startsAt = toDate(cohortFields.startsAt); + + return { + productId: product.id, + productName: product.name, + productType: product.type ?? 'self-paced', + unitAmount: product.price ? Number(product.price.unitAmount) : null, + cohort: cohort + ? { + id: cohort.id, + title: cohortFields.title ?? null, + startsAt, + endsAt: toDate(cohortFields.endsAt), + } + : null, + enrollment: getEnrollmentWindow({ + now, + productActive: isProductActive(product), + openEnrollment: productFields.openEnrollment, + closeEnrollment: productFields.closeEnrollment, + cohortStartsAt: startsAt, + }), + }; +} + +/** + * Load a Workshop by slug plus the Workshop Ticket its page should sell. + * + * Tickets come from products linked to a Cohort that contains this Workshop + * (`createCohort` shape), or from products linked to the Workshop directly + * (`workshop create --create-product` shape). + */ +export async function getWorkshopForSale( + slug: string, + now = new Date(), +): Promise { + const workshop = await db.query.contentResource.findFirst({ + where: and( + eq(contentResource.type, 'workshop'), + or(eq(contentResource.slug, slug), eq(contentResource.id, slug)), + ), + with: { + resources: { + with: { resource: true }, + orderBy: asc(contentResourceResource.position), + }, + resourceProducts: { + with: { product: { with: { price: true } } }, + }, + }, + }); + + if (!workshop) return null; + + const fields = (workshop.fields ?? {}) as Fields; + if (!isWorkshopVisible(fields)) return null; + + const cohortLinks = await db.query.contentResourceResource.findMany({ + where: eq(contentResourceResource.resourceId, workshop.id), + with: { + resourceOf: { + with: { + resourceProducts: { + with: { product: { with: { price: true } } }, + }, + }, + }, + }, + }); + + const candidates = [ + ...cohortLinks + .map((link) => link.resourceOf) + .filter((parent) => parent?.type === 'cohort') + .flatMap((cohort) => + cohort.resourceProducts.map((link) => + toTicket(link.product as ProductRow, cohort, now), + ), + ), + ...workshop.resourceProducts.map((link) => + toTicket(link.product as ProductRow, null, now), + ), + ] + .filter((ticket) => ticket.productId) + .map((ticket) => ({ + ticket, + enrollment: ticket.enrollment, + cohortStartsAt: ticket.cohort?.startsAt ?? null, + })); + + return { + id: workshop.id, + slug: workshop.slug ?? slug, + title: fields.title ?? 'Workshop', + description: fields.description ?? null, + body: typeof fields.body === 'string' ? fields.body : null, + outline: workshop.resources + .map((link) => link.resource) + .filter(Boolean) + .map((resource) => ({ + id: resource.id, + type: resource.type, + title: ((resource.fields ?? {}) as Fields).title ?? 'Untitled', + })), + ticket: pickCurrentTicket(candidates)?.ticket ?? null, + }; +} + +/** + * Load the ticket for a product id, for the server-side checkout guard. + * Uses the first Cohort the product is linked to, if any. + */ +export async function getTicketForProduct( + productId: string, + now = new Date(), +): Promise { + const product = await db.query.products.findFirst({ + where: eq(products.id, productId), + with: { + price: true, + resources: { with: { resource: true } }, + }, + }); + + if (!product) return null; + + const cohort = + product.resources + .map((link) => link.resource) + .find((resource) => resource?.type === 'cohort') ?? null; + + return toTicket(product as ProductRow, cohort, now); +} + +/** + * Path of the Workshop page a product sells, for the post-purchase screen. + */ +export async function getWorkshopPathForProduct(productId: string) { + const product = await db.query.products.findFirst({ + where: eq(products.id, productId), + with: { + resources: { + with: { + resource: { + with: { resources: { with: { resource: true } } }, + }, + }, + }, + }, + }); + + for (const link of product?.resources ?? []) { + const resource = link.resource; + if (!resource) continue; + + const workshop = + resource.type === 'workshop' + ? resource + : resource.resources + .map((child) => child.resource) + .find((child) => child?.type === 'workshop'); + + if (workshop?.slug) return `/workshops/${workshop.slug}`; + } + + return null; +} + +/** + * Workshop Access through a Workshop Ticket: a Valid or Restricted (PPP) + * individual purchase, or a claimed Team Seat. A team buyer's bulk purchase + * does not grant a seat by itself (CONTEXT.md). + */ +export async function getTicketPurchase(userId: string, productIds: string[]) { + if (productIds.length === 0) return null; + + return ( + (await db.query.purchases.findFirst({ + where: and( + eq(purchases.userId, userId), + inArray(purchases.productId, productIds), + inArray(purchases.status, ACCESS_STATUSES), + isNull(purchases.bulkCouponId), + ), + })) ?? null + ); +} + +/** + * Resolve a `?code=` value (coupon id or human-readable code) to a coupon id + * the pricing endpoint accepts. Returns null when the coupon is unknown, + * expired, or restricted to another product. + */ +export async function resolveCouponCode( + code: string | null | undefined, + productId: string, +) { + const trimmed = code?.trim(); + if (!trimmed || trimmed.length > 191) return null; + + const coupon = await courseBuilderAdapter.couponForIdOrCode({ + code: trimmed, + couponId: trimmed, + }); + + if (!coupon) return null; + if ( + coupon.restrictedToProductId && + coupon.restrictedToProductId !== productId + ) + return null; + + return coupon.id; +} diff --git a/apps/website/src/pages/404.astro b/apps/website/src/pages/404.astro index 5d108d0b..6516cb54 100644 --- a/apps/website/src/pages/404.astro +++ b/apps/website/src/pages/404.astro @@ -1,7 +1,9 @@ --- import Layout from '../layouts/default.astro'; -export const prerender = true; +// On-demand pages (including workshops) rewrite here when a resource is missing. +export const prerender = false; +Astro.response.status = 404; const issueURL = new URL('https://github.com'); issueURL.pathname = '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/codetv-dev/codetv.dev/issues/new'; diff --git a/apps/website/src/pages/api/commerce/checkout-status.ts b/apps/website/src/pages/api/commerce/checkout-status.ts new file mode 100644 index 00000000..e07df6a8 --- /dev/null +++ b/apps/website/src/pages/api/commerce/checkout-status.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from 'astro'; + +import { getCheckoutStatus } from '../../../coursebuilder/checkout-status'; + +export const prerender = false; + +/** Polled by the Purchase Processing screen at /thanks/purchase. */ +export const GET: APIRoute = async ({ url }) => { + const sessionId = url.searchParams.get('session_id') ?? ''; + const attempt = url.searchParams.get('attempt'); + const status = await getCheckoutStatus(sessionId); + + if (attempt === 'gave-up' && status.status !== 'ready') { + // The buyer is looking at the "payment succeeded, setup delayed" message. + console.error('purchase-processing.gave-up', { + sessionId, + status: status.status, + }); + } else if (status.status !== 'ready') { + console.info('purchase-processing.poll', { + sessionId, + attempt, + status: status.status, + ...(status.status === 'processing' || status.status === 'not-paid' + ? { paymentStatus: status.paymentStatus } + : {}), + }); + } + + return Response.json(status, { + status: status.status === 'error' ? 400 : 200, + headers: { 'Cache-Control': 'no-store, max-age=0' }, + }); +}; diff --git a/apps/website/src/pages/api/coursebuilder/[...coursebuilder].ts b/apps/website/src/pages/api/coursebuilder/[...coursebuilder].ts new file mode 100644 index 00000000..186733c0 --- /dev/null +++ b/apps/website/src/pages/api/coursebuilder/[...coursebuilder].ts @@ -0,0 +1,282 @@ +import type { APIContext, APIRoute } from 'astro'; +import { Coursebuilder } from '@coursebuilder/astro/server'; + +import { + decideCheckoutCoupon, + getPppMerchantCoupon, + getValidSiteCoupon, +} from '../../../coursebuilder/checkout-coupons'; +import { + getSafeReturnUrl, + getStripeCheckoutUrl, +} from '../../../coursebuilder/checkout-redirects'; +import { getTrustedCountry } from '../../../coursebuilder/request-country'; +import { + getCommerceBaseUrl, + verifyStripeWebhookSignature, +} from '../../../coursebuilder/stripe-provider'; +import { getCourseBuilderUserForClerkUser } from '../../../coursebuilder/users'; +import { + getTicketForProduct, + getTicketPurchase, +} from '../../../coursebuilder/workshops'; +import { courseBuilderAdapter } from '../../../db'; + +/** + * CodeTV owns the `/api/coursebuilder/*` route (the integration runs with + * `injectEndpoints: false`) so the server, not the browser, decides who is + * buying, from which country, and whether sales are open. Everything else is + * passed straight to `@coursebuilder/core`. + */ + +export const prerender = false; + +const { GET: courseBuilderGet, POST: courseBuilderPost } = Coursebuilder(); + +/** Core actions that authorize with `x-skill-secret`. */ +const SKILL_SECRET_ACTIONS = new Set([ + 'refund', + 'transfer', + 'lookup', + 'create-magic-link', +]); + +function getAction(url: URL) { + return url.pathname.replace(/^\/api\/coursebuilder\/?/, '').split('/')[0]; +} + +/** Hand core a different Request while keeping Astro's locals and helpers. */ +function withRequest(context: APIContext, request: Request): APIContext { + return new Proxy(context, { + get(target, property) { + if (property === 'request') return request; + return Reflect.get(target, property, target); + }, + }); +} + +function redirect(location: string, status = 303) { + return new Response(null, { status, headers: { Location: location } }); +} + +function backTo(returnUrl: URL, checkout: string) { + const url = new URL(returnUrl); + url.searchParams.set('checkout', checkout); + return redirect(url.toString()); +} + +async function getViewer(context: APIContext) { + const clerkUser = await context.locals.currentUser?.(); + return getCourseBuilderUserForClerkUser(clerkUser ?? null); +} + +async function handleCheckout(context: APIContext) { + const url = new URL(context.request.url); + const returnUrl = getSafeReturnUrl( + url.searchParams.get('cancelUrl'), + context.url, + [new URL(getCommerceBaseUrl()).origin], + ); + const productId = url.searchParams.get('productId'); + + if (!productId) return backTo(returnUrl, 'error'); + + const viewer = await getViewer(context); + if (!viewer) { + console.info('checkout.blocked', { productId, reason: 'signed-out' }); + return backTo(returnUrl, 'sign-in-required'); + } + + const ticket = await getTicketForProduct(productId); + if (!ticket) { + console.warn('checkout.blocked', { + productId, + reason: 'product-not-found', + }); + return backTo(returnUrl, 'error'); + } + + if (ticket.enrollment.state !== 'open') { + console.info('checkout.blocked', { + productId, + userId: viewer.id, + reason: `enrollment-${ticket.enrollment.state}`, + }); + return backTo(returnUrl, 'enrollment-closed'); + } + + // TODO: team seats. Port the AI Hero bulk purchase path + // (ai-hero/apps/ai-hero/src/app/(content)/cohorts/[slug]/_components/cohort-pricing-widget-container.tsx) + // before allowing quantity > 1 or a second ticket for the same Viewer. + if (await getTicketPurchase(viewer.id, [productId])) { + console.info('checkout.blocked', { + productId, + userId: viewer.id, + reason: 'already-has-ticket', + }); + return backTo(returnUrl, 'already-purchased'); + } + + const coupon = await decideCheckoutCoupon({ + productId, + trustedCountry: getTrustedCountry(context), + requestedPpp: url.searchParams.get('ppp') === '1', + usedCouponId: url.searchParams.get('usedCouponId'), + adapter: courseBuilderAdapter, + }); + + // Rebuild the query from scratch; nothing else from the browser reaches core. + const checkoutUrl = new URL(url.pathname, url); + checkoutUrl.searchParams.set('productId', productId); + checkoutUrl.searchParams.set('userId', viewer.id); + checkoutUrl.searchParams.set('quantity', '1'); + checkoutUrl.searchParams.set('bulk', 'false'); + checkoutUrl.searchParams.set('country', coupon.country); + checkoutUrl.searchParams.set('cancelUrl', returnUrl.toString()); + if (coupon.couponId) + checkoutUrl.searchParams.set('couponId', coupon.couponId); + if (coupon.usedCouponId) { + checkoutUrl.searchParams.set('usedCouponId', coupon.usedCouponId); + } + try { + checkoutUrl.searchParams.set('ip_address', context.clientAddress); + } catch { + // clientAddress is unavailable in some dev setups. + } + + // Core reads everything from the query string; drop the form body. + const headers = new Headers(context.request.headers); + headers.delete('content-length'); + headers.delete('content-type'); + + const response = await courseBuilderPost( + withRequest(context, new Request(checkoutUrl, { method: 'POST', headers })), + ); + + const stripeCheckoutUrl = getStripeCheckoutUrl( + response.headers.get('Location'), + ); + + if (!stripeCheckoutUrl) { + console.error('checkout.failed', { + productId, + userId: viewer.id, + status: response.status, + }); + return backTo(returnUrl, 'error'); + } + + console.info('checkout.redirect', { + productId, + userId: viewer.id, + coupon: coupon.reason, + }); + return redirect(stripeCheckoutUrl); +} + +async function handlePricesFormatted(context: APIContext) { + let body: Record = {}; + try { + body = (await context.request.json()) as Record; + } catch { + return Response.json('JSON body is required', { status: 400 }); + } + + const productId = typeof body.productId === 'string' ? body.productId : null; + if (!productId) + return Response.json('productId is required', { status: 400 }); + + const trustedCountry = getTrustedCountry(context); + const [viewer, siteCoupon, pppCoupon] = await Promise.all([ + getViewer(context), + getValidSiteCoupon( + typeof body.couponId === 'string' ? body.couponId : null, + productId, + courseBuilderAdapter, + ), + body.ppp === true + ? getPppMerchantCoupon(trustedCountry, courseBuilderAdapter) + : null, + ]); + + const headers = new Headers(context.request.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + // Core reads the country from this header first; never let the browser set it. + headers.set('x-vercel-ip-country', trustedCountry); + + // Only the fields the pricing block needs, decided server-side. PPP is + // opt-in: it shows up in `availableCoupons` and applies only when chosen. + return courseBuilderPost( + withRequest( + context, + new Request(context.request.url, { + method: 'POST', + headers, + body: JSON.stringify({ + productId, + quantity: 1, + autoApplyPPP: false, + ...(siteCoupon && { couponId: siteCoupon.id }), + ...(pppCoupon && { merchantCoupon: pppCoupon }), + ...(viewer && { userId: viewer.id }), + }), + }), + ), + ); +} + +/** + * Core skips signature checks when the header is missing and does not await + * the check when it is present, so verify the raw body here first. + */ +async function handleWebhook(context: APIContext) { + const signature = context.request.headers.get('stripe-signature'); + const rawBody = await context.request.text(); + + if (!verifyStripeWebhookSignature(rawBody, signature)) { + console.warn('webhook.rejected', { + reason: signature + ? 'invalid-stripe-signature' + : 'missing-stripe-signature', + }); + return Response.json('Invalid stripe-signature', { status: 400 }); + } + + return courseBuilderPost( + withRequest( + context, + new Request(context.request.url, { + method: 'POST', + headers: context.request.headers, + body: rawBody, + }), + ), + ); +} + +export const GET: APIRoute = (context) => courseBuilderGet(context); + +export const POST: APIRoute = async (context) => { + const action = getAction(new URL(context.request.url)); + + switch (action) { + case 'webhook': + return handleWebhook(context); + case 'checkout': + return handleCheckout(context); + case 'prices-formatted': + return handlePricesFormatted(context); + default: + // Core treats a missing SKILL_SECRET as a match for a missing header. + if ( + SKILL_SECRET_ACTIONS.has(action) && + (!process.env.SKILL_SECRET || + context.request.headers.get('x-skill-secret') !== + process.env.SKILL_SECRET) + ) { + return Response.json('unauthorized', { status: 401 }); + } + return courseBuilderPost(context); + } +}; diff --git a/apps/website/src/pages/api/products.ts b/apps/website/src/pages/api/products.ts index 4e8616cc..1c27fa67 100644 --- a/apps/website/src/pages/api/products.ts +++ b/apps/website/src/pages/api/products.ts @@ -136,7 +136,7 @@ export const POST: APIRoute = async ({ request }) => try { const { product, merchantVerification } = await createVerifiedProduct( - body as { name?: unknown; price?: unknown }, + body as Record, ); return json( { success: true, product, merchantVerification }, @@ -160,19 +160,13 @@ export const PUT: APIRoute = async ({ request }) => return json({ error: 'JSON body is required' }, { status: 400 }); } - const payload = body as { - id?: unknown; - productId?: unknown; - name?: unknown; - price?: unknown; - }; + const payload = body as Record; try { const { product, merchantVerification } = await updateVerifiedProductPatch({ + ...payload, id: payload.id ?? payload.productId, - name: payload.name, - price: payload.price, }); return json({ success: true, product, merchantVerification }); } catch (error) { diff --git a/apps/website/src/pages/thanks/purchase.astro b/apps/website/src/pages/thanks/purchase.astro new file mode 100644 index 00000000..44cd7237 --- /dev/null +++ b/apps/website/src/pages/thanks/purchase.astro @@ -0,0 +1,62 @@ +--- +import Layout from '../../layouts/default.astro'; +import { PurchaseProcessing } from '../../components/workshops/purchase-processing'; +import { + getCheckoutStatus, + isCheckoutSessionId, +} from '../../coursebuilder/checkout-status'; + +export const prerender = false; + +// Stripe Checkout returns here: CourseBuilder core sets the success URL to +// `${baseUrl}/thanks/purchase?session_id={CHECKOUT_SESSION_ID}&provider=stripe`. +const sessionId = Astro.url.searchParams.get('session_id'); + +if (!isCheckoutSessionId(sessionId)) { + return Astro.redirect('/'); +} + +const initialStatus = await getCheckoutStatus(sessionId as string); +--- + + +
+ +
+
+ + diff --git a/apps/website/src/pages/workshops/[slug].astro b/apps/website/src/pages/workshops/[slug].astro new file mode 100644 index 00000000..da6fdf61 --- /dev/null +++ b/apps/website/src/pages/workshops/[slug].astro @@ -0,0 +1,223 @@ +--- +import { SignInButton } from '@clerk/astro/components'; +import Layout from '../../layouts/default.astro'; +import Block from '../../components/design-system/block.astro'; +import { WorkshopPricing } from '../../components/workshops/workshop-pricing'; +import { getCourseBuilderUserForClerkUser } from '../../coursebuilder/users'; +import { + getTicketPurchase, + getWorkshopForSale, + resolveCouponCode, +} from '../../coursebuilder/workshops'; + +export const prerender = false; + +const slug = Astro.params.slug ?? ''; +const workshop = await getWorkshopForSale(slug); + +if (!workshop) { + return Astro.rewrite('/404'); +} + +const ticket = workshop.ticket; +const clerkUser = await Astro.locals.currentUser?.(); +const viewer = await getCourseBuilderUserForClerkUser(clerkUser ?? null); +const hasTicket = + viewer && ticket + ? Boolean(await getTicketPurchase(viewer.id, [ticket.productId])) + : false; +const couponId = ticket + ? await resolveCouponCode( + Astro.url.searchParams.get('code'), + ticket.productId, + ) + : null; + +// Return here after sign-in or a cancelled checkout, keeping the coupon code. +const returnUrl = new URL(`/workshops/${workshop.slug}`, Astro.url); +const code = Astro.url.searchParams.get('code'); +if (code) returnUrl.searchParams.set('code', code); + +const dateFormat = new Intl.DateTimeFormat('en-US', { + dateStyle: 'long', + timeZone: 'America/Los_Angeles', +}); +const cohort = ticket?.cohort; +const cohortDates = + cohort?.startsAt && cohort?.endsAt + ? dateFormat.formatRange(cohort.startsAt, cohort.endsAt) + : cohort?.startsAt + ? dateFormat.format(cohort.startsAt) + : null; + +// Placeholder copy until the CodeTV team writes the page. Matches the +// Workshop Ticket definition in CONTEXT.md. +const included = [ + 'A seat in this live Cohort', + 'Lifetime access to the Workshop recordings and materials', +]; +--- + + +
+ +
+

Workshop

+
+
+

{workshop.title}

+ {workshop.description &&

{workshop.description}

} + { + cohortDates && ( +

+ Cohort: {cohortDates} (Pacific) +

+ ) + } +
+
+ + +

Get a ticket

+
+ { + ticket ? ( + <> + + {!viewer && ticket.enrollment.state === 'open' && ( + + )} + + ) : ( +

Tickets aren’t on sale yet. Check back soon.

+ ) + } +
+
+ + +

What’s included

+
+
    + {included.map((item) =>
  • {item}
  • )} +
+ { + workshop.outline.length > 0 && ( + <> +

Workshop outline

+
    + {workshop.outline.map((item) => ( +
  1. {item.title}
  2. + ))} +
+ + ) + } +
+
+
+
+ + diff --git a/apps/website/src/server/trpc/commerce.ts b/apps/website/src/server/trpc/commerce.ts new file mode 100644 index 00000000..a03e62f0 --- /dev/null +++ b/apps/website/src/server/trpc/commerce.ts @@ -0,0 +1,336 @@ +import { randomUUID } from 'node:crypto'; + +import { TRPCError } from '@trpc/server'; +import { and, desc, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { ensureStripeMerchantAccount } from '../../coursebuilder/merchant-account'; +import { applyProductFieldOptions } from '../../coursebuilder/products'; +import { + getCommerceBaseUrl, + getStripeProvider, + isStripeLiveMode, +} from '../../coursebuilder/stripe-provider'; +import { getWorkshopPathForProduct } from '../../coursebuilder/workshops'; +import { courseBuilderAdapter, db } from '../../db'; +import { contentResource, merchantCoupon, purchases } from '../../db/schema'; +import { operatorProcedure, router } from './router-base'; + +/** + * Operator procedures for selling a Workshop Ticket. The CourseBuilder CLI + * calls them with `cb trpc get|post commerce.`; see + * docs/sop/workshop-launch.md. + */ + +/** Percentages `createCoupon` snaps to; each needs a `special` merchant coupon. */ +const SPECIAL_PERCENTAGES = [0.1, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9, 0.95]; +/** PPP discounts CourseBuilder computes (40% to 75% in 5% steps). */ +const PPP_PERCENTAGES = [0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75]; + +const isoDate = z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: 'must be an ISO date', + }) + .transform((value) => new Date(value)); + +const percentage = z.coerce.number().gt(0).lt(1); + +async function shareUrlForProduct(productId: string, code: string) { + const path = await getWorkshopPathForProduct(productId); + if (!path) return null; + const url = new URL(path, getCommerceBaseUrl()); + url.searchParams.set('code', code); + return url.toString(); +} + +export const commerceRouter = router({ + /** + * Create a Cohort for a Workshop plus its Workshop Ticket product + * (type `cohort`, published, sales close at the Cohort start), and an + * optional early bird default coupon. + */ + createCohort: operatorProcedure + .input( + z.object({ + workshopId: z.string().min(1), + title: z.string().min(2).max(90), + description: z.string().optional(), + startsAt: isoDate, + endsAt: isoDate, + price: z.number().positive(), + openEnrollment: isoDate.optional(), + earlyBird: z + .object({ + percentageDiscount: percentage, + expires: isoDate, + }) + .optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const workshop = await db.query.contentResource.findFirst({ + where: and( + eq(contentResource.id, input.workshopId), + eq(contentResource.type, 'workshop'), + ), + }); + if (!workshop) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Workshop not found', + }); + } + + await ensureStripeMerchantAccount(); + + const { cohort, product } = await courseBuilderAdapter.createCohort( + { + cohort: { title: input.title, description: input.description }, + dates: { start: input.startsAt, end: input.endsAt }, + createProduct: true, + pricing: { price: input.price }, + ...(input.earlyBird && { + coupon: { + enabled: true, + percentageDiscount: String(input.earlyBird.percentageDiscount), + expires: input.earlyBird.expires, + }, + }), + workshops: [{ id: input.workshopId }], + }, + ctx.user.id, + ); + + // createCohort logs and swallows product failures; surface them. + if (!product?.id) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Cohort ${cohort.id} was created but its product was not. Check Stripe env, then create the product with POST /api/products and resourceId=${cohort.id}.`, + }); + } + + if (input.openEnrollment) { + await applyProductFieldOptions(product.id, { + openEnrollment: input.openEnrollment.toISOString(), + }); + } + + const defaults = await courseBuilderAdapter.getDefaultCoupon([ + product.id, + ]); + + return { + cohort, + product: await courseBuilderAdapter.getProduct(product.id), + earlyBird: input.earlyBird + ? { + created: Boolean(defaults?.defaultCoupon), + hint: defaults?.defaultCoupon + ? undefined + : 'No early bird coupon: run commerce.seedMerchantCoupons, then commerce.createCoupon with default=true.', + } + : undefined, + workshopPath: await getWorkshopPathForProduct(product.id), + }; + }), + + /** List buyers of a product (the operator check). */ + listPurchases: operatorProcedure + .input(z.object({ productId: z.string().min(1) })) + .query(async ({ input }) => { + const rows = await db.query.purchases.findMany({ + where: eq(purchases.productId, input.productId), + with: { user: true }, + orderBy: desc(purchases.createdAt), + }); + + return rows.map((purchase) => ({ + id: purchase.id, + status: purchase.status, + createdAt: purchase.createdAt, + totalAmount: purchase.totalAmount, + country: purchase.country, + couponId: purchase.couponId, + bulkCouponId: purchase.bulkCouponId, + redeemedBulkCouponId: purchase.redeemedBulkCouponId, + user: purchase.user + ? { + id: purchase.user.id, + email: purchase.user.email, + name: purchase.user.name, + } + : null, + })); + }), + + /** + * Create the Stripe-backed merchant coupons CourseBuilder needs for PPP and + * percentage coupons. Idempotent. Refuses a live Stripe key unless + * `allowLive` is true. + */ + seedMerchantCoupons: operatorProcedure + .input( + z + .object({ + dryRun: z.boolean().default(false), + allowLive: z.boolean().default(false), + }) + .default({ dryRun: false, allowLive: false }), + ) + .mutation(async ({ input }) => { + const live = isStripeLiveMode(); + if (live && !input.allowLive) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: + 'CourseBuilder Stripe key is live. Re-run with allowLive=true only when you mean to create live coupons.', + }); + } + + const stripeProvider = getStripeProvider(); + if (!stripeProvider) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'CourseBuilder Stripe env is not configured', + }); + } + + const account = await ensureStripeMerchantAccount(); + if (!account) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Stripe merchant account row is missing', + }); + } + + const wanted = [ + ...SPECIAL_PERCENTAGES.map((value) => ({ type: 'special', value })), + ...PPP_PERCENTAGES.map((value) => ({ type: 'ppp', value })), + ]; + const results: Array<{ + type: string; + percentageDiscount: number; + status: 'exists' | 'created' | 'would-create'; + id?: string; + }> = []; + + for (const { type, value } of wanted) { + const existing = + await courseBuilderAdapter.getMerchantCouponForTypeAndPercent({ + type, + percentageDiscount: value, + }); + + if (existing) { + results.push({ + type, + percentageDiscount: value, + status: 'exists', + id: existing.id, + }); + continue; + } + + if (input.dryRun) { + results.push({ + type, + percentageDiscount: value, + status: 'would-create', + }); + continue; + } + + const percent = Math.round(value * 100); + const identifier = + await stripeProvider.options.paymentsAdapter.createCoupon({ + percent_off: percent, + duration: 'forever', + name: + type === 'ppp' + ? `Regional pricing ${percent}%` + : `${percent}% off`, + metadata: { type, source: 'codetv-coursebuilder' }, + }); + const id = `mcoupon_${randomUUID()}`; + + await db.insert(merchantCoupon).values({ + id, + identifier, + merchantAccountId: account.id, + status: 1, + percentageDiscount: value.toFixed(2), + type, + }); + + results.push({ + type, + percentageDiscount: value, + status: 'created', + id, + }); + } + + return { live, results }; + }), + + /** + * Create a coupon for a product. Share it as `/workshops/?code=`. + * Set `default: true` for a sitewide sale such as the early bird. + */ + createCoupon: operatorProcedure + .input( + z.object({ + productId: z.string().min(1), + percentageDiscount: percentage, + code: z + .string() + .regex(/^[A-Za-z0-9_-]{3,64}$/) + .optional(), + expires: isoDate.optional(), + maxUses: z.number().int().min(-1).default(-1), + default: z.boolean().default(false), + label: z.string().max(90).optional(), + }), + ) + .mutation(async ({ input }) => { + const product = await courseBuilderAdapter.getProduct(input.productId); + if (!product) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Product not found', + }); + } + + // `createCoupon` copies extra input (here `code`) onto the Coupon row. + const ids = await courseBuilderAdapter.createCoupon({ + quantity: '1', + maxUses: input.maxUses, + expires: input.expires ?? null, + restrictedToProductId: input.productId, + percentageDiscount: String(input.percentageDiscount), + status: 1, + default: input.default, + fields: input.label ? { label: input.label } : {}, + ...(input.code && { code: input.code }), + } as Parameters[0]); + + const couponId = ids?.[0]; + if (!couponId) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: + 'No matching `special` merchant coupon. Run commerce.seedMerchantCoupons first.', + }); + } + + return { + couponId, + code: input.code ?? null, + shareUrl: await shareUrlForProduct( + input.productId, + input.code ?? couponId, + ), + }; + }), +}); diff --git a/apps/website/src/server/trpc/router-base.ts b/apps/website/src/server/trpc/router-base.ts index 696c0035..5de388e6 100644 --- a/apps/website/src/server/trpc/router-base.ts +++ b/apps/website/src/server/trpc/router-base.ts @@ -1,8 +1,24 @@ -import { initTRPC } from '@trpc/server'; +import { initTRPC, TRPCError } from '@trpc/server'; +import { getUserAbilityForRequest } from '../ability'; import type { TRPCContext } from './context'; const t = initTRPC.context().create(); export const router = t.router; export const publicProcedure = t.procedure; + +/** + * Operator-only procedures, authorized the same way as the CLI REST routes: + * a device-flow bearer token for a user whose ability can update Content. + */ +export const operatorProcedure = t.procedure.use(async ({ ctx, next }) => { + const { user, ability } = await getUserAbilityForRequest(ctx.req); + + if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' }); + if (ability.cannot('update', 'Content')) { + throw new TRPCError({ code: 'FORBIDDEN' }); + } + + return next({ ctx: { ...ctx, user } }); +}); diff --git a/apps/website/src/server/trpc/router.ts b/apps/website/src/server/trpc/router.ts index e8fcb8c1..fbbe9ede 100644 --- a/apps/website/src/server/trpc/router.ts +++ b/apps/website/src/server/trpc/router.ts @@ -1,9 +1,11 @@ +import { commerceRouter } from './commerce'; import { deviceVerificationRouter } from './device-verification'; import { publicProcedure, router } from './router-base'; export const appRouter = router({ health: publicProcedure.query(() => ({ ok: true })), deviceVerification: deviceVerificationRouter, + commerce: commerceRouter, }); export type AppRouter = typeof appRouter; diff --git a/docs/sop/workshop-launch.md b/docs/sop/workshop-launch.md new file mode 100644 index 00000000..d875a363 --- /dev/null +++ b/docs/sop/workshop-launch.md @@ -0,0 +1,190 @@ +# SOP: Launch a Workshop Cohort and sell Workshop Tickets + +This is the runbook for selling the first CodeTV **Workshop** **Cohort** on codetv.dev through CourseBuilder and Stripe Checkout. It covers env vars, the Stripe webhook, creating the Workshop, Cohort and **Workshop Ticket** with the CourseBuilder CLI (`cb`), and a test-mode purchase on a Netlify deploy preview. + +Use the domain words from `CONTEXT.md`: Workshop, Cohort, Workshop Ticket, Purchase Processing, Workshop Access. + +## What ships in this slice + +- `/workshops/{workshopSlug}`: the Workshop page with Cohort dates and a pricing block for the current Workshop Ticket. +- Pricing through CourseBuilder `prices-formatted`: the early bird default coupon, a `?code=` coupon, and opt-in PPP (regional pricing). +- Enrollment window: `open`, `not-yet-open` or `closed`, from the product's `openEnrollment` / `closeEnrollment`. When there is no `closeEnrollment`, sales close at the Cohort start (day one). +- Sign-in before checkout (Clerk). Signed-in Viewers go to Stripe Checkout. +- Stripe webhook at `/api/coursebuilder/webhook/stripe` records the purchase in the CodeTV CourseBuilder database. Workshop Access is purchase-based: a `Valid` or `Restricted` (PPP) individual purchase of the ticket product. +- `/thanks/purchase`: the Purchase Processing screen. It polls until the purchase is verified, then shows the welcome state. After about 2 minutes it shows the "payment succeeded, setup delayed" message. +- Operator procedures for `cb`: `commerce.seedMerchantCoupons`, `commerce.createCohort`, `commerce.createCoupon` and `commerce.listPurchases`. + +Not in this slice: team seats and `/dashboard/team`, Ticket Transfer, the CodeTV Invoice page, purchase emails, Discord role grant and Kit sync. The code has TODOs that name the AI Hero files to port. + +## How fulfillment works + +CourseBuilder core's webhook does not write purchases itself. It sends `stripe/checkout-session-completed` to an Inngest client, and core's `stripeCheckoutSessionComplete` function writes `MerchantCharge`, `MerchantSession` and `Purchase`. CodeTV's Inngest app (`apps/workflows`) can't reach the CourseBuilder database, so `src/coursebuilder/fulfillment.ts` runs that same core handler inside the webhook request. + +- If fulfillment fails, the webhook returns non-2xx and Stripe retries (for up to 3 days). +- A retried delivery is safe: core creates one purchase per Stripe charge. +- Logs to search in Netlify function logs: `stripe.webhook.checkout.session.completed`, `purchase.flow.started`, `commerce.fulfillment.step`, `purchase.completed`, `commerce.fulfillment.completed`, `purchase-processing.poll`, and `purchase-processing.gave-up` (a buyer saw the delayed-setup message). + +## Safety guards in `/api/coursebuilder/*` + +CodeTV owns this route (`src/pages/api/coursebuilder/[...coursebuilder].ts`; the integration runs with `injectEndpoints: false`). It works around these `@coursebuilder/core` 1.2.1 problems: + +| Core 1.2.1 behavior | CodeTV guard | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Webhook signature is skipped when the `stripe-signature` header is missing, and the async check is not awaited when it is present. | The route verifies the raw body with the Stripe SDK first. Missing or bad signature: `400`. | +| `refund`, `transfer`, `lookup` and `create-magic-link` compare `x-skill-secret` to `SKILL_SECRET`. When `SKILL_SECRET` is unset, a request without the header passes. | These actions return `401` unless `SKILL_SECRET` is set and matches. | +| Checkout mints a Stripe promotion code from any `couponId` it receives and auto-applies PPP from the query country. | The browser sends only `usedCouponId` (from `?code=`) and a `ppp=1` opt-in. The server picks the merchant coupon and pins the country to `US` unless PPP was chosen and the geolocated country qualifies. | +| Checkout trusts `userId`, `quantity` and `country` from the query string. | The server sets them from the Clerk session, `1`, and Netlify geolocation. | + +## Environment variables (Netlify, `apps/website` site) + +Set these per deploy context in Netlify: **Deploy previews** use Stripe **test** keys, **Production** uses live keys. + +| Variable | Required | Notes | +| ------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | yes | CodeTV CourseBuilder database (PlanetScale). Already set for the CLI routes. | +| `COURSEBUILDER_STRIPE_SECRET_TOKEN` | yes | Stripe secret key for the CourseBuilder account. Test key (`sk_test_...`) on deploy previews. | +| `COURSEBUILDER_STRIPE_WEBHOOK_SECRET` | yes | Signing secret (`whsec_...`) of the webhook endpoint below. Each Stripe endpoint has its own secret. | +| `COURSEBUILDER_STRIPE_ACCOUNT_ID` | optional | `acct_...`, stored on the `ctv_MerchantAccount` row. | +| `COURSEBUILDER_URL` | optional | Set it **only in the production context** (`https://codetv.dev`). Leave it unset for deploy previews: Stripe success and cancel URLs then use `DEPLOY_PRIME_URL`. | +| `SKILL_SECRET` | optional | Enables core's support actions (`refund`, `transfer`, `lookup`, `create-magic-link`). Leave it unset to keep them disabled. | +| `DEFAULT_COUNTRY` | optional | Fallback country when Netlify has no geolocation. Defaults to `US`. | + +The `COURSEBUILDER_STRIPE_*` vars override `STRIPE_*` for CourseBuilder only. Jason's membership checkout keeps using `STRIPE_*`. + +Clerk must work on the deploy preview's domain or nobody can sign in to buy. Check which Clerk instance deploy previews use before the test purchase. + +## Stripe webhook + +In the Stripe dashboard (test mode for a preview), add an endpoint: + +- URL: `https:///api/coursebuilder/webhook/stripe`. For production, use the canonical host (`https://codetv.dev/...`, or `www` if that is canonical). +- Events, minimum: `checkout.session.completed`, `charge.refunded`, `charge.dispute.created`. +- Events, full set core handles: add `customer.subscription.updated`, `invoice.payment_succeeded`, `charge.succeeded`, `charge.dispute.funds_withdrawn`, `customer.updated`, `customer.subscription.created`, `customer.subscription.deleted`, `checkout.session.async_payment_failed` and `checkout.session.async_payment_succeeded`. +- Copy the endpoint's signing secret into `COURSEBUILDER_STRIPE_WEBHOOK_SECRET` for that deploy context and redeploy. + +Locally, forward events with the Stripe CLI and use the secret it prints: + +```sh +stripe listen --forward-to localhost:4321/api/coursebuilder/webhook/stripe +``` + +## Operator setup with `cb` + +The operator must be a CourseBuilder user with `role = 'admin'` in `ctv_User`. Commands below use `$APP_URL` for the deployment you're setting up, for example a deploy preview. + +```sh +export APP_URL=https://deploy-preview---.netlify.app +cb auth login --app codetv --base-url "$APP_URL" +cb auth whoami --app codetv --base-url "$APP_URL" +``` + +### 1. Seed merchant coupons (once per Stripe account) + +PPP and percentage coupons only work when Stripe-backed `MerchantCoupon` rows exist: `ppp` at 40–75% in 5% steps, and `special` at 10, 25, 40, 50, 60, 75, 90 and 95%. This is idempotent. It refuses a live Stripe key unless you pass `"allowLive": true`. + +```sh +cb trpc post commerce.seedMerchantCoupons --app codetv --base-url "$APP_URL" --body '{"dryRun":true}' +cb trpc post commerce.seedMerchantCoupons --app codetv --base-url "$APP_URL" --body '{}' +``` + +### 2. Create and publish the Workshop + +```sh +cb crud workshop create --app codetv --base-url "$APP_URL" \ + --title "Example Workshop" \ + --description "One-line description of the workshop." \ + --structure '[]' + +# Use the workshop id from the response. +cb resource update --app codetv --base-url "$APP_URL" \ + --body '{"fields":{"slug":"example-workshop","state":"published","visibility":"unlisted"}}' +``` + +`unlisted` works by URL and is enough to sell. Use `public` once it should be listed. + +### 3. Create the Cohort and Workshop Ticket + +This creates a `cohort` resource linked to the Workshop, plus a published `cohort`-type product priced in USD. Sales close at `startsAt` (day one). `openEnrollment` and `earlyBird` are optional. The early bird is a default coupon, so it applies automatically until it expires. + +Day one at midnight Pacific is `08:00Z` in winter (PST) and `07:00Z` in summer (PDT). + +```sh +cb trpc post commerce.createCohort --app codetv --base-url "$APP_URL" --body '{ + "workshopId": "", + "title": "Example Workshop: Cohort 1", + "startsAt": "2026-11-09T08:00:00.000Z", + "endsAt": "2026-11-14T08:00:00.000Z", + "price": 100, + "openEnrollment": "2026-10-20T07:00:00.000Z", + "earlyBird": { "percentageDiscount": 0.25, "expires": "2026-10-27T07:00:00.000Z" } +}' +``` + +Check `earlyBird.created` in the response. If it is `false`, step 1 didn't run against this Stripe account. + +To change the window later, use the product id from the response: + +```sh +curl -X PUT "$APP_URL/api/products" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"id":"","closeEnrollment":"2026-11-09T08:00:00.000Z"}' +``` + +`cb product update --state draft` takes the ticket off sale immediately. + +### 4. Coupon codes + +`createCoupon` snaps the discount to the nearest seeded `special` percentage. The response includes a share URL (`/workshops/?code=`). + +```sh +cb trpc post commerce.createCoupon --app codetv --base-url "$APP_URL" --body '{ + "productId": "", + "percentageDiscount": 0.1, + "code": "EXAMPLE10", + "label": "Example coupon" +}' +``` + +### 5. Verify the product + +```sh +cb crud product list --slug-or-id --app codetv --base-url "$APP_URL" +``` + +`merchantVerification.verified` must be `true`: the Stripe product and price exist and are active. + +## Test purchase on a deploy preview (Stripe test mode) + +1. Open `$APP_URL/workshops/example-workshop`. Check the title, Cohort dates, price, "Sales close …" line, and the early bird discount if one is active. +2. Open `$APP_URL/workshops/example-workshop?code=EXAMPLE10` and check the coupon price. +3. Signed out, click **Sign in to buy**, sign in, and check that you land back on the same URL with the code. +4. Click **Buy a ticket**. Stripe Checkout opens in test mode with the discount applied. +5. Pay with `4242 4242 4242 4242`, any future expiry, any CVC. +6. You land on `$APP_URL/thanks/purchase?session_id=cs_test_...`. It shows "Finalizing your ticket…", then "You're in!" with a link back to the Workshop. +7. Reload the Workshop page: it shows "You have a ticket for this cohort." A second checkout attempt bounces back with "You already have a ticket". +8. List buyers: + + ```sh + # input = {"productId":""}, URL-encoded + cb trpc get commerce.listPurchases --app codetv --base-url "$APP_URL" \ + --query 'input=%7B%22productId%22%3A%22%22%7D' + cb crud product enrollment --app codetv --base-url "$APP_URL" + ``` + +9. In Stripe (test), check the webhook endpoint's delivery for `checkout.session.completed` returned `200`. +10. Optional: refund the test payment in Stripe. The `charge.refunded` webhook sets the purchase to `Refunded`, and the Workshop page offers a ticket again. + +PPP can't be faked on Netlify because the country comes from Netlify geolocation. Test it through a VPN in a PPP country. Locally, `astro dev` accepts `?ppp_country=IN` on the Workshop page. + +## Checks + +```sh +pnpm --filter @codetv/website test # node --test: enrollment, polling, coupons, redirects, fulfillment +pnpm --filter @codetv/website exec astro check # 11 errors exist on main; this change adds none +pnpm --filter @codetv/website exec astro build +``` + +## Rollback + +- Before merge: close the PR. +- After merge: `cb product update --state draft` stops sales. Removing the CourseBuilder Stripe env vars disables CourseBuilder checkout and webhooks without touching Jason's membership checkout. diff --git a/plans/coursebuilder-codetv-api-surface.md b/plans/coursebuilder-codetv-api-surface.md index 69176f40..ac2d45ec 100644 --- a/plans/coursebuilder-codetv-api-surface.md +++ b/plans/coursebuilder-codetv-api-surface.md @@ -13,7 +13,7 @@ 2. Device auth, CASL ability helpers, `getUserAbilityForRequest`, and `withSkill`. — started: OAuth device-flow routes, discovery metadata, activation page, tRPC verification procedure, minimal ability helper, and `withSkill` wrapper added. 3. CLI discovery and tRPC fetch-adapter route. — started: `/.well-known/coursebuilder-app`, `/api/trpc/*` fetch adapter route, health procedure, and `deviceVerification.verify` scaffolded. 4. Core content CRUD: resources, edges, workshops, lessons, products. — started: public `/api` entrypoint, CLI-compatible `/api/resources` GET/POST/PUT, `/api/resources/edges` GET/POST/PATCH/DELETE, `/api/lessons` GET/PUT, `/api/workshops` GET/POST, and `/api/products` GET plus availability/enrollment smoke-tested. -5. Pricing, checkout, Workshop Ticket purchase, and team claim flows. +5. Pricing, checkout, Workshop Ticket purchase, and team claim flows. — started: `/workshops/{slug}` page, `prices-formatted` pricing with PPP opt-in and `?code=` coupons, enrollment window, sign-in-gated checkout through a CodeTV-owned `/api/coursebuilder/*` route, in-process webhook fulfillment, `/thanks/purchase` Purchase Processing, and `cb trpc` operator procedures (`commerce.*`). Team seats, Ticket Transfer, Invoice and purchase emails remain. Runbook: `docs/sop/workshop-launch.md`. 6. Upload/media routes needed by creator/import workflows. 7. Support, search, memory, shortlinks, surveys, and remaining operator surfaces.