diff --git a/COMMERCIAL.md b/COMMERCIAL.md index 963690f8..421a62ff 100644 --- a/COMMERCIAL.md +++ b/COMMERCIAL.md @@ -4,12 +4,10 @@ Most libraries in this repository — `@ngaf/render`, `@ngaf/agent`, `@ngaf/lang ## `@ngaf/chat` -Starting with the next published version, `@ngaf/chat` is dual-licensed: +`@ngaf/chat` is dual-licensed: -- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30-day commercial evaluation). -- **Threadplane commercial license** for commercial production use. - -Historical MIT releases of `@ngaf/chat` remain under their original terms. +- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30 calendar days of commercial evaluation from first commercial use). +- **ThreadPlane Commercial license** for commercial production use. Sold via [threadplane.ai/pricing](https://threadplane.ai/pricing); see [/docs/licensing](https://threadplane.ai/docs/licensing) for installation. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/LICENSE-COMMERCIAL.md`](./libs/chat/LICENSE-COMMERCIAL.md), and [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md) for the full terms. diff --git a/README.md b/README.md index d818dc90..0e4c078c 100644 --- a/README.md +++ b/README.md @@ -131,4 +131,4 @@ That's it. `chat.messages()` and `chat.status()` are Angular Signals. Bind them Most libraries in this repository (`@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/licensing`, `@ngaf/telemetry`, `@ngaf/design-tokens`) are released under the **MIT License** — free for any use, including commercial, with attribution. -**`@ngaf/chat`** is the exception. Future versions are licensed under **PolyForm Noncommercial 1.0.0 OR a Threadplane commercial license**. Historical npm releases remain MIT. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), and [`COMMERCIAL.md`](./COMMERCIAL.md) for details. +**`@ngaf/chat`** is the exception. It is dual-licensed under **PolyForm Noncommercial 1.0.0** for free noncommercial use, or a **ThreadPlane Commercial license** for production use inside a for-profit context. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), [`COMMERCIAL.md`](./COMMERCIAL.md), and [threadplane.ai/docs/licensing](https://threadplane.ai/docs/licensing) for details. diff --git a/apps/minting-service/handlers/stripe-webhook.ts b/apps/minting-service/handlers/stripe-webhook.ts index 6b5b58b7..dd0985ad 100644 --- a/apps/minting-service/handlers/stripe-webhook.ts +++ b/apps/minting-service/handlers/stripe-webhook.ts @@ -7,6 +7,7 @@ import { deleteProcessedEvent, upsertLicense, getLicense, + getLicensesByCustomerId, revokeLicense, } from '@ngaf/db'; import { loadEnv } from '../src/lib/env.js'; @@ -58,6 +59,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse): deleteProcessedEvent, upsertLicense, getLicense, + getLicensesByCustomerId, revokeLicense, mintToken, sendLicenseEmail, diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts index f98f6a4b..d580fba0 100644 --- a/apps/minting-service/scripts/remint.ts +++ b/apps/minting-service/scripts/remint.ts @@ -57,7 +57,7 @@ export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise { expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----'); + expect(out.text).toContain('-----BEGIN THREADPLANE LICENSE-----'); expect(out.text).toContain('PAYLOAD.SIG'); - expect(out.text).toContain('-----END CACHEPLANE LICENSE-----'); + expect(out.text).toContain('-----END THREADPLANE LICENSE-----'); }); it('subject includes tier and seat count with plural s for seats > 1', () => { @@ -27,12 +27,12 @@ describe('renderLicenseEmail', () => { it('subject uses singular seat for seats === 1', () => { const out = renderLicenseEmail({ - tier: 'app_deployment', + tier: 'team', seats: 1, token: 't.s', expiresAt: new Date('2027-04-20T00:00:00Z'), }); - expect(out.subject).toBe('Your ThreadPlane license — app_deployment (1 seat)'); + expect(out.subject).toBe('Your ThreadPlane license — team (1 seat)'); }); it('includes ISO 8601 UTC expiry in text body', () => { @@ -54,6 +54,6 @@ describe('renderLicenseEmail', () => { }); expect(out.html).toContain(' + // .env + THREADPLANE_LICENSE= Docs: https://threadplane.ai/docs/licensing Questions: reply to this email. @@ -48,16 +51,21 @@ Questions: reply to this email. -- The ThreadPlane team `; - const html = `

Thanks for subscribing to ThreadPlane.

-

Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:

-
-----BEGIN CACHEPLANE LICENSE-----
+  const html = `

Thanks for your ThreadPlane license purchase.

+

Your license is valid for 12 months from today. Paste the token below into your @ngaf/chat configuration:

+
-----BEGIN THREADPLANE LICENSE-----
 ${escapeHtml(vars.token)}
------END CACHEPLANE LICENSE-----
+-----END THREADPLANE LICENSE-----

Tier: ${escapeHtml(vars.tier)}
Seats: ${vars.seats}
Expires: ${escapeHtml(expiresIso)}

Installation:

-
export CACHEPLANE_LICENSE="<paste token above>"
+
provideChat({
+  license: process.env['THREADPLANE_LICENSE'],
+});
+
+// .env
+THREADPLANE_LICENSE=<paste token above>

Docs: threadplane.ai/docs/licensing
Questions: reply to this email.

-- The ThreadPlane team

diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts index 357a7d0c..4b2161f5 100644 --- a/apps/minting-service/src/lib/handlers.spec.ts +++ b/apps/minting-service/src/lib/handlers.spec.ts @@ -1,7 +1,14 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect, vi } from 'vitest'; import type Stripe from 'stripe'; -import { handleEvent, handleCheckoutCompleted, handleChargeRefunded, type HandlerDeps } from './handlers.js'; +import { + handleEvent, + handleSubscriptionCreated, + handleSubscriptionUpdated, + handleInvoicePaid, + handleChargeRefunded, + type HandlerDeps, +} from './handlers.js'; function makeDeps(overrides: Partial = {}): HandlerDeps { return { @@ -11,6 +18,7 @@ function makeDeps(overrides: Partial = {}): HandlerDeps { deleteProcessedEvent: vi.fn().mockResolvedValue(undefined), upsertLicense: vi.fn(), getLicense: vi.fn(), + getLicensesByCustomerId: vi.fn().mockResolvedValue([]), revokeLicense: vi.fn(), mintToken: vi.fn().mockResolvedValue('mock.token'), sendLicenseEmail: vi.fn().mockResolvedValue({ resendId: 're_mock' }), @@ -27,92 +35,92 @@ function evt(type: string, obj: unknown = {}): Stripe.Event { return { id: `evt_${type}`, type, data: { object: obj } } as Stripe.Event; } -function paymentSession(overrides: Partial = {}): Stripe.Checkout.Session { +// 2027-01-01T00:00:00Z = 1798761600 (unix seconds) +const PERIOD_END_EPOCH = 1798761600; +const PERIOD_END_DATE = new Date(PERIOD_END_EPOCH * 1000); + +function subscription(overrides: Partial = {}): Stripe.Subscription { return { - id: 'cs_test_123', - mode: 'payment', - payment_intent: 'pi_test_123', + id: 'sub_test_123', customer: 'cus_test_123', - customer_details: { email: 'buyer@example.com' } as Stripe.Checkout.Session.CustomerDetails, - line_items: { + status: 'active', + current_period_end: PERIOD_END_EPOCH, + metadata: {}, + items: { data: [ { quantity: 1, price: { - metadata: { ngaf_tier_slug: 'indie' }, + metadata: { ngaf_tier_slug: 'developer_seat' }, } as Stripe.Price, - } as Stripe.LineItem, + } as Stripe.SubscriptionItem, ], - } as Stripe.ApiList, + } as Stripe.ApiList, ...overrides, - } as Stripe.Checkout.Session; + } as unknown as Stripe.Subscription; +} + +function stripeWithCustomer(email: string | null = 'buyer@example.com'): Stripe { + return { + customers: { + retrieve: vi.fn().mockResolvedValue({ id: 'cus_test_123', email, deleted: false } as Stripe.Customer), + }, + subscriptions: { + retrieve: vi.fn(), + }, + } as unknown as Stripe; } describe('handleEvent', () => { it('returns early if markEventProcessed returns false (duplicate)', async () => { const deps = makeDeps({ markEventProcessed: vi.fn().mockResolvedValue(false), + stripe: stripeWithCustomer(), }); - await handleEvent(evt('checkout.session.completed', { id: 'cs_x' }), deps); + await handleEvent(evt('customer.subscription.created', subscription()), deps); expect(deps.upsertLicense).not.toHaveBeenCalled(); expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); }); - it('no-ops on unknown event types (including subscription events)', async () => { + it('no-ops on unknown event types (checkout, subscription.deleted, payment_succeeded)', async () => { const deps = makeDeps(); - await handleEvent(evt('customer.subscription.updated'), deps); + await handleEvent(evt('checkout.session.completed'), deps); await handleEvent(evt('customer.subscription.deleted'), deps); await handleEvent(evt('invoice.payment_succeeded'), deps); expect(deps.upsertLicense).not.toHaveBeenCalled(); expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); }); - it('dispatches checkout.session.completed to handleCheckoutCompleted', async () => { - const session = paymentSession(); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); - await handleEvent(evt('checkout.session.completed', session), deps); - expect(stripe.checkout.sessions.retrieve).toHaveBeenCalledWith('cs_test_123', expect.any(Object)); + it('dispatches customer.subscription.created to handleSubscriptionCreated', async () => { + const sub = subscription(); + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await handleEvent(evt('customer.subscription.created', sub), deps); expect(deps.upsertLicense).toHaveBeenCalledTimes(1); expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); }); it('compensating-deletes the processed-event marker when handler throws', async () => { - const session = paymentSession({ payment_intent: null }); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); + const sub = subscription({ items: { data: [] } as unknown as Stripe.ApiList }); + const deps = makeDeps({ stripe: stripeWithCustomer() }); await expect( - handleEvent(evt('checkout.session.completed', session), deps), - ).rejects.toThrow(/no payment_intent/); + handleEvent(evt('customer.subscription.created', sub), deps), + ).rejects.toThrow(/no line items/); expect(deps.deleteProcessedEvent).toHaveBeenCalledTimes(1); }); }); -describe('handleCheckoutCompleted', () => { - it('mints, upserts, and emails on a complete one-time payment session', async () => { - const session = paymentSession(); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); - await handleCheckoutCompleted(session, deps); +describe('handleSubscriptionCreated', () => { + it('mints, upserts, and emails on a new subscription', async () => { + const sub = subscription(); + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await handleSubscriptionCreated(sub, deps); expect(deps.mintToken).toHaveBeenCalledWith( expect.objectContaining({ stripeCustomerId: 'cus_test_123', - tier: 'indie', + tier: 'developer_seat', seats: 1, - expiresAt: expect.any(Date), + expiresAt: PERIOD_END_DATE, }), 'a'.repeat(64), ); @@ -120,125 +128,240 @@ describe('handleCheckoutCompleted', () => { {} as never, expect.objectContaining({ stripeCustomerId: 'cus_test_123', - stripePaymentId: 'pi_test_123', + stripeSubscriptionId: 'sub_test_123', customerEmail: 'buyer@example.com', - tier: 'indie', + tier: 'developer_seat', seats: 1, + expiresAt: PERIOD_END_DATE, lastToken: 'mock.token', }), ); expect(deps.sendLicenseEmail).toHaveBeenCalledWith( expect.objectContaining({ - from: 'noreply@example.com', to: 'buyer@example.com', - vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }), + vars: expect.objectContaining({ tier: 'developer_seat', seats: 1, token: 'mock.token' }), }), ); }); - it('skips subscription-mode sessions without minting', async () => { - const session = paymentSession({ mode: 'subscription' }); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); - await handleCheckoutCompleted(session, deps); - expect(deps.mintToken).not.toHaveBeenCalled(); - expect(deps.upsertLicense).not.toHaveBeenCalled(); - expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + it('reads tier from subscription.metadata.ngaf_tier_slug, overriding price metadata', async () => { + const sub = subscription({ + metadata: { ngaf_tier_slug: 'team' }, + items: { + data: [ + { + quantity: 5, + price: { metadata: { ngaf_tier_slug: 'developer_seat' } } as Stripe.Price, + } as Stripe.SubscriptionItem, + ], + } as Stripe.ApiList, + }); + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await handleSubscriptionCreated(sub, deps); + expect(deps.upsertLicense).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ tier: 'team', seats: 5 }), + ); }); - it('throws when the session has no payment_intent', async () => { - const session = paymentSession({ payment_intent: null }); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); - await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no payment_intent/); + it('throws if subscription has no current_period_end', async () => { + const sub = subscription({ current_period_end: undefined as unknown as number }); + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await expect(handleSubscriptionCreated(sub, deps)).rejects.toThrow(/current_period_end/); }); - it('throws when the session has no customer', async () => { - const session = paymentSession({ customer: null }); - const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, - }, - } as unknown as Stripe; - const deps = makeDeps({ stripe }); - await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/customer_creation/); + it('throws if the customer has no email', async () => { + const sub = subscription(); + const deps = makeDeps({ stripe: stripeWithCustomer(null) }); + await expect(handleSubscriptionCreated(sub, deps)).rejects.toThrow(/no email/); }); +}); + +describe('handleSubscriptionUpdated', () => { + function existing(overrides: Partial<{ seats: number; expiresAt: Date }> = {}) { + return { + id: 'lic_1', + stripeCustomerId: 'cus_test_123', + stripeSubscriptionId: 'sub_test_123', + customerEmail: 'existing@example.com', + tier: 'developer_seat', + seats: 1, + expiresAt: PERIOD_END_DATE, + lastToken: 'old.token', + revokedAt: null, + issuedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; + } - it('dispatches charge.refunded to handleChargeRefunded', async () => { - const charge = { id: 'ch_x', payment_intent: 'pi_test_123' } as Stripe.Charge; + it('re-mints when seats change', async () => { + const sub = subscription({ + items: { + data: [ + { + quantity: 3, + price: { metadata: { ngaf_tier_slug: 'developer_seat' } } as Stripe.Price, + } as Stripe.SubscriptionItem, + ], + } as Stripe.ApiList, + }); const deps = makeDeps({ - getLicense: vi.fn().mockResolvedValue({ - customerEmail: 'buyer@example.com', - tier: 'indie', - }), - revokeLicense: vi.fn().mockResolvedValue({}), + stripe: stripeWithCustomer(), + getLicense: vi.fn().mockResolvedValue(existing({ seats: 1 })), }); - await handleEvent(evt('charge.refunded', charge), deps); - expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123'); - expect(deps.sendRevocationEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'buyer@example.com', - vars: { tier: 'indie' }, - }), + await handleSubscriptionUpdated(sub, deps); + expect(deps.upsertLicense).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ seats: 3 }), ); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); }); - it('throws when the session has no customer email', async () => { - const session = paymentSession({ - customer_details: { email: null } as Stripe.Checkout.Session.CustomerDetails, + it('re-mints when period_end changes', async () => { + const sub = subscription({ current_period_end: PERIOD_END_EPOCH + 86400 }); + const deps = makeDeps({ + stripe: stripeWithCustomer(), + getLicense: vi.fn().mockResolvedValue(existing()), }); + await handleSubscriptionUpdated(sub, deps); + expect(deps.upsertLicense).toHaveBeenCalledTimes(1); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('no-ops on status-only change with same seats/period (does not revoke immediately)', async () => { + const sub = subscription({ status: 'unpaid' }); + const deps = makeDeps({ + stripe: stripeWithCustomer(), + getLicense: vi.fn().mockResolvedValue(existing()), + }); + await handleSubscriptionUpdated(sub, deps); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + expect(deps.sendLicenseEmail).not.toHaveBeenCalled(); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + }); + + it('mints fresh if no existing license is found', async () => { + const sub = subscription(); + const deps = makeDeps({ + stripe: stripeWithCustomer(), + getLicense: vi.fn().mockResolvedValue(null), + }); + await handleSubscriptionUpdated(sub, deps); + expect(deps.upsertLicense).toHaveBeenCalledTimes(1); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); +}); + +describe('handleInvoicePaid', () => { + function invoice(overrides: Partial = {}): Stripe.Invoice { + return { + id: 'in_test', + billing_reason: 'subscription_cycle', + subscription: 'sub_test_123', + ...overrides, + } as unknown as Stripe.Invoice; + } + + it('re-mints on a renewal invoice (subscription_cycle)', async () => { + const sub = subscription({ current_period_end: PERIOD_END_EPOCH + 30 * 86400 }); const stripe = { - checkout: { - sessions: { retrieve: vi.fn().mockResolvedValue(session) }, + customers: { + retrieve: vi.fn().mockResolvedValue({ id: 'cus_test_123', email: 'buyer@example.com' } as Stripe.Customer), + }, + subscriptions: { + retrieve: vi.fn().mockResolvedValue(sub), }, } as unknown as Stripe; const deps = makeDeps({ stripe }); - await expect(handleCheckoutCompleted(session, deps)).rejects.toThrow(/no customer email/); + await handleInvoicePaid(invoice(), deps); + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith('sub_test_123'); + expect(deps.upsertLicense).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ + stripeSubscriptionId: 'sub_test_123', + expiresAt: new Date((PERIOD_END_EPOCH + 30 * 86400) * 1000), + }), + ); + expect(deps.sendLicenseEmail).toHaveBeenCalledTimes(1); + }); + + it('skips the first-time subscription_create invoice', async () => { + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await handleInvoicePaid(invoice({ billing_reason: 'subscription_create' }), deps); + expect(deps.upsertLicense).not.toHaveBeenCalled(); + }); + + it('skips invoices with no subscription', async () => { + const deps = makeDeps({ stripe: stripeWithCustomer() }); + await handleInvoicePaid( + invoice({ subscription: null } as unknown as Partial), + deps, + ); + expect(deps.upsertLicense).not.toHaveBeenCalled(); }); }); describe('handleChargeRefunded', () => { const charge = (overrides: Partial = {}): Stripe.Charge => - ({ id: 'ch_test', payment_intent: 'pi_test_123', ...overrides }) as Stripe.Charge; + ({ id: 'ch_test', customer: 'cus_test_123', ...overrides }) as Stripe.Charge; - it('revokes and emails when a license exists for the charge', async () => { - const deps = makeDeps({ - getLicense: vi.fn().mockResolvedValue({ + it('revokes all active licenses for the customer and emails each', async () => { + const licenses = [ + { + stripeSubscriptionId: 'sub_a', customerEmail: 'buyer@example.com', tier: 'developer_seat', - }), + revokedAt: null, + }, + { + stripeSubscriptionId: 'sub_b', + customerEmail: 'buyer@example.com', + tier: 'team', + revokedAt: null, + }, + ]; + const deps = makeDeps({ + getLicensesByCustomerId: vi.fn().mockResolvedValue(licenses), revokeLicense: vi.fn().mockResolvedValue({}), }); await handleChargeRefunded(charge(), deps); - expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'pi_test_123'); - expect(deps.sendRevocationEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'buyer@example.com', - vars: { tier: 'developer_seat' }, - }), - ); + expect(deps.revokeLicense).toHaveBeenCalledTimes(2); + expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'sub_a'); + expect(deps.revokeLicense).toHaveBeenCalledWith({} as never, 'sub_b'); + expect(deps.sendRevocationEmail).toHaveBeenCalledTimes(2); }); - it('no-ops when no license exists for the payment_intent', async () => { + it('skips already-revoked licenses', async () => { const deps = makeDeps({ - getLicense: vi.fn().mockResolvedValue(null), + getLicensesByCustomerId: vi.fn().mockResolvedValue([ + { + stripeSubscriptionId: 'sub_a', + customerEmail: 'buyer@example.com', + tier: 'developer_seat', + revokedAt: new Date(), + }, + ]), + revokeLicense: vi.fn().mockResolvedValue({}), + }); + await handleChargeRefunded(charge(), deps); + expect(deps.revokeLicense).not.toHaveBeenCalled(); + expect(deps.sendRevocationEmail).not.toHaveBeenCalled(); + }); + + it('no-ops when the customer has no licenses', async () => { + const deps = makeDeps({ + getLicensesByCustomerId: vi.fn().mockResolvedValue([]), }); await handleChargeRefunded(charge(), deps); expect(deps.revokeLicense).not.toHaveBeenCalled(); expect(deps.sendRevocationEmail).not.toHaveBeenCalled(); }); - it('no-ops when charge has no payment_intent', async () => { + it('no-ops when charge has no customer', async () => { const deps = makeDeps(); - await handleChargeRefunded(charge({ payment_intent: null }), deps); - expect(deps.getLicense).not.toHaveBeenCalled(); + await handleChargeRefunded(charge({ customer: null }), deps); + expect(deps.getLicensesByCustomerId).not.toHaveBeenCalled(); }); }); diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts index a85109ea..574024b6 100644 --- a/apps/minting-service/src/lib/handlers.ts +++ b/apps/minting-service/src/lib/handlers.ts @@ -7,7 +7,7 @@ import type { } from '@ngaf/db'; import type { MintInput } from './sign.js'; import type { LicenseEmailVars, RevocationEmailVars } from './email.js'; -import { extractTier, computeSeats } from './tier.js'; +import { extractTier, computeSeats, type MintableTier } from './tier.js'; /** * All external collaborators are injected so handlers are unit-testable. @@ -18,8 +18,9 @@ export interface HandlerDeps { markEventProcessed: (db: Db, id: string, type: string) => Promise; deleteProcessedEvent: (db: Db, id: string) => Promise; upsertLicense: (db: Db, input: UpsertLicenseInput) => Promise; - getLicense: (db: Db, stripePaymentId: string) => Promise; - revokeLicense: (db: Db, stripePaymentId: string) => Promise; + getLicense: (db: Db, stripeSubscriptionId: string) => Promise; + getLicensesByCustomerId: (db: Db, customerId: string) => Promise; + revokeLicense: (db: Db, stripeSubscriptionId: string) => Promise; mintToken: (input: MintInput, privateKeyHex: string) => Promise; sendLicenseEmail: (args: { resendApiKey: string; @@ -45,8 +46,14 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi try { switch (event.type) { - case 'checkout.session.completed': - await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, deps); + case 'customer.subscription.created': + await handleSubscriptionCreated(event.data.object as Stripe.Subscription, deps); + break; + case 'customer.subscription.updated': + await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, deps); + break; + case 'invoice.paid': + await handleInvoicePaid(event.data.object as Stripe.Invoice, deps); break; case 'charge.refunded': await handleChargeRefunded(event.data.object as Stripe.Charge, deps); @@ -60,53 +67,78 @@ export async function handleEvent(event: Stripe.Event, deps: HandlerDeps): Promi } } -/** - * Handles a completed Stripe Checkout session in `mode: 'payment'` - * (one-time payment). Subscription mode is not handled — the only paid - * tiers ship as one-time 12-month payments. A subscription-mode session - * is logged and dropped. - */ -export async function handleCheckoutCompleted( - session: Stripe.Checkout.Session, - deps: HandlerDeps, -): Promise { - const expanded = await deps.stripe.checkout.sessions.retrieve(session.id, { - expand: ['line_items.data.price'], - }); +interface SubscriptionLineFacts { + tier: MintableTier; + seats: number; + quantity: number; +} - if (expanded.mode !== 'payment') { - console.log(`handleCheckoutCompleted: skipping non-payment session ${session.id} (mode=${expanded.mode})`); - return; +function readSubscriptionFacts(subscription: Stripe.Subscription): SubscriptionLineFacts { + const item = subscription.items?.data?.[0]; + if (!item) { + throw new Error(`subscription ${subscription.id} has no line items`); } + const subMetadata = (subscription.metadata ?? {}) as Record; + const priceMetadata = (item.price?.metadata ?? {}) as Record; + const merged: Record = { + ...priceMetadata, + ...(subMetadata['ngaf_tier_slug'] ? { ngaf_tier_slug: subMetadata['ngaf_tier_slug'] } : {}), + }; + const tier = extractTier(merged); + const quantity = item.quantity ?? 1; + const seats = computeSeats(tier, quantity); + return { tier, seats, quantity }; +} - const lineItem = expanded.line_items?.data?.[0]; - if (!lineItem) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no line items`); - } - const priceMetadata = (lineItem.price?.metadata ?? {}) as Record; - const tier = extractTier(priceMetadata); - const seats = computeSeats(tier, lineItem.quantity); - - const paymentId = typeof expanded.payment_intent === 'string' - ? expanded.payment_intent - : expanded.payment_intent?.id; - if (!paymentId) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no payment_intent`); +function readCustomerId(subscription: Stripe.Subscription): string { + const customerId = typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer?.id; + if (!customerId) { + throw new Error(`subscription ${subscription.id} has no customer`); } + return customerId; +} - const customerId = typeof expanded.customer === 'string' - ? expanded.customer - : expanded.customer?.id; - if (!customerId) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer (customer_creation: 'always' must be set on the Checkout session)`); +function periodEnd(subscription: Stripe.Subscription): Date { + // current_period_end is unix seconds; convert to Date. + const epoch = (subscription as unknown as { current_period_end?: number }).current_period_end; + if (!epoch) { + throw new Error(`subscription ${subscription.id} has no current_period_end`); } + return new Date(epoch * 1000); +} - const email = expanded.customer_details?.email; +async function resolveCustomerEmail( + subscription: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + const customer = subscription.customer; + if (customer && typeof customer !== 'string') { + const email = (customer as Stripe.Customer).email; + if (email) return email; + } + const customerId = readCustomerId(subscription); + const fetched = await deps.stripe.customers.retrieve(customerId); + if ('deleted' in fetched && fetched.deleted) { + throw new Error(`subscription ${subscription.id}: customer ${customerId} is deleted`); + } + const email = (fetched as Stripe.Customer).email; if (!email) { - throw new Error(`handleCheckoutCompleted: session ${session.id} has no customer email`); + throw new Error(`subscription ${subscription.id}: customer ${customerId} has no email`); } + return email; +} - const expiresAt = new Date(Date.now() + deps.defaultTtlDays * 24 * 60 * 60 * 1000); +async function mintAndEmail( + subscription: Stripe.Subscription, + deps: HandlerDeps, + opts: { email?: string } = {}, +): Promise { + const { tier, seats } = readSubscriptionFacts(subscription); + const customerId = readCustomerId(subscription); + const expiresAt = periodEnd(subscription); + const email = opts.email ?? (await resolveCustomerEmail(subscription, deps)); const token = await deps.mintToken( { stripeCustomerId: customerId, tier, seats, expiresAt }, @@ -115,7 +147,7 @@ export async function handleCheckoutCompleted( await deps.upsertLicense(deps.db, { stripeCustomerId: customerId, - stripePaymentId: paymentId, + stripeSubscriptionId: subscription.id, customerEmail: email, tier, seats, @@ -132,42 +164,107 @@ export async function handleCheckoutCompleted( } /** - * Handles a Stripe charge.refunded event by revoking the matching license - * and notifying the customer. Both partial and full refunds revoke; the - * heuristic is that any refund signals the customer wants out, and they - * can re-purchase if needed. + * Mint a license on a brand-new subscription. + */ +export async function handleSubscriptionCreated( + subscription: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + await mintAndEmail(subscription, deps); +} + +/** + * Re-mint when seats or status changes. Status transitions to canceled/unpaid + * do NOT immediately revoke — the license expires naturally at + * current_period_end. + */ +export async function handleSubscriptionUpdated( + subscription: Stripe.Subscription, + deps: HandlerDeps, +): Promise { + const existing = await deps.getLicense(deps.db, subscription.id); + if (!existing) { + // We never saw a `customer.subscription.created` for this one; treat as + // a fresh mint so we don't drop the customer on the floor. + await mintAndEmail(subscription, deps); + return; + } + + const facts = readSubscriptionFacts(subscription); + const seatsChanged = facts.seats !== existing.seats; + const expiresAtNew = periodEnd(subscription); + const periodChanged = expiresAtNew.getTime() !== existing.expiresAt.getTime(); + + if (seatsChanged || periodChanged) { + await mintAndEmail(subscription, deps, { email: existing.customerEmail }); + } + // Other status transitions (active <-> past_due, canceled-at-period-end) + // are no-ops: the existing license stays valid through its expires_at. +} + +/** + * On a renewal invoice (`billing_reason: subscription_cycle`), re-mint with + * the new period_end and email the new token. Skip non-subscription invoices + * and the first-time `subscription_create` invoice (handled by + * customer.subscription.created). + */ +export async function handleInvoicePaid( + invoice: Stripe.Invoice, + deps: HandlerDeps, +): Promise { + if (invoice.billing_reason !== 'subscription_cycle') { + return; + } + const subscriptionRef = (invoice as unknown as { + subscription?: string | Stripe.Subscription | null; + }).subscription; + const subscriptionId = typeof subscriptionRef === 'string' + ? subscriptionRef + : subscriptionRef?.id; + if (!subscriptionId) { + console.log(`handleInvoicePaid: invoice ${invoice.id} has no subscription`); + return; + } + + const subscription = await deps.stripe.subscriptions.retrieve(subscriptionId); + await mintAndEmail(subscription, deps); +} + +/** + * Revoke any active license owned by the customer behind this charge. * - * Idempotent: re-runs on a refunded charge whose license is already - * revoked simply re-send the email; the DB row stays revoked. + * One-time payments are gone, so we can't key the license directly off + * the charge's payment_intent. Instead we look up the customer and revoke + * every non-revoked license they own. The customer can re-subscribe to + * re-issue. */ export async function handleChargeRefunded( charge: Stripe.Charge, deps: HandlerDeps, ): Promise { - const paymentId = typeof charge.payment_intent === 'string' - ? charge.payment_intent - : charge.payment_intent?.id; - if (!paymentId) { - console.log(`handleChargeRefunded: charge ${charge.id} has no payment_intent`); + const customerId = typeof charge.customer === 'string' + ? charge.customer + : charge.customer?.id; + if (!customerId) { + console.log(`handleChargeRefunded: charge ${charge.id} has no customer`); return; } - const existing = await deps.getLicense(deps.db, paymentId); - if (!existing) { - console.log(`handleChargeRefunded: no license for payment_intent ${paymentId}`); + const licenses = await deps.getLicensesByCustomerId(deps.db, customerId); + const active = licenses.filter((l) => !l.revokedAt); + if (active.length === 0) { + console.log(`handleChargeRefunded: no active licenses for customer ${customerId}`); return; } - const revoked = await deps.revokeLicense(deps.db, paymentId); - if (!revoked) { - console.log(`handleChargeRefunded: revokeLicense returned null for ${paymentId}`); - return; + for (const license of active) { + const revoked = await deps.revokeLicense(deps.db, license.stripeSubscriptionId); + if (!revoked) continue; + await deps.sendRevocationEmail({ + resendApiKey: deps.resendApiKey, + from: deps.emailFrom, + to: license.customerEmail, + vars: { tier: license.tier as 'developer_seat' | 'team' }, + }); } - - await deps.sendRevocationEmail({ - resendApiKey: deps.resendApiKey, - from: deps.emailFrom, - to: existing.customerEmail, - vars: { tier: existing.tier as 'indie' | 'developer_seat' | 'app_deployment' }, - }); } diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts index 433f4d86..21085f56 100644 --- a/apps/minting-service/src/lib/sign.spec.ts +++ b/apps/minting-service/src/lib/sign.spec.ts @@ -42,7 +42,7 @@ describe('mintToken', () => { mintToken( { stripeCustomerId: 'cus_x', - tier: 'app_deployment', + tier: 'team', seats: 1, expiresAt: new Date('2027-01-01T00:00:00Z'), }, diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts index 274ecbf6..b0de2a2a 100644 --- a/apps/minting-service/src/lib/tier.spec.ts +++ b/apps/minting-service/src/lib/tier.spec.ts @@ -2,16 +2,12 @@ import { extractTier, computeSeats } from './tier.js'; describe('extractTier', () => { - it('returns indie from price metadata', () => { - expect(extractTier({ ngaf_tier_slug: 'indie' })).toBe('indie'); - }); - it('returns developer_seat from price metadata', () => { expect(extractTier({ ngaf_tier_slug: 'developer_seat' })).toBe('developer_seat'); }); - it('returns app_deployment from price metadata', () => { - expect(extractTier({ ngaf_tier_slug: 'app_deployment' })).toBe('app_deployment'); + it('returns team from price metadata', () => { + expect(extractTier({ ngaf_tier_slug: 'team' })).toBe('team'); }); it('throws when ngaf_tier_slug is missing', () => { @@ -32,12 +28,9 @@ describe('computeSeats', () => { expect(computeSeats('developer_seat', 5)).toBe(5); }); - it('returns 1 for app_deployment regardless of quantity', () => { - expect(computeSeats('app_deployment', 10)).toBe(1); - }); - - it('returns 1 for indie regardless of quantity', () => { - expect(computeSeats('indie', 10)).toBe(1); + it('returns 5 for team regardless of quantity', () => { + expect(computeSeats('team', 1)).toBe(5); + expect(computeSeats('team', 10)).toBe(5); }); it('defaults developer_seat to 1 when quantity is null', () => { diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts index ae593248..c99a3e0c 100644 --- a/apps/minting-service/src/lib/tier.ts +++ b/apps/minting-service/src/lib/tier.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import type { LicenseTier } from '@ngaf/licensing'; -export type MintableTier = Extract; +export type MintableTier = Extract; -const VALID_TIERS: readonly MintableTier[] = ['indie', 'developer_seat', 'app_deployment'] as const; +const VALID_TIERS: readonly MintableTier[] = ['developer_seat', 'team'] as const; const METADATA_KEY = 'ngaf_tier_slug'; +const TEAM_SEAT_COUNT = 5; + /** * Extract the tier slug from a Stripe price metadata bag. * Throws if the field is missing or holds an unknown value. @@ -27,12 +29,14 @@ export function extractTier(metadata: Record | null | undefined) /** * Compute the `seats` claim from the Stripe line-item quantity. * - developer_seat: tracks Stripe quantity (minimum 1). - * - indie: always 1. - * - app_deployment: always 1. + * - team: always 5 (the bundle size baked into the SKU). */ export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number { if (tier === 'developer_seat') { return quantity && quantity > 0 ? quantity : 1; } + if (tier === 'team') { + return TEAM_SEAT_COUNT; + } return 1; } diff --git a/apps/website/content/docs/licensing/api/api-docs.json b/apps/website/content/docs/licensing/api/api-docs.json index 9f450918..5d8e42be 100644 --- a/apps/website/content/docs/licensing/api/api-docs.json +++ b/apps/website/content/docs/licensing/api/api-docs.json @@ -158,7 +158,7 @@ "name": "LicenseTier", "kind": "type", "description": "The tier a license grants.", - "signature": "\"indie\" | \"developer_seat\" | \"app_deployment\" | \"enterprise\"", + "signature": "\"developer_seat\" | \"team\" | \"enterprise\"", "examples": [] }, { diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 58d32928..8639a5c0 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -23,7 +23,9 @@ test('landing page renders feature blocks (Stream/Render/Ship)', async ({ page } test('pricing page shows plan cards', async ({ page }) => { await page.goto('/pricing'); - await expect(page.getByText('Open Source').first()).toBeVisible(); + await expect(page.getByText('Community').first()).toBeVisible(); + await expect(page.getByText('Developer Seat').first()).toBeVisible(); + await expect(page.getByText('Team').first()).toBeVisible(); await expect(page.getByText('Enterprise').first()).toBeVisible(); }); @@ -37,7 +39,7 @@ test('pricing page lead form validates required fields', async ({ page }) => { await page.goto('/pricing'); const leadForm = page.locator('#lead-form form'); await expect(leadForm).toBeVisible(); - await leadForm.getByRole('button', { name: 'Get in touch' }).click(); + await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click(); await expect(leadForm).toBeVisible(); expect(checkoutCalled).toBe(false); expect(await leadForm.evaluate((form) => (form as HTMLFormElement).checkValidity())).toBe(false); @@ -85,11 +87,12 @@ test('pricing lead form posts to /api/leads and renders success state', async ({ }); await page.goto('/pricing#lead-form'); - await page.getByLabel('Name').fill('Jane Smith'); - await page.getByLabel('Work email').fill('jane@acme.com'); - await page.getByLabel('Company').fill('Acme'); - await page.getByLabel('Tell us about your use case').fill('Volume seats and security review.'); - await page.getByRole('button', { name: 'Get in touch' }).click(); + const leadForm = page.locator('#lead-form form'); + await leadForm.getByLabel('Name').fill('Jane Smith'); + await leadForm.getByLabel('Work email').fill('jane@acme.com'); + await leadForm.getByLabel('Company').fill('Acme'); + await leadForm.getByLabel('Tell us about your use case').fill('Volume seats and security review.'); + await leadForm.getByRole('button', { name: 'Request enterprise quote' }).click(); await expect(page.getByText(/we'll be in touch within one business day/i)).toBeVisible(); expect(leadPayload).toMatchObject({ @@ -236,7 +239,7 @@ test('docs pages render canonical and social metadata', async ({ page }) => { }); test('marketing pages render canonical and page-specific social URLs', async ({ page }) => { - for (const route of ['/', '/angular', '/render', '/chat', '/pricing', '/contact', '/pilot-to-prod', '/solutions']) { + for (const route of ['/', '/langgraph', '/ag-ui', '/render', '/chat', '/pricing', '/contact', '/pilot-to-prod', '/solutions']) { await page.goto(route); const expectedUrl = route === '/' ? 'https://threadplane.ai' : `https://threadplane.ai${route}`; @@ -273,7 +276,7 @@ test('representative docs pages do not create page-level horizontal overflow', a test('marketing pages link to downloadable whitepaper PDFs', async ({ page }) => { const expectedDownloads: Record = { '/': '/whitepaper.pdf', - '/angular': '/whitepapers/angular.pdf', + '/langgraph': '/whitepapers/angular.pdf', '/render': '/whitepapers/render.pdf', '/chat': '/whitepapers/chat.pdf', }; diff --git a/apps/website/src/app/ag-ui/page.tsx b/apps/website/src/app/ag-ui/page.tsx new file mode 100644 index 00000000..3631e449 --- /dev/null +++ b/apps/website/src/app/ag-ui/page.tsx @@ -0,0 +1,166 @@ +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { Container } from '../../components/ui/Container'; +import { Section } from '../../components/ui/Section'; +import { Eyebrow } from '../../components/ui/Eyebrow'; +import { Button } from '../../components/ui/Button'; +import { Pill } from '../../components/ui/Pill'; +import { BrowserFrame } from '../../components/ui/BrowserFrame'; +import { FeatureBlock } from '../../components/landing/FeatureBlock'; +import { FinalCTA } from '../../components/landing/FinalCTA'; +import { BackendsGrid } from '../../components/landing/ag-ui/BackendsGrid'; +import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata'; + +export const metadata = createPageMetadata({ + title: '@ngaf/ag-ui — Agent UI for Angular', + description: SHORT_POSITIONING_DESCRIPTION, + pathname: '/ag-ui', + type: 'website', +}); + +export default async function AgUiPage() { + return ( + <> + {/* Hero */} +
+ +
+ @ngaf/ag-ui +

+ One adapter. Eight backends. +

+

+ Build an Angular agent UI on any AG-UI-compatible runtime — LangGraph, CrewAI, Mastra, Microsoft Agent Framework, AG2, Pydantic AI, AWS Strands, CopilotKit. Same primitives, same chat surface, same testing story. +

+
+ + +
+
+ MIT + Angular 20+ + AG-UI protocol +
+

+ Already on LangGraph?{' '} + + See @ngaf/langgraph + {' '} + for native streaming, checkpoints, and the typed LangGraph SDK path. +

+
+
+
+ + } + /> + + +
+{`import { provideAgUiAgent, AG_UI_AGENT } from '@ngaf/ag-ui';
+import { ChatComponent } from '@ngaf/chat';
+
+// app.config.ts
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideAgUiAgent({
+      url: 'https://your.agent.endpoint',
+    }),
+  ],
+};
+
+// component
+@Component({
+  imports: [ChatComponent],
+  template: \`\`,
+})
+export class App {
+  protected readonly agent = inject(AG_UI_AGENT);
+}`}
+            
+ + } + /> + + + + ); +} diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts index cc61e1fa..510f90de 100644 --- a/apps/website/src/app/api/checkout/session/route.spec.ts +++ b/apps/website/src/app/api/checkout/session/route.spec.ts @@ -10,9 +10,8 @@ vi.mock('../../../../lib/stripe', () => ({ vi.mock('../../../../../../../pricing/tiers.generated', () => ({ STRIPE_PRICE_IDS: { - indie: 'price_test_indie', - developer_seat: 'price_test_seat', - app_deployment: 'price_test_app', + developer_seat: { monthly: 'price_seat_monthly', annual: 'price_seat_annual' }, + team: { monthly: 'price_team_monthly', annual: 'price_team_annual' }, }, })); @@ -47,17 +46,31 @@ describe('POST /api/checkout/session', () => { expect(res.status).toBe(400); }); - it('returns 303 redirect to Stripe for indie', async () => { - const res = await POST(makeReq({ tier: 'indie' })); - expect(res.status).toBe(303); - expect(res.headers.get('location')).toBe('https://checkout.stripe.com/c/pay/cs_test_abc'); - expect(stripeCreate).toHaveBeenCalledTimes(1); + it('returns 400 for invalid billing_cycle', async () => { + const res = await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'weekly' })); + expect(res.status).toBe(400); + }); + + it('defaults to annual billing cycle when omitted', async () => { + await POST(makeReq({ tier: 'developer_seat' })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.mode).toBe('subscription'); + expect(args.line_items[0].price).toBe('price_seat_annual'); + expect(args.metadata.ngaf_billing_cycle).toBe('annual'); + }); + + it('routes to the monthly price when billing_cycle=monthly', async () => { + await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'monthly' })); + const args = stripeCreate.mock.calls[0]?.[0]; + expect(args.line_items[0].price).toBe('price_seat_monthly'); + expect(args.metadata.ngaf_billing_cycle).toBe('monthly'); + }); + + it('routes Team to the team annual price by default', async () => { + await POST(makeReq({ tier: 'team' })); const args = stripeCreate.mock.calls[0]?.[0]; - expect(args.mode).toBe('payment'); - expect(args.customer_creation).toBe('always'); - expect(args.line_items[0].price).toBe('price_test_indie'); - expect(args.line_items[0].quantity).toBe(1); - expect(args.metadata.ngaf_tier_slug).toBe('indie'); + expect(args.line_items[0].price).toBe('price_team_annual'); + expect(args.metadata.ngaf_tier_slug).toBe('team'); }); it('enables adjustable_quantity only for developer_seat', async () => { @@ -78,7 +91,7 @@ describe('POST /api/checkout/session', () => { it('returns 502 if Stripe returns no URL', async () => { stripeCreate.mockResolvedValueOnce({ url: null }); - const res = await POST(makeReq({ tier: 'indie' })); + const res = await POST(makeReq({ tier: 'developer_seat' })); expect(res.status).toBe(502); }); }); diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts index 7362a862..e75d5657 100644 --- a/apps/website/src/app/api/checkout/session/route.ts +++ b/apps/website/src/app/api/checkout/session/route.ts @@ -1,13 +1,19 @@ // SPDX-License-Identifier: MIT import { NextRequest, NextResponse } from 'next/server'; import { getStripe } from '../../../../lib/stripe'; -import { TIERS, type TierSlug } from '../../../../../../../pricing/tiers.config'; +import { + TIERS, + type TierSlug, + type BillingCycle, +} from '../../../../../../../pricing/tiers.config'; import { STRIPE_PRICE_IDS } from '../../../../../../../pricing/tiers.generated'; -const BUYABLE_SLUGS = new Set(['indie', 'developer_seat', 'app_deployment']); +const BUYABLE_SLUGS = new Set(['developer_seat', 'team']); +const VALID_CYCLES = new Set(['monthly', 'annual']); interface RequestBody { tier?: string; + billing_cycle?: string; quantity?: number; } @@ -31,6 +37,8 @@ export async function POST(req: NextRequest) { const form = await req.formData(); body = { tier: typeof form.get('tier') === 'string' ? (form.get('tier') as string) : undefined, + billing_cycle: + typeof form.get('billing_cycle') === 'string' ? (form.get('billing_cycle') as string) : undefined, quantity: form.get('quantity') ? Number(form.get('quantity')) : undefined, }; } @@ -41,10 +49,16 @@ export async function POST(req: NextRequest) { } const tierSlug = tier as Exclude; - const priceId = STRIPE_PRICE_IDS[tierSlug]; + const cycle = (body.billing_cycle ?? 'annual') as BillingCycle; + if (!VALID_CYCLES.has(cycle)) { + return NextResponse.json({ error: 'Invalid billing_cycle (expected monthly or annual)' }, { status: 400 }); + } + + const tierPrices = STRIPE_PRICE_IDS[tierSlug]; + const priceId = tierPrices?.[cycle]; if (!priceId) { return NextResponse.json( - { error: 'Checkout not yet configured for this tier. Run scripts/stripe/sync-products.ts.' }, + { error: `Checkout not yet configured for tier=${tierSlug} cycle=${cycle}. Run scripts/stripe/sync-products.ts.` }, { status: 503 }, ); } @@ -61,8 +75,7 @@ export async function POST(req: NextRequest) { const stripe = getStripe(); const session = await stripe.checkout.sessions.create({ - mode: 'payment', - customer_creation: 'always', + mode: 'subscription', line_items: [ { price: priceId, @@ -74,8 +87,10 @@ export async function POST(req: NextRequest) { ], success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/pricing`, - metadata: { ngaf_tier_slug: tierSlug }, - payment_intent_data: { metadata: { ngaf_tier_slug: tierSlug } }, + metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle }, + subscription_data: { + metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle }, + }, }); if (!session.url) { diff --git a/apps/website/src/app/docs/licensing/page.tsx b/apps/website/src/app/docs/licensing/page.tsx new file mode 100644 index 00000000..5d67167e --- /dev/null +++ b/apps/website/src/app/docs/licensing/page.tsx @@ -0,0 +1,292 @@ +import Link from 'next/link'; +import { tokens } from '@ngaf/design-tokens'; +import { Container } from '../../../components/ui/Container'; +import { Section } from '../../../components/ui/Section'; +import { Eyebrow } from '../../../components/ui/Eyebrow'; +import { Button } from '../../../components/ui/Button'; +import { createPageMetadata } from '../../../lib/site-metadata'; + +export const metadata = createPageMetadata({ + title: 'Licensing — ThreadPlane', + description: + 'How the ThreadPlane Commercial license works, who needs one, and how to install your license token in @ngaf/chat.', + pathname: '/docs/licensing', + type: 'website', +}); + +const headingStyle = { + fontFamily: tokens.typography.h2.family, + fontSize: tokens.typography.h2.size, + lineHeight: tokens.typography.h2.line, + fontWeight: 700, + color: tokens.colors.textPrimary, + margin: 0, + marginBottom: 16, + letterSpacing: '-0.01em', +} as const; + +const h3Style = { + fontFamily: tokens.typography.h3.family, + fontSize: tokens.typography.h3.size, + lineHeight: tokens.typography.h3.line, + fontWeight: 600, + color: tokens.colors.textPrimary, + margin: 0, + marginTop: 24, + marginBottom: 8, +} as const; + +const bodyStyle = { + fontFamily: tokens.typography.body.family, + fontSize: tokens.typography.body.size, + lineHeight: tokens.typography.body.line, + color: tokens.colors.textSecondary, + margin: '0 0 16px', + maxWidth: '64ch', +} as const; + +const codeBlockStyle = { + fontFamily: tokens.typography.fontMono, + fontSize: 13, + lineHeight: 1.6, + background: tokens.surfaces.surfaceTinted, + border: `1px solid ${tokens.surfaces.border}`, + borderRadius: 8, + padding: 16, + overflow: 'auto', + color: tokens.colors.textPrimary, + margin: '0 0 16px', + whiteSpace: 'pre' as const, +} as const; + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse' as const, + fontFamily: tokens.typography.body.family, + fontSize: 14, + color: tokens.colors.textSecondary, + margin: '0 0 24px', +} as const; + +const cellStyle = { + padding: '10px 12px', + borderBottom: `1px solid ${tokens.surfaces.border}`, + verticalAlign: 'top' as const, +} as const; + +const headerCellStyle = { + ...cellStyle, + color: tokens.colors.textPrimary, + fontWeight: 600, + background: tokens.surfaces.surfaceTinted, +} as const; + +export default function LicensingPage() { + return ( + <> +
+ +
+ Documentation +

+ Licensing +

+

+ How the ThreadPlane licensing model works, who needs a paid license, and how to install your license token. +

+
+
+
+ +
+ +
+

The model

+

+ Agent UI for Angular is a suite of libraries. Most are{' '} + MIT-licensed and free for any use, + commercial or not. Only @ngaf/chat is + dual-licensed. +

+

+ @ngaf/chat is source-available under{' '} + PolyForm Noncommercial 1.0.0 for free + noncommercial use, or a ThreadPlane Commercial license{' '} + for production use inside a for-profit context. The same source ships under both — you don't get a + different build. +

+ +

Do you need a paid license?

+

+ You need a ThreadPlane Commercial license if you use @ngaf/chat{' '} + in any of: +

+
    +
  • A commercial product or SaaS
  • +
  • An internal business tool inside a for-profit company
  • +
  • An agency deliverable or paid client project
  • +
  • Any application operated by or for a for-profit entity
  • +
+

You do not need a paid license for:

+
    +
  • Personal, hobby, student, academic, or nonprofit projects
  • +
  • Public demos and tutorials
  • +
  • Open-source applications released under an OSI-approved license
  • +
  • Commercial evaluation, up to 30 calendar days from your first commercial use
  • +
+
+
+
+ +
+ +
+

Install your license

+

+ After purchase, ThreadPlane emails a signed license token to the address on your receipt. Paste it + into your app's provideChat(){' '} + configuration: +

+
{`// app.config.ts
+import { ApplicationConfig } from '@angular/core';
+import { provideChat } from '@ngaf/chat';
+
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideChat({
+      license: process.env['THREADPLANE_LICENSE'],
+    }),
+  ],
+};`}
+

+ The library verifies the token's Ed25519 signature on boot. The check is{' '} + advisory-only: a missing, expired, or + tampered token logs a console.warn but + never blocks rendering. Verification is fully offline; no calls leave your app at runtime. +

+

+ The token is safe to commit to a private repository, or to read from a build-time environment variable + for public repos. Public-repo demos are exempt from the commercial-use definition, but if your + public repo backs a commercial product, the deployed bundle does need a license. +

+
+
+
+ +
+ +
+

Tier scoping

+

+ Pick the tier that matches how you'll deploy. All paid tiers grant the same{' '} + ThreadPlane Commercial license; the difference is the scope of use and the number of seats. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TierDevelopersBest for
Developer Seat — $29/dev/mo or $299/dev/yrPer seatSolo devs, growing teams
Team — $149/mo or $1,495/yr5 seats includedSmall teams that want a single SKU and renewal
Enterprise — from $4,000/moCustomSLA, security review, Pilot-to-Prod engagement, Slack Connect
+
+

+ Paid tiers are recurring subscriptions. Annual saves ~15% vs monthly. Cancel anytime — the license + stays valid through the end of the current paid period. +

+
+
+
+ +
+ +
+

Evaluation

+

+ You may use @ngaf/chat commercially + for 30 calendar days from your first + commercial use as a good-faith evaluation. There is no telemetry, no registration, no email check — + we trust you to count the days. After 30 days you must either purchase a license or stop the + commercial use. +

+ +

Refunds

+

+ If you refund a license through Stripe, the token is revoked automatically and we email a confirmation. + The verification check warns on boot. There's no clawback of the source code you already have — + everything is source-available under PolyForm Noncommercial by default. +

+ +

Questions

+

+ Volume pricing, multi-app licensing, audit clauses, custom terms — any of those, reach out and we'll + work it out. +

+
+ + + + Pricing FAQ → + +
+
+
+
+ + ); +} diff --git a/apps/website/src/app/angular/page.tsx b/apps/website/src/app/langgraph/page.tsx similarity index 96% rename from apps/website/src/app/angular/page.tsx rename to apps/website/src/app/langgraph/page.tsx index 59a88133..b922128e 100644 --- a/apps/website/src/app/angular/page.tsx +++ b/apps/website/src/app/langgraph/page.tsx @@ -8,17 +8,17 @@ import { BrowserFrame } from '../../components/ui/BrowserFrame'; import { FeatureBlock } from '../../components/landing/FeatureBlock'; import { WhitePaperBlock } from '../../components/landing/WhitePaperBlock'; import { FinalCTA } from '../../components/landing/FinalCTA'; -import { AngularCodeShowcase } from '../../components/landing/angular/AngularCodeShowcase'; +import { LangGraphCodeShowcase } from '../../components/landing/langgraph/LangGraphCodeShowcase'; import { createPageMetadata, SHORT_POSITIONING_DESCRIPTION } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ title: '@ngaf/langgraph — Agent UI for Angular', description: SHORT_POSITIONING_DESCRIPTION, - pathname: '/angular', + pathname: '/langgraph', type: 'website', }); -export default async function AngularPage() { +export default async function LangGraphPage() { return ( <> {/* Hero */} @@ -136,7 +136,7 @@ export class ChatComponent { ]} cta={{ label: 'Read the streaming guide', href: '/docs/agent/api/agent' }} visualLeft - visual={} + visual={} /> diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx index de07919f..823c7ddd 100644 --- a/apps/website/src/app/pricing/page.tsx +++ b/apps/website/src/app/pricing/page.tsx @@ -2,7 +2,6 @@ import { tokens } from '@ngaf/design-tokens'; import { Container } from '../../components/ui/Container'; import { Section } from '../../components/ui/Section'; import { Eyebrow } from '../../components/ui/Eyebrow'; -import { PricingGrid } from '../../components/pricing/PricingGrid'; import { CompareTable } from '../../components/pricing/CompareTable'; import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix'; import { PricingFAQ } from '../../components/pricing/PricingFAQ'; @@ -13,33 +12,15 @@ import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ title: 'Pricing — Agent UI for Angular', description: - '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a Threadplane license. Other libraries remain MIT.', + '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a ThreadPlane Commercial license. Other libraries remain MIT.', pathname: '/pricing', type: 'website', }); -function SmallNote({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ); -} - export default function PricingPage() { return ( <> -
+
Pricing @@ -52,47 +33,17 @@ export default function PricingPage() { lineHeight: tokens.typography.h1.line, color: tokens.colors.textPrimary, margin: 0, - marginBottom: 16, letterSpacing: '-0.02em', }} > - Pricing for production AI chat interfaces + Simple, transparent pricing -

- @ngaf/chat is free for noncommercial use. Commercial production use requires a Threadplane license. Other libraries in the framework remain MIT. -

- - -
- - - A license is required when @ngaf/chat is used in a commercial product, SaaS app, internal business tool, paid client project, or production application operated by or for a for-profit entity. - - -
- -
- - - Commercial evaluation is free for 30 days. A paid license is required before production deployment. - - -
-
Compatibility @@ -123,14 +74,6 @@ export default function PricingPage() { -
- - - Because commercial use requires a license, @ngaf/chat is source-available rather than OSI open source. Threadplane keeps ecosystem packages (@ngaf/render, @ngaf/agent, @ngaf/langgraph, @ngaf/ag-ui, @ngaf/a2ui, @ngaf/licensing, @ngaf/telemetry, @ngaf/design-tokens) permissively MIT-licensed. - - -
- diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index c9c264d6..a6658e5e 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -22,7 +22,7 @@ interface PageProps { } const LIBRARY_HREF: Record = { - Agent: '/angular', + Agent: '/langgraph', Render: '/render', Chat: '/chat', }; diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx index 1dd98470..cf2f5cb9 100644 --- a/apps/website/src/app/thanks/page.tsx +++ b/apps/website/src/app/thanks/page.tsx @@ -7,7 +7,7 @@ import { Button } from '../../components/ui/Button'; import { createPageMetadata } from '../../lib/site-metadata'; export const metadata = createPageMetadata({ - title: 'Payment received — Threadplane', + title: 'Payment received — ThreadPlane', description: 'Your @ngaf/chat license token will be emailed shortly.', pathname: '/thanks', type: 'website', @@ -57,8 +57,8 @@ export default function ThanksPage() { If you don't see the email within 10 minutes, check spam or contact us.

- + + ); + } + return ( + + ); +} + +const LABEL_COL_WIDTH = '22%'; + +function BillingToggle({ + cycle, + setCycle, + discountPct, +}: { + cycle: BillingCycle; + setCycle: (c: BillingCycle) => void; + discountPct: number; +}) { + const baseBtn = { + fontFamily: tokens.typography.fontSans, + fontSize: 13, + fontWeight: 600, + padding: '8px 16px', + border: 'none', + cursor: 'pointer', + transition: 'background 150ms ease, color 150ms ease', + background: 'transparent', + color: tokens.colors.textSecondary, + borderRadius: 999, + } as const; + const activeBtn = { + ...baseBtn, + background: tokens.colors.accent, + color: '#fff', + } as const; + return ( +
+
+ + +
+
+ ); +} + +function SectionTable({ + title, + rows, + cycle, + showPrice, +}: { + title: string; + rows: FeatureRow[]; + cycle: BillingCycle; + showPrice: boolean; +}) { return ( -
+
- +
- + - {TIERS.map((t) => ( + {TIERS.map((tier) => ( ))} + {showPrice ? ( + + + {TIERS.map((tier) => { + const p = tier.prices[cycle]; + return ( + + ); + })} + + ) : ( + + + )} - {ROWS.map((row) => ( + {rows.map((row, i) => ( (e.currentTarget.style.background = tokens.colors.accentSurface)} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + style={{ + borderBottom: i === rows.length - 1 ? 'none' : `1px solid ${tokens.surfaces.border}`, + }} > - - {TIERS.map((t) => ( - ))} @@ -121,6 +436,67 @@ export function CompareTable() {
- Feature + {title} - {t.label} + {tier.highlight && ( +
+ MOST POPULAR +
+ )} +
+ {tier.name} +
+ Price + +
+ + {p.display} + + {p.period && ( + + {p.period} + + )} +
+
+ {TIERS.map((tier) => ( + + ))} +
+ {row.feature} - {renderCell(row.cells[t.key])} + {TIERS.map((tier) => ( + + {renderCell(row.cells[tier.slug])}
+
+ ); +} + +function CtaStrip({ cycle }: { cycle: BillingCycle }) { + return ( +
+
+ {TIERS.map((tier) => ( +
+
+ +
+
+ ))} +
+ ); +} + +export function CompareTable() { + const [cycle, setCycle] = useState('annual'); + const discountPct = annualDiscountPercent(); + + return ( +
+ + + +
+ +
+ +
+ + +
+ +
); } diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx index ea604aae..1e23d715 100644 --- a/apps/website/src/components/pricing/LeadForm.tsx +++ b/apps/website/src/components/pricing/LeadForm.tsx @@ -9,8 +9,30 @@ import { Eyebrow } from '../ui/Eyebrow'; import { Button } from '../ui/Button'; import { Card } from '../ui/Card'; +const VALUE_PROPS = [ + { + title: 'ThreadPlane Commercial license', + body: 'Multi-app coverage, unlimited developers, custom contract — built for procurement.', + }, + { + title: 'SLA + security review', + body: 'Response SLAs, security questionnaires, and a private support channel.', + }, + { + title: 'Pilot-to-Prod engagement', + body: '8-week concierge delivery. We ship your first Angular agent on your real data, in your real app — and your engineers own it at the end.', + highlight: true, + link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' }, + }, + { + title: 'Procurement support', + body: 'Master services agreement, security review, custom indemnification — handled by humans, not portals.', + }, +]; + export function LeadForm() { const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -25,7 +47,7 @@ export function LeadForm() { const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + body: JSON.stringify({ ...data, pilot_interest: pilotInterest }), }); if (res.ok) { track(analyticsEvents.marketingLeadFormSuccess, { @@ -63,11 +85,11 @@ export function LeadForm() { outline: 'none', }; - const handleFocus = (e: React.FocusEvent) => { + const handleFocus = (e: React.FocusEvent) => { e.target.style.borderColor = tokens.colors.accent; e.target.style.boxShadow = tokens.shadows.focus; }; - const handleBlur = (e: React.FocusEvent) => { + const handleBlur = (e: React.FocusEvent) => { e.target.style.borderColor = tokens.surfaces.border; e.target.style.boxShadow = 'none'; }; @@ -75,91 +97,302 @@ export function LeadForm() { return (
-
- Enterprise -

+
+ Enterprise +

+ Built for procurement.
Backed by delivery. +

+

+ Volume licensing, custom contract, and optional concierge delivery — so your first Angular agent ships, not just compiles. +

+
+ +
- Need volume seats or a custom contract? -

- {status === 'sent' ? ( -

- Thanks — we'll be in touch within one business day. -

- ) : ( - -
- - - - - - - -