diff --git a/AGENTS.md b/AGENTS.md index 0506d62f2..535cfb6a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -812,7 +812,7 @@ and SMTP credentials, dedicated IP pools for sub-accounts, tracking and receivin endpoint filters by sub-account, budgets per sub-account, a portal for end users, MCP support, rollups of sub-account reputation counts (each evaluation counts its window live), and sub-account tenant pause webhooks, Sign's archival timestamps in seals (PAdES B-LTA: renewing a document timestamp before its TSA certificate expires) and a standalone certificate download, -a price for `sign.completed_requests`, and Sign in the iOS app and the admin console, +Sign in the iOS app and the admin console, Forms in the iOS app, malware scanning of form uploads, conditional or multi-page forms, a drag-and-drop form builder, per-form webhook URLs (endpoints filter by event type), custom domains for hosted forms, sending from inboxes at coss.com's managed inbound domain in live environments (they only receive), inbox diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fdd674d4d..ab41ee0b5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -131,8 +131,8 @@ export type CreateAppOptions = { plans?: PlanGate; /** * Hard usage caps (`createUsageGate`): what a new live inbox reserves, and what a canceled or - * paused broadcast gives back of its reservation. Must match the email service's. Defaults to - * none. + * paused broadcast gives back of its reservation (also the default Sign service's). Must match + * the email service's. Defaults to none. */ billing?: UsageGate; /** The caller's address for per-IP limits (`CLIENT_IP_HEADER`). Defaults to none. */ @@ -361,7 +361,7 @@ export function createApp(opts: CreateAppOptions) { }), signService: opts.signService === undefined - ? createSignService({ store: new FakeDocumentStore() }) + ? createSignService({ store: new FakeDocumentStore(), billing: opts.billing }) : opts.signService, domainDeps, webhookDeps: opts.webhookDeps, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5270009ee..58e84b540 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -112,7 +112,7 @@ const app = createApp({ : null, }, // Documents live in SIGN_DOCUMENTS_BUCKET; production without it has no Sign (never Postgres). - signService: signStore ? createSignService({ store: signStore }) : null, + signService: signStore ? createSignService({ store: signStore, billing: usage }) : null, billing: usage, links: { keys: linkKeys, baseUrl: trackingUrl }, domainDeps: email.domainDeps, diff --git a/apps/api/src/routes/sign.ts b/apps/api/src/routes/sign.ts index 1ea37c9ab..aa7c3ad4c 100644 --- a/apps/api/src/routes/sign.ts +++ b/apps/api/src/routes/sign.ts @@ -1,6 +1,5 @@ import { authorizeLiveSender } from "@coss/email"; import { - cancelSignRequest, createSignSession, getSignDocument, getSignRequest, @@ -12,7 +11,6 @@ import { requestSignReminders, type SignActor, type SignService, - sendSignRequest, toSignSettingsObject, updateSignSettings, } from "@coss/sign"; @@ -633,7 +631,7 @@ export function createSignRoutes({ router.openapi(sendSignRequestRoute, async (c) => { const { id } = c.req.valid("param"); const { organizationId, environmentId } = c.var.auth; - const request = await sendSignRequest({ + const request = await available().send({ organizationId, environmentId, requestId: id, @@ -645,7 +643,7 @@ export function createSignRoutes({ router.openapi(cancelSignRequestRoute, async (c) => { const { id } = c.req.valid("param"); const { organizationId, environmentId } = c.var.auth; - const request = await cancelSignRequest({ + const request = await available().cancel({ organizationId, environmentId, requestId: id, diff --git a/apps/site/content/docs/sign/index.mdx b/apps/site/content/docs/sign/index.mdx index 98ae84633..d9599031d 100644 --- a/apps/site/content/docs/sign/index.mdx +++ b/apps/site/content/docs/sign/index.mdx @@ -67,6 +67,15 @@ sealed, so any later change shows. +## Pricing + +Each completed request is , charged once when its sealed +document exists, however many signers and documents it has. Declined, canceled and expired requests +are free, and so are sandbox environments. The Free plan includes +: a live request counts when it's sent (a canceled one +gives its place back), and past that sending fails with `402 usage_limit_exceeded` until the next +month or an upgrade. See [Billing](/docs/billing). + ## Tools - **CLI**: [`coss sign`](/docs/cli#sign) `upload`, `create`, `send`, `list`, `get`, `remind`, `cancel`, `download`, diff --git a/apps/site/content/docs/sign/testing.mdx b/apps/site/content/docs/sign/testing.mdx index be620224a..3682a3f5a 100644 --- a/apps/site/content/docs/sign/testing.mdx +++ b/apps/site/content/docs/sign/testing.mdx @@ -10,7 +10,10 @@ page, signers sign, and the request is sealed and completed. What's different: provider: read them in the dashboard's emails page instead. - **The signing page says it's a test** ("signing it has no legal effect"), and so does the certificate. -- **Nothing is billed.** +- **The seal is a test seal.** The completed PDF is sealed and verifies, but with a self-signed + certificate ("coss.com Sign (sandbox test seal)"), so PDF readers like Acrobat show the signature + as not trusted. Live requests are sealed with coss.com's trusted certificate. +- **Nothing is billed**, and sandbox requests don't count against the Free plan's requests. - **`allowed_origins` can include `http://localhost`** for embedding while you develop. Test PDFs need nothing special; put anchors like `{{signature:signer_1}}` in them to place fields diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts index a3e30bc3b..2fe4e1b77 100644 --- a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts +++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts @@ -2,7 +2,6 @@ import { authorizeLiveSender } from "@coss/email"; import { - cancelSignRequest, createSignBuilderSession, deleteSignTemplate, requestSignReminders, @@ -73,7 +72,7 @@ export async function cancelSignRequestAction( const environment = await environmentFromForm(context, formData); const requestId = formString(formData, "requestId"); const request = await signed(() => - cancelSignRequest({ + signService().cancel({ organizationId: context.organization.id, environmentId: environment.id, requestId, diff --git a/apps/web/src/lib/sign.ts b/apps/web/src/lib/sign.ts index 4254f01d3..7a8fd40d2 100644 --- a/apps/web/src/lib/sign.ts +++ b/apps/web/src/lib/sign.ts @@ -1,10 +1,13 @@ import "server-only"; +import { createUsageGate } from "@coss/billing"; import { createSignService, type DocumentStore, type SignService } from "@coss/sign"; import { signStoreFromEnv } from "@coss/sign/runtime"; import { env } from "@/env"; import { ActionError } from "@/lib/actions"; let store: DocumentStore | null | undefined; +// The dashboard only cancels requests (giving their reservations back): sending is the API's. +const usage = createUsageGate(); /** Sign's document store (S3, or the fake one outside production); `null` while Sign is off. */ export function signStore(): DocumentStore | null { @@ -16,7 +19,7 @@ export function signStore(): DocumentStore | null { export function signService(): SignService { const current = signStore(); if (!current) throw new ActionError("Sign is temporarily unavailable."); - return createSignService({ store: current }); + return createSignService({ store: current, billing: usage }); } /** Where signing and builder links point (`SIGN_PUBLIC_URL`); unset until the app is deployed. */ diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1be847e55..92b071f19 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -252,8 +252,8 @@ const secretsKeys = parseSecretsKeys(env.WEBHOOK_SECRETS_KEY); // The dashboard accepts local endpoints outside production; let them be reached there too. const send = createWebhookSender({ allowPrivateNetworks: process.env.NODE_ENV !== "production" }); -// Sign: completed requests are sealed here. Production has no document store (nor a certificate) -// yet, so nothing waits there to be sealed. +// Sign: completed requests are sealed here (live ones with the CA's certificate, sandbox ones with a +// self-signed test seal). const signSeal = await sealerFromEnv(env); const signStore = signStoreFromEnv(env); const signCompletion = signSeal && signStore ? { store: signStore, ...signSeal } : null; diff --git a/packages/billing/src/catalog/meters.ts b/packages/billing/src/catalog/meters.ts index f5283d8e6..88a4b024d 100644 --- a/packages/billing/src/catalog/meters.ts +++ b/packages/billing/src/catalog/meters.ts @@ -135,8 +135,8 @@ export const METERS = { dimensions: ["fee"], requires: "plan", }, - // Sign: a signature request every signer signed, once its sealed document exists. Not priced yet: - // until a rate card prices it, invoices show the usage as an uncharged line. + // Sign: a signature request every signer signed, once its sealed document exists (live + // environments only). "sign.completed_requests": { product: "sign", label: "Signature requests", diff --git a/packages/billing/src/catalog/plans.ts b/packages/billing/src/catalog/plans.ts index 15e5c5f5f..f2d4fbe02 100644 --- a/packages/billing/src/catalog/plans.ts +++ b/packages/billing/src/catalog/plans.ts @@ -2,8 +2,8 @@ * The plans. A plan's monthly fee is credit spendable on any product's usage, so a month costs * the larger of the fee and the usage (plus add-ons). Free has no credit but includes 3,000 * emails, 1,000 MB each of attachments and received data past each email's first MB, 1,000 form - * submissions, 1,000 MB of their uploads and 50,000 Hooks messages a month and blocks usage beyond - * its allowance. Every plan includes 100 phone checks, 3 inboxes, Hooks messages, Scheduling hosts + * submissions, 1,000 MB of their uploads, 50,000 Hooks messages and 5 completed signature requests + * a month and blocks usage beyond its allowance. Every plan includes 100 phone checks, 3 inboxes, Hooks messages, Scheduling hosts * and Risk assessments a month (past Free's, assessments go on in a reduced form instead of * stopping: `packages/risk/AGENTS.md`). */ @@ -84,6 +84,8 @@ export const PLANS = { "forms.submissions": 1_000, "forms.file_uploads": 1_000, "scheduling.bookings": 100, + // Counted when a live request is sent (Sign reserves one), so at most 5 can complete. + "sign.completed_requests": 5, "email.inboxes": INBOX_DAYS_INCLUDED, "risk.phone_checks": PHONE_CHECKS_INCLUDED, "hooks.messages": HOOKS_MESSAGES_INCLUDED.free, diff --git a/packages/billing/src/catalog/rates.ts b/packages/billing/src/catalog/rates.ts index ebbb30893..1b5cb2005 100644 --- a/packages/billing/src/catalog/rates.ts +++ b/packages/billing/src/catalog/rates.ts @@ -150,6 +150,10 @@ const NOVEMBER_2026: RateCard = { ], }, }, + // Sign: $0.50 per completed request. Its seal is one signing on SSL.com eSigner ($0.25 each at + // 1,000 a month, $0.1875 on its annual tier, less by contract above that). First priced when + // Sign was switched on in production (2026-11-06); no organization had completed one there. + "sign.completed_requests": { default: flatRate(500_000_000n) }, // $0.25 per 1,000 content checks. Jev costs about $0.042 per million input tokens, so at most // about $0.10 per 1,000 for the longest text classified (8,000 characters). "risk.content_checks": { default: flatRate(250_000n) }, diff --git a/packages/billing/test/engine.test.ts b/packages/billing/test/engine.test.ts index 0af2ac399..289aa2ae3 100644 --- a/packages/billing/test/engine.test.ts +++ b/packages/billing/test/engine.test.ts @@ -122,7 +122,7 @@ describe("terms", () => { "sms.received_segments": 0, "sms.numbers": 0, "sms.registrations": 0, - "sign.completed_requests": 0, + "sign.completed_requests": 5, "risk.phone_checks": 100, "verify.verifications": 0, "verify.sms": 0, diff --git a/packages/sign/AGENTS.md b/packages/sign/AGENTS.md index 0e99bbd98..0d0eb21a5 100644 --- a/packages/sign/AGENTS.md +++ b/packages/sign/AGENTS.md @@ -83,7 +83,8 @@ templates and the embedded builder. See "Not built yet" in the root `AGENTS.md` `authMode: explicit`; the chain comes from `credentials/info` in `prepare()`, before the first seal, never at start; a signature that doesn't verify against the chain is refused and the credential re-read) and `LocalSealer` (a self-signed key per process). `sealerFromEnv` - (`./runtime`) reads `signSealEnvShape` (`./env`). **Long-term validation** (B-LT, `ltv.ts` + + (`./runtime`) reads `signSealEnvShape` (`./env`) and adds a `sandboxSealer` (a `LocalSealer`; + the same one with `local`) that `SignCompletionDeps` uses for sandbox requests. **Long-term validation** (B-LT, `ltv.ts` + `dss.ts`): after the CMS is written, every certificate in it and in its timestamp token gets an OCSP response (SHA-1 CertID; signed by the issuer or a delegated `id-kp-OCSPSigning` responder it issued — checked by `signedByResponder`, not pkijs's `verify`, which wants the signer @@ -156,9 +157,14 @@ templates and the embedded builder. See "Not built yet" in the root `AGENTS.md` racing the deletion loses its fenced update and deletes its own copy. Deleting a document (`deleteSignDocument`) records its key the same way (`store-cleanup.ts`). - **Billing**: meter `sign.completed_requests` (`@coss/billing/metering`), recorded once in the - sealing transaction (behind its compare-and-set), live environments only. It has no rate yet: - invoices show the usage as an uncharged line until a rate card prices it (pricing is a product - decision). + sealing transaction (behind its compare-and-set), live environments only: $0.50 each (from the + 2026-11 card; a seal is one SSL.com eSigner signing, about $0.19–0.25 at our tier). Free includes + 5 a month, enforced by `SignServiceOptions.billing` (`UsageGate`): a live request reserves one + when it is sent (`create` with `send`, `send`), in that transaction, and `cancel` of a sent one + releases it to the period it was sent in; declined and expired requests keep theirs. Sends go + through the service (`service.send`/`service.cancel`), never the bare `sendSignRequest`/ + `cancelSignRequest`, which are uncapped. Sandbox requests are sealed by `sealerFromEnv`'s + `sandboxSealer` (a self-signed test seal per process), so they never spend the CA's signings. - **Retention**: `RETENTION_POLICY` `signature_requests` — kept until the customer deletes them; `signing_sessions` — signing and builder sessions `RETENTION_DAYS.signSessions` after expiry (`purgeExpiredSignSessions`, the worker's `retention` loop). @@ -193,6 +199,8 @@ Sign is on in production since these steps (in this order): (`SIGN_CSC_CREDENTIAL_ID`); seal a test PDF with it before switching. 4. **Switch on (done):** `SIGN_DOCUMENTS_BUCKET` (web, api, worker, sign), `terraform apply`, deploy. `SIGN_NOTIFICATIONS_FROM` is unset (`NOTIFICATIONS_FROM`'s address). -5. **Before announcing:** legal reviewed `DEFAULT_SIGN_DISCLOSURE` and the certificate wording - (2026-11-06; change them only with another review); `sign.completed_requests` still needs a - price (a new rate card). +5. **Before announcing (done):** legal reviewed `DEFAULT_SIGN_DISCLOSURE` and the certificate + wording (2026-11-06; change them only with another review), and `sign.completed_requests` is + priced (above). SSL.com's eSigner trial (unlimited signings) ends 30 days after the certificate + was issued (2026-12-06): an eSigner document signing tier must be bought before then, or live + seals fail (and retry with backoff). diff --git a/packages/sign/src/complete.ts b/packages/sign/src/complete.ts index e5aeb39f7..1f0e5067d 100644 --- a/packages/sign/src/complete.ts +++ b/packages/sign/src/complete.ts @@ -35,6 +35,8 @@ type RequestRow = typeof signRequest.$inferSelect; export type SignCompletionDeps = { store: DocumentStore; sealer: Sealer; + /** Seals sandbox requests (`sealerFromEnv`'s self-signed test seal); default `sealer`. */ + sandboxSealer?: Sealer; /** Without one, seals carry no trusted timestamp (PAdES B-B instead of B-T). */ tsa?: TimestampAuthority | null; /** Fetches the seal's validation data (OCSP, CRLs); default the global `fetch`. */ @@ -200,7 +202,7 @@ async function seal(request: RequestRow, deps: SignCompletionDeps, now: Date): P const completed = await completedRequest(request, deps.store); const document = await buildCompletedPdf(completed); const bytes = await sealPdf(document, { - sealer: deps.sealer, + sealer: completed.sandbox ? (deps.sandboxSealer ?? deps.sealer) : deps.sealer, tsa: deps.tsa, name: "coss.com", reason: `Completed signature request ${request.id}: every signer signed.`, diff --git a/packages/sign/src/requests.ts b/packages/sign/src/requests.ts index 3040f5aaf..c296169b3 100644 --- a/packages/sign/src/requests.ts +++ b/packages/sign/src/requests.ts @@ -3,6 +3,7 @@ * with their signers and fields, in one transaction with the first events of their audit trail. * Reads return the latest shape (`SignRequestObject`). */ +import type { UsageGate } from "@coss/billing/gate"; import { BUDGET_EXCEEDED_MESSAGES, budgetHoldFor, isOnBillingHold } from "@coss/billing/metering"; import { db, type Executor, schema, type Transaction } from "@coss/db"; import { ID_PREFIXES, isId, newId } from "@coss/db/id"; @@ -108,6 +109,10 @@ export type SignService = { input: Parameters[1], ): Promise; deleteDocument(input: Parameters[1]): Promise; + /** `sendSignRequest`, under the plan's cap. */ + send(input: Parameters[0]): Promise; + /** `cancelSignRequest`, giving a sent request's reservation back. */ + cancel(input: Parameters[0]): Promise; deleteRequest(input: Parameters[1]): Promise; createTemplate( input: Parameters[1], @@ -119,6 +124,11 @@ export type SignService = { export type SignServiceOptions = { store: DocumentStore; + /** + * Capped plans' completed requests (`createUsageGate`): a live request reserves one when it is + * sent and gives it back if it is canceled. Without it, uncapped. + */ + billing?: UsageGate | undefined; /** The clock, for tests. */ now?: () => Date; }; @@ -371,6 +381,8 @@ async function transition( refused: (status: SignRequestStatus) => SignError; /** Throws when the locked row can't make this change after all. */ check?: (row: { expiresAt: Date | null }) => void; + /** Runs in the change's transaction, after it (billing). */ + after?: (tx: Transaction, before: SignRequestRow, sandbox: boolean) => Promise; }, now: Date, ): Promise { @@ -392,10 +404,13 @@ async function transition( { request: updated ?? row, sandbox }, { type: change.event, actor: input.actor, occurredAt: now }, ); + await change.after?.(tx, row, sandbox); }); return getSignRequest(input); } +const SIGN_METER = "sign.completed_requests"; + /** A live request is usage once sealed, and its emails cost before that. */ async function refuseWhileHeld( source: { organizationId: string; projectId: string }, @@ -415,6 +430,7 @@ async function refuseWhileHeld( export async function sendSignRequest( input: { organizationId: string; environmentId: string; requestId: string; actor: SignActor }, now: Date = new Date(), + opts: { billing?: UsageGate | undefined } = {}, ): Promise { if (!(await environmentIsSandbox(input))) { const [found] = await db @@ -446,6 +462,15 @@ export async function sendSignRequest( ); } }, + // A live request counts against a capped plan from when it is sent. + after: async (tx, _row, sandbox) => { + if (sandbox) return; + await opts.billing?.reserve(tx, { + organizationId: input.organizationId, + meter: SIGN_METER, + quantity: 1, + }); + }, }, now, ); @@ -454,6 +479,7 @@ export async function sendSignRequest( export async function cancelSignRequest( input: { organizationId: string; environmentId: string; requestId: string; actor: SignActor }, now: Date = new Date(), + opts: { billing?: UsageGate | undefined } = {}, ): Promise { return transition( input, @@ -464,6 +490,17 @@ export async function cancelSignRequest( event: "canceled", refused: (status) => new SignError("request_not_cancelable", `A ${status} request can't be canceled.`), + // A sent request will never complete now: its reservation goes back to the period it was + // sent in. Declined and expired ones keep theirs (their signers were asked). + after: async (tx, row, sandbox) => { + if (sandbox || row.status !== "sent") return; + await opts.billing?.release?.(tx, { + organizationId: input.organizationId, + meter: SIGN_METER, + quantity: 1, + reservedAt: row.sentAt ?? now, + }); + }, }, now, ); @@ -613,6 +650,13 @@ export function createSignService(opts: SignServiceOptions): SignService { { organizationId: env.organizationId, projectId: env.projectId }, tx, ); + if (send) { + await opts.billing?.reserve(tx, { + organizationId: env.organizationId, + meter: SIGN_METER, + quantity: 1, + }); + } } checkExpiry(params.expires_at, now); if (inline.length > 0) await tx.insert(signDocument).values(inline); @@ -741,6 +785,8 @@ export function createSignService(opts: SignServiceOptions): SignService { replay, uploadDocument: (input) => uploadSignDocument(opts.store, input), deleteDocument: (input) => deleteSignDocument(opts.store, input), + send: (input) => sendSignRequest(input, clock(), { billing: opts.billing }), + cancel: (input) => cancelSignRequest(input, clock(), { billing: opts.billing }), deleteRequest: (input) => deleteSignRequest(opts.store, input), createTemplate: (input) => createSignTemplate(opts.store, input), updateTemplate: (input) => updateSignTemplate(opts.store, input), diff --git a/packages/sign/src/runtime.ts b/packages/sign/src/runtime.ts index 06cd923d4..5b0dad57e 100644 --- a/packages/sign/src/runtime.ts +++ b/packages/sign/src/runtime.ts @@ -26,11 +26,20 @@ type SignSealEnv = { SIGN_TSA_URL?: string; }; +/** + * What seals completed requests: live ones with `sealer`, sandbox ones with `sandboxSealer`, a + * self-signed test seal (no viewer trusts it), so tests never spend a CA's signings. + */ export async function sealerFromEnv( env: SignSealEnv, -): Promise<{ sealer: Sealer; tsa: TimestampAuthority | null } | null> { +): Promise<{ sealer: Sealer; sandboxSealer: Sealer; tsa: TimestampAuthority | null } | null> { const tsa = env.SIGN_TSA_URL ? new HttpTsa(env.SIGN_TSA_URL) : null; - if (env.SIGN_SEALER === "local") return { sealer: await LocalSealer.create(), tsa }; + if (env.SIGN_SEALER === "local") { + const sealer = await LocalSealer.create(); + return { sealer, sandboxSealer: sealer, tsa }; + } + if (!env.SIGN_SEALER) return null; + const sandboxSealer = await LocalSealer.create("coss.com Sign (sandbox test seal)"); if (env.SIGN_SEALER === "kms") { if (!env.SIGN_SEAL_KMS_KEY_ID || !env.SIGN_SEAL_CERTIFICATE_CHAIN) { throw new Error( @@ -42,6 +51,7 @@ export async function sealerFromEnv( keyId: env.SIGN_SEAL_KMS_KEY_ID, certificateChain: certificatesFromPem(env.SIGN_SEAL_CERTIFICATE_CHAIN), }), + sandboxSealer, tsa, }; } @@ -71,6 +81,7 @@ export async function sealerFromEnv( credentialId, totpSecret: env.SIGN_CSC_TOTP_SECRET, }), + sandboxSealer, tsa, }; } diff --git a/packages/sign/test/ship.test.ts b/packages/sign/test/ship.test.ts index db44f81de..c0b95963f 100644 --- a/packages/sign/test/ship.test.ts +++ b/packages/sign/test/ship.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { BillingError, createUsageGate } from "@coss/billing"; import { db, schema } from "@coss/db"; import { createTestTenant } from "@coss/tenancy/testing"; import { and, eq } from "drizzle-orm"; @@ -17,12 +18,15 @@ import { openSigning, prepareDocument, purgeExpiredSignSessions, + readPdfSeal, + readSignedDocument, runDueSignCleanup, runDueSignCompletions, S3DocumentStore, type SignEnvironment, SignError, type SignS3Api, + sendSignRequest, signAsSigner, } from "../src/index.ts"; import { signStoreFromEnv } from "../src/runtime.ts"; @@ -111,6 +115,98 @@ describe("billing", () => { }); }); +describe("Free's cap", () => { + const capped = createSignService({ store, billing: createUsageGate() }); + const create = async (env: SignEnvironment, send = true) => + ( + await capped.create({ + environment: env, + apiKeyId: null, + actor: { kind: "api_key" }, + params: { + title: "NDA", + send, + documents: [{ filename: "nda.pdf", content: await buildTestPdfBase64() }], + signers: [ + { + name: "Ada", + email: "ada@example.com", + fields: [{ type: "signature", anchor: "{{signature:signer_1}}" }], + }, + ], + }, + }) + ).request; + const actor = { kind: "api_key" as const }; + + test("live requests count when sent, up to 5 a month; a canceled one gives its place back", async () => { + const { env } = await environment("live"); + const sent = []; + for (let index = 0; index < 5; index++) sent.push(await create(env)); + const refused = create(env); + await expect(refused).rejects.toBeInstanceOf(BillingError); + await expect(refused).rejects.toMatchObject({ code: "usage_limit_exceeded" }); + + // Drafts don't count until they are sent. + const draft = await create(env, false); + await expect(capped.send({ ...env, requestId: draft.id, actor })).rejects.toBeInstanceOf( + BillingError, + ); + expect((await getSignRequest({ ...env, requestId: draft.id })).status).toBe("draft"); + + // Canceling a draft frees nothing; canceling a sent request does. + await capped.cancel({ ...env, requestId: draft.id, actor }); + await expect(create(env)).rejects.toBeInstanceOf(BillingError); + await capped.cancel({ ...env, requestId: sent[0]?.id ?? "", actor }); + // A retried cancel gives nothing more back. + await capped.cancel({ ...env, requestId: sent[0]?.id ?? "", actor }); + await create(env); + await expect(create(env)).rejects.toBeInstanceOf(BillingError); + }); + + test("sandbox requests never count", async () => { + const { env } = await environment("sandbox"); + for (let index = 0; index < 6; index++) await create(env); + const draft = await create(env, false); + await capped.send({ ...env, requestId: draft.id, actor }); + }); + + test("without a gate, sending is uncapped", async () => { + const { env } = await environment("live"); + const draft = await create(env, false); + expect((await sendSignRequest({ ...env, requestId: draft.id, actor })).status).toBe("sent"); + }); +}); + +describe("sandbox seals", () => { + test("are made with the sandbox sealer, live ones with the real one", async () => { + const live = await environment("live"); + const sandbox = await environment("sandbox"); + const liveRequest = await signed(live.env); + const sandboxRequest = await signed(sandbox.env); + const sealer = await LocalSealer.create("real seal"); + const sandboxSealer = await LocalSealer.create("test seal"); + await runDueSignCompletions( + { + limit: 5, + scope: { organizationIds: [live.tenant.organization.id, sandbox.tenant.organization.id] }, + }, + { store, sealer, sandboxSealer }, + ); + const certificateOf = async (env: SignEnvironment, requestId: string) => { + const { content } = await readSignedDocument({ ...env, requestId, store }); + return Buffer.from(readPdfSeal(content).cms).toString("latin1"); + }; + const latin1 = (der: Uint8Array | undefined) => Buffer.from(der ?? []).toString("latin1"); + expect(await certificateOf(live.env, liveRequest.id)).toContain( + latin1(sealer.certificateChain[0]), + ); + const sandboxCms = await certificateOf(sandbox.env, sandboxRequest.id); + expect(sandboxCms).toContain(latin1(sandboxSealer.certificateChain[0])); + expect(sandboxCms).not.toContain(latin1(sealer.certificateChain[0])); + }); +}); + describe("deleting requests", () => { test("only when not sent; takes the signatures and signed document with it", async () => { const { tenant, env } = await environment("sandbox");