diff --git a/.changeset/scheduling-hosts-meter.md b/.changeset/scheduling-hosts-meter.md new file mode 100644 index 000000000..b67fcd1a9 --- /dev/null +++ b/.changeset/scheduling-hosts-meter.md @@ -0,0 +1,5 @@ +--- +"coss": patch +--- + +Billing: the `scheduling.hosts` meter is new, and the unreleased `video.participant_minutes` is gone. diff --git a/AGENTS.md b/AGENTS.md index be22afd39..96c8ef926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,6 @@ # AGENTS.md — coss.com monorepo -coss.com is a unified developer platform: one API key + one SDK (`coss.email.send()`, `coss.sms.send()`, later -video, scheduling…). This repo holds the dashboard, the public API, shared packages and the public SDK. +coss.com is a unified developer platform: one API key + one SDK (`coss.email.send()`, `coss.sms.send()`, `coss.scheduling`…). This repo holds the dashboard, the public API, shared packages and the public SDK. Email delivery goes through the provider interface in `packages/email/src/provider.ts` (Amazon SES v2 with one SES tenant per organization, or the fake provider); sandbox environments always use the fake provider. Delivery events drive per-recipient status and signed customer webhooks. Environments also receive email (an SES receipt rule → S3 + @@ -414,7 +413,7 @@ parent(id, organization_id)` and the `createdAt()`/`updatedAt()` helpers (histor Auth hard-deletes keys); `bun run db:generate`, review and commit the SQL + `migrations/meta/`, `bun run db:migrate`. Never edit an applied migration; do renames as a separate step (drizzle-kit prompts interactively). -**Add a product** (e.g. `video`; `packages/scheduling` is the most recent one built this way): +**Add a product** (`packages/scheduling` is the most recent one built this way): 1. Tables + ID prefix (above). 2. `packages/`: `src/schemas.ts` (zod `strictObject`, snake_case, `.meta({ id })`, no DB imports, exported as `./schemas`), provider interface + fake provider, a service taking the authenticated environment, an error class with @@ -442,8 +441,7 @@ Never edit an applied migration; do renames as a separate step (drizzle-kit prom (only for rows actually inserted, so deduplicated events aren't logged twice), and add a `LogSource` value (enum migration + `LOG_SOURCES` + SDK `LogSource`). Mutations of the product's settings record `recordAudit` (dashboard) / `recordApiAudit` (API) audit events. -10. Billing: its meter is in `METERS` and priced in `RATE_CARDS` (`@coss/billing/catalog`; video and scheduling - already are). Call `recordUsage(tx, …)` from `@coss/billing/metering` once per billable event, in the transaction +10. Billing: its meter goes in `METERS` and is priced in `RATE_CARDS` (`@coss/billing/catalog`). Call `recordUsage(tx, …)` from `@coss/billing/metering` once per billable event, in the transaction that makes it billable (guard it with a compare-and-set, like `email.usage_recorded_at`), and reserve capped usage at acceptance through an injected `UsageGate` (as `EmailServiceOptions.billing` does). Plan-gated creation takes an injected `PlanGate`. @@ -533,7 +531,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu `recordOpen`/`recordClick` (from the email's tracking token), `trackingBaseUrlFor` (dispatch; reads domain names across orgs like `authorizeLiveSender`), `receiveEmail`/`routeRecipients` (receiving; from the envelope recipients), `recordSmsEvent`/`ingestSmsEvents` (from the `sms_id` of the callback URL), `receiveSms` (from the receiving number), `handleStatusCallback`/`handleInboundCallback` (from the callback's subaccount), `authorizeLiveSmsSender`, `ingestDmarcReports` (from the report address's token), `reencryptWebhookSecrets`, `reencryptSchedulingData` (operator scripts), `authenticateHookPortalSession` (from the session), `ingestSupportEmails` (one environment, across senders) and the rest of `@coss/support`'s worker functions, `submitForm`/`getHostedForm` (from the form id of `/f/:id`), `confirmFormSubmission`/`findConfirmation` (from the verification token), and the worker scans `runDueWebhookDeliveries`, `runDueLogDrainDeliveries`, `runDueHookDeliveries`, `parkHeldHookDeliveries`, `runDueHookRecoveries`, the Hooks purges, `recheckDueDomains`, `recheckDueTrackingDomains`, `recheckDueReceivingDomains`, `reconcileDedicatedIpPools`, `sweepStaleClaims`, `sweepUnclaimedEmails`, `runDueReputationEvaluations`, `runDueSubaccountEvaluations`, `runDueOrganizationDeletions`, `runDueOrganizationLogos`, `runDueSubaccountDeletions`, `runDueDmarcNotifications`, `runDueMigrations`, `EmailService.dispatchDue`, - `unclaimedEmailBacklog`, `unclaimedSmsBacklog` (the `dispatch-backlog` loop: counts only), `SmsService.dispatchDue`, `sweepStaleSmsClaims`, `sweepUnclaimedSms`, `advanceDueSmsRegistrations`, `chargeDueSmsFees`, `chargeDueInboxFees`, `recordDueVerifySmsUsage` (bills each organization for its codes from coss.com's shared numbers, reading coss.com's messages), + `unclaimedEmailBacklog`, `unclaimedSmsBacklog` (the `dispatch-backlog` loop: counts only), `SmsService.dispatchDue`, `sweepStaleSmsClaims`, `sweepUnclaimedSms`, `advanceDueSmsRegistrations`, `chargeDueSmsFees`, `chargeDueInboxFees`, `chargeDueHostFees`, `recordDueVerifySmsUsage` (bills each organization for its codes from coss.com's shared numbers, reading coss.com's messages), `authenticateSchedulingSession` (from the token), `handleCalendarPush`, `completeCalendarAuthorization` (from the push's channel and the consent's state), `runDueCalendarDelegations`, `handleZoomDeauthorization` (from Zoom's signed event), `refreshIdleZoomConnections`, `runDueCalendarSyncs`, `runDueCalendarWrites`, `renewDueCalendarChannels`, `checkHostEditedEvents`, `runDueSchedulingNotifications`, `expireDueBookings`, `runDueFormDeliveries`, `runDueFormStoreCleanup` (and the dispatch jobs, from the job's `organizationId`); `@coss/sms/admin` is platform-level like `@coss/email/admin`. Server actions take only slugs + @@ -703,14 +701,16 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu published; `/f/*` is served only on `FORMS_PUBLIC_URL`'s host in production, never coss.com's. - **Scheduling** (`packages/scheduling/AGENTS.md`): only `claims.ts` writes claims, in the fixed lock order; slots are computed only in Bun, through `isBookable`; calendars contribute busy intervals and nothing else, and an - unreadable one never reads as free; answers never leave `getBooking`; coss.com never auto-cancels a booking. + unreadable one never reads as free; answers never leave `getBooking`; coss.com never auto-cancels a booking; + plan gates and billing holds apply to live environments only, and refuse new setup and new bookings, never what + already exists. - **Support** (`packages/support/AGENTS.md`): `From` proves nothing, so a match never grants access by itself: customers see only a confirmed organization's tickets, an unverified message stays rep-only, we always reply to `From` and never copy inbound Cc from unauthenticated mail; notes never leave `@coss/support/admin`; threads never match on a subject. - **HIPAA mode** (`packages/compliance/AGENTS.md`): a new vendor or a new path that sends customer content outside AWS needs a gate and an entry in `HIPAA_VENDORS`; a new sender that can fall back to coss.com's - environment passes `hipaaOrigin` for organizations in the mode; it is never a billing entitlement. + environment passes `hipaaOrigin` for organizations in the mode; it is contract-only (a sales contract to turn it on) but never a billing entitlement (billing never turns it off). - **Organization deletion** (`packages/tenancy/AGENTS.md`): a table with many rows per organization belongs in `deleteOrganizationData`, anything held outside Postgres in `releaseProviderResources`. @@ -822,8 +822,7 @@ editing an API key's permissions (create a new key), API keys restricted to send uploading an organization logo (logos are only found automatically), uploading a profile picture, email change, email/SMS two-factor codes (through Verify), sign-in providers other than Google and GitHub, "trust this device" for 2FA, renaming passkeys, webhooks and notification emails for dedicated IP status changes, standard (manually warmed) dedicated IPs or more than one pool per organization, an automatic direct-debit policy (only per-account -required payment methods), multi-currency, metering for video (its meter -and rate exist; the product doesn't), SMS alphanumeric sender IDs, short codes, MMS, batch SMS, SMS templates, +required payment methods), multi-currency, SMS alphanumeric sender IDs, short codes, MMS, batch SMS, SMS templates, sole-proprietor 10DLC brands, `sms.registration.*` webhooks and push notifications, Twilio usage reconciliation, and these Hooks features: channels, payload transformations, custom endpoint headers, sending one message to several applications, strict ordering, portal custom domains, per-destination rate-limit overrides, Hooks attempts in diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a29cb7ddd..d470d8b15 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1623,8 +1623,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages", null ], @@ -2289,8 +2289,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages", null ], @@ -2322,8 +2322,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages" ], "description": "What is counted, e.g. `email.recipients`." @@ -27334,8 +27334,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages" ], "description": "What is counted, e.g. `email.recipients`." @@ -27753,8 +27753,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages" ], "description": "What is counted, e.g. `email.recipients`." @@ -28179,8 +28179,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages", null ], @@ -57082,6 +57082,27 @@ } } }, + "402": { + "description": "In live environments: an overdue invoice or spend limit (`billing_hold`), or the project's budget (`budget_exceeded`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -58054,6 +58075,27 @@ } } }, + "402": { + "description": "In live environments: an overdue invoice or spend limit (`billing_hold`), or the project's budget (`budget_exceeded`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -65634,6 +65676,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -68065,6 +68128,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -68780,6 +68864,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -69096,6 +69201,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -69378,6 +69504,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -69609,6 +69756,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -69895,6 +70063,27 @@ } } }, + "402": { + "description": "In live environments: an overdue invoice or spend limit (`billing_hold`), the project's budget (`budget_exceeded`), the plan's allowance (`usage_limit_exceeded`) or what it includes (`plan_feature_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -72449,6 +72638,27 @@ } } }, + "402": { + "description": "In live environments: an overdue invoice or spend limit (`billing_hold`), the project's budget (`budget_exceeded`), the plan's allowance (`usage_limit_exceeded`) or what it includes (`plan_feature_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -73260,6 +73470,27 @@ } } }, + "402": { + "description": "In live environments: an overdue invoice or spend limit (`billing_hold`), the project's budget (`budget_exceeded`), the plan's allowance (`usage_limit_exceeded`) or what it includes (`plan_feature_unavailable`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -73533,6 +73764,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -77455,6 +77707,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -77707,6 +77980,27 @@ } } }, + "402": { + "description": "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past the plan's limit (`plan_limit_reached`).", + "headers": { + "X-Request-Id": { + "description": "Unique id of this request. Include it when contacting support.", + "schema": { + "type": "string", + "examples": [ + "req_01k7c8m4q2f3e9v6t5r1n0p8yz" + ] + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "403": { "description": "The API key lacks the permission this endpoint needs (`permission_error`), or its organization is suspended (`organization_suspended`).", "headers": { @@ -93697,8 +93991,8 @@ "verify.sms", "forms.submissions", "forms.file_uploads", - "video.participant_minutes", "scheduling.bookings", + "scheduling.hosts", "hooks.messages" ], "description": "Only this meter." diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a285221ef..ab67223ca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -302,7 +302,7 @@ export function createApp(opts: CreateAppOptions) { app.route( SCHEDULING_CLIENT_PATH, createSchedulingClientRoutes({ - deps: { ...opts.scheduling, billing: opts.billing }, + deps: { ...opts.scheduling, billing: opts.billing, plans: opts.plans }, hostedUrl: opts.scheduling.publicUrl ?? null, }), ); @@ -367,6 +367,7 @@ export function createApp(opts: CreateAppOptions) { scheduling: opts.scheduling && { ...opts.scheduling, billing: opts.billing, + plans: opts.plans, screenSetup: opts.scheduling.screenSetup ?? hostedPageScreening(opts.riskDeps), }, riskDeps: opts.riskDeps, diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index c8b8b14e2..cab7d25dd 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -308,6 +308,9 @@ function fromSmsError(error: SmsError): ApiError { function fromSignError(error: SignError): ApiError { switch (error.code) { + case "billing_hold": + case "budget_exceeded": + return new ApiError(402, { type: "billing_error", code: error.code, message: error.message }); case "request_in_progress": case "idempotency_key_reused": return fromIdempotencyError({ code: error.code, message: error.message }); @@ -398,6 +401,10 @@ function fromFormError(error: FormError): ApiError { function fromSchedulingError(error: SchedulingError): ApiError { switch (error.code) { + case "billing_hold": + case "budget_exceeded": + // Paying or raising the budget is the fix: 402, never retried by the SDK. + return new ApiError(402, { type: "billing_error", code: error.code, message: error.message }); case "not_found": return new ApiError(404, { type: "not_found_error", @@ -480,6 +487,9 @@ function fromContactError(error: ContactError): ApiError { } function fromInboxError(error: InboxError): ApiError { + if (error.code === "billing_hold" || error.code === "budget_exceeded") { + return new ApiError(402, { type: "billing_error", code: error.code, message: error.message }); + } if (error.code.endsWith("not_found")) { return new ApiError(404, { type: "not_found_error", diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts index d08014196..02944634e 100644 --- a/apps/api/src/middleware/rate-limit.ts +++ b/apps/api/src/middleware/rate-limit.ts @@ -29,6 +29,30 @@ export function rateLimitError( return new ApiError(429, { type: "rate_limit_error", ...error }, headers); } +/** + * Scheduling's live slot lists and searches, per organization, at the plan's rate: what a booking + * page or a marketplace's search costs. Sandbox environments keep only their key's own bucket. + */ +export async function limitSchedulingSlots( + rateLimiter: AppEnv["Variables"]["rateLimiter"], + env: { organizationId: string; mode: "live" | "sandbox" }, +): Promise { + if (env.mode !== "live") return; + const decision = await rateLimiter.consume({ + buckets: ["scheduling.slots.second"], + subject: env.organizationId, + organizationId: env.organizationId, + }); + if (!decision.allowed) { + throw rateLimitError(decision, { + code: "scheduling_rate_limited", + message: + "Too many slot lists and searches for this organization. Retry after `Retry-After` " + + "seconds; higher plans allow more.", + }); + } +} + /** * Route-level middleware, after `requireApiKey`: counts the request against the key's bucket for * this route class (`api.read`, `api.write`, `api.send`), with the organization's overrides. diff --git a/apps/api/src/routes/scheduling-client.ts b/apps/api/src/routes/scheduling-client.ts index 944774e53..f7f038e60 100644 --- a/apps/api/src/routes/scheduling-client.ts +++ b/apps/api/src/routes/scheduling-client.ts @@ -55,6 +55,7 @@ import { import { type Context, Hono, type MiddlewareHandler } from "hono"; import { z } from "zod"; import { ApiError } from "../errors.ts"; +import { limitSchedulingSlots } from "../middleware/rate-limit.ts"; import type { AppEnv } from "../types.ts"; export const SCHEDULING_CLIENT_PATH = "/scheduling/client"; @@ -159,6 +160,24 @@ export function createSchedulingClientRoutes(opts: { await next(); }); + /** The person booking isn't the customer: their billing stays out of what the page says. */ + const bookingForPerson = async (run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + if ( + error instanceof SchedulingError && + (error.code === "billing_hold" || error.code === "budget_exceeded") + ) { + throw new SchedulingError( + "account_suspended", + "This account can't take bookings right now.", + ); + } + throw error; + } + }; + const authenticated: MiddlewareHandler = async (c, next) => { const byAddress = await c.var.rateLimiter.consume({ buckets: ["scheduling.ip"], @@ -257,6 +276,11 @@ export function createSchedulingClientRoutes(opts: { brand_name: settings?.brandName ?? null, accent_color: settings?.accentColor ?? null, logo: logo ? { data_url: logoDataUrl(logo), width: logo.width, height: logo.height } : null, + // "Scheduling by coss.com" on a live page, on plans without the white label. + coss_attribution: + env.mode === "live" && deps.plans + ? !(await deps.plans.hasFeature(env.organizationId, "schedulingWhiteLabel")) + : false, }; }; @@ -325,8 +349,9 @@ export function createSchedulingClientRoutes(opts: { eventType: string, hostRefs: readonly string[] | undefined, attributes?: Record, - ) => - slotList( + ) => { + await limitSchedulingSlots(c.var.rateLimiter, c.var.session.env); + return slotList( c, await listEventTypeSlots(c.var.session.env, { ...slotWindow(c), @@ -335,9 +360,11 @@ export function createSchedulingClientRoutes(opts: { attributes, }), ); + }; /** Where this booking could move to: its own hosts, length and buffers. */ const rescheduleSlots = async (c: Context, id: string) => { + await limitSchedulingSlots(c.var.rateLimiter, c.var.session.env); const { fromMs, toMs, nowMs } = slotWindow(c); return slotList(c, await listRescheduleSlots(c.var.session.env, id, { fromMs, toMs, nowMs })); }; @@ -358,23 +385,25 @@ export function createSchedulingClientRoutes(opts: { const fixed = fixedAttendee(session, deps); const attendee = fixed ?? body.attendee; if (!attendee) throw invalidBody("`attendee` is required.", "attendee"); - const { booking, replayed } = await createBooking( - env, - { - eventType, - startMs: instant(body.start, "start"), - host: session.hostId ?? undefined, - attributes: sessionAttributes(session, deps), - durationMinutes: body.duration_minutes, - attendee, - guests: body.guests, - answers: body.answers, - location: body.location, - locationFromBrowser: true, - idempotencyKey: c.req.header("idempotency-key"), - }, - deps, - sessionActor(session), + const { booking, replayed } = await bookingForPerson(() => + createBooking( + env, + { + eventType, + startMs: instant(body.start, "start"), + host: session.hostId ?? undefined, + attributes: sessionAttributes(session, deps), + durationMinutes: body.duration_minutes, + attendee, + guests: body.guests, + answers: body.answers, + location: body.location, + locationFromBrowser: true, + idempotencyKey: c.req.header("idempotency-key"), + }, + deps, + sessionActor(session), + ), ); if (replayed) c.header("Idempotent-Replayed", "true"); const emailed = diff --git a/apps/api/src/routes/scheduling.ts b/apps/api/src/routes/scheduling.ts index 8856ee823..4793d7ac7 100644 --- a/apps/api/src/routes/scheduling.ts +++ b/apps/api/src/routes/scheduling.ts @@ -48,6 +48,7 @@ import { listSchedules, markNoShow, previewSeries, + requireRouting, rescheduleBooking, type SchedulingDeps, SchedulingError, @@ -129,7 +130,7 @@ import { recordApiAudit } from "../audit.ts"; import { ApiError } from "../errors.ts"; import { requireApiKey } from "../middleware/api-key.ts"; import { upgradeRequestBody } from "../middleware/api-version.ts"; -import { rateLimit } from "../middleware/rate-limit.ts"; +import { limitSchedulingSlots, rateLimit } from "../middleware/rate-limit.ts"; import type { AppEnv } from "../types.ts"; import { billingErrorDoc, commonErrors, errorDoc, jsonDoc, requestIdHeader } from "./docs.ts"; import { idempotencyHeaders, idempotencyHook, idempotentReplayedHeader } from "./idempotency.ts"; @@ -189,6 +190,15 @@ const bodyErrors = { 400: errorDoc("The body is not valid JSON (`invalid_json`)."), 415: errorDoc("The body is not sent as `application/json` (`unsupported_media_type`)."), }; +const planRefused = errorDoc( + "In live environments: the plan doesn't include this (`plan_feature_unavailable`), or it's past " + + "the plan's limit (`plan_limit_reached`).", +); +const bookingRefused = errorDoc( + "In live environments: an overdue invoice or spend limit (`billing_hold`), the project's budget " + + "(`budget_exceeded`), the plan's allowance (`usage_limit_exceeded`) or what it includes " + + "(`plan_feature_unavailable`).", +); const notFound = errorDoc("Nothing with this id or uid exists in the environment (`not_found`)."); const invalid = errorDoc( "The request failed validation (`validation_error`, see `issues`), or can't be done: see `code`.", @@ -253,6 +263,7 @@ export const createHostRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateHostRequest } } }, }, responses: { + 402: planRefused, 201: jsonDoc("The host.", HostObject), ...commonErrors, ...bodyErrors, @@ -436,6 +447,7 @@ export const createEventTypeRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateEventTypeRequest } } }, }, responses: { + 402: planRefused, 201: jsonDoc("The event type.", EventTypeObject), ...commonErrors, ...bodyErrors, @@ -490,6 +502,7 @@ export const updateEventTypeRoute = createRoute({ body: { required: true, content: { "application/json": { schema: UpdateEventTypeRequest } } }, }, responses: { + 402: planRefused, 200: jsonDoc("The event type.", EventTypeObject), ...commonErrors, ...bodyErrors, @@ -511,6 +524,7 @@ export const listSlotsRoute = createRoute({ middleware: read(), request: { params: EventTypeParams, query: SlotsQuery }, responses: { + 402: planRefused, 200: jsonDoc("The slots.", SlotList), ...commonErrors, 404: notFound, @@ -531,6 +545,7 @@ export const explainSlotRoute = createRoute({ middleware: read(), request: { params: EventTypeParams, query: ExplainSlotQuery }, responses: { + 402: planRefused, 200: jsonDoc("Whether it can be booked, and why.", SlotExplanation), ...commonErrors, 404: notFound, @@ -559,6 +574,7 @@ export const searchAvailabilityRoute = createRoute({ }, }, responses: { + 402: planRefused, 200: jsonDoc("Hosts with slots.", AvailabilitySearchResult), ...commonErrors, ...bodyErrors, @@ -583,6 +599,7 @@ export const createBookingRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateBookingRequest } } }, }, responses: { + 402: bookingRefused, 201: createdBooking("The booking."), ...commonErrors, ...bodyErrors, @@ -792,6 +809,7 @@ export const createHoldRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateHoldRequest } } }, }, responses: { + 402: bookingRefused, 201: createdBooking("The held booking."), ...commonErrors, ...bodyErrors, @@ -858,6 +876,7 @@ export const createSeriesRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateSeriesRequest } } }, }, responses: { + 402: bookingRefused, 201: createdBooking("The series and its bookings.", SeriesResult), ...commonErrors, ...bodyErrors, @@ -884,6 +903,7 @@ export const previewSeriesRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateSeriesRequest } } }, }, responses: { + 402: planRefused, 200: jsonDoc("Each occurrence.", SeriesPreview), ...commonErrors, ...bodyErrors, @@ -1309,6 +1329,7 @@ export const createHostSessionRoute = createRoute({ body: { required: true, content: { "application/json": { schema: CreateHostSessionRequest } } }, }, responses: { + 402: planRefused, 201: jsonDoc("The session.", CreatedSessionBody), ...commonErrors, ...bodyErrors, @@ -1334,6 +1355,7 @@ export const createBookerSessionRoute = createRoute({ }, }, responses: { + 402: planRefused, 201: jsonDoc("The session.", CreatedSessionBody), ...commonErrors, ...bodyErrors, @@ -1465,7 +1487,11 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA }); router.openapi(createHostRoute, async (c) => { - const host = await createHost(c.var.auth, c.req.valid("json"), { screen: deps.screenSetup }); + const host = await createHost(c.var.auth, c.req.valid("json"), { + screen: deps.screenSetup, + plans: deps.plans, + billing: deps.billing, + }); await recordApiAudit(c, { action: "scheduling_host.created", target: { type: "scheduling_host", id: host.id, label: host.name }, @@ -1494,7 +1520,9 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA }); router.openapi(deleteHostRoute, async (c) => { - const { id } = await deleteHost(c.var.auth, c.req.valid("param").host); + const { id } = await deleteHost(c.var.auth, c.req.valid("param").host, { + billing: deps.billing, + }); await recordApiAudit(c, { action: "scheduling_host.deleted", target: { type: "scheduling_host", id }, @@ -1530,6 +1558,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(createEventTypeRoute, async (c) => { const eventType = await createEventType(c.var.auth, c.req.valid("json"), { screen: deps.screenSetup, + plans: deps.plans, }); await recordApiAudit(c, { action: "scheduling_event_type.created", @@ -1552,7 +1581,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA c.var.auth, c.req.valid("param").event_type, c.req.valid("json"), - { screen: deps.screenSetup }, + { screen: deps.screenSetup, plans: deps.plans }, ); await recordApiAudit(c, { action: "scheduling_event_type.updated", @@ -1563,6 +1592,8 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(listSlotsRoute, async (c) => { const query = c.req.valid("query"); + await limitSchedulingSlots(c.var.rateLimiter, c.var.auth); + await requireRouting(c.var.auth, deps.plans, attributesParam(query.attributes)); const slots = await listEventTypeSlots(c.var.auth, { eventTypeRef: c.req.valid("param").event_type, fromMs: instant(query.start, "start"), @@ -1589,6 +1620,8 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(explainSlotRoute, async (c) => { const query = c.req.valid("query"); + await limitSchedulingSlots(c.var.rateLimiter, c.var.auth); + await requireRouting(c.var.auth, deps.plans, attributesParam(query.attributes)); const explained = await explainSlot(c.var.auth, { eventTypeRef: c.req.valid("param").event_type, startMs: instant(query.start, "start"), @@ -1618,6 +1651,9 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA router.openapi(searchAvailabilityRoute, async (c) => { const body = c.req.valid("json"); + await limitSchedulingSlots(c.var.rateLimiter, c.var.auth); + await requireRouting(c.var.auth, deps.plans, body.attributes); + const { organizationId, mode } = c.var.auth; const found = await searchAvailability(c.var.auth, { eventTypeRef: body.event_type, fromMs: instant(body.start, "start"), @@ -1627,6 +1663,10 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA hostRefs: body.hosts, slotsPerHost: body.slots_per_host, limit: body.limit, + maxHosts: + mode === "live" + ? await deps.plans?.currentLimit(organizationId, "schedulingSearchHosts") + : null, }); return c.json( { @@ -1646,6 +1686,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA body: z.output, idempotencyKey: string | undefined, ) => ({ + gateRouting: true, eventType: body.event_type, startMs: instant(body.start, "start"), host: body.host, @@ -1836,6 +1877,7 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA ); router.openapi(previewSeriesRoute, async (c) => { + await requireRouting(c.var.auth, deps.plans, c.req.valid("json").attributes); const data = await previewSeries( c.var.auth, seriesRequest(c.req.valid("json"), undefined), @@ -2062,12 +2104,14 @@ export function createSchedulingRoutes(configured: SchedulingDeps | null): OpenA readOnly: body.read_only, apiKeyId: c.var.auth.apiKeyId, publicUrl: deps.publicUrl ?? null, + plans: deps.plans, }); return c.json(sessionBody(session), 201); }); router.openapi(createBookerSessionRoute, async (c) => { const body = c.req.valid("json"); + await requireRouting(c.var.auth, deps.plans, body.attributes); const session = await createBookerSession( c.var.auth, { diff --git a/apps/api/src/routes/sign.ts b/apps/api/src/routes/sign.ts index 7049c341e..1ea37c9ab 100644 --- a/apps/api/src/routes/sign.ts +++ b/apps/api/src/routes/sign.ts @@ -92,6 +92,10 @@ const documentNotFound = errorDoc( const requestNotFound = errorDoc( "No signature request with this id exists in the API key's environment (`not_found`).", ); +const heldBilling = errorDoc( + "In live environments: an overdue invoice or spend limit (`billing_hold`), or the project's budget " + + "(`budget_exceeded`).", +); const unavailable = errorDoc("Sign is temporarily unavailable (`sign_unavailable`)."); export const uploadSignDocumentRoute = createRoute({ @@ -213,6 +217,7 @@ export const createSignRequestRoute = createRoute({ }, }, responses: { + 402: heldBilling, 201: { description: "The signature request (`sent`, or `draft`). A replay returns the original request in its current state.", @@ -287,6 +292,7 @@ export const sendSignRequestRoute = createRoute({ middleware: [requireApiKey({ sign: ["send"] }), rateLimit("api.write")], request: { params: RequestIdParams }, responses: { + 402: heldBilling, 200: jsonDoc("The sent request.", SignRequestObject), ...commonErrors, 404: requestNotFound, diff --git a/apps/api/test/scheduling-plans.test.ts b/apps/api/test/scheduling-plans.test.ts new file mode 100644 index 000000000..ec390aa9f --- /dev/null +++ b/apps/api/test/scheduling-plans.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, test } from "bun:test"; +import { BillingError, createPlanGate, createUsageGate, placeHold } from "@coss/billing"; +import type { UsageGate } from "@coss/billing/gate"; +import { createTestContract } from "@coss/billing/testing"; +import { db, pool } from "@coss/db"; +import { createForm, type FormEnvironment } from "@coss/forms"; +import { CreateFormRequest } from "@coss/forms/schemas"; +import { testFormChecks } from "@coss/forms/testing"; +import { memoryLimiter } from "@coss/ratelimit/testing"; +import { chargeDueHostFees, countLiveHosts, createBooking, createHost } from "@coss/scheduling"; +import { testSchedulingDeps } from "@coss/scheduling/testing"; +import { buildTestPdfBase64 } from "@coss/sign/testing"; +import { createTestTenant, type TestTenant } from "@coss/tenancy/testing"; +import { apiRequest, createTestApp, errorOf, type TestApp } from "./helpers.ts"; + +const MONDAY = "2031-01-06"; +const at = (time: string) => `${MONDAY}T${time}:00.000Z`; +const FORMS_URL = "http://forms.coss.test"; + +function call(app: TestApp, key: string, method: string, path: string, body?: unknown) { + return Promise.resolve( + app.request(`/v1/scheduling${path}`, { + method, + headers: { + Authorization: `Bearer ${key}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }), + ); +} + +const hostBody = (uid: string) => ({ uid, name: `Host ${uid}`, time_zone: "UTC" }); + +const attendee = { uid: "patient_1", name: "Sam Lee", email: "sam@example.com", time_zone: "UTC" }; + +function enforced(extra: Parameters[0] = {}) { + return createTestApp({ plans: createPlanGate(), billing: createUsageGate(), ...extra }).app; +} + +async function onPlan(plan: "pro" | "scale"): Promise { + const tenant = await createTestTenant(); + await createTestContract( + tenant.organization.id, + { version: 1, plan }, + { effectiveFrom: new Date("2026-01-01T00:00:00Z") }, + ); + return tenant; +} + +/** A host with hours and a one-on-one event type, in the key's environment. */ +async function bookable(app: TestApp, key: string) { + expect((await call(app, key, "POST", "/hosts", hostBody("h1"))).status).toBe(201); + const schedule = await call(app, key, "POST", "/hosts/h1/schedules", { + rules: [{ days: [0, 1, 2, 3, 4, 5, 6], start: "09:00", end: "17:00" }], + }); + expect(schedule.status).toBe(201); + const created = await call(app, key, "POST", "/event_types", { + title: "Consultation", + duration_minutes: 30, + hosts: [{ host: "h1" }], + }); + expect(created.status).toBe(201); + return ((await created.json()) as { id: string }).id; +} + +describe("Scheduling's plan gates", () => { + test("Free has 3 live hosts across environments; sandbox has no limit", async () => { + const tenant = await createTestTenant(); + const app = enforced(); + for (const uid of ["a", "b", "c"]) { + expect((await call(app, tenant.liveKey, "POST", "/hosts", hostBody(uid))).status).toBe(201); + } + const refused = await call(app, tenant.liveKey, "POST", "/hosts", hostBody("d")); + expect(refused.status).toBe(402); + expect(await errorOf(refused)).toMatchObject({ + type: "billing_error", + code: "plan_limit_reached", + message: expect.stringContaining("3 scheduling hosts"), + }); + for (const uid of ["a", "b", "c", "d", "e"]) { + expect((await call(app, tenant.sandboxKey, "POST", "/hosts", hostBody(uid))).status).toBe( + 201, + ); + } + }); + + test("deleting a live host makes room for another", async () => { + const tenant = await createTestTenant(); + const app = enforced(); + for (const uid of ["a", "b", "c"]) { + await call(app, tenant.liveKey, "POST", "/hosts", hostBody(uid)); + } + expect((await call(app, tenant.liveKey, "DELETE", "/hosts/a")).status).toBe(200); + expect((await call(app, tenant.liveKey, "POST", "/hosts", hostBody("d"))).status).toBe(201); + }); + + test("Scale has no host limit past what it includes", async () => { + const tenant = await onPlan("scale"); + const app = enforced(); + for (const uid of ["a", "b", "c", "d"]) { + expect((await call(app, tenant.liveKey, "POST", "/hosts", hostBody(uid))).status).toBe(201); + } + }); + + test("concurrent creates can't pass the limit, and the gate isn't asked under the lock", async () => { + const tenant = await createTestTenant(); + const gate = createPlanGate(); + let asked = 0; + const app = enforced({ + plans: { + ...gate, + requireCapacity: async (...args) => { + asked++; + return gate.requireCapacity(...args); + }, + }, + }); + const results = await Promise.all( + ["a", "b", "c", "d", "e"].map((uid) => + call(app, tenant.liveKey, "POST", "/hosts", hostBody(uid)), + ), + ); + expect(results.filter((res) => res.status === 201)).toHaveLength(3); + expect(results.filter((res) => res.status === 402)).toHaveLength(2); + expect(await countLiveHosts(tenant.organization.id)).toBe(3); + // Only the refused ones: a create that fits never takes a second connection under the lock. + expect(asked).toBe(2); + }); + + test("resending an unchanged host filter isn't a change, whatever its key order", async () => { + const free = await createTestTenant(); + const { app: unenforced } = createTestApp(); + const created = await call(unenforced, free.liveKey, "POST", "/event_types", { + title: "Pool", + kind: "pool", + duration_minutes: 30, + hosts: [], + host_filter: { language: "es", specialty: "dermatology" }, + }); + expect(created.status).toBe(201); + const { id } = (await created.json()) as { id: string }; + const app = enforced(); + const edit = (host_filter: Record) => + call(app, free.liveKey, "PATCH", `/event_types/${id}`, { host_filter, title: "Renamed" }); + // jsonb stores shorter keys first, so this is another order than the one read back. + expect((await edit({ specialty: "dermatology", language: "es" })).status).toBe(200); + expect((await edit({ language: "es", specialty: "dermatology" })).status).toBe(200); + expect((await edit({ language: "fr", specialty: "dermatology" })).status).toBe(402); + }); + + test("an empty host_filter is a filter: it matches every active host", async () => { + const pro = await onPlan("pro"); + const app = enforced(); + const res = await call(app, pro.liveKey, "POST", "/event_types", { + title: "Pool", + kind: "pool", + duration_minutes: 30, + hosts: [], + host_filter: {}, + }); + expect(res.status).toBe(402); + }); + + test("a retry of a key already used meets no plan gate", async () => { + const scale = await onPlan("scale"); + const app = enforced(); + const eventType = await bookable(app, scale.liveKey); + const body = { event_type: eventType, start: at("09:00"), attendee }; + const keyed = (target: TestApp, key: string, payload: object) => + target.request("/v1/scheduling/holds", { + method: "POST", + headers: { + Authorization: `Bearer ${scale.liveKey}`, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: JSON.stringify(payload), + }); + expect((await keyed(app, "hold-key", body)).status).toBe(201); + // The plan no longer has it, as after a downgrade. + const lower = enforced({ + plans: { + ...createPlanGate(), + hasFeature: async () => false, + requireFeature: async () => { + throw new BillingError("plan_feature_unavailable", "Not on this plan."); + }, + }, + }); + const replay = await keyed(lower, "hold-key", body); + expect(replay.status).toBe(201); + expect(replay.headers.get("Idempotent-Replayed")).toBe("true"); + expect((await keyed(lower, "another-key", { ...body, start: at("10:00") })).status).toBe(402); + }); + + test("an expired key makes something new, so it meets the plan", async () => { + const free = await createTestTenant(); + const app = enforced(); + const eventType = await bookable(app, free.liveKey); + const body = { event_type: eventType, start: at("09:00"), attendee }; + const withKey = (path: string, payload: object) => + app.request(`/v1/scheduling${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${free.liveKey}`, + "Content-Type": "application/json", + "Idempotency-Key": "old-key", + }, + body: JSON.stringify(payload), + }); + expect((await withKey("/bookings", body)).status).toBe(201); + await pool.query( + "update scheduling_booking set created_at = now() - interval '2 days' where organization_id = $1 and idempotency_key = 'old-key'", + [free.organization.id], + ); + expect((await withKey("/holds", { ...body, start: at("10:00") })).status).toBe(402); + }); + + test("the API's retry of a request made before the gate existed still replays", async () => { + const tenant = await onPlan("scale"); + const app = enforced(); + const eventType = await bookable(app, tenant.liveKey); + const start = at("09:00"); + // What a pre-deploy request hashed: no `gateRouting` in it. + const { booking } = await createBooking( + { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live", + }, + { eventType, startMs: new Date(start).getTime(), attendee, idempotencyKey: "pre-deploy" }, + testSchedulingDeps(), + { kind: "api_key", id: "key_test" }, + ); + const replay = await app.request("/v1/scheduling/bookings", { + method: "POST", + headers: { + Authorization: `Bearer ${tenant.liveKey}`, + "Content-Type": "application/json", + "Idempotency-Key": "pre-deploy", + }, + body: JSON.stringify({ event_type: eventType, start, attendee }), + }); + expect(replay.status).toBe(201); + expect(replay.headers.get("Idempotent-Replayed")).toBe("true"); + expect(((await replay.json()) as { id: string }).id).toBe(booking.id); + }); + + test("the embedded host portal needs Scale", async () => { + const free = await createTestTenant(); + const app = enforced(); + await call(app, free.liveKey, "POST", "/hosts", hostBody("a")); + const refused = await call(app, free.liveKey, "POST", "/hosts/a/sessions", {}); + expect(refused.status).toBe(402); + expect(await errorOf(refused)).toMatchObject({ + code: "plan_feature_unavailable", + message: expect.stringContaining("Scale"), + }); + + await call(app, free.sandboxKey, "POST", "/hosts", hostBody("a")); + expect((await call(app, free.sandboxKey, "POST", "/hosts/a/sessions", {})).status).toBe(201); + + const scale = await onPlan("scale"); + await call(app, scale.liveKey, "POST", "/hosts", hostBody("a")); + expect((await call(app, scale.liveKey, "POST", "/hosts/a/sessions", {})).status).toBe(201); + }); + + test("holds need a paid plan; what they hold still confirms", async () => { + const free = await createTestTenant(); + const app = enforced(); + const eventType = await bookable(app, free.liveKey); + const refused = await call(app, free.liveKey, "POST", "/holds", { + event_type: eventType, + start: at("09:00"), + attendee, + }); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).message).toContain("Pro"); + + const pro = await onPlan("pro"); + const proEvent = await bookable(app, pro.liveKey); + const held = await call(app, pro.liveKey, "POST", "/holds", { + event_type: proEvent, + start: at("09:00"), + attendee, + }); + expect(held.status).toBe(201); + }); + + test("attribute routing needs Scale, in live environments", async () => { + const pro = await onPlan("pro"); + const app = enforced(); + const eventType = await bookable(app, pro.liveKey); + const onHold = await call(app, pro.liveKey, "POST", "/holds", { + event_type: eventType, + start: at("09:00"), + attendee, + attributes: { specialty: "dermatology" }, + }); + expect(onHold.status).toBe(402); + expect((await errorOf(onHold)).code).toBe("plan_feature_unavailable"); + const attributes = encodeURIComponent('{"specialty":"dermatology"}'); + const explained = await call( + app, + pro.liveKey, + "GET", + `/event_types/${eventType}/slots/explain?start=${at("09:00")}&attributes=${attributes}`, + ); + expect(explained.status).toBe(402); + const filtered = await call(app, pro.liveKey, "POST", "/event_types", { + title: "Pool", + kind: "pool", + duration_minutes: 30, + hosts: [], + host_filter: { specialty: "dermatology" }, + }); + expect(filtered.status).toBe(402); + const booked = await call(app, pro.liveKey, "POST", "/bookings", { + event_type: eventType, + start: at("09:00"), + attendee, + attributes: { specialty: "dermatology" }, + }); + expect(booked.status).toBe(402); + expect((await errorOf(booked)).code).toBe("plan_feature_unavailable"); + const plain = await call(app, pro.liveKey, "POST", "/bookings", { + event_type: eventType, + start: at("09:00"), + attendee, + }); + expect(plain.status).toBe(201); + }); + + test("a group session's seats are limited by plan, and an existing size can stay", async () => { + const free = await createTestTenant(); + const app = enforced(); + await call(app, free.liveKey, "POST", "/hosts", hostBody("a")); + const group = (capacity: number) => ({ + title: "Class", + kind: "group", + capacity, + duration_minutes: 60, + hosts: [{ host: "a" }], + }); + const refused = await call(app, free.liveKey, "POST", "/event_types", group(11)); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).message).toContain("10 seats per group session"); + const created = await call(app, free.liveKey, "POST", "/event_types", group(10)); + expect(created.status).toBe(201); + const { id } = (await created.json()) as { id: string }; + expect( + (await call(app, free.liveKey, "PATCH", `/event_types/${id}`, { capacity: 11 })).status, + ).toBe(402); + expect( + (await call(app, free.liveKey, "PATCH", `/event_types/${id}`, { capacity: 8 })).status, + ).toBe(200); + }); + + test("a live search covers the plan's hosts and says there were more", async () => { + const pro = await onPlan("pro"); + const app = enforced(); + const uids = Array.from({ length: 3 }, (_, index) => `h${index}`); + for (const uid of uids) { + await call(app, pro.liveKey, "POST", "/hosts", hostBody(uid)); + await call(app, pro.liveKey, "POST", `/hosts/${uid}/schedules`, { + rules: [{ days: [0, 1, 2, 3, 4, 5, 6], start: "09:00", end: "17:00" }], + }); + } + const created = await call(app, pro.liveKey, "POST", "/event_types", { + title: "Pool", + kind: "pool", + duration_minutes: 30, + hosts: uids.map((host) => ({ host })), + }); + const { id } = (await created.json()) as { id: string }; + const search = async () => { + const res = await call(app, pro.liveKey, "POST", "/availability/search", { + event_type: id, + start: at("09:00"), + end: at("12:00"), + }); + expect(res.status).toBe(200); + return (await res.json()) as { data: unknown[]; has_more: boolean }; + }; + expect(await search()).toMatchObject({ has_more: false }); + expect((await search()).data).toHaveLength(3); + + // The same search with the plan's limit below the pool. + const small = enforced({ plans: { ...createPlanGate(), currentLimit: async () => 2 } }); + const res = await call(small, pro.liveKey, "POST", "/availability/search", { + event_type: id, + start: at("09:00"), + end: at("12:00"), + }); + const cut = (await res.json()) as { data: unknown[]; has_more: boolean }; + expect(cut.data).toHaveLength(2); + expect(cut.has_more).toBe(true); + }); + + test("live slot lists are limited per organization at the plan's rate", async () => { + const tenant = await createTestTenant(); + const app = enforced({ rateLimiter: memoryLimiter() }); + const eventType = await bookable(app, tenant.liveKey); + const path = `/event_types/${eventType}/slots?start=${at("09:00")}&end=${at("10:00")}`; + let status = 200; + for (let index = 0; index < 60 && status === 200; index++) { + status = (await call(app, tenant.liveKey, "GET", path)).status; + } + expect(status).toBe(429); + }); + + test("pages show the attribution below Scale, and not on it", async () => { + const app = enforced(); + const sessionOf = async (tenant: TestTenant, key = tenant.liveKey) => { + const eventType = await bookable(app, key); + const res = await call(app, key, "POST", "/booker_sessions", { + event_type: eventType, + }); + const { token } = (await res.json()) as { token: string }; + const session = await app.request("/scheduling/client/session", { + headers: { Authorization: `Bearer ${token}` }, + }); + return (await session.json()) as { coss_attribution: boolean }; + }; + expect((await sessionOf(await createTestTenant())).coss_attribution).toBe(true); + expect((await sessionOf(await onPlan("scale"))).coss_attribution).toBe(false); + // A sandbox has everything on every plan. + const sandbox = await createTestTenant(); + expect((await sessionOf(sandbox, sandbox.sandboxKey)).coss_attribution).toBe(false); + }); + + test("a scheduled downgrade changes what's in use only when it applies", async () => { + const tenant = await createTestTenant(); + await createTestContract( + tenant.organization.id, + { version: 1, plan: "scale" }, + { + effectiveFrom: new Date("2026-01-01T00:00:00Z"), + effectiveUntil: new Date("2099-01-01T00:00:00Z"), + }, + ); + await createTestContract( + tenant.organization.id, + { version: 1, plan: "pro" }, + { effectiveFrom: new Date("2099-01-01T00:00:00Z") }, + ); + const app = enforced(); + const gate = createPlanGate(); + expect(await gate.currentLimit(tenant.organization.id, "schedulingSearchHosts")).toBe(500); + expect(await gate.limit(tenant.organization.id, "schedulingSearchHosts")).toBe(25); + + const eventType = await bookable(app, tenant.liveKey); + const slots = await call( + app, + tenant.liveKey, + "GET", + `/event_types/${eventType}/slots?start=${at("09:00")}&end=${at("10:00")}&attributes=${encodeURIComponent('{"language":"es"}')}`, + ); + expect(slots.status).not.toBe(402); + expect((await call(app, tenant.liveKey, "POST", "/hosts/h1/sessions", {})).status).toBe(201); + // Setting up what the lower plan lacks is refused already: it would block the downgrade. + const filtered = await call(app, tenant.liveKey, "POST", "/event_types", { + title: "Pool", + kind: "pool", + duration_minutes: 30, + hosts: [], + host_filter: { language: "es" }, + }); + expect(filtered.status).toBe(402); + }); + + test("a scheduled downgrade keeps the attribution off until it applies", async () => { + const tenant = await createTestTenant(); + await createTestContract( + tenant.organization.id, + { version: 1, plan: "scale" }, + { + effectiveFrom: new Date("2026-01-01T00:00:00Z"), + effectiveUntil: new Date("2099-01-01T00:00:00Z"), + }, + ); + await createTestContract( + tenant.organization.id, + { version: 1, plan: "pro" }, + { effectiveFrom: new Date("2099-01-01T00:00:00Z") }, + ); + expect(await createPlanGate().hasFeature(tenant.organization.id, "schedulingWhiteLabel")).toBe( + true, + ); + }); +}); + +describe("the host fee", () => { + test("a live host an older task made during a rollout still counts and pays", async () => { + const tenant = await onPlan("scale"); + const app = enforced(); + expect((await call(app, tenant.liveKey, "POST", "/hosts", hostBody("a"))).status).toBe(201); + expect((await call(app, tenant.sandboxKey, "POST", "/hosts", hostBody("b"))).status).toBe(201); + await pool.query( + "update scheduling_host set next_fee_at = null where organization_id = $1 and uid = 'a'", + [tenant.organization.id], + ); + expect(await countLiveHosts(tenant.organization.id)).toBe(0); + const scope = { organizationIds: [tenant.organization.id] }; + await chargeDueHostFees({ now: new Date("2026-12-01T00:00:00Z"), scope }); + expect(await countLiveHosts(tenant.organization.id)).toBe(1); + const { rows } = await pool.query<{ uid: string; next_fee_at: Date | null }>( + "select uid, next_fee_at from scheduling_host where organization_id = $1 order by uid", + [tenant.organization.id], + ); + expect(rows[0]?.next_fee_at).toEqual(new Date("2027-01-01T00:00:00Z")); + // The sandbox host is never given one. + expect(rows[1]?.next_fee_at).toBeNull(); + }); + + test("past a capped plan's allowance a new host is refused, an existing one written off", async () => { + const tenant = await createTestTenant(); + const refusing: UsageGate = { + async reserve() { + throw new BillingError("usage_limit_exceeded", "Past the allowance."); + }, + }; + const env = { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live" as const, + }; + const after = new Date("2027-02-01T00:00:00Z"); + await expect( + createHost( + env, + { uid: "over", name: "Over", time_zone: "UTC" }, + { billing: refusing, now: after }, + ), + ).rejects.toMatchObject({ code: "usage_limit_exceeded" }); + expect(await countLiveHosts(tenant.organization.id)).toBe(0); + }); + + const usage = async (organizationId: string) => { + const { rows } = await pool.query<{ quantity: string }>( + "select coalesce(sum(quantity), 0) as quantity from billing_usage where organization_id = $1 and meter = 'scheduling.hosts'", + [organizationId], + ); + return Number(rows[0]?.quantity); + }; + + test("nothing is charged before the meter is priced, and a day at a time after", async () => { + const tenant = await onPlan("scale"); + const app = enforced(); + await createHost( + { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live", + }, + hostBody("a"), + { now: new Date("2026-12-01T00:00:00Z") }, + ); + await call(app, tenant.sandboxKey, "POST", "/hosts", hostBody("sandbox")); + const scope = { organizationIds: [tenant.organization.id] }; + expect(await chargeDueHostFees({ now: new Date("2026-12-31T23:00:00Z"), scope })).toBe(0); + expect(await usage(tenant.organization.id)).toBe(0); + // The first day of the priced card, once; the sandbox host is never charged. + expect(await chargeDueHostFees({ now: new Date("2027-01-01T00:00:01Z"), scope })).toBe(1); + expect(await chargeDueHostFees({ now: new Date("2027-01-01T00:00:02Z"), scope })).toBe(0); + expect(await chargeDueHostFees({ now: new Date("2027-01-02T00:00:01Z"), scope })).toBe(1); + expect(await usage(tenant.organization.id)).toBe(2); + }); +}); + +describe("holds reach every new live usage", () => { + const held = () => onPlan("scale"); + const hold = (tenant: TestTenant) => + db.transaction((tx) => placeHold(tx, tenant.organization.id, "delinquent", "Overdue")); + + test("new bookings and series are refused; holds already made still confirm", async () => { + const tenant = await held(); + const app = enforced(); + const eventType = await bookable(app, tenant.liveKey); + const earlier = await call(app, tenant.liveKey, "POST", "/holds", { + event_type: eventType, + start: at("10:00"), + attendee, + }); + expect(earlier.status).toBe(201); + const { id } = (await earlier.json()) as { id: string }; + await hold(tenant); + + const refused = await call(app, tenant.liveKey, "POST", "/bookings", { + event_type: eventType, + start: at("09:00"), + attendee, + }); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).code).toBe("billing_hold"); + expect( + ( + await call(app, tenant.liveKey, "POST", "/holds", { + event_type: eventType, + start: at("11:00"), + attendee, + }) + ).status, + ).toBe(402); + expect((await call(app, tenant.liveKey, "POST", `/holds/${id}/confirm`, {})).status).toBe(200); + }); + + test("a retry of what was already made still answers with it", async () => { + const tenant = await held(); + const app = enforced(); + const eventType = await bookable(app, tenant.liveKey); + const keyed = (path: string, body: object, key: string) => + app.request(`/v1/scheduling${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${tenant.liveKey}`, + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: JSON.stringify(body), + }); + const booking = { event_type: eventType, start: at("09:00"), attendee }; + const series = { + event_type: eventType, + start: at("12:00"), + attendee, + recurrence: { frequency: "weekly", interval: 1, count: 2 }, + }; + const made = await keyed("/bookings", booking, "booking-key"); + expect(made.status).toBe(201); + const madeSeries = await keyed("/series", series, "series-key"); + expect(madeSeries.status).toBe(201); + await hold(tenant); + const again = await keyed("/bookings", booking, "booking-key"); + expect(again.status).toBe(201); + expect(again.headers.get("Idempotent-Replayed")).toBe("true"); + const seriesAgain = await keyed("/series", series, "series-key"); + expect(seriesAgain.status).toBe(201); + expect(seriesAgain.headers.get("Idempotent-Replayed")).toBe("true"); + expect((await keyed("/bookings", { ...booking, start: at("10:00") }, "new-key")).status).toBe( + 402, + ); + }); + + test("new live hosts are refused: they would be a new monthly charge", async () => { + const tenant = await held(); + const app = enforced(); + await hold(tenant); + const refused = await call(app, tenant.liveKey, "POST", "/hosts", hostBody("a")); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).code).toBe("billing_hold"); + expect((await call(app, tenant.sandboxKey, "POST", "/hosts", hostBody("a"))).status).toBe(201); + }); + + test("the person on the booking page doesn't hear about the customer's billing", async () => { + const tenant = await held(); + const app = enforced(); + const eventType = await bookable(app, tenant.liveKey); + const session = await call(app, tenant.liveKey, "POST", "/booker_sessions", { + event_type: eventType, + }); + const { token } = (await session.json()) as { token: string }; + await hold(tenant); + const res = await app.request("/scheduling/client/bookings", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ start: at("09:00"), attendee }), + }); + expect(res.status).toBe(403); + const error = await errorOf(res); + expect(error.code).toBe("account_suspended"); + expect(error.message).not.toMatch(/invoice|spend|budget/i); + }); + + test("a sandbox keeps booking", async () => { + const tenant = await held(); + const app = enforced(); + const eventType = await bookable(app, tenant.sandboxKey); + await hold(tenant); + expect( + ( + await call(app, tenant.sandboxKey, "POST", "/bookings", { + event_type: eventType, + start: at("09:00"), + attendee, + }) + ).status, + ).toBe(201); + }); + + test("hosted forms close to live submissions, and look closed", async () => { + const tenant = await held(); + const env: FormEnvironment = { + organizationId: tenant.organization.id, + projectId: tenant.project.id, + environmentId: tenant.production.id, + mode: "live", + }; + const form = await createForm(env, CreateFormRequest.parse({ name: "Contact" }), { + links: { publicUrl: FORMS_URL }, + checks: testFormChecks(), + }); + const { app } = createTestApp(); + const post = () => + app.fetch( + new Request(`${FORMS_URL}/f/${form.id}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "text/html,application/xhtml+xml,*/*;q=0.8", + Origin: "https://www.example.com", + }, + body: new URLSearchParams({ email: "ada@example.com", message: "Hi" }).toString(), + }), + ); + expect((await post()).status).toBe(200); + await hold(tenant); + const refused = await post(); + expect(refused.status).toBe(404); + expect(await refused.text()).not.toMatch(/invoice|spend|billing/i); + }); + + test("new live inboxes are refused", async () => { + const tenant = await held(); + const { app } = createTestApp(); + await hold(tenant); + const refused = await apiRequest(app, "POST", "/v1/inboxes", { + key: tenant.liveKey, + body: { username: "support" }, + }); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).code).toBe("billing_hold"); + }); + + test("a retry of a signature request, and sending one already sent, still answer", async () => { + const tenant = await held(); + const { app } = createTestApp(); + const body = { + title: "NDA", + documents: [{ filename: "nda.pdf", content: await buildTestPdfBase64() }], + signers: [ + { + name: "Ada Lovelace", + email: "ada@example.com", + fields: [{ type: "signature", anchor: "{{signature:signer_1}}" }], + }, + ], + }; + const send = () => + apiRequest(app, "POST", "/v1/sign/requests", { + key: tenant.liveKey, + headers: { "Idempotency-Key": "sign-key" }, + body, + }); + const made = await send(); + expect(made.status).toBe(201); + const { id } = (await made.json()) as { id: string }; + await hold(tenant); + const again = await send(); + expect(again.status).toBeLessThan(300); + expect(((await again.json()) as { id: string }).id).toBe(id); + const sentAgain = await apiRequest(app, "POST", `/v1/sign/requests/${id}/send`, { + key: tenant.liveKey, + body: {}, + }); + expect(sentAgain.status).toBe(200); + }); + + test("new live signature requests are refused", async () => { + const tenant = await held(); + const { app } = createTestApp(); + await hold(tenant); + const refused = await apiRequest(app, "POST", "/v1/sign/requests", { + key: tenant.liveKey, + body: { + title: "NDA", + documents: [{ filename: "nda.pdf", content: await buildTestPdfBase64() }], + signers: [ + { + name: "Ada Lovelace", + email: "ada@example.com", + fields: [{ type: "signature", anchor: "{{signature:signer_1}}" }], + }, + ], + }, + }); + expect(refused.status).toBe(402); + expect((await errorOf(refused)).code).toBe("billing_hold"); + }); +}); diff --git a/apps/scheduling/AGENTS.md b/apps/scheduling/AGENTS.md index 2e436735f..b6a674881 100644 --- a/apps/scheduling/AGENTS.md +++ b/apps/scheduling/AGENTS.md @@ -14,7 +14,8 @@ would make it same-site with the dashboard). Product rules are in `packages/sche `packages/sdk/src/scheduling.ts`. - Times are UTC instants from the API, shown in the browser's zone. This app never works out what's available. - A page is the customer's: their logo or name (`BrandMark`) and accent (`useAccentColor`, which also picks the text - color), and nothing of coss.com's. The logo arrives as a data URL in `/session`, so the CSP stays free of other hosts. + color), and nothing of coss.com's but `CossAttribution` ("Scheduling by coss.com", a plain link below the page, on + plans without `schedulingWhiteLabel`: never an image or anything else that loads). The logo arrives as a data URL in `/session`, so the CSP stays free of other hosts. - The Google button (`continue-with-google.tsx`) follows Google's branding rules, and its OAuth app passed verification: keep the white fill, the `#747775` border, the `#1F1F1F` Google Sans Medium 14/20 text, the padding and the logo. Its height and corners match the buttons beside it, which the rules leave open. diff --git a/apps/scheduling/src/app/b/[sessionId]/booker.tsx b/apps/scheduling/src/app/b/[sessionId]/booker.tsx index 36c3cb4b7..2a6e0f8af 100644 --- a/apps/scheduling/src/app/b/[sessionId]/booker.tsx +++ b/apps/scheduling/src/app/b/[sessionId]/booker.tsx @@ -29,6 +29,7 @@ import { EventTypeDescription } from "@/components/event-type-description"; import { BrandMark, BrokenLink, + CossAttribution, Detail, DetailList, LoadingPanel, @@ -222,6 +223,7 @@ function Confirmed(props: { session: Session; done: Done; when: string; timeZone + ); } @@ -515,6 +517,7 @@ export function Booker({ sessionId, apiUrl }: { sessionId: string; apiUrl: strin )} + ); } diff --git a/apps/scheduling/src/app/h/[sessionId]/host-portal.tsx b/apps/scheduling/src/app/h/[sessionId]/host-portal.tsx index d72f8ee79..efa285b01 100644 --- a/apps/scheduling/src/app/h/[sessionId]/host-portal.tsx +++ b/apps/scheduling/src/app/h/[sessionId]/host-portal.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { BrandMark, BrokenLink, + CossAttribution, LoadingPanel, PageTitle, Panel, @@ -75,6 +76,7 @@ export function HostPortal({ sessionId, apiUrl }: { sessionId: string; apiUrl: s + ); } diff --git a/apps/scheduling/src/app/m/[sessionId]/manage.tsx b/apps/scheduling/src/app/m/[sessionId]/manage.tsx index 20df0a3f3..6062176c3 100644 --- a/apps/scheduling/src/app/m/[sessionId]/manage.tsx +++ b/apps/scheduling/src/app/m/[sessionId]/manage.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useState } from "react"; import { BrandMark, BrokenLink, + CossAttribution, Detail, DetailList, LoadingPanel, @@ -217,6 +218,7 @@ export function Manage({ sessionId, apiUrl }: { sessionId: string; apiUrl: strin ) : null} + ); } diff --git a/apps/scheduling/src/components/page.tsx b/apps/scheduling/src/components/page.tsx index 40c38ff9e..3bab9a277 100644 --- a/apps/scheduling/src/components/page.tsx +++ b/apps/scheduling/src/components/page.tsx @@ -8,7 +8,24 @@ import type { Branding } from "@/lib/branding"; const LOGO_MAX_WIDTH = 180; const LOGO_MAX_HEIGHT = 40; -/** The customer's logo, else their name; nothing of coss.com's on a page that's theirs. */ +/** "Scheduling by coss.com", below the page, on plans without the white label. */ +export function CossAttribution({ branding }: { branding: Branding | null | undefined }) { + if (!branding?.coss_attribution) return null; + return ( +

+ + Scheduling by coss.com + +

+ ); +} + +/** The customer's logo, else their name; the attribution above is the only mark of coss.com's. */ export function BrandMark({ branding }: { branding: Branding }) { const { logo, brand_name: name } = branding; if (logo) { diff --git a/apps/scheduling/src/lib/branding.ts b/apps/scheduling/src/lib/branding.ts index 6203049ef..0e971935a 100644 --- a/apps/scheduling/src/lib/branding.ts +++ b/apps/scheduling/src/lib/branding.ts @@ -7,6 +7,8 @@ export type Branding = { brand_name: string | null; accent_color: string | null; logo: Logo | null; + /** The plan shows "Scheduling by coss.com" on its pages. */ + coss_attribution?: boolean; }; /** The customer's accent color, with text that reads on it. A white page would hide a pale one. */ diff --git a/apps/scheduling/test/attribution.test.tsx b/apps/scheduling/test/attribution.test.tsx new file mode 100644 index 000000000..41187d701 --- /dev/null +++ b/apps/scheduling/test/attribution.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CossAttribution } from "../src/components/page.tsx"; + +const branding = { brand_name: "Acme", accent_color: null, logo: null }; + +describe("CossAttribution", () => { + test("shows on plans without the white label, as a link that sends no referrer", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Scheduling by coss.com"); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).not.toContain(" { + for (const value of [{ ...branding, coss_attribution: false }, branding, null, undefined]) { + expect(renderToStaticMarkup()).toBe(""); + } + }); +}); diff --git a/apps/site/content/docs/(platform)/billing.mdx b/apps/site/content/docs/(platform)/billing.mdx index 7427d6d41..87820dcc4 100644 --- a/apps/site/content/docs/(platform)/billing.mdx +++ b/apps/site/content/docs/(platform)/billing.mdx @@ -85,13 +85,15 @@ after removing it. On a contract, add-ons are part of the contract. - On **Free**, live sends past the month's allowance are refused with a `402` `billing_error` (`usage_limit_exceeded`) until the next month or an upgrade, and so are new live inboxes past - its inbox-days. Received mail past its allowance is dropped (and counted on **Received emails**). - Once only an attachment or received data allowance is used up, emails within their included 1 MB - still go through. + its inbox-days and new live Scheduling hosts past its host-days. Received mail past its allowance + is dropped (and counted on **Received emails**). Once only an attachment or received data + allowance is used up, emails within their included 1 MB still go through. - If an invoice can't be collected, we retry it and email your billing contacts. Once it's overdue past the grace period (14 days unless your contract says otherwise), live sending is - paused (`402`, `billing_hold`) until it's paid. Sandbox keys keep working, and scheduled emails - wait rather than fail. + paused (`402`, `billing_hold`) until it's paid. So are new live bookings, Scheduling hosts, signature + requests and inboxes, and hosted forms stop accepting submissions. Sandbox keys keep working, scheduled emails + wait rather than fail, and bookings already made stay. Mail still arrives, and monthly charges for + inboxes, hosts and phone numbers you keep continue. - Owners can set a **spend limit**: when usage beyond your credit reaches it, live sending pauses until the next month or until the limit is raised. Usage alerts go out at 80% and 100% of your credit (owners can change the thresholds). diff --git a/apps/site/content/docs/(platform)/hipaa.mdx b/apps/site/content/docs/(platform)/hipaa.mdx index c5b0fe302..941f94e53 100644 --- a/apps/site/content/docs/(platform)/hipaa.mdx +++ b/apps/site/content/docs/(platform)/hipaa.mdx @@ -15,13 +15,13 @@ Don't send PHI to coss.com without a signed Business Associate Agreement. Our ## Turning it on -You can't turn HIPAA mode on yourself, by any key or role. Ask for it from **Settings → Compliance** +HIPAA mode is contract-only: it comes with an Enterprise contract and isn't available on self-serve +plans. You can't turn it on yourself, by any key or role. Ask for it from **Settings → Compliance** in the dashboard. Then: 1. **You sign a Business Associate Agreement (BAA)** with coss.com, and the mode becomes a line on your contract. -2. **Your plan must include the audit log**, as the plans do, or - your contract must grant it. +2. **Your contract must include the audit log**, as Enterprise does. 3. **coss.com staff turn it on** and record the BAA's date and reference. It applies at once, including to messages still waiting to go out. diff --git a/apps/site/content/docs/(platform)/spend.mdx b/apps/site/content/docs/(platform)/spend.mdx index 73f1f3159..586287b29 100644 --- a/apps/site/content/docs/(platform)/spend.mdx +++ b/apps/site/content/docs/(platform)/spend.mdx @@ -66,7 +66,10 @@ you change them) it emails the billing contacts, and the member it concerns, and start of the next month, or as soon as an owner raises (or removes) the budget. Checks run every few minutes, so spend can pass a budget by what is sent in between. Emails -already accepted before the pause still go out. A budget of $0 allows no billable usage at all. +already accepted before the pause still go out. New live bookings, Scheduling hosts, signature +requests and inboxes in a project over budget are refused too, and its hosted forms stop accepting submissions. Mail +still arrives and monthly charges for what you keep continue, so even a budget of $0 can't stop +those. Budgets sit alongside your organization's **spend limit** (in billing settings), which pauses all live sending once usage beyond your credit reaches it. diff --git a/apps/site/content/docs/scheduling/bookings.mdx b/apps/site/content/docs/scheduling/bookings.mdx index fcc3e0233..c7ac7f7ff 100644 --- a/apps/site/content/docs/scheduling/bookings.mdx +++ b/apps/site/content/docs/scheduling/bookings.mdx @@ -65,7 +65,7 @@ ask why a whole range is empty, without naming a start, use `coss scheduling dia ## Taking payment first -Hold the slot, charge the customer, then confirm. A hold isn't charged and tells nobody. Its slot stays +Hold the slot, charge the customer, then confirm. Live holds need . A hold isn't charged and tells nobody. Its slot stays yours until it expires, though a plan's usage limit can still refuse the confirmation. ```ts twoslash diff --git a/apps/site/content/docs/scheduling/embedding.mdx b/apps/site/content/docs/scheduling/embedding.mdx index 5a43046fc..3d78d1f03 100644 --- a/apps/site/content/docs/scheduling/embedding.mdx +++ b/apps/site/content/docs/scheduling/embedding.mdx @@ -12,6 +12,10 @@ thing, with its token in the URL's fragment, which never reaches a server. | `createForBooker` | Book one event type | 30 minutes by default, up to a day | | `createForBooking` | Reschedule or cancel one booking | Until a day after it ends. Stops when it's cancelled | +In live environments, `createForHost` needs . On other plans, connect +a host's calendars and set their hours through the API. Live pages show "Scheduling by coss.com" below them, except on +. + ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); diff --git a/apps/site/content/docs/scheduling/hosts.mdx b/apps/site/content/docs/scheduling/hosts.mdx index e799dcb20..28558d4db 100644 --- a/apps/site/content/docs/scheduling/hosts.mdx +++ b/apps/site/content/docs/scheduling/hosts.mdx @@ -18,6 +18,10 @@ await coss.scheduling.hosts.create({ }); ``` +Your plan sets how many live hosts your organization can have: on +Free and on Pro. Scale includes +and charges for more. Delete a host to stop counting them. + An `email` is where their notifications go, not an identity: two hosts can share one, and a host needs none. `attributes` are what [pools](/docs/scheduling/pools-and-groups) choose hosts by. diff --git a/apps/site/content/docs/scheduling/pools-and-groups.mdx b/apps/site/content/docs/scheduling/pools-and-groups.mdx index ab5c8d4e5..234e970b4 100644 --- a/apps/site/content/docs/scheduling/pools-and-groups.mdx +++ b/apps/site/content/docs/scheduling/pools-and-groups.mdx @@ -48,6 +48,9 @@ See [Routing](/docs/scheduling/routing) for sending each person to the right kin ## Who's free? For a marketplace's search page: each matching host's soonest slots, soonest first, across up to 500 hosts. +In live environments your plan sets how many hosts one search covers +( on Free, on Pro, + on Scale). `has_more` is `true` when there were more than it looked at. ```ts twoslash import { Coss } from "coss"; @@ -73,7 +76,9 @@ interpreter. Add at most one `member`, and one of a pool is chosen alongside the With `kind: "group"` and a `capacity`, each attendee books a seat in the same session, with a booking and manage link of their own. The host's time is taken once, by the first seat, and given back with the last. A full session answers `slot_unavailable`. A seat moves by cancelling it and booking another session. -Slots of a group event type carry `seats_left`, and the hosted booking page shows it next to each time. +Slots of a group event type carry `seats_left`, and the hosted booking page shows it next to each time. In +live environments your plan sets the largest `capacity`: on +Free, on Pro and on Scale. A session is one meeting. The first seat's `location` is everyone's, so don't pass one attendee's own details in it: a `phone` location only ever shows the number on the event type, and a booking page's seat can't fill one in. diff --git a/apps/site/content/docs/scheduling/routing.mdx b/apps/site/content/docs/scheduling/routing.mdx index fd2f99a5d..8fe495470 100644 --- a/apps/site/content/docs/scheduling/routing.mdx +++ b/apps/site/content/docs/scheduling/routing.mdx @@ -4,7 +4,8 @@ description: Send each person to the right host, such as a specialist or someone --- Describe your hosts with attributes, then ask for what each person needs. Coss.com only offers times, and only -books, among the hosts who match. +books, among the hosts who match. In live environments, `host_filter` and `attributes` need +; sandbox environments have them on every plan. ## Describe your hosts diff --git a/apps/site/content/products/scheduling.mdx b/apps/site/content/products/scheduling.mdx index 2c334aa74..3ec29708d 100644 --- a/apps/site/content/products/scheduling.mdx +++ b/apps/site/content/products/scheduling.mdx @@ -195,9 +195,9 @@ Our [Terms](/legal/terms) don't allow special categories of personal data or pro 10 minutes by default, 1 minute to 24 hours 1 day by default, up to 30 days up to 52 occurrences, weekly or monthly -2 to 10,000 seats +2 to 10,000 seats, by plan up to 62 days -up to 500 hosts +up to 500 hosts, by plan 400 days 25 1 hour by default, at most 24 hours @@ -209,14 +209,29 @@ Our [Terms](/legal/terms) don't allow special categories of personal data or pro -
+
- + The Free plan includes . A group session is one booking however many attend, and each occurrence of a series is one. Rescheduling is free, and a cancelled booking stays charged. Booking emails sent from your own domain are billed like any email, and SMS reminders like any text message. See [pricing](/pricing) for the plans.
+
+ +- **Hosts**: on Free and on Pro. Scale includes and charges for more by the day. +- **Holds** for taking payment first: . +- **The embedded host portal**: . On other plans, set hours and connect calendars through the API. +- **Attribute routing** with `host_filter` and `attributes`: . +- **Seats per group session**: on Free, on Pro and on Scale. +- **Hosts per availability search**: on Free, on Pro and on Scale. +- **Hosted pages without "Scheduling by coss.com"**: . +- **Patient data**: a BAA and [HIPAA mode](/docs/hipaa), by Enterprise contract. + +What you already set up keeps working if you change plans. See [compare plans](/pricing#compare). + +
+
With a sandbox (`coss_test_…`) key, emails go to the fake provider, so nobody's mailed, and the dashboard shows them with their links. Connect a simulated calendar and set its busy times with `coss.scheduling.calendars.setSimulatedBusy`, and test your handlers with `coss trigger scheduling.booking.confirmed`. See [testing](/docs/scheduling/testing). diff --git a/apps/site/content/products/security.mdx b/apps/site/content/products/security.mdx index 0ab66f644..908080573 100644 --- a/apps/site/content/products/security.mdx +++ b/apps/site/content/products/security.mdx @@ -15,6 +15,7 @@ features: - An audit log of every change - Reputation guard and content screening - A published data retention policy + - HIPAA mode with a signed BAA, by contract related: - platform - risk @@ -117,6 +118,20 @@ Every kind of data has a published retention period, enforced by the worker's pu
+
+ +HIPAA mode comes with an Enterprise contract and is turned on by coss.com once the BAA is signed. It isn't available on self-serve plans. The safeguards support your HIPAA compliance, which also depends on how you use coss.com, and the Terms don't allow protected health information without a signed agreement. + +- **No AI on your content**: messages are never sampled or sent to a model, and forms, Risk and Scheduling skip content scores. +- **Less kept**: email content is deleted once each email is finished, SMS bodies aren't kept, tracking is off and request logs keep no bodies. +- **Tighter access**: two-factor is required, idle dashboard sessions end after 15 minutes, and coss.com staff can't view content or impersonate your members. +- **Encrypted delivery**: email goes out only over TLS. A message is dropped rather than delivered unencrypted. +- **What's covered**: **Settings → Compliance** shows the mode's status, your BAA and each vendor's BAA status. + +See [HIPAA mode](/docs/hipaa) for every control, what it refuses and what stays up to you. + +
+
The [Data Processing Addendum](/legal/dpa) is part of the [Terms](/legal/terms): it covers the security measures above, the subprocessors and their locations, and transfers under the EU-U.S. Data Privacy Framework and the Standard Contractual Clauses. The [Privacy Policy](/legal/privacy) and the Acceptable Use Policy say what coss.com collects and what it allows. Vulnerabilities can be reported through the responsible disclosure program at [cal.com/security](https://cal.com/security). diff --git a/apps/site/content/solutions/booking.mdx b/apps/site/content/solutions/booking.mdx index 1bdcfbe0f..ff07f966e 100644 --- a/apps/site/content/solutions/booking.mdx +++ b/apps/site/content/solutions/booking.mdx @@ -220,8 +220,8 @@ Bookings and calendars send [webhooks](/docs/webhooks) with ids, times and statu
- + -The Free plan includes . Booking emails sent from your own domain are billed like any email and SMS reminders like any text message, and live SMS needs a paid plan. Sandbox environments are free. See [pricing](/pricing) for the plans. +The Free plan includes and hosts, and Scale includes . A marketplace's search, attribute routing and embedded host portal need . Booking emails sent from your own domain are billed like any email and SMS reminders like any text message, and live SMS needs a paid plan. Sandbox environments are free. See [pricing](/pricing) for the plans.
diff --git a/apps/site/src/app/(home)/pricing/page.tsx b/apps/site/src/app/(home)/pricing/page.tsx index 09de3f4b8..7c73e8154 100644 --- a/apps/site/src/app/(home)/pricing/page.tsx +++ b/apps/site/src/app/(home)/pricing/page.tsx @@ -114,7 +114,8 @@ export default function PricingPage() {

Annual contracts from {formatPrice(PLANS.enterprise.feeCents)} a month: volume rates and - committed spend, directory sync, invoicing with payment terms and direct debit. + committed spend, directory sync, HIPAA mode with a signed BAA, invoicing with payment + terms and direct debit.