{"openapi":"3.1.0","info":{"title":"Timjami API","version":"0.1.0","description":"Time tracking API for Timjami.\n\n**Conventions**\n- Timestamps are ISO-8601 with an offset.\n- Money is `{ amountMinor, currency }` — integers in minor units, never floats.\n- Rates on the wire are always *hourly*; daily-rate display is a client-side view.\n- Errors are RFC 9457 `application/problem+json`.\n\n**Authentication.** A session (browser or bearer token) is the person and reaches\neverything they may. A personal access token and an OAuth access token are scoped:\neach operation names the scope it needs in `x-thymian-scope`, and a scope never\nwidens what its owner may already do. Endpoints that hand out access — minting a\ntoken, approving a client, changing your preferences — accept a session only.\n\nAuthentication and organization management live under `/api/auth` and are documented\nin the \"Auth\" source of this reference."},"servers":[{"url":"https://timjami.app","description":"This server"}],"tags":[{"name":"Me","description":"Current user, preferences, effective display settings."},{"name":"Organization","description":"Settings of the active organization."},{"name":"Customers","description":"Customers of the active organization."},{"name":"Projects","description":"Projects, their billing rules and assigned members."},{"name":"Tasks","description":"Tasks within a project."},{"name":"Icons","description":"Customer and project images, served from signed URLs so they can be used directly as an image source."},{"name":"Time entries","description":"Tracking time, including the running timer."},{"name":"Invoices","description":"Billing a period of tracked time. An invoice groups entries that already carry their rate; it never resolves one, and an hour it claims cannot be billed twice."},{"name":"Harvest","description":"Exporting your own tracked time to Harvest as time entries, using a personal connection you set up once. Re-exporting an entry updates the same Harvest entry rather than creating a second one."},{"name":"Reports","description":"Aggregates over the organization, gated to owners and admins — unlike the time entries they are built from, which every member can already read."},{"name":"Hints","description":"What collectors observed you working on, offered as suggestions for time entries. Visible only to the person they belong to."},{"name":"Collectors","description":"Getting a collector installed. The hook scripts are served here so a project that is not a Timjami checkout can fetch them."},{"name":"Events","description":"Live notifications that something changed, so a client can refresh instead of polling. Signals only — the change itself is read through the endpoints above."},{"name":"Access tokens","description":"Minting and revoking the scoped credentials of the public API. A session only — a token that could mint a token would survive its own revocation."},{"name":"MCP","description":"A Model Context Protocol server, so an assistant can read projects and record time for the person who connected it. Authenticated by OAuth 2.1 rather than by a session, and scoped to the one organization chosen when the client was approved."}],"components":{"securitySchemes":{"SessionCookie":{"type":"apiKey","in":"cookie","name":"better-auth.session_token","description":"Session cookie set by POST /api/auth/sign-in/email. Local worktrees use their configured cookie prefix."},"BearerAuth":{"type":"http","scheme":"bearer","description":"Session token as a bearer token, for clients without a cookie jar (desktop, mobile). Enabled by the better-auth bearer plugin."},"PersonalAccessToken":{"type":"apiKey","in":"header","name":"X-API-Key","description":"A personal access token, minted at `POST /api/v1/access-tokens` and carrying the\nscopes chosen there. May also be sent as `Authorization: Bearer <token>`; it is\nrecognisable either way by its `thyp_` prefix.\n\nIts authority is its scopes **intersected** with what its owner may do — a scope never\nwidens a role. The operation’s `x-thymian-scope` names the one it needs."},"OAuth2":{"type":"oauth2","description":"OAuth 2.1 access token for the domain API. Issued by this server; PKCE required. The organization is chosen by the person on the approval page and stored with the grant — it is never a request parameter (ADR 0046). A client that registered itself can be granted `thymian:read`, `thymian:track` and the two hint scopes; anything wider needs a personal access token.","flows":{"authorizationCode":{"authorizationUrl":"/api/auth/oauth2/authorize","tokenUrl":"/api/auth/oauth2/token","refreshUrl":"/api/auth/oauth2/token","scopes":{"thymian:read":"Read the organization’s customers, projects, tasks, budgets and recorded time — everybody’s hours, not only yours — and its settings.","thymian:track":"Start and stop your timer and manage your own breaks. Record, correct and delete time entries in the organization, a colleague’s included, as every member may. Record absences for yourself — or for anybody, if you administer the organization.","thymian:customers.write":"Create and edit customers — name, aliases, code, colour and logo. Cannot archive a customer, and cannot touch projects or tasks.","thymian:project-icons.write":"Set or remove a project's logo. Cannot rename a project, touch its rate rules or members, or archive it.","thymian:projects.write":"Create and change customers, projects and tasks, including archiving them.","thymian:persons.read":"Read the organization’s people — id, name, email and whether they are still active. No customers, projects, tasks, time or money.","thymian:budgets.write":"Agree and supersede budgets, and set or remove monthly adjustments. Connected clients cannot correct, archive, restore or delete budgets.","thymian:billing.read":"Read recurring billing rules and proposals — customer, period, recurrence, due date, state and affected projects. No amount, rate, or invoice.","thymian:billing-proposals.write":"Confirm a billing proposal with nothing on it needs no invoice, or move a service between two open proposals of the same rule. Cannot create, pause or resume a rule, or touch an invoice.","thymian:billing-rules.write":"Create a recurring billing rule, or change its schedule or which projects it covers from a future period. Cannot pause or resume a rule, move a proposal’s service, or touch an invoice.","thymian:invoice-status.read":"Read an invoice’s customer, period, status and transfer state — draft, transferred to the invoicing tool, or withdrawn. No amount, rate, currency or the invoicing tool’s document.","thymian:invoices.read":"Read invoices, their lines and amounts, write-offs, and what has not been billed yet. For a connected client this stays off until you also switch it on for that client in Timjami’s settings.","thymian:reports.read":"Read organization-wide aggregates over everybody’s hours, revenue included. Only works if you are an owner or admin; for a third-party client the revenue stays off until you also switch money reading on in Timjami’s settings.","thymian:hints.write":"Report what it observed you working on.","thymian:hints.read":"Read back what collectors observed about you — prompts, branches, message text. For a third-party client this stays off until you also switch it on in Timjami’s settings.","thymian:planning.read":"Read the organization’s staff plan, customer and project requirements, person experience, budget planning context, allocation notes, person names, weekly capacity, absence days (kinds only with absence details), tracked weekly totals and budget hours. Only works if you are an owner, admin, or hold the planning role.","thymian:planning.write":"Create, replace or clear any person’s planned hours and note on a customer, budget or project for an ISO week, and planning context for customers, persons, projects and budgets. Only works if you are an owner, admin, or hold the planning role. Does not record time or change budgets."}}}},"McpBearer":{"type":"oauth2","description":"OAuth 2.1 access token for the MCP endpoint (ADR 0045). Issued by this server; PKCE required.","flows":{"authorizationCode":{"authorizationUrl":"/api/auth/oauth2/authorize","tokenUrl":"/api/auth/oauth2/token","refreshUrl":"/api/auth/oauth2/token","scopes":{"thymian:master-data.read":"Read the organization’s customers, projects and tasks. No time, budget, rate or member.","thymian:master-data.write":"Create and edit customers, projects and tasks — names, codes, colours, logos, a project’s visibility, a task’s category, and a new task’s billability. Cannot archive, set rates, change members, or change an existing task’s billability.","thymian:project-members.read":"Read who is assigned to a project.","thymian:project-members.write":"Assign people to projects and remove them, as far as you may.","thymian:project-rates.read":"Read a project’s rate rules.","thymian:project-rates.write":"Set a project’s rate rules and whether a task is billable. Never for hours an invoice already bills.","thymian:time.read-own":"Read your own time entries, your running timer and your totals. Nobody else’s.","thymian:time.write-own":"Start and stop your timer, record time and correct your own entries. Nobody else’s.","thymian:time.read-others":"Read colleagues’ time entries as the organization’s time list shows them to you; a named person only if you administer the organization.","thymian:budgets.read":"Read budgets and how many hours they have used. No money.","thymian:invoice-drafts.write":"Build an invoice draft from tracked time and agreed work, and delete a draft. The answer shows the draft’s amounts even with money reading off, so building and deleting a draft reads them. Nothing is sent, exported or transferred: a person does that. Only works once billing writes are switched on for this client.","thymian:time-allowances.read":"Read monthly allowances („Pauschalen“) and their monthly adjustments, in hours. No money.","thymian:time-allowances.write":"Create, replace, correct and delete monthly allowances („Pauschalen“) and their monthly adjustments, which decide the hours a customer is billed. Never a month a live invoice holds. Only works once billing writes are switched on for this client.","thymian:fixed-price.read":"Read fixed-price agreements and items, including their amounts. Only works once money reading is switched on for this client.","thymian:fixed-price.write":"Create, change and delete fixed-price agreements and items, and link a task to an agreement, which decide what a customer is billed. The answer shows the amounts the call wrote. Never an item an invoice already bills. Only works once billing writes are switched on for this client.","thymian:persons.read":"Read the organization’s people — id, name, email and whether they are still active. No customers, projects, tasks, time or money.","thymian:budgets.write":"Agree and supersede budgets, and set or remove monthly adjustments. Connected clients cannot correct, archive, restore or delete budgets.","thymian:billing.read":"Read recurring billing rules and proposals — customer, period, recurrence, due date, state and affected projects. No amount, rate, or invoice.","thymian:billing-proposals.write":"Confirm a billing proposal with nothing on it needs no invoice, or move a service between two open proposals of the same rule. Cannot create, pause or resume a rule, or touch an invoice.","thymian:billing-rules.write":"Create a recurring billing rule, or change its schedule or which projects it covers from a future period. Cannot pause or resume a rule, move a proposal’s service, or touch an invoice.","thymian:invoice-status.read":"Read an invoice’s customer, period, status and transfer state — draft, transferred to the invoicing tool, or withdrawn. No amount, rate, currency or the invoicing tool’s document.","thymian:invoices.read":"Read invoices, their lines and amounts, write-offs, and what has not been billed yet. For a connected client this stays off until you also switch it on for that client in Timjami’s settings.","thymian:reports.read":"Read organization-wide aggregates over everybody’s hours, revenue included. Only works if you are an owner or admin; for a third-party client the revenue stays off until you also switch money reading on in Timjami’s settings.","thymian:hints.write":"Report what it observed you working on.","thymian:hints.read":"Read back what collectors observed about you — prompts, branches, message text. For a third-party client this stays off until you also switch it on in Timjami’s settings.","thymian:planning.read":"Read the organization’s staff plan, customer and project requirements, person experience, budget planning context, allocation notes, person names, weekly capacity, absence days (kinds only with absence details), tracked weekly totals and budget hours. Only works if you are an owner, admin, or hold the planning role.","thymian:planning.write":"Create, replace or clear any person’s planned hours and note on a customer, budget or project for an ISO week, and planning context for customers, persons, projects and budgets. Only works if you are an owner, admin, or hold the planning role. Does not record time or change budgets."}}}},"UsageKey":{"type":"apiKey","in":"header","name":"X-API-Key","description":"A coding agent’s usage key (`thyu_…`), minted by `thymian collector install claude-code|codex`. Reaches only the OTLP logs endpoint."}},"schemas":{"AuthProviders":{"type":"object","properties":{"google":{"type":"boolean"}},"required":["google"]},"Problem":{"type":"object","properties":{"type":{"type":"string","example":"https://timjami.suora.cloud/problems/not-found"},"title":{"type":"string","example":"Not found"},"status":{"type":"integer","example":404},"detail":{"type":"string"},"errors":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}},"required":["field","message"]},"description":"Field-level errors, present on validation problems."},"requestId":{"type":"string","format":"uuid","description":"Correlates this response with the API log lines it produced. Absent on error bodies better-auth's own endpoints answer, which do not pass through this API's error handler.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["type","title","status"]},"InviteCodeCheckResult":{"type":"object","properties":{"email":{"type":["string","null"],"description":"The address this code is bound to, or null when it redeems for anybody."}},"required":["email"]},"InviteCodeCheckRequest":{"type":"object","properties":{"code":{"type":"string","minLength":1,"maxLength":64,"description":"An invite code, in any casing or spacing — normalized server-side.","example":"TIMJ-AAAA-AAAA"}},"required":["code"]},"AccessRequestAccepted":{"type":"object","properties":{}},"AccessRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"email":{"type":"string","maxLength":320,"format":"email"},"company":{"type":"string","minLength":1,"maxLength":200},"message":{"type":"string","maxLength":2000}},"required":["name","email","company"]},"OtlpExportResponse":{"type":"object","properties":{}},"OtlpLogsRequest":{"type":"object","properties":{"resourceLogs":{"type":["array","null"],"items":{"type":"object","properties":{"resource":{"type":["object","null"],"properties":{"attributes":{"type":["array","null"],"items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":["object","null"],"properties":{"stringValue":{"type":["string","null"]},"intValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"doubleValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"boolValue":{"type":["boolean","null"]}},"additionalProperties":{}}},"required":["key"],"additionalProperties":{}}}},"additionalProperties":{}},"scopeLogs":{"type":["array","null"],"items":{"type":"object","properties":{"logRecords":{"type":["array","null"],"items":{"type":"object","properties":{"timeUnixNano":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"observedTimeUnixNano":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"eventName":{"type":["string","null"]},"body":{"type":["object","null"],"properties":{"stringValue":{"type":["string","null"]},"intValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"doubleValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"boolValue":{"type":["boolean","null"]}},"additionalProperties":{}},"attributes":{"type":["array","null"],"items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":["object","null"],"properties":{"stringValue":{"type":["string","null"]},"intValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"doubleValue":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"null"}]},"boolValue":{"type":["boolean","null"]}},"additionalProperties":{}}},"required":["key"],"additionalProperties":{}}}},"additionalProperties":{}}}},"additionalProperties":{}}}},"additionalProperties":{}}}},"additionalProperties":{}},"UserSessions":{"type":"array","items":{"$ref":"#/components/schemas/UserSession"}},"UserSession":{"type":"object","properties":{"id":{"type":"string"},"current":{"type":"boolean"},"expiresAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"ipAddress":{"type":["string","null"]},"userAgent":{"type":["string","null"]},"clientType":{"type":"string"}},"required":["id","current","expiresAt","createdAt","updatedAt","ipAddress","userAgent","clientType"]},"GoogleProvisionResult":{"type":"object","properties":{"outcome":{"type":"string","enum":["joined","already-member","no-google-account","no-hosted-domain","no-matching-domain","domain-not-currently-verified","membership-conflict"]},"organizationId":{"type":["string","null"]}},"required":["outcome","organizationId"]},"Me":{"type":"object","properties":{"user":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"},"avatarUrl":{"type":["string","null"]},"image":{"type":["string","null"],"deprecated":true},"avatar":{"$ref":"#/components/schemas/ResourceIcon"}},"required":["id","name","email","avatarUrl","image","avatar"]},"person":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","description":"The organization’s name for this person — the account’s while it has one, the row’s own otherwise."}},"required":["id","name"]},"activeOrganizationId":{"type":["string","null"]},"preferences":{"$ref":"#/components/schemas/UserPreferences"},"organizationSettings":{"$ref":"#/components/schemas/OrganizationSettings"},"organizationFeatures":{"type":"array","items":{"$ref":"#/components/schemas/Feature"},"description":"The features in effect for this caller in the active organization, including any an operator switched on for this account alone — a feature whose prerequisite is not on is left out. Empty without an active organization."},"effectiveRateDisplay":{"type":"string","enum":["hourly","daily"],"description":"How rates are presented in the UI. Does not affect stored data.","example":"hourly"},"effectiveShowRunningTimerAvatars":{"type":"boolean"},"tourAvailable":{"type":"boolean"},"effectiveTourEnabled":{"type":"boolean"},"jamiAvailable":{"type":"boolean"},"effectiveJamiEnabled":{"type":"boolean"},"effectiveJamiNotesEnabled":{"type":"boolean","description":"Whether Jami shows this person his notes on what changed, such as the reorganized main menu. Needs the `jami-notes` feature in effect and the organization’s and the person’s Jami settings on, but not the tour. `false` without an active organization."},"workSchedules":{"type":"array","items":{"$ref":"#/components/schemas/WorkSchedule"}},"isPlatformOperator":{"type":"boolean"},"canCreateOrganization":{"type":"boolean","description":"Whether this account may create an organization: a platform operator, or holding an unused invite-code redemption."},"isImpersonating":{"type":"boolean"}},"required":["user","person","activeOrganizationId","preferences","organizationSettings","organizationFeatures","effectiveRateDisplay","effectiveShowRunningTimerAvatars","tourAvailable","effectiveTourEnabled","jamiAvailable","effectiveJamiEnabled","effectiveJamiNotesEnabled","workSchedules","isPlatformOperator","canCreateOrganization","isImpersonating"]},"ResourceIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"UserPreferences":{"type":"object","properties":{"rateDisplay":{"type":["string","null"],"enum":["hourly","daily",null],"description":"How rates are presented in the UI. Does not affect stored data.","example":"hourly"},"locale":{"type":"string","example":"de-DE"},"timeZone":{"type":["string","null"]},"llmHintEvaluationConsented":{"type":"boolean","description":"Whether this person permits manual LLM evaluation of their own hints.","example":false},"keyboardShortcutsEnabled":{"type":"boolean","description":"Whether keyboard shortcuts and their visual key hints are enabled.","example":false},"runningTimerAvatarsEnabled":{"type":"boolean","description":"Whether this person's own header shows colleagues' running-timer avatars. On by default; only takes effect while the organization's own setting is also on — turning this on cannot bring the header back once the organization has turned it off.","example":true},"tourEnabled":{"type":"boolean","description":"Whether the web app's onboarding tour appears for this person. On by default; only takes effect while the organization has the `tour` feature and its own setting is on (`effectiveTourEnabled`).","example":true},"jamiEnabled":{"type":"boolean","description":"Whether Jami guides the tour for this person. On by default; only takes effect while the organization has the `jami` feature in effect, its own setting is on, and the tour appears for this person (`effectiveJamiEnabled`). Without him the tour speaks in neutral text and draws no figure. Off, it also keeps his notes on what changed away (`effectiveJamiNotesEnabled`).","example":true},"forgottenTimerEmailEnabled":{"type":"boolean","description":"Whether the forgotten-running-timer rule may deliver an email.","example":true},"forgottenTimerThresholdSeconds":{"type":"integer","minimum":3600,"maximum":86400,"description":"How long a timer must run uninterrupted before it counts as unusually long. The rule has a second trigger this does not govern: a timer still running well past the hour this person usually stops at fires on its own, earlier.","example":21600},"operatorNewUserEmailEnabled":{"type":"boolean","description":"Whether a current platform operator receives email when an account is created.","example":false},"operatorNewOrganizationEmailEnabled":{"type":"boolean","description":"Whether a current platform operator receives email when an organization is created.","example":false},"organizationNewMemberEmailEnabled":{"type":"boolean","description":"Whether a current organization owner or admin receives email when a member joins.","example":false},"dailyOrgReportEmailEnabled":{"type":"boolean","description":"Whether a current holder of report.readOthers receives the daily organization revenue digest.","example":false},"dailyOrgReportSendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7,"description":"ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately.","example":[1,2,3,4,5]},"dailyOrgReportSendHour":{"type":"integer","minimum":0,"maximum":23,"description":"The hour, in the organization's own time zone, the digest is sent at on a scheduled day. Only delays the send within that day — never changes which days it covers.","example":7},"budgetAlertEmailEnabled":{"type":"boolean","description":"Whether this person is mailed when a budget reaches a configured threshold. Off unless turned on; a budget the person cannot see is never mailed about, whatever this says.","example":false},"dayTab":{"type":["string","null"],"enum":["list","calendar","max",null],"description":"The tab „Heute“ opens on: the one this account last chose, on any device. `null` until one was chosen, which opens `list`. A remembered `max` whose tab is no longer offered opens `list`.","example":"calendar"},"hintMaxViewEnabled":{"type":"boolean","description":"Whether „Heute“ offers the „Max 2.0“ tab, which shows the day with its hints in it, beside „Liste“ and „Kalender“. The other hint* settings shape that tab alone. Off unless turned on.","example":false},"hintCorrectionsEnabled":{"type":"boolean","description":"Whether „Heute“ marks a finished entry that this person's own hints contradict, with a one-click correction.","example":true},"hintBoundariesEnabled":{"type":"boolean","description":"Whether „Heute“ offers presence observed before the first or after the last entry of the day as the start or end of the workday.","example":true},"hintMinDeviationSeconds":{"type":"integer","minimum":0,"maximum":3600,"description":"On „Heute“, an entry’s own task continuing past it, a workday edge or a correction shorter than this many seconds is not offered.","example":900},"hintMinDurationSeconds":{"type":"integer","minimum":0,"maximum":3600,"description":"A rule-named hint observed for less than this many seconds is left out of „Heute“ altogether; 0 shows every hint. Presence and unclassified hints are never left out.","example":0},"hintConfirmationsEnabled":{"type":"boolean","description":"Whether „Heute“ names the hint sources that agree with a finished entry.","example":false},"hintInboxEnabled":{"type":"boolean","description":"Whether „Heute“ lists hint subjects that are not classified yet.","example":true},"hintEvidenceLabelsEnabled":{"type":"boolean","description":"Whether a hint row on „Heute“ names its sources and subjects as text, or shows only their icons.","example":true},"hintExcludedSources":{"type":"array","items":{"type":"string","minLength":1,"maxLength":64},"maxItems":64,"description":"Hint sources „Heute“ leaves out. Presentation only: the hints themselves and GET /hints are unaffected.","example":["browser"]}},"required":["rateDisplay","locale","timeZone","llmHintEvaluationConsented","keyboardShortcutsEnabled","runningTimerAvatarsEnabled","tourEnabled","jamiEnabled","forgottenTimerEmailEnabled","forgottenTimerThresholdSeconds","operatorNewUserEmailEnabled","operatorNewOrganizationEmailEnabled","organizationNewMemberEmailEnabled","dailyOrgReportEmailEnabled","dailyOrgReportSendWeekdays","dailyOrgReportSendHour","budgetAlertEmailEnabled","dayTab","hintMaxViewEnabled","hintCorrectionsEnabled","hintBoundariesEnabled","hintMinDeviationSeconds","hintMinDurationSeconds","hintConfirmationsEnabled","hintInboxEnabled","hintEvidenceLabelsEnabled","hintExcludedSources"]},"OrganizationSettings":{"type":["object","null"],"properties":{"organizationId":{"type":"string"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"rateDisplay":{"type":"string","enum":["hourly","daily"],"description":"How rates are presented in the UI. Does not affect stored data.","example":"hourly"},"hoursPerDay":{"type":"string","example":"8.00"},"timeZone":{"type":"string","example":"Europe/Berlin"},"weekStartsOn":{"type":"integer","minimum":1,"maximum":7,"example":1},"hintRetentionDays":{"type":"integer","minimum":1,"maximum":3650,"description":"Days a hint is kept before `POST /hints` prunes it. Enforced when a collector reports, not by a scheduler.","example":30},"runningTimerAvatarsEnabled":{"type":"boolean","description":"Whether the header shows avatars of colleagues whose timer is running. The organization-wide default; a member may still turn it off for themselves.","example":true},"tourEnabled":{"type":"boolean","description":"Whether the web app's onboarding tour may appear for anybody in this organization. A member may still turn it off for themselves; a member's own setting never overrides this being off. Has no effect while the organization does not have the `tour` feature (`GET /me` `organizationFeatures`), and cannot be changed to `true` then.","example":true},"jamiEnabled":{"type":"boolean","description":"Whether Jami appears for anybody in this organization, with the same levels as `tourEnabled`. Under the `jami` feature, which takes effect only together with `tour`, it decides whether he guides the tour; without him the tour speaks in neutral text and draws no figure. Under `jami-notes` it decides whether his notes on what changed appear; off, they reach nobody here. Has no effect while neither feature is in effect, and cannot be changed to `true` then.","example":true},"trackingOpenMonths":{"type":["integer","null"],"minimum":1,"maximum":120,"description":"How many completed months stay open for tracking beside the current one, or `null` for no limit at all. Written by `PUT /organization/tracking-window`, never here.","example":2},"earliestOpenDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-06-01","description":"What `trackingOpenMonths` currently means: the first day hours may still be recorded on, or `null` where no limit is set. **Derived, and this is what every refusal compares against** — a client never re-derives it from the number of months. It moves at a month turn with nobody acting, so a page open across one holds a stale value; the write path refuses regardless."},"llmHintEvaluationEnabled":{"type":"boolean","description":"Whether administrators permit manual LLM evaluation of hints.","example":false},"billingEscalationDelayDays":{"type":"integer","minimum":1,"maximum":365,"description":"Written by `PUT /organization/billing-escalation`, never here.","example":7},"billingEscalationRecipientPersonId":{"type":["string","null"],"format":"uuid","description":"Written by `PUT /organization/billing-escalation`, never here.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["organizationId","currency","rateDisplay","hoursPerDay","timeZone","weekStartsOn","hintRetentionDays","runningTimerAvatarsEnabled","tourEnabled","jamiEnabled","trackingOpenMonths","earliestOpenDay","llmHintEvaluationEnabled","billingEscalationDelayDays","billingEscalationRecipientPersonId"]},"Feature":{"type":"string","enum":["harvest","company-metrics","agent-usage","day-export","tour","jami","jami-notes","max-view","entry-split","planning","customer-portal","tickets","absences"],"description":"Something an operator switches on, or an organization switches off, for itself (ADR-2026-09-29-17JKR). A feature either gates routes — each says so in `x-thymian-feature` and answers `403 feature-not-enabled` without it — or, without routes of its own, is enforced where its organization switch is written: switching that on answers the same 403 while the organization does not have the feature (ADR-2026-09-30-17KCB).","example":"harvest"},"WorkSchedule":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The day this version starts holding. The next version ends the previous one — there is no `validUntilDay`."},"kind":{"type":"string","enum":["weekly","monthly"],"description":"`weekly`: the seven weekday fields are the agreement. `monthly`: `monthlySeconds` is, and a working day’s own share is derived."},"mondaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"tuesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"wednesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"thursdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"fridaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"saturdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"sundaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"monthlySeconds":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":2678400,"description":"Set for `kind: monthly` only."},"note":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","personId","validFromDay","kind","mondaySeconds","tuesdaySeconds","wednesdaySeconds","thursdaySeconds","fridaySeconds","saturdaySeconds","sundaySeconds","monthlySeconds","note","createdAt","updatedAt"]},"RedeemInviteCodeRequest":{"type":"object","properties":{"code":{"type":"string","minLength":1,"maxLength":64,"description":"An invite code, in any casing or spacing — normalized server-side.","example":"TIMJ-AAAA-AAAA"}},"required":["code"]},"PutIcon":{"type":"object","properties":{"contentType":{"type":"string","enum":["image/webp","image/png"],"description":"Must match what the bytes actually are; a mismatch is rejected.","example":"image/webp"},"data":{"type":"string","maxLength":262144,"description":"Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge.","example":"UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA..."}},"required":["contentType","data"]},"UpdateUserPreferences":{"type":"object","properties":{"rateDisplay":{"type":["string","null"],"enum":["hourly","daily",null],"description":"How rates are presented in the UI. Does not affect stored data.","example":"hourly"},"locale":{"type":"string","minLength":2,"maxLength":10},"timeZone":{"type":["string","null"],"minLength":1},"llmHintEvaluationConsented":{"type":"boolean"},"keyboardShortcutsEnabled":{"type":"boolean"},"runningTimerAvatarsEnabled":{"type":"boolean"},"tourEnabled":{"type":"boolean"},"jamiEnabled":{"type":"boolean"},"forgottenTimerEmailEnabled":{"type":"boolean"},"forgottenTimerThresholdSeconds":{"type":"integer","minimum":3600,"maximum":86400},"operatorNewUserEmailEnabled":{"type":"boolean"},"operatorNewOrganizationEmailEnabled":{"type":"boolean"},"organizationNewMemberEmailEnabled":{"type":"boolean"},"dailyOrgReportEmailEnabled":{"type":"boolean"},"dailyOrgReportSendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7},"dailyOrgReportSendHour":{"type":"integer","minimum":0,"maximum":23},"budgetAlertEmailEnabled":{"type":"boolean"},"dayTab":{"type":"string","enum":["list","calendar","max"],"description":"„Heute“’s tabs: `list` („Liste“), `calendar` („Kalender“) and `max` („Max 2.0“).","example":"calendar"},"hintMaxViewEnabled":{"type":"boolean"},"hintCorrectionsEnabled":{"type":"boolean"},"hintBoundariesEnabled":{"type":"boolean"},"hintMinDeviationSeconds":{"type":"integer","minimum":0,"maximum":3600},"hintMinDurationSeconds":{"type":"integer","minimum":0,"maximum":3600},"hintConfirmationsEnabled":{"type":"boolean"},"hintInboxEnabled":{"type":"boolean"},"hintEvidenceLabelsEnabled":{"type":"boolean"},"hintExcludedSources":{"type":"array","items":{"type":"string","minLength":1,"maxLength":64},"maxItems":64}}},"AssistantTours":{"type":"array","items":{"$ref":"#/components/schemas/AssistantTourProgress"}},"AssistantTourProgress":{"type":"object","properties":{"tour":{"type":"string","enum":["onboarding","menu-tabs"],"description":"Which of Jami’s tours this is: `onboarding`, the tour itself, or `menu-tabs`, his note that the main menu now groups pages as tabs.","example":"onboarding"},"status":{"type":"string","enum":["offered","active","completed","dismissed"],"description":"`offered` — this account already used the application when the tour became available to its organization, and is asked rather than walked through it; `active` — under way at `step`; `completed` or `dismissed` — over, restartable. No entry at all means `onboarding` has never been offered, and the web app starts it on arrival; for `menu-tabs` it means there is nothing to tell, since only accounts that knew the earlier menu were offered it."},"step":{"type":["string","null"],"minLength":1,"maxLength":64,"description":"Set exactly while `status` is `active`.","example":"timer"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["tour","status","step","updatedAt"]},"PutAssistantTourProgress":{"oneOf":[{"type":"object","properties":{"status":{"type":"string","enum":["active"]},"step":{"type":"string","minLength":1,"maxLength":64,"description":"The step the tour stands at. The steps are the web app’s own; an unknown one restarts the tour there.","example":"timer"}},"required":["status","step"]},{"type":"object","properties":{"status":{"type":"string","enum":["completed","dismissed"]}},"required":["status"]}]},"WatchCredential":{"type":"object","properties":{"token":{"type":"string","minLength":1},"expiresAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["token","expiresAt"]},"AppInstallationCheckIn":{"type":"object","properties":{"installationId":{"type":"string","format":"uuid"},"app":{"type":"string","enum":["desktop","cli","ios","browser-extension","raycast"]},"version":{"type":"string","maxLength":64,"pattern":"^\\d+\\.\\d+\\.\\d+$"},"platform":{"type":"string","minLength":1,"maxLength":64},"architecture":{"type":"string","minLength":1,"maxLength":64}},"required":["installationId","app","version","platform","architecture"]},"ImpersonationSession":{"type":"object","properties":{"sessionToken":{"type":"string","minLength":1},"sessionId":{"type":"string","minLength":1},"expiresAt":{"type":"string","format":"date-time"},"user":{"type":"object","properties":{"id":{"type":"string","minLength":1},"name":{"type":"string"},"email":{"type":"string","format":"email"},"image":{"type":["string","null"]}},"required":["id","name","email","image"]}},"required":["sessionToken","sessionId","expiresAt","user"]},"PlatformAppVersions":{"type":"object","properties":{"activeWindowDays":{"type":"integer","exclusiveMinimum":0},"versions":{"type":"array","items":{"type":"object","properties":{"app":{"type":"string"},"version":{"type":"string"},"outdated":{"type":"boolean"},"recentInstallations":{"type":"integer","minimum":0},"knownInstallations":{"type":"integer","minimum":0},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"users":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"name":{"type":"string","minLength":1,"maxLength":200},"email":{"type":"string","format":"email"},"avatarUrl":{"type":["string","null"]},"recentInstallations":{"type":"integer","minimum":0},"knownInstallations":{"type":"integer","exclusiveMinimum":0},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["userId","name","email","avatarUrl","recentInstallations","knownInstallations","lastSeenAt"]}}},"required":["app","version","outdated","recentInstallations","knownInstallations","lastSeenAt","users"]}}},"required":["activeWindowDays","versions"]},"PlatformUsage":{"type":"object","properties":{"windowDays":{"type":"integer","exclusiveMinimum":0},"areas":{"type":"array","items":{"type":"object","properties":{"area":{"type":"string","enum":["time-entries","ticket-references","breaks","absences","work-schedules","period-close","day-tab-calendar","day-tab-max","invoices","recurring-billing","fixed-price","time-allowances","budgets","budget-reports","planning","planning-notes","customer-portal","dashboards","company-metrics","agent-usage","hints","mcp","slack","easybill","sevdesk","google-calendar","harvest","app-desktop","app-cli","app-ios","app-raycast","app-browser-extension"]},"kind":{"type":"string","enum":["activity","setup","held"]},"organizationsUsing":{"type":"integer","minimum":0},"organizationsEligible":{"type":"integer","minimum":0}},"required":["area","kind","organizationsUsing","organizationsEligible"]}},"organizations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"notInEffect":{"type":"array","items":{"type":"string","enum":["time-entries","ticket-references","breaks","absences","work-schedules","period-close","day-tab-calendar","day-tab-max","invoices","recurring-billing","fixed-price","time-allowances","budgets","budget-reports","planning","planning-notes","customer-portal","dashboards","company-metrics","agent-usage","hints","mcp","slack","easybill","sevdesk","google-calendar","harvest","app-desktop","app-cli","app-ios","app-raycast","app-browser-extension"]}},"usage":{"type":"array","items":{"type":"object","properties":{"area":{"type":"string","enum":["time-entries","ticket-references","breaks","absences","work-schedules","period-close","day-tab-calendar","day-tab-max","invoices","recurring-billing","fixed-price","time-allowances","budgets","budget-reports","planning","planning-notes","customer-portal","dashboards","company-metrics","agent-usage","hints","mcp","slack","easybill","sevdesk","google-calendar","harvest","app-desktop","app-cli","app-ios","app-raycast","app-browser-extension"]},"count":{"type":"integer","minimum":0},"sinceAt":{"type":"string","format":"date-time"},"lastAt":{"type":"string","format":"date-time"}},"required":["area","count","sinceAt","lastAt"]}}},"required":["id","name","slug","icon","notInEffect","usage"]}}},"required":["windowDays","areas","organizations"]},"PlatformOrganizations":{"type":"object","properties":{"organizations":{"type":"array","items":{"$ref":"#/components/schemas/PlatformOrganization"}}},"required":["organizations"]},"PlatformOrganization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"createdAt":{"type":"string","format":"date-time"},"memberCount":{"type":"integer","minimum":0},"lastActivityAt":{"type":["string","null"],"format":"date-time"},"trackedSecondsLast7Days":{"type":"integer","minimum":0},"trackedSecondsPrevious7Days":{"type":"integer","minimum":0},"activePeopleLast7Days":{"type":"integer","minimum":0},"excludedFromAnalytics":{"type":"boolean"}},"required":["id","name","slug","icon","createdAt","memberCount","lastActivityAt","trackedSecondsLast7Days","trackedSecondsPrevious7Days","activePeopleLast7Days","excludedFromAnalytics"]},"PlatformPushLogs":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/PlatformPushLogEntry"}},"truncated":{"type":"boolean"}},"required":["entries","truncated"]},"PlatformPushLogEntry":{"type":"object","properties":{"timestamp":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"level":{"type":"string","enum":["debug","info","warn","error","fatal"]},"event":{"type":"string"},"message":{"type":"string"},"outcome":{"type":["string","null"]},"pushType":{"type":["string","null"]},"tokenKind":{"type":["string","null"]},"platform":{"type":["string","null"]},"kind":{"type":["string","null"]},"activityId":{"type":["string","null"]},"reason":{"type":["string","null"]},"detail":{"type":["string","null"]}},"required":["timestamp","level","event","message","outcome","pushType","tokenKind","platform","kind","activityId","reason","detail"]},"PlatformOrganizationDetail":{"allOf":[{"$ref":"#/components/schemas/PlatformOrganization"},{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/PlatformOrganizationMember"}},"features":{"type":"array","items":{"$ref":"#/components/schemas/OperatorFeature"}},"switchedOff":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationFeature"}}},"required":["members","features","switchedOff"]}]},"PlatformOrganizationMember":{"type":"object","properties":{"status":{"type":"string","enum":["active","invited"]},"userId":{"type":["string","null"]},"name":{"type":["string","null"]},"email":{"type":"string"},"role":{"type":"string"},"lastLoginAt":{"type":["string","null"],"format":"date-time"},"invitedAt":{"type":["string","null"],"format":"date-time"},"invitationExpired":{"type":"boolean"},"appInstallations":{"type":"array","items":{"$ref":"#/components/schemas/PlatformAppInstallation"}},"features":{"type":"array","items":{"$ref":"#/components/schemas/OperatorFeature"}}},"required":["status","userId","name","email","role","lastLoginAt","invitedAt","invitationExpired","appInstallations","features"]},"PlatformAppInstallation":{"type":"object","properties":{"installationId":{"type":"string","format":"uuid"},"app":{"type":"string"},"version":{"type":"string"},"platform":{"type":"string"},"architecture":{"type":"string"},"firstSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["installationId","app","version","platform","architecture","firstSeenAt","lastSeenAt"]},"OperatorFeature":{"type":"string","enum":["harvest","company-metrics","agent-usage","day-export","tour","jami","jami-notes","max-view","entry-split"],"description":"A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG).","example":"harvest"},"OrganizationFeature":{"type":"string","enum":["planning","customer-portal","tickets","absences"],"description":"A feature an organization switches off for itself; it is on until it does (ADR-2026-10-25-09PNG).","example":"planning"},"PlatformAccessLog":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/PlatformAccessLogEntry"}}},"required":["entries"]},"PlatformAccessLogEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"actorUserId":{"type":"string"},"actorName":{"type":"string"},"actorEmail":{"type":"string"},"action":{"type":"string"},"organizationId":{"type":["string","null"]},"organizationName":{"type":["string","null"]},"recordedAt":{"type":"string","format":"date-time"}},"required":["id","actorUserId","actorName","actorEmail","action","organizationId","organizationName","recordedAt"]},"PlatformPushTokens":{"type":"object","properties":{"iosNotificationTokens":{"type":"array","items":{"$ref":"#/components/schemas/PlatformIosNotificationPushToken"}},"widgetTokens":{"type":"array","items":{"$ref":"#/components/schemas/PlatformWidgetPushToken"}},"liveActivityStartToken":{"$ref":"#/components/schemas/PlatformLiveActivityStartToken"},"liveActivityUpdateTokens":{"type":"array","items":{"$ref":"#/components/schemas/PlatformLiveActivityUpdateToken"}}},"required":["iosNotificationTokens","widgetTokens","liveActivityStartToken","liveActivityUpdateTokens"]},"PlatformIosNotificationPushToken":{"type":"object","properties":{"organizationId":{"type":"string"},"installationId":{"type":"string","format":"uuid"},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["organizationId","installationId","environment","lastSeenAt","createdAt","updatedAt"]},"PlatformWidgetPushToken":{"type":"object","properties":{"organizationId":{"type":"string"},"platform":{"type":"string","enum":["ios","watchos"]},"kind":{"type":"string"},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["organizationId","platform","kind","environment","lastSeenAt","createdAt","updatedAt"]},"PlatformLiveActivityStartToken":{"type":["object","null"],"properties":{"organizationId":{"type":"string"},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["organizationId","environment","lastSeenAt","createdAt","updatedAt"]},"PlatformLiveActivityUpdateToken":{"type":"object","properties":{"organizationId":{"type":"string"},"activityId":{"type":"string"},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["organizationId","activityId","environment","lastSeenAt","createdAt","updatedAt"]},"PlatformReleases":{"type":"object","properties":{"web":{"type":"object","properties":{"deployWorkflow":{"type":["object","null"],"properties":{"status":{"type":"string"},"conclusion":{"type":["string","null"]},"htmlUrl":{"type":"string","format":"uri"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"headSha":{"type":"string"},"headCommitMessage":{"type":["string","null"]}},"required":["status","conclusion","htmlUrl","updatedAt","headSha","headCommitMessage"]},"activeDeployWorkflows":{"type":"array","items":{"$ref":"#/components/schemas/PlatformWorkflowRun"}},"pendingCiWorkflows":{"type":"array","items":{"$ref":"#/components/schemas/PlatformWorkflowRun"}},"blockingCiWorkflow":{"type":["object","null"],"properties":{"status":{"type":"string"},"conclusion":{"type":["string","null"]},"htmlUrl":{"type":"string","format":"uri"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"headSha":{"type":"string"},"headCommitMessage":{"type":["string","null"]}},"required":["status","conclusion","htmlUrl","updatedAt","headSha","headCommitMessage"]}},"required":["deployWorkflow","activeDeployWorkflows","pendingCiWorkflows","blockingCiWorkflow"]},"cli":{"$ref":"#/components/schemas/PlatformProductRelease"},"desktop":{"$ref":"#/components/schemas/PlatformProductRelease"},"extension":{"$ref":"#/components/schemas/PlatformProductRelease"},"safariExtension":{"$ref":"#/components/schemas/PlatformProductRelease"},"ios":{"$ref":"#/components/schemas/PlatformProductRelease"},"raycast":{"$ref":"#/components/schemas/PlatformProductRelease"}},"required":["web","cli","desktop","extension","safariExtension","ios","raycast"]},"PlatformWorkflowRun":{"type":"object","properties":{"status":{"type":"string"},"conclusion":{"type":["string","null"]},"htmlUrl":{"type":"string","format":"uri"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"headSha":{"type":"string"},"headCommitMessage":{"type":["string","null"]}},"required":["status","conclusion","htmlUrl","updatedAt","headSha","headCommitMessage"]},"PlatformProductRelease":{"type":"object","properties":{"version":{"type":["string","null"]},"tag":{"type":["string","null"]},"releaseUrl":{"type":["string","null"],"format":"uri"},"releaseWorkflow":{"type":["object","null"],"properties":{"status":{"type":"string"},"conclusion":{"type":["string","null"]},"htmlUrl":{"type":"string","format":"uri"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"headSha":{"type":"string"},"headCommitMessage":{"type":["string","null"]}},"required":["status","conclusion","htmlUrl","updatedAt","headSha","headCommitMessage"]},"activeReleaseWorkflows":{"type":"array","items":{"$ref":"#/components/schemas/PlatformWorkflowRun"}}},"required":["version","tag","releaseUrl","releaseWorkflow","activeReleaseWorkflows"]},"AdminAccessRequestList":{"type":"object","properties":{"requests":{"type":"array","items":{"$ref":"#/components/schemas/AdminAccessRequest"}}},"required":["requests"]},"AdminAccessRequest":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":"string","format":"email"},"company":{"type":"string"},"message":{"type":["string","null"]},"status":{"type":"string","enum":["pending","approved","declined"]},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"decidedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"inviteCode":{"type":["string","null"],"description":"The code this request’s approval issued, or null while pending or declined."}},"required":["id","name","email","company","message","status","createdAt","decidedAt","inviteCode"]},"ApproveAccessRequestResult":{"type":"object","properties":{"code":{"type":"string","example":"TIMJ-AAAA-AAAA"},"emailSent":{"type":"boolean","description":"Whether the invite-code email reached the outgoing mail service. The code is issued and visible in the invite-code list either way — a failed send never loses it."}},"required":["code","emailSent"]},"ApproveAccessRequestRequest":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/OperatorFeature"},"description":"The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once.","example":["tour","jami"]}},"required":["features"]},"AdminInviteCodeList":{"type":"object","properties":{"codes":{"type":"array","items":{"$ref":"#/components/schemas/AdminInviteCode"}}},"required":["codes"]},"AdminInviteCode":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"label":{"type":"string"},"email":{"type":["string","null"],"format":"email"},"maxUses":{"type":"integer"},"uses":{"type":"integer"},"expiresAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"revokedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"status":{"type":"string","enum":["active","used-up","expired","revoked"]},"features":{"type":"array","items":{"$ref":"#/components/schemas/OperatorFeature"},"description":"The features an organization founded with this code starts with, in catalogue order (ADR-2026-09-30-17KCB)."},"redemptions":{"type":"array","items":{"$ref":"#/components/schemas/InviteCodeRedemption"}}},"required":["id","code","label","email","maxUses","uses","expiresAt","revokedAt","createdAt","status","features","redemptions"]},"InviteCodeRedemption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"userId":{"type":["string","null"],"description":"Null once the account was deleted; the redemption still counts as a use of the code."},"userEmail":{"type":["string","null"],"format":"email"},"userName":{"type":["string","null"]},"redeemedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"organizationId":{"type":["string","null"]},"organizationName":{"type":["string","null"]}},"required":["id","userId","userEmail","userName","redeemedAt","organizationId","organizationName"]},"CreateInviteCodeRequest":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/OperatorFeature"},"description":"The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once.","example":["tour","jami"]},"label":{"type":"string","minLength":1,"maxLength":200},"email":{"type":"string","format":"email"},"maxUses":{"type":"integer","minimum":1,"maximum":10000},"expiresAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["features","label","maxUses","expiresAt"]},"McpConsentRequest":{"type":"object","properties":{"clientName":{"type":["string","null"]},"redirectHost":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"}},"grants":{"type":"object","properties":{"hintsReadEnabled":{"type":"boolean"},"budgetWriteEnabled":{"type":"boolean"},"moneyReadEnabled":{"type":"boolean"},"billingWriteEnabled":{"type":"boolean"}},"required":["hintsReadEnabled","budgetWriteEnabled","moneyReadEnabled","billingWriteEnabled"]},"unavailable":{"type":"array","items":{"type":"string"}}},"required":["clientName","redirectHost","scopes","grants","unavailable"]},"McpConsentDecision":{"type":"object","properties":{"redirectURI":{"type":"string","format":"uri","description":"Where the browser must be sent to hand the result back to the client."}},"required":["redirectURI"]},"McpGrant":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"clientId":{"type":"string","description":"The OAuth client id. A lookup key chosen by the client, never a label."},"clientName":{"type":["string","null"],"description":"The name chosen by the dynamically registered client. Advisory only."},"redirectHost":{"type":["string","null"],"description":"Host of the redirect URI verified during the most recent approval."},"organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"]},"budgetWriteEnabled":{"type":"boolean","description":"Live second gate for ordinary budget writes. It never adds a scope to an access token."},"canEnableBudgetWrite":{"type":"boolean","description":"Whether current membership and role allow the owner to turn budget writing on now."},"moneyReadEnabled":{"type":"boolean","description":"Live second gate for OAuth money reads — invoices, their lines and amounts, write-offs, what is unbilled, and organization-wide revenue (invariant 79). It never adds a scope to an access token, and the current role still decides what each read answers."},"canEnableMoneyRead":{"type":"boolean","description":"Whether the owner is still a member of the grant’s organization and may turn money reading on now."},"billingWriteEnabled":{"type":"boolean","description":"Live second gate for what a client writes towards a bill: invoice drafts, monthly allowances and fixed-price work (ADR-2026-11-03-156MW). It never adds a scope to an access token, and the current role still decides what each write may do."},"canEnableBillingWrite":{"type":"boolean","description":"Whether the owner is still a member of the grant’s organization and may turn billing writes on now."},"hintsReadEnabled":{"type":"boolean","description":"Live second gate for reading your hints back through this client. Off unless turned on here or on the consent screen; the `thymian:hints.read` scope alone is not enough."},"canEnableHintsRead":{"type":"boolean","description":"Whether the owner is still a member of the grant’s organization and may turn hint reading on now."},"scopes":{"type":"array","items":{"type":"string"},"description":"The Thymian scopes the client’s newest access token carries — what it was allowed on the consent screen. Empty once no token is left."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","clientId","clientName","redirectHost","organization","budgetWriteEnabled","canEnableBudgetWrite","moneyReadEnabled","canEnableMoneyRead","billingWriteEnabled","canEnableBillingWrite","hintsReadEnabled","canEnableHintsRead","scopes","createdAt","updatedAt"]},"CustomerList":{"type":"object","properties":{"data":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Customer"},{"type":"object","properties":{"activeProjectCount":{"type":"integer","minimum":0,"description":"Non-archived projects this viewer may see for this customer (invariant 51 — a private project counts only for a member of it).","example":3}},"required":["activeProjectCount"]}]}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"Customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Acme GmbH"},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"code":{"type":["string","null"],"maxLength":32,"example":"ACME"},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"colorSource":{"type":"string","enum":["default","suggested","manual"],"description":"`default` — nobody has touched it. `suggested` — the logo-colour batch action set it. `manual` — a person set it through create or update, and it blocks both the batch action and the suggestion picker’s pre-selection."},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","aliases","code","color","colorSource","icon","archivedAt","createdAt","updatedAt"]},"CreateCustomer":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"code":{"type":["string","null"],"maxLength":32},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}},"required":["name"]},"CustomerSetupResult":{"type":"object","properties":{"customer":{"$ref":"#/components/schemas/Customer"},"project":{"anyOf":[{"$ref":"#/components/schemas/Project"},{"type":"null"}]},"billingRule":{"anyOf":[{"$ref":"#/components/schemas/BillingRule"},{"type":"null"}]},"easybill":{"anyOf":[{"allOf":[{"$ref":"#/components/schemas/EasybillCustomerMapping"},{"type":"object","properties":{"status":{"type":"string","enum":["mapped"]}},"required":["status"]}]},{"type":"object","properties":{"status":{"type":"string","enum":["not-mapped"]},"reason":{"type":"string","enum":["not-connected","not-found","rate-limited","refused","unavailable"]},"detail":{"type":"string","description":"Why, in German, ready to show."}},"required":["status","reason","detail"]},{"type":"null"}],"description":"`null` when the request named no Easybill customer."},"droppedRateRules":{"type":"array","items":{"$ref":"#/components/schemas/DroppedRateRule"}}},"required":["customer","project","billingRule","easybill","droppedRateRules"]},"Project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Website Relaunch"},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"rules":{"type":"array","items":{"$ref":"#/components/schemas/RateRule"},"description":"The project’s billing rules, least specific first. Empty means nothing on this project has a rate."},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"viewerMayWrite":{"type":"boolean"},"memberCount":{"type":"integer","minimum":0},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"iconInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"},"colorInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"closedThroughDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","customerId","name","aliases","rules","visibility","viewerMayWrite","memberCount","color","icon","iconInheritedFrom","colorInheritedFrom","archivedAt","closedThroughDay","createdAt","updatedAt"]},"RateRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"taskId":{"type":["string","null"],"format":"uuid","description":"The task this rule is for, or null for any task."},"personId":{"type":["string","null"],"format":"uuid","description":"The person this rule is for, or null for anybody.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"The budget whose hours this rule prices, or null for any budget — hours no budget covers included. An hour is priced by the pot the allocation put it in, so a rule naming one prices exactly those seconds."},"validFromMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month this rule’s version starts in, or null for \"from the beginning\". The next version replaces it whole."},"hourlyRateMinor":{"type":"integer","minimum":0,"description":"Hourly rate in minor units of the organization currency. Never null: no rate is the absence of a rule.","example":8500}},"required":["id","taskId","personId","budgetId","validFromMonth","hourlyRateMinor"]},"VisualProvenance":{"type":["object","null"],"properties":{"kind":{"type":"string","enum":["project","customer"]},"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["kind","id","name"]},"BillingRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["active","paused"]},"pausedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"projectMode":{"type":"string","enum":["selected","all"],"description":"`selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later."},"recurrence":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"dueDayOfMonth":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstPeriodStart":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"historyMode":{"type":"string","enum":["separate-periods","collect-history","handle-later"],"description":"The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month."},"catchUpThroughPeriod":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"hasProposals":{"type":"boolean","description":"Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused."},"responsiblePersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people who receive due/escalation email for this rule."},"coverage":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"source":{"type":"string","enum":["explicit","dynamic-all","history"],"description":"`history`: a closed row over months before the first period that the rule took on at setup, starting at the sentinel month `1900-01`."},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["projectId","projectName","source","validFromMonth","validUntilMonth"]}},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","customerId","customerName","name","status","pausedAt","projectMode","recurrence","dueDayOfMonth","selectionMode","defaultGrouping","firstPeriodStart","historyMode","catchUpThroughPeriod","hasProposals","responsiblePersonIds","coverage","createdAt","updatedAt"]},"EasybillCustomerMapping":{"type":"object","properties":{"easybillCustomerId":{"type":"string","example":"4711"},"easybillCustomerName":{"type":["string","null"],"example":"Muster GmbH"}},"required":["easybillCustomerId","easybillCustomerName"]},"DroppedRateRule":{"type":"object","properties":{"reason":{"type":"string","enum":["budget","task","person"],"description":"`budget`: a budget rule, never copied. `task`: no new task carries its task’s name. `person`: its person is a former member, whose membership ended or whose account was deleted."},"taskName":{"type":["string","null"]},"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":["string","null"]},"budgetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetName":{"type":["string","null"]},"hourlyRateMinor":{"type":"integer","minimum":0}},"required":["reason","taskName","personId","personName","budgetId","budgetName","hourlyRateMinor"]},"CustomerSetup":{"type":"object","properties":{"customer":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"code":{"type":["string","null"],"maxLength":32}},"required":["name"]},"easybill":{"type":"object","properties":{"easybillCustomerId":{"type":"string","minLength":1,"example":"4711"}},"required":["easybillCustomerId"],"description":"The Easybill customer to map, read back from Easybill before anything is written. Needs `easybill.map`. When the read fails, everything else is still created and `easybill` in the answer says why the mapping is missing."},"project":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"hourlyRateMinor":{"type":"integer","minimum":0,"description":"The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`.","example":9900},"rateTemplateProjectId":{"type":"string","format":"uuid","description":"A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"tasks":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"billingMode":{"type":"string","enum":["hourly","productive","internal"],"description":"The billing mode to set. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === \"hourly\"`), otherwise the write answers 422."},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."}},"required":["name"]},"maxItems":100,"description":"The tasks to create. No two may share a name, compared case-insensitively."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"The people to assign. You are assigned anyway, named here or not."}},"required":["name"],"description":"The first project."},"billing":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"A human-readable name for the rule."},"recurrence":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"dueDayOfMonth":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstPeriodStart":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first period, normalized to a quarter start when `recurrence` is `quarterly`. Defaults to the current month; a period that has already ended is refused."},"responsiblePersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people reminded when a proposal is due. Defaults to you."}},"required":["name","recurrence","dueDayOfMonth","selectionMode","defaultGrouping"],"description":"A recurring billing rule for all of the customer’s projects. Needs `invoice.write`. Older hours are left for later — a new customer has none — so the rule produces no proposal before its first period ends and can be deleted until then."}},"required":["customer"]},"CustomerSetupTemplate":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"tasks":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it."},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"hourly\"`, so it is false for every other mode, including work that is billed but not by the hour."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."}},"required":["name","billingMode","isBillable","isProductive","isCatchAll"]},"description":"The project’s tasks that are not archived, by name."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people assigned to the project, without former members — somebody whose membership ended or whose account was deleted. A colleague who never had an account is included. The same people a template copy keeps person rates for."}},"required":["projectId","visibility","tasks","personIds"]},"BatchSuggestColorsResult":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/BatchColorSuggestionOutcome"}}},"required":["results"]},"BatchColorSuggestionOutcome":{"oneOf":[{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"outcome":{"type":"string","enum":["applied"]},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}},"required":["customerId","outcome","color"]},{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"outcome":{"type":"string","enum":["skipped"]},"reason":{"type":"string","enum":["manual","no-suggestion","not-found"],"description":"`manual` — a person already chose this customer’s colour. `no-suggestion` — no icon, an unsupported format, or no usable colour in it. `not-found` — no such customer in this organization."}},"required":["customerId","outcome","reason"]}]},"BatchSuggestColors":{"type":"object","properties":{"customerIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":200}},"required":["customerIds"]},"ColorSuggestions":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/ColorSuggestion"}},"best":{"anyOf":[{"$ref":"#/components/schemas/ColorSuggestion"},{"type":"null"}]}},"required":["suggestions","best"]},"ColorSuggestion":{"type":"object","properties":{"hex":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"name":{"type":"string","enum":["Vibrant","DarkVibrant","LightVibrant","Muted","DarkMuted","LightMuted"],"description":"node-vibrant's own swatch name — stable, not translated."},"population":{"type":"integer","minimum":0,"description":"How many pixels of the icon this swatch covers; the ranking key."}},"required":["hex","name","population"]},"PeriodCloseResult":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"moved":{"type":"boolean"},"fromDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"closedThroughDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["projectId","moved","fromDay","closedThroughDay"]}}},"required":["projects"]},"MoveCustomerPeriodClose":{"allOf":[{"$ref":"#/components/schemas/MovePeriodClose"},{"type":"object","properties":{"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"description":"Only these projects of the customer, still in one transaction. Omitted means every project of the customer; an id that is not one of them is 422."}}}]},"MovePeriodClose":{"type":"object","properties":{"closedThroughDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The last day to close, inclusive — `null` opens everything again. A day later than the one stored is a **close** (owner, admin or `billing`); an earlier one, `null` included, is an **opening** (owner or admin) and needs `reason`."},"reason":{"type":"string","maxLength":500,"description":"Why the period is being opened again. **Required** — 422 with `reasonRequired` — for any backwards movement, and ignored for a close: closing is a statement of completeness, and the completeness is the reason. Kept with the movement, and shown in the project’s history."}},"required":["closedThroughDay"]},"UpdateCustomer":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"code":{"type":["string","null"],"maxLength":32},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}}},"ProjectListItem":{"allOf":[{"$ref":"#/components/schemas/ProjectDetail"},{"type":"object","properties":{"members":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["personId"]},"description":"Who is assigned. Resolve to a name/avatar client-side via the org’s member-avatars endpoint rather than here — this list already fetches that once for the whole page."}},"required":["members"]}]},"ProjectDetail":{"allOf":[{"$ref":"#/components/schemas/Project"},{"type":"object","properties":{"hoursThisMonthSeconds":{"type":"integer","minimum":0,"description":"All tracked seconds this month, billable or not — an entry still running has none yet."},"unbilled":{"type":["object","null"],"properties":{"seconds":{"type":"integer"},"amountMinor":{"type":"integer"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"unratedSeconds":{"type":"integer"}},"required":["seconds","amountMinor","currency","unratedSeconds"],"description":"Present only with `includeUnbilled=true`; absent otherwise, which is not the same as `null` (nothing outstanding). Computing it reads the whole organization’s open time, so ask for it only when the screen shows the amount."},"lastActivityAt":{"type":["string","null"],"format":"date-time","description":"Most recent time entry’s start, across everyone. `null` = never tracked.","example":"2026-07-29T09:00:00.000Z"},"running":{"type":"boolean","description":"Somebody has a timer running on this project."}},"required":["hoursThisMonthSeconds","lastActivityAt","running"]}]},"CreateProject":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"rules":{"type":"array","items":{"$ref":"#/components/schemas/WriteRateRule"}},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}},"required":["customerId","name"]},"WriteRateRule":{"type":"object","properties":{"taskId":{"type":["string","null"],"format":"uuid","description":"The task this rule is for. Omit or null for any task.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":["string","null"],"format":"uuid","description":"The person this rule is for. Omit or null for anybody.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"The budget whose hours this rule prices. Omit or null for any budget. Must be a budget that can hold this project’s hours — the ones covering it, plus anything downstream of them by overflow — or the request is refused: a rule naming a pot the project cannot reach prices nothing, and a silent blank is what this model exists to avoid.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month of the version this rule belongs to. Omit or null for \"from the beginning\" — an assertion somebody makes, not a default the server invents."},"hourlyRateMinor":{"type":"integer","minimum":0,"description":"Hourly rate in minor units. Never null — leave the rule out instead.","example":9900}},"required":["hourlyRateMinor"]},"ProjectSetupResult":{"type":"object","properties":{"project":{"$ref":"#/components/schemas/Project"},"droppedRateRules":{"type":"array","items":{"$ref":"#/components/schemas/DroppedRateRule"}}},"required":["project","droppedRateRules"]},"ProjectSetup":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"The customer the project is for. 404 when you cannot see it.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"project":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"hourlyRateMinor":{"type":"integer","minimum":0,"description":"The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`.","example":9900},"rateTemplateProjectId":{"type":"string","format":"uuid","description":"A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"tasks":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"billingMode":{"type":"string","enum":["hourly","productive","internal"],"description":"The billing mode to set. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === \"hourly\"`), otherwise the write answers 422."},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."}},"required":["name"]},"maxItems":100,"description":"The tasks to create. No two may share a name, compared case-insensitively."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"The people to assign. You are assigned anyway, named here or not."}},"required":["name"],"description":"The project."}},"required":["customerId","project"]},"UpdateProject":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"aliases":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"description":"Alternative names. They are included in resource search and when filtering task pickers.","example":["Acme Corp","ACME"]},"visibility":{"type":"string","enum":["public","private"],"description":"Who may see the project. `public` is the whole organization."},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}}},"PeriodCloseChange":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"fromDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"toDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"reason":{"type":["string","null"]},"movedByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"movedByName":{"type":["string","null"]},"movedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","projectId","fromDay","toDay","reason","movedByPersonId","movedByName","movedAt"]},"ProjectMember":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"The assigned person.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","projectId","personId","createdAt","updatedAt"]},"UpsertProjectMember":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["personId"]},"RateRules":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RateRule"}},"exposureByMonth":{"type":"array","items":{"$ref":"#/components/schemas/RateExposureMonth"}},"pricingBudgets":{"type":"array","items":{"$ref":"#/components/schemas/PricingBudget"}},"namedBudgets":{"type":"array","items":{"$ref":"#/components/schemas/RetiredPricingBudget"}}},"required":["data","exposureByMonth","pricingBudgets","namedBudgets"]},"RateExposureMonth":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"trackedSeconds":{"type":"integer","example":79200},"invoicedSeconds":{"type":"integer","description":"Of those, the seconds a live invoice claims. What makes a change refusable.","example":36000}},"required":["month","trackedSeconds","invoicedSeconds"]},"PricingBudget":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","example":"Übertrag alte Konditionen"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"entersDirectly":{"type":"boolean","description":"Whether this project’s hours enter this budget directly, or whether it is only reachable from it by overflow. Both can be priced; the difference is what a picker has to say to be readable."}},"required":["id","name","validFromMonth","validUntilMonth","entersDirectly"]},"RetiredPricingBudget":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","example":"Übertrag alte Konditionen"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"archived":{"type":"boolean","description":"Whether the budget was archived. `false` means it still exists but its chain no longer reaches this project — a different sentence for a screen to say."}},"required":["id","name","validFromMonth","validUntilMonth","archived"]},"PutRateRules":{"type":"object","properties":{"rules":{"type":"array","items":{"$ref":"#/components/schemas/WriteRateRule"}}},"required":["rules"]},"ProjectWriteOffCut":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"entries":{"anyOf":[{"$ref":"#/components/schemas/WriteOffCut"},{"type":"null"}]},"allowance":{"anyOf":[{"$ref":"#/components/schemas/AllowanceWriteOffCut"},{"type":"null"}]}},"required":["currency","entries","allowance"]},"WriteOffCut":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"seconds":{"type":"integer","description":"Everything waived, over every rate."},"amountMinor":{"type":"integer","description":"The sum of the per-rate rows, which are sums of per-claim amounts."},"entryCount":{"type":"integer","description":"How many entries contribute at least one claim."},"rates":{"type":"array","items":{"$ref":"#/components/schemas/WriteOffRateTotal"}},"claims":{"type":"array","items":{"$ref":"#/components/schemas/WriteOffClaim"}}},"required":["currency","seconds","amountMinor","entryCount","rates","claims"]},"WriteOffRateTotal":{"type":"object","properties":{"rateMinor":{"type":"integer"},"seconds":{"type":"integer"},"amountMinor":{"type":"integer","description":"The sum of this rate’s claims, each already rounded on its own — never the summed seconds rounded again, or a row and the claims under it would be cents apart."},"claimCount":{"type":"integer"}},"required":["rateMinor","seconds","amountMinor","claimCount"]},"WriteOffClaim":{"type":"object","properties":{"entryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"seconds":{"type":"integer","description":"The seconds this claim waives — the free stretch of the entry that this rate priced, never the entry’s whole duration."},"rateMinor":{"type":"integer","description":"What these seconds would have been billed at, in minor units per hour. Never null: a write-off records the amount it waives, which is the decision being made (ADR-2026-08-24-11D67 decision 4)."},"amountMinor":{"type":"integer","description":"What was waived, rounded **once over this claim’s own seconds** — the write-off’s own rounding rule, deliberately not an invoice line’s."},"startedAt":{"type":"string","format":"date-time","description":"The entry’s start, for the row’s label.","example":"2026-07-29T09:00:00.000Z"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":"string"},"customerName":{"type":"string"},"projectName":{"type":"string"},"taskName":{"type":"string"}},"required":["entryId","seconds","rateMinor","amountMinor","startedAt","personId","personName","customerName","projectName","taskName"]},"AllowanceWriteOffCut":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"seconds":{"type":"integer"},"amountMinor":{"type":"integer"},"months":{"type":"array","items":{"$ref":"#/components/schemas/AllowanceWriteOffMonthCut"}}},"required":["currency","seconds","amountMinor","months"]},"AllowanceWriteOffMonthCut":{"type":"object","properties":{"allowanceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"seconds":{"type":"integer"},"personName":{"type":"string"},"projectName":{"type":"string"},"taskName":{"type":"string"},"amountMinor":{"type":"integer"}},"required":["allowanceId","month","seconds","personName","projectName","taskName","amountMinor"]},"PreviewProjectWriteOff":{"type":"object","properties":{"from":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen\" itself."},"until":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden\" on."}}},"CreatedProjectWriteOff":{"type":"object","properties":{"entries":{"$ref":"#/components/schemas/CreatedProjectEntriesWriteOff"},"allowance":{"$ref":"#/components/schemas/CreatedProjectAllowanceWriteOff"}},"required":["entries","allowance"]},"CreatedProjectEntriesWriteOff":{"type":["object","null"],"properties":{"writeOffId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"cut":{"$ref":"#/components/schemas/WriteOffCut"}},"required":["writeOffId","cut"]},"CreatedProjectAllowanceWriteOff":{"type":["object","null"],"properties":{"writeOffId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"cut":{"$ref":"#/components/schemas/AllowanceWriteOffCut"}},"required":["writeOffId","cut"]},"CreateProjectWriteOff":{"type":"object","properties":{"from":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen\" itself."},"until":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden\" on."},"reason":{"type":"string","minLength":1,"maxLength":2000,"description":"Why these hours and months will never be billed. Required, for the same reason `CreateWriteOff.reason` is.","example":"Projekt nach Vorarbeit abgesagt — Kulanz"}},"required":["reason"]},"Task":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Frontend"},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it."},"fixedPriceAgreementId":{"type":["string","null"],"format":"uuid","description":"The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"fixedPriceAgreement":{"$ref":"#/components/schemas/TaskFixedPriceAgreement"},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"hourly\"`, so it is false for every other mode, including work that is billed but not by the hour."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"iconInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"},"colorInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","projectId","name","displayName","displayNameParts","billingMode","isBillable","isProductive","isCatchAll","color","icon","iconInheritedFrom","colorInheritedFrom","archivedAt","createdAt","updatedAt"]},"TaskFixedPriceAgreement":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","example":"React-Workshop Okt 2026"}},"required":["id","name"]},"BookableTask":{"type":"object","properties":{"task":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]},"isBillable":{"type":"boolean"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"iconInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"}},"required":["id","name","displayName","displayNameParts","isBillable","icon","iconInheritedFrom"]},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}},"required":["id","name","color"]},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200}},"required":["id","name"]},"searchText":{"type":"string","description":"Canonical customer, project and task names plus customer/project aliases and the customer code. Clients apply the documented word-wise local filter to this value.","example":"Acme GmbH ACME Website Relaunch Frontend"}},"required":["task","project","customer","searchText"]},"CreateTask":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === \"hourly\"`), otherwise the write answers 422."},"fixedPriceAgreementId":{"type":["string","null"],"format":"uuid","description":"The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Whether hours on this task are priced by the hour. The task is `hourly` by default, which applies only when `billingMode`, `isBillable` and `isProductive` are all omitted; sending `isProductive: true` alone creates a `productive` task."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."}},"required":["projectId","name"]},"TaskNameSuggestion":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Meetings","description":"The spelling most of the organization’s tasks with this name use."},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"The billing mode most of these tasks carry, as on a task."},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"hourly\"`, so it is false for every other mode, including work that is billed but not by the hour."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"projectCount":{"type":"integer","minimum":1,"description":"How many projects carry the name — what the suggestions are ranked by."}},"required":["name","billingMode","isBillable","isProductive","projectCount"]},"RecentTask":{"type":"object","properties":{"task":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]},"isBillable":{"type":"boolean"},"icon":{"$ref":"#/components/schemas/ResourceIcon"}},"required":["id","name","displayName","displayNameParts","isBillable","icon"]},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"}},"required":["id","name","color"]},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]},"isBillable":{"type":"boolean"},"billableRateMinor":{"type":["integer","null"],"minimum":0,"description":"Hourly rate in minor units of the organization currency. Always hourly, never a day rate.","example":8500},"rateAxes":{"type":["array","null"],"items":{"type":"string","enum":["taskId","personId","budgetId"]},"description":"Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer.","example":["taskId"]},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"lastBookedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"entryCount":{"type":"integer","example":12}},"required":["task","project","customer","isBillable","billableRateMinor","rateAxes","currency","lastBookedAt","entryCount"]},"UpdateTask":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === \"hourly\"`), otherwise the write answers 422."},"fixedPriceAgreementId":{"type":["string","null"],"format":"uuid","description":"Attach the task to a fixed-price agreement, or `null` to detach it. `fixed_price` requires one, `hourly` and `productive` forbid one, and `internal` may carry one. Neither this nor `billingMode` changes once the task has entries, for any task that has or would get an agreement: answers **409**, with no owner or admin exception.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"isBillable":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. `true` makes the task `hourly`. `false` makes it `internal`, or leaves it `productive` when it already is."},"isProductive":{"type":"boolean","deprecated":true,"description":"Deprecated, use `billingMode`. Derived: `billingMode === \"productive\"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."},"isCatchAll":{"type":"boolean","description":"Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled."}}},"SearchResult":{"type":"object","properties":{"customers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Acme GmbH"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","icon","color","archivedAt"]}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Website Relaunch"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","color","archivedAt"]}},"required":["id","name","icon","color","archivedAt","customer"]}},"tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Frontend"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]},"isBillable":{"type":"boolean"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","color","archivedAt"]},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","color","archivedAt"]}},"required":["id","name","icon","displayName","displayNameParts","isBillable","archivedAt","project","customer"]}}},"required":["customers","projects","tasks"]},"TimeEntryList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeEntryWithContext"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"TimeEntryWithContext":{"allOf":[{"$ref":"#/components/schemas/TimeEntry"},{"type":"object","properties":{"task":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]},"isBillable":{"type":"boolean"}},"required":["id","name","displayName","displayNameParts","isBillable"]},"project":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"iconInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"},"colorInheritedFrom":{"$ref":"#/components/schemas/VisualProvenance"}},"required":["id","name","color","icon","iconInheritedFrom","colorInheritedFrom"]},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"}},"required":["id","name","color","icon"]},"updatedAtPrecise":{"type":"string"},"harvestExport":{"type":["object","null"],"properties":{"harvestTimeEntryId":{"type":"string"},"syncedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"origin":{"type":"string","enum":["export","import"],"description":"Which side ran the timer: `import` means Harvest did, `export` means this entry was tracked here and written to Harvest. It decides whether Harvest can hold sub-minute time this entry does not — for an exported entry Harvest's `hours` is a re-derivation of the `HH:mm` we sent it, and since a minute is 0.0166… h that re-derivation disagrees with our own minutes by up to 12 seconds. Written once at pairing time and never changed. See ADR-2026-08-20-11ZJ9."}},"required":["harvestTimeEntryId","syncedAt","origin"]},"settlement":{"type":["object","null"],"properties":{"settledSeconds":{"type":"integer","minimum":0,"description":"How many of this entry’s seconds a live claim holds, clamped to the entry as it stands **now**. Less than `durationSeconds` means part of it is still open — an entry extended after it was billed owes its tail to „Abzurechnen\" (invariant 17) — and it can be less than the claims add up to, when somebody shortened the entry after it was billed. It is never more.","example":5400},"claims":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The claim row — the same identity `GET /invoices/divergences` publishes as `claimId`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["invoice","external","write-off"],"description":"What settled these seconds. `invoice` is a Timjami invoice, `external` a document in another system (the Harvest era), `write-off` hours waived with a reason. An entry can carry several, of different kinds.","example":"invoice"},"seconds":{"type":"integer","minimum":0,"description":"This one claim’s overlap with the entry as it stands now.","example":5400},"invoiceId":{"type":["string","null"],"format":"uuid","description":"The Timjami invoice, for `kind: \"invoice\"` — and `null` for every claim when the caller does not hold `invoice.read`, along with the other four document fields. That the seconds are settled is stated regardless; which document took them is a billing fact.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"invoiceStatus":{"type":["string","null"],"enum":["draft","transferred","withdrawn",null]},"invoicePeriodStart":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"invoicePeriodEnd":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"writeOffReason":{"type":["string","null"],"description":"Why these hours will never be billed, for `kind: \"write-off\"` — the reason its batch had to state.","example":"Projekt abgebrochen, Aufwand nicht weiterberechenbar"},"externalDocumentRef":{"type":["string","null"],"description":"What the other system calls the document, for `kind: \"external\"` — for Harvest, `harvest:invoice:<id>:<number>`. `null` for a source that named none.","example":"harvest:invoice:13150403:1001"}},"required":["id","kind","seconds","invoiceId","invoiceStatus","invoicePeriodStart","invoicePeriodEnd","writeOffReason","externalDocumentRef"]},"description":"In fill order within the entry, like `rateSlices`. Never empty — an entry nothing settles carries `settlement: null` instead."}},"required":["settledSeconds","claims"]},"policy":{"$ref":"#/components/schemas/EntryWritePolicy"},"externalReferences":{"type":"array","items":{"$ref":"#/components/schemas/ExternalReference"}}},"required":["task","project","customer","updatedAtPrecise","harvestExport","settlement","policy","externalReferences"]}]},"EntryWritePolicy":{"type":["object","null"],"properties":{"times":{"type":"string","enum":["free","reason","locked"],"description":"`startedAt` and `endedAt`."},"task":{"type":"string","enum":["free","reason","locked"],"description":"`free` — nothing is in the way. `reason` — allowed, and the save must carry `editReason`. `locked` — refused for this caller whatever they type."},"notes":{"type":"string","enum":["free","reason","locked"],"description":"Always `free`: a note was never part of what a document claims."},"remove":{"type":"string","enum":["free","reason","locked"],"description":"Deleting, and being absorbed by a merge. `locked` for **everybody** while a claim holds the entry — that is `on delete restrict` in the database, not a policy an owner can pass."},"blockers":{"type":"array","items":{"$ref":"#/components/schemas/PolicyBlocker"},"description":"Empty exactly when every field above is `free`."}},"required":["times","task","notes","remove","blockers"]},"PolicyBlocker":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["claimed"]},"claims":{"type":"array","items":{"type":"string","enum":["invoice","external","write-off"]},"description":"The kinds holding these seconds, in the order every sentence renders them."}},"required":["kind","claims"]},{"type":"object","properties":{"kind":{"type":"string","enum":["period-closed"]},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The closed day this entry’s `startedAt` falls in."},"closedThroughDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"How far the project is closed — inclusive, so `day <= closedThroughDay`."}},"required":["kind","day","closedThroughDay"]},{"type":"object","properties":{"kind":{"type":"string","enum":["backdating-limit"]},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The frozen day this entry’s `startedAt` falls in."},"earliestOpenDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The first day the organization still records time on — **exclusive** of the refusal, so `day < earliestOpenDay`. Organization-wide and derived from a number of months; it moves by itself at a month turn, with nobody acting."}},"required":["kind","day","earliestOpenDay"]}]},"ExternalReference":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"provider":{"type":"string","enum":["linear","jira","azure_devops","github","generic"],"description":"The ticket system. `generic` is any system that is not one of the four named ones — the server never checks the provider against the URL’s host, because a self-hosted Jira or Azure DevOps Server lands on a host nothing here can enumerate.","example":"linear"},"key":{"type":"string","description":"The ticket’s identifier in that system, as that system writes it.","example":"TIM-199"},"url":{"type":["string","null"],"description":"Where the ticket lives. Always `https://`, and `null` where nobody supplied one.","example":"https://linear.app/suora/issue/TIM-199"},"title":{"type":["string","null"],"description":"The ticket’s title as it read when this was attached — a label, never a cache. It is allowed to go stale.","example":"External ticket references on time entries"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["id","provider","key","url","title","createdAt","createdByPersonId"]},"TimeEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated: the better-auth account behind `personId`. Present so clients built before the rename keep parsing; it falls with the `?userId=` query alias. `null` where the person has no account.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"notes":{"type":["string","null"]},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"durationSeconds":{"type":"integer","example":5400},"isRunning":{"type":"boolean"},"isBillable":{"type":"boolean"},"billableRateMinor":{"type":["integer","null"],"minimum":0,"description":"Hourly rate in minor units of the organization currency. Always hourly, never a day rate.","example":8500},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"rateAxes":{"type":["array","null"],"items":{"type":"string","enum":["taskId","personId","budgetId"]},"description":"Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer.","example":["taskId"]},"billableAmountMinor":{"type":["integer","null"],"example":12750},"rateSlices":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":["string","null"],"format":"uuid","description":"The budget that **priced** these seconds, or null when no budget priced them. Not a statement about consumption: when no rule of the project names a budget nothing is allocated at all, so every slice answers null while `/budgets/{id}/consumption` goes on counting the same hours against a pot."},"seconds":{"type":"integer","minimum":0},"rateMinor":{"type":["integer","null"],"minimum":0,"description":"Hourly rate in minor units of the organization currency. Always hourly, never a day rate.","example":8500},"amountMinor":{"type":["integer","null"],"example":8500},"rateAxes":{"type":["array","null"],"items":{"type":"string","enum":["taskId","personId","budgetId"]},"description":"Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer.","example":["taskId"]}},"required":["budgetId","seconds","rateMinor","amountMinor","rateAxes"]},"description":"Which pot holds which of the entry’s seconds, and what each is worth, **in the order the budgets are filled** — contiguous, so their seconds sum to `durationSeconds` and each slice is a stretch of the entry even though no offsets are given. A slice may itself be unpriced, which is how an entry ends up with an amount over only part of its hours — count „billable time with no rate\" from here, never from `billableAmountMinor === null`. The **number** of slices is not a stable property of an entry: an entry no budget rule prices is one slice wherever it appears, and the same entry is split only in a response where some rule does name a budget. The amounts are the same either way, so read the slices, not their count."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","taskId","personId","userId","notes","startedAt","endedAt","durationSeconds","isRunning","isBillable","billableRateMinor","currency","rateAxes","billableAmountMinor","rateSlices","createdAt","updatedAt"]},"StartTimeEntry":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"notes":{"type":["string","null"],"maxLength":2000},"startedAt":{"type":"string","format":"date-time","description":"When the timer started. Defaults to now — useful for \"I forgot to start the timer 10 minutes ago\". Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"stopRunning":{"type":"boolean","default":false},"editReason":{"type":"string","maxLength":500,"description":"Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history."}},"required":["taskId"]},"StopTimeEntry":{"type":"object","properties":{"endedAt":{"type":"string","format":"date-time","description":"When to stop. Defaults to now. Stopping inside the second the timer started in leaves an entry of no length, which is an ordinary row. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"}}},"CreateTimeEntry":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"When it ended. May equal `startedAt`, which records an entry of no length; earlier than it is rejected. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"],"maxLength":2000},"editReason":{"type":"string","maxLength":500,"description":"Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history."}},"required":["taskId","startedAt","endedAt"]},"MergeTimeEntries":{"type":"object","properties":{"entryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":2,"maxItems":10},"notes":{"type":["string","null"],"maxLength":2000}},"required":["entryIds"]},"InsertTimeEntry":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"When it ended — required: the window is what gets cut out. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"],"maxLength":2000},"affected":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"keep":{"type":"boolean"}},"required":["id","keep"]},"maxItems":20,"description":"Every own entry overlapping `[startedAt, endedAt)`, each with the caller’s choice: `keep: true` leaves it overlapping, `keep: false` cuts it. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again."},"editReason":{"type":"string","maxLength":500,"description":"Why hours are being booked, cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends."}},"required":["taskId","startedAt","endedAt","affected"]},"SplitTimeEntryResult":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeEntry"}}},"required":["data"]},"SplitTimeEntry":{"type":"object","properties":{"parts":{"type":"array","items":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"Where this part starts. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"Where this part ends. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"}},"required":["taskId","startedAt","endedAt"]},"minItems":2,"maxItems":12,"description":"The parts in clock order. They must fill the entry exactly: the first starts where it starts, each next one where the one before ends, and the last ends where it ends."},"editReason":{"type":"string","maxLength":500,"description":"Why hours are being split inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends."}},"required":["parts"]},"BreakPeriod":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","startedAt","endedAt","createdAt","updatedAt"]},"InsertBreak":{"type":"object","properties":{"startedAt":{"type":"string","format":"date-time","description":"When the break started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"When it ended. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"affected":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":20,"description":"Every own entry overlapping `[startedAt, endedAt)`; each is cut. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again."},"editReason":{"type":"string","maxLength":500,"description":"Why entries are being cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends."}},"required":["startedAt","endedAt","affected"]},"BatchTimeEntryResult":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"entryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["done","unchanged","failed"]},"refusal":{"$ref":"#/components/schemas/BatchRefusal"}},"required":["entryId","status"]}},"done":{"type":"integer","minimum":0},"unchanged":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0}},"required":["results","done","unchanged","failed"]},"BatchRefusal":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer","description":"The status the single-entry call would have had."},"detail":{"type":"string"}},"required":["type","title","status"],"additionalProperties":{}},"RetaskTimeEntries":{"type":"object","properties":{"entryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":500},"taskId":{"type":"string","format":"uuid","description":"The task every named entry moves onto.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"editReason":{"type":"string","maxLength":500,"description":"Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history."}},"required":["entryIds","taskId"]},"DeleteTimeEntries":{"type":"object","properties":{"entryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":500},"editReason":{"type":"string","maxLength":500,"description":"Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history."}},"required":["entryIds"]},"WritePolicy":{"type":"object","properties":{"permission":{"type":"string","enum":["free","reason","locked"],"description":"`free` — nothing is in the way. `reason` — allowed, and the save must carry `editReason`. `locked` — refused for this caller whatever they type."},"blockers":{"type":"array","items":{"$ref":"#/components/schemas/PolicyBlocker"}}},"required":["permission","blockers"]},"TimeProgress":{"type":"object","properties":{"currency":{"type":"string"},"total":{"$ref":"#/components/schemas/TimeRangeProgress"},"throughToday":{"allOf":[{"$ref":"#/components/schemas/TimeRangeProgress"},{"description":"The same figures, restricted to entries and days up to and including today in the organization's own timezone. Identical to total for a range entirely in the past, all zero for one entirely in the future."}]},"perDay":{"type":"array","items":{"$ref":"#/components/schemas/DayProgress"},"description":"Every day in the range, oldest first, including days after today."}},"required":["currency","total","throughToday","perDay"]},"TimeRangeProgress":{"type":"object","properties":{"trackedSeconds":{"type":"integer","description":"Finished, tracked seconds in the range."},"billableSeconds":{"type":"integer","description":"Of trackedSeconds, how much was recorded on a billable task."},"nonBillableSeconds":{"type":"integer"},"unratedSeconds":{"type":"integer","description":"Billable seconds no rule priced — reported, never billed at zero (invariant 19)."},"billableAmountMinor":{"type":"integer","description":"The sum of every priced slice. Zero, not omitted, when nothing was priced — unratedSeconds says whether that means nothing billable happened or nothing priced what did."},"billableRateMinor":{"type":["integer","null"],"description":"The one rate every priced slice agreed on, or null — which covers both \"nothing priced\" and \"gemischt\", exactly like a single entry's own billableRateMinor."},"entryCount":{"type":"integer"},"targetSeconds":{"type":"integer","description":"What the range was expected to hold: the person's working time model (or the organization's hoursPerDay × Mo–Fr fallback), less absences."},"targetDerived":{"type":"boolean","description":"True when any day's target came from a monthly working time model's pro-rata division rather than a stated daily figure."},"runningIncluded":{"type":"boolean","description":"True when `includeRunning` was asked for and the caller’s own running entry falls inside this range — so a client can mark the figure as still growing."}},"required":["trackedSeconds","billableSeconds","nonBillableSeconds","unratedSeconds","billableAmountMinor","billableRateMinor","entryCount","targetSeconds","targetDerived","runningIncluded"]},"DayProgress":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"trackedSeconds":{"type":"integer","description":"Finished seconds only — the same rule every other figure here follows."},"targetSeconds":{"type":"integer","description":"What this one day was expected to hold, alone — zero for a day nobody works or one an absence covers in full."}},"required":["day","trackedSeconds","targetSeconds"]},"TimeEntryLogEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"action":{"type":"string","enum":["created","updated","deleted","reference.attached","reference.detached"]},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"mergedIntoId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorUserId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated: the better-auth account behind `actorPersonId`. It falls with the `?userId=` query alias.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"actorName":{"type":["string","null"]},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated: the better-auth account behind `personId`. It falls with the `?userId=` query alias. `null` where the person has no account.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"]},"demoDataSetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskName":{"type":["string","null"]},"projectName":{"type":["string","null"]},"customerName":{"type":["string","null"]},"reason":{"type":["string","null"]},"externalReference":{"$ref":"#/components/schemas/RevisionExternalReference"}},"required":["id","timeEntryId","action","recordedAt","mergedIntoId","actorPersonId","actorUserId","actorName","taskId","personId","userId","startedAt","endedAt","notes","demoDataSetId","taskName","projectName","customerName","reason","externalReference"]},"RevisionExternalReference":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"provider":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"key":{"type":"string"},"url":{"type":["string","null"]},"title":{"type":["string","null"]}},"required":["id","provider","key","url","title"]},"TimeEntryLogHead":{"type":"object","properties":{"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"]},"taskName":{"type":["string","null"]},"projectName":{"type":["string","null"]},"customerName":{"type":["string","null"]}},"required":["timeEntryId","taskId","startedAt","endedAt","notes","taskName","projectName","customerName"]},"DeletedTimeEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorName":{"type":["string","null"]},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"]},"taskName":{"type":["string","null"]},"projectName":{"type":["string","null"]},"customerName":{"type":["string","null"]}},"required":["id","timeEntryId","recordedAt","actorPersonId","actorName","taskId","personId","startedAt","endedAt","notes","taskName","projectName","customerName"]},"TimeEntryRevision":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"action":{"type":"string","enum":["created","updated","deleted","reference.attached","reference.detached"]},"position":{"type":"integer","minimum":0},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"mergedIntoId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorName":{"type":["string","null"]},"actorUserId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated: the better-auth account behind `actorPersonId`. Present so clients built before the rename keep reading this log; it falls with the `?userId=` query alias.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated: the better-auth account behind `personId`. Present so clients built before the rename keep reading this log; it falls with the `?userId=` query alias. `null` where the person has no account.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"]},"demoDataSetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskName":{"type":["string","null"]},"projectName":{"type":["string","null"]},"customerName":{"type":["string","null"]},"reason":{"type":["string","null"]},"externalReference":{"$ref":"#/components/schemas/RevisionExternalReference"}},"required":["id","timeEntryId","action","position","recordedAt","mergedIntoId","actorPersonId","actorName","actorUserId","taskId","personId","userId","startedAt","endedAt","notes","demoDataSetId","taskName","projectName","customerName","reason","externalReference"]},"UpdateTimeEntry":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"`null` turns a finished entry back into a running one. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"],"maxLength":2000},"editReason":{"type":"string","maxLength":500,"description":"Why hours that are already billed are being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the request changes `startedAt`, `endedAt` or `taskId` on an entry a live claim is holding, or moves such a fact into or out of a **closed tracking period**, and gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other save. It is kept with the revision this write appends, readable in the entry’s history and beside the entry in `GET /invoices/divergences`."}}},"AttachExternalReference":{"type":"object","properties":{"provider":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"key":{"type":"string","minLength":1,"maxLength":120,"description":"The ticket’s identifier. Trimmed; a key containing whitespace is refused rather than collapsed, because no tracker issues one and accepting it would make two references to the same ticket look different forever.","example":"TIM-199"},"url":{"type":["string","null"],"maxLength":2048,"description":"Where the ticket lives. Must be `https://`; `http://` and every other scheme are refused, because this is stored once and rendered as a link for the whole organization.","example":"https://linear.app/suora/issue/TIM-199"},"title":{"type":["string","null"],"maxLength":500,"description":"A label for the ticket. Trimmed; blank is stored as `null`."}},"required":["provider","key"]},"TimerSnapshot":{"type":"object","properties":{"running":{"oneOf":[{"$ref":"#/components/schemas/TimeEntryWithContext"},{"type":"null"}]},"recentTasks":{"type":"array","items":{"$ref":"#/components/schemas/RecentTask"},"maxItems":5},"completedTodaySeconds":{"type":"integer","minimum":0},"completedBillableTodaySeconds":{"type":"integer","minimum":0},"dailyTargetSeconds":{"type":"integer","minimum":0},"capturedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["running","recentTasks","completedTodaySeconds","completedBillableTodaySeconds","dailyTargetSeconds","capturedAt"]},"TimerStart":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["taskId"]},"TimerStopResult":{"allOf":[{"$ref":"#/components/schemas/TimerSnapshot"},{"type":"object","properties":{"stopped":{"oneOf":[{"$ref":"#/components/schemas/TimeEntryWithContext"},{"type":"null"}],"description":"The entry this request stopped, with its final duration, or `null` when nothing was running."}},"required":["stopped"]}]},"IosNotificationPushToken":{"type":"object","properties":{"installationId":{"type":"string","format":"uuid"},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["installationId","environment","lastSeenAt"]},"IosNotificationPushTokenRegistration":{"type":"object","properties":{"installationId":{"type":"string","format":"uuid"},"environment":{"type":"string","enum":["sandbox","production"]},"token":{"type":"string","minLength":1}},"required":["installationId","environment","token"]},"IosNotificationPushTokenForget":{"type":"object","properties":{"installationId":{"type":"string","format":"uuid"}},"required":["installationId"]},"WidgetPushToken":{"type":"object","properties":{"platform":{"type":"string","enum":["ios","watchos"]},"kind":{"type":"string","minLength":1},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["platform","kind","environment","lastSeenAt"]},"WidgetPushTokenRegistration":{"type":"object","properties":{"platform":{"type":"string","enum":["ios","watchos"]},"kind":{"type":"string","minLength":1},"environment":{"type":"string","enum":["sandbox","production"]},"token":{"type":"string","minLength":1}},"required":["platform","kind","environment","token"]},"LiveActivityStartToken":{"type":"object","properties":{"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["environment","lastSeenAt"]},"LiveActivityStartTokenRegistration":{"type":"object","properties":{"environment":{"type":"string","enum":["sandbox","production"]},"token":{"type":"string","minLength":1}},"required":["environment","token"]},"LiveActivityUpdateToken":{"type":"object","properties":{"activityId":{"type":"string","minLength":1},"environment":{"type":"string","enum":["sandbox","production"]},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["activityId","environment","lastSeenAt"]},"LiveActivityUpdateTokenRegistration":{"type":"object","properties":{"activityId":{"type":"string","minLength":1},"environment":{"type":"string","enum":["sandbox","production"]},"token":{"type":"string","minLength":1}},"required":["activityId","environment","token"]},"BreakDismissal":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","startedAt","endedAt","createdAt","updatedAt"]},"AbsenceList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Absence"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"Absence":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive, and never before `fromDay`."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400,"description":"Whole seconds per covered day. `null` is the whole working day, resolved against the organization’s `hoursPerDay` when a screen asks.","example":14400},"note":{"type":["string","null"]},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"`manual` for a person; else a system slug."},"externalId":{"type":["string","null"],"description":"The source system’s own id, so a re-import updates this row instead of adding another. Unique per organization and source.","example":"personio-absence-4711"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAtPrecise":{"type":"string"}},"required":["id","personId","kind","fromDay","untilDay","secondsPerDay","note","source","externalId","createdAt","updatedAt","updatedAtPrecise"]},"CreateAbsence":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Whose absence. Defaults to the caller; naming somebody else needs owner or admin.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Equal to `fromDay` for a single day."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400,"description":"Omit or send `null` for whole days. A number is an absolute duration per covered day — 14400 for half of an eight-hour day, 1800 for thirty minutes."},"fractionOfDay":{"type":"number","exclusiveMinimum":0,"maximum":1,"description":"A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`.","example":0.5},"note":{"type":["string","null"],"maxLength":500},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"Defaults to `manual`."},"externalId":{"type":["string","null"],"minLength":1,"maxLength":200},"editReason":{"type":"string","maxLength":500,"description":"Why an absence is being recorded behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history."}},"required":["kind","fromDay","untilDay"]},"UpsertAbsence":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Whose absence. Defaults to the caller; naming somebody else — including moving an existing absence to somebody else — needs owner or admin.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Equal to `fromDay` for a single day."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400,"description":"Omit or send `null` for whole days. Omitting it on an absence that had one **clears** it: this is the whole absence, not a patch."},"fractionOfDay":{"type":"number","exclusiveMinimum":0,"maximum":1,"description":"A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`.","example":0.5},"note":{"type":["string","null"],"maxLength":500,"description":"Omitting it clears an existing note, for the reason `secondsPerDay` gives."},"editReason":{"type":"string","maxLength":500,"description":"Why an absence is being imported behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history."}},"required":["kind","fromDay","untilDay"]},"AbsenceLogEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"absenceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"action":{"type":"string","enum":["created","updated","deleted"]},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorName":{"type":["string","null"]},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400},"note":{"type":["string","null"]},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"`manual` for a person; else a system slug."},"externalId":{"type":["string","null"]},"reason":{"type":["string","null"]}},"required":["id","absenceId","action","recordedAt","actorPersonId","actorName","personId","kind","fromDay","untilDay","secondsPerDay","note","source","externalId","reason"]},"AbsenceLogHead":{"type":"object","properties":{"absenceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400},"note":{"type":["string","null"]},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"`manual` for a person; else a system slug."}},"required":["absenceId","kind","fromDay","untilDay","secondsPerDay","note","source"]},"AbsenceRevision":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"absenceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"action":{"type":"string","enum":["created","updated","deleted"]},"position":{"type":"integer","minimum":0},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"actorName":{"type":["string","null"]},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400},"note":{"type":["string","null"]},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"`manual` for a person; else a system slug."},"externalId":{"type":["string","null"]},"reason":{"type":["string","null"]}},"required":["id","absenceId","action","position","recordedAt","actorPersonId","actorName","personId","kind","fromDay","untilDay","secondsPerDay","note","source","externalId","reason"]},"UpdateAbsence":{"type":"object","properties":{"kind":{"type":"string","enum":["vacation","sick","holiday","other"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"secondsPerDay":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":86400},"fractionOfDay":{"type":"number","exclusiveMinimum":0,"maximum":1,"description":"A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`.","example":0.5},"note":{"type":["string","null"],"maxLength":500},"editReason":{"type":"string","maxLength":500,"description":"Why an absence that sits behind the organization’s tracking deadline — or would be moved there — is being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history."}}},"AbsenceSubject":{"type":"object","properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Timjami’s own, stable id for this subject. Today every subject is a login, so this is a **better-auth account id** — the value `POST /absences` accepts under its deprecated `userId` alias, not the `personId` that endpoint now prefers. See `kind`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"displayName":{"type":"string"},"email":{"type":"string","description":"For mapping against an external system. Always present today."},"status":{"type":"string","enum":["active","deactivated"],"description":"`active` while the account can sign in; `deactivated` once it has been banned. Every subject is returned on every page — a mapping integration is expected to keep its own record of one it has already seen rather than have this list forget it."},"kind":{"type":"string","enum":["account"],"description":"Always `account` today: every subject is a Timjami login account. Reserved so a future subject with no login is additive."}},"required":["id","displayName","email","status","kind"]},"Person":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The person’s stable id — the same value every `personId` field of this API carries, time entries included. Never a login account id.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"displayName":{"type":"string","description":"The linked account’s name while there is one, otherwise the name Timjami keeps for the person."},"email":{"type":["string","null"],"description":"The linked account’s address, otherwise an address recorded when the person was imported (never verified), otherwise `null`."},"status":{"type":"string","enum":["active","inactive"],"description":"`active` while the person has access to this organization — a live membership whose account is not banned. `inactive` otherwise: the membership ended, the account is banned, or the person never had access (for example one imported from Harvest). An inactive person is still listed; historical time may name them."}},"required":["id","displayName","email","status"]},"CreateWorkSchedule":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Whose working time model. Always named — there is no default to the caller, since only an owner or admin may write this at all.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"kind":{"type":"string","enum":["weekly","monthly"]},"mondaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"tuesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"wednesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"thursdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"fridaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"saturdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"sundaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"monthlySeconds":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":2678400},"note":{"type":["string","null"],"maxLength":500}},"required":["personId","validFromDay","kind","mondaySeconds","tuesdaySeconds","wednesdaySeconds","thursdaySeconds","fridaySeconds","saturdaySeconds","sundaySeconds","monthlySeconds"]},"UpdateWorkSchedule":{"type":"object","properties":{"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"kind":{"type":"string","enum":["weekly","monthly"]},"mondaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"tuesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"wednesdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"thursdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"fridaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"saturdaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"sundaySeconds":{"type":["integer","null"],"minimum":0,"maximum":86400},"monthlySeconds":{"type":["integer","null"],"exclusiveMinimum":0,"maximum":2678400},"note":{"type":["string","null"],"maxLength":500}}},"CapacityAgreements":{"type":"object","properties":{"agreements":{"type":"array","items":{"$ref":"#/components/schemas/CapacityAgreement"}}},"required":["agreements"]},"CapacityAgreement":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive first day this version holds. The next version ends it."},"kind":{"type":"string","enum":["share","fixed"],"description":"share = a percentage of contracted time, which follows a contract change by itself. fixed = an absolute sold week, which survives one by itself."},"sharePercent":{"type":["string","null"],"description":"kind=share only: percentage of contracted time sold, \"0.00\" to \"100.00\".","example":"100.00"},"soldSecondsPerWeek":{"type":["integer","null"],"exclusiveMinimum":0,"description":"kind=fixed only: the sold week itself, in whole seconds.","example":72000},"employment":{"type":"string","enum":["internal","external"],"description":"internal (employed) or external (freelancer)."},"inTeamMetric":{"type":"boolean","description":"Whether this person’s hours are in the group whose billable quota is measured."},"note":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","personId","validFromDay","kind","sharePercent","soldSecondsPerWeek","employment","inTeamMetric","note","createdAt","updatedAt"]},"CreateCapacityAgreement":{"oneOf":[{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"employment":{"type":"string","enum":["internal","external"],"description":"internal (employed) or external (freelancer)."},"inTeamMetric":{"type":"boolean"},"note":{"type":["string","null"],"maxLength":500},"kind":{"type":"string","enum":["share"]},"sharePercent":{"type":"string","pattern":"^(100(\\.0{1,2})?|\\d{1,2}(\\.\\d{1,2})?)$","example":"50.00"}},"required":["personId","validFromDay","employment","inTeamMetric","kind","sharePercent"]},{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"employment":{"type":"string","enum":["internal","external"],"description":"internal (employed) or external (freelancer)."},"inTeamMetric":{"type":"boolean"},"note":{"type":["string","null"],"maxLength":500},"kind":{"type":"string","enum":["fixed"]},"soldSecondsPerWeek":{"type":"integer","exclusiveMinimum":0,"description":"A sold week of nothing is a share version at \"0.00\" rather than a zero here, so that there is one spelling of „sells nothing\" instead of two.","example":72000}},"required":["personId","validFromDay","employment","inTeamMetric","kind","soldSecondsPerWeek"]}]},"UpdateCapacityAgreement":{"type":"object","properties":{"validFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"kind":{"type":"string","enum":["share","fixed"],"description":"share = a percentage of contracted time, which follows a contract change by itself. fixed = an absolute sold week, which survives one by itself."},"sharePercent":{"type":["string","null"],"pattern":"^(100(\\.0{1,2})?|\\d{1,2}(\\.\\d{1,2})?)$","example":"50.00"},"soldSecondsPerWeek":{"type":["integer","null"],"exclusiveMinimum":0},"employment":{"type":"string","enum":["internal","external"],"description":"internal (employed) or external (freelancer)."},"inTeamMetric":{"type":"boolean"},"note":{"type":["string","null"],"maxLength":500}}},"InvoiceList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceListItem"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"InvoiceListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["draft","transferred","withdrawn"],"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","example":"draft"},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]},"grouping":{"type":"string","enum":["member","project","task","entry"],"description":"What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates.","example":"project"},"periodStart":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"periodEnd":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"subtotalMinor":{"type":"integer","example":372500},"notes":{"type":["string","null"]},"transferredAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"withdrawnAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"export":{"$ref":"#/components/schemas/InvoiceExport"},"transferredByHand":{"type":"boolean"},"document":{"$ref":"#/components/schemas/InvoiceDocument"},"recordedDocument":{"$ref":"#/components/schemas/InvoiceRecordedDocument"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAtPrecise":{"type":"string"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]}},"required":["id","status","customer","grouping","periodStart","periodEnd","currency","subtotalMinor","notes","transferredAt","withdrawnAt","createdByPersonId","export","transferredByHand","document","recordedDocument","createdAt","updatedAt","updatedAtPrecise","customerIcon"]},"InvoiceExport":{"type":["object","null"],"properties":{"provider":{"$ref":"#/components/schemas/InvoicingProvider"},"documentId":{"type":["string","null"]},"exportedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"unclear":{"type":"boolean"},"documentLink":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri"},"providerName":{"type":"string","example":"sevdesk"}},"required":["url","providerName"]}},"required":["provider","documentId","exportedAt","unclear","documentLink"]},"InvoicingProvider":{"type":"string","enum":["easybill","sevdesk"],"description":"The invoicing tool an export went to."},"InvoiceDocument":{"type":["object","null"],"properties":{"provider":{"allOf":[{"$ref":"#/components/schemas/InvoicingProvider"},{"description":"The tool this reading comes from — the invoice's `export.provider`. The figures below are that tool's, not Timjami's."}]},"state":{"type":"string","enum":["deleted","cancelled","draft","paid","partiallyPaid","overdue","sent","finalized","open","unknown"],"description":"The state of the document in the invoicing tool, read off its fields by Timjami — neither tool has a status that says all of this. The first that holds wins: `deleted` the tool answers 404 for it; `cancelled` a Storno document names it; `draft` not finalised yet; `paid` the paid amount covers it; `partiallyPaid` something but not all of it is paid; `overdue` its due day has passed; `sent` a send was triggered from Easybill; `finalized` Easybill, none of the above; `open` sevdesk, finalised and neither paid nor overdue; `unknown` a sevdesk status Timjami does not interpret. Easybill never answers `open` or `unknown`, sevdesk never `sent` or `finalized`.","example":"sent"},"number":{"type":["string","null"],"description":"The Rechnungsnummer the tool assigned on finalising — `null` while a draft.","example":"RE-2026-0042"},"archived":{"type":["boolean","null"],"description":"Easybill only, `null` otherwise: archived there. Not a state: an archived invoice is still paid or not."},"amountMinor":{"type":["integer","null"],"description":"The tool's gross total in minor units of the invoice's `currency` — the tool's figure, including its VAT, not Timjami's. This and the two amounts below are `null` when the document was switched to another currency there.","example":443275},"amountNetMinor":{"type":["integer","null"],"description":"Easybill only, `null` otherwise: its net total in minor units. Easybill computes it from the exported quantities, rounded to two places of an hour (ADR-2026-09-07-12QAJ) — the same arithmetic `subtotalMinor` uses since ADR-2026-10-08-14AU2, so the two differ only where somebody edited the document, or for an invoice frozen before that change.","example":372500},"paidAmountMinor":{"type":["integer","null"],"description":"What the tool records as paid, in minor units.","example":0},"paidDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"When the tool records it as paid."},"documentDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The Rechnungsdatum."},"dueDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The Fälligkeitsdatum. sevdesk has no such field: there it is the Rechnungsdatum plus the Zahlungsziel in calendar days, and `null` for an invoice without a Zahlungsziel — Timjami does not guess one."},"duePassed":{"type":"boolean","description":"Whether `dueDay` is behind the organization's today. Its own field rather than `state === 'overdue'`, because that state is a label chosen by precedence and `partiallyPaid` outranks it — a document half paid and months late carries the paid label and is late all the same. `GET /invoices/easybill-outstanding` splits its overdue figure by this same reading."},"editedDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Easybill only, `null` otherwise: the day it last changed the document. A day and not a timestamp, because Easybill states its time without a zone."},"sentAt":{"type":["string","null"],"format":"date-time","description":"sevdesk only, `null` otherwise: when the document was sent, as sevdesk records it.","example":"2026-07-29T09:00:00.000Z"},"fetchedAt":{"type":"string","format":"date-time","description":"When Timjami last read this from the tool — how current everything above is.","example":"2026-07-29T09:00:00.000Z"}},"required":["provider","state","number","archived","amountMinor","amountNetMinor","paidAmountMinor","paidDay","documentDay","dueDay","duePassed","editedDay","sentAt","fetchedAt"]},"InvoiceRecordedDocument":{"type":["object","null"],"properties":{"state":{"type":"string","enum":["issued","sent","overdue","paid"],"description":"Read off the recorded days by Timjami, against today in the organization’s zone. The first that holds wins: `paid` a paid day is recorded; `overdue` the due day has passed; `sent` a sent day is recorded; `issued` none of these — the invoice was written, and that is all anybody said.","example":"sent"},"number":{"type":["string","null"],"description":"The Rechnungsnummer as the invoicing program assigned it, copied by hand. Neither unique nor checked — Timjami assigns no number (ADR 0068).","example":"RE-2026-0042"},"documentDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The Rechnungsdatum."},"dueDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The Fälligkeitsdatum."},"duePassed":{"type":"boolean","description":"Whether `dueDay` is behind the organization's today — its own field for the reason the Easybill mirror has one: `paid` outranks `overdue` in `state`."},"url":{"type":["string","null"],"description":"A link to the document, always `https://`.","example":"https://example.test/rechnungen/42"},"sentDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"When it was sent to the customer."},"paidDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"When it was paid."}},"required":["state","number","documentDay","dueDay","duePassed","url","sentDay","paidDay"]},"InvoiceStatusList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceStatusEntry"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"InvoiceStatusEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["draft","transferred","withdrawn"],"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","example":"draft"},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]},"periodStart":{"type":"string","format":"date-time","description":"The start of the billed period.","example":"2026-07-29T09:00:00.000Z"},"periodEnd":{"type":"string","format":"date-time","description":"The end of the billed period, exclusive.","example":"2026-07-29T09:00:00.000Z"},"transferredAt":{"type":["string","null"],"format":"date-time","description":"When this left Timjami for the invoicing tool. `null` for a draft.","example":"2026-07-29T09:00:00.000Z"},"withdrawnAt":{"type":["string","null"],"format":"date-time","description":"When a transferred invoice was taken back into Timjami. `null` otherwise.","example":"2026-07-29T09:00:00.000Z"},"export":{"$ref":"#/components/schemas/InvoiceStatusExport"},"transferredByHand":{"type":"boolean","description":"Transferred while no invoicing tool was connected — the transfer is the whole settlement."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","status","customer","periodStart","periodEnd","transferredAt","withdrawnAt","export","transferredByHand","createdAt","updatedAt"]},"InvoiceStatusExport":{"type":["object","null"],"properties":{"provider":{"$ref":"#/components/schemas/InvoicingProvider"},"exportedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["provider","exportedAt"],"description":"When this was exported to the invoicing tool, which is what transfers it in one step. `null` for a draft, and for an invoice transferred without an export. Never the document id (invariant 82)."},"InvoiceDraft":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"lines":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceDraftLine"}},"subtotalMinor":{"type":"integer"},"entryCount":{"type":"integer"},"durationSeconds":{"type":"integer"},"allowanceSeconds":{"type":"integer","description":"The part of `durationSeconds` that is monthly time allowances („Pauschale\") rather than tracked time — the document’s own answer to how much of it no timesheet backs."},"fixedPriceItemCount":{"type":"integer","description":"The fixed-price items that made it onto a line. Items are no entries."},"fixedPriceAmountMinor":{"type":"integer","description":"Their total in minor units, a part of `subtotalMinor`."},"excluded":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceExclusion"}},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"periodStart":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"periodEnd":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["currency","lines","subtotalMinor","entryCount","durationSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","excluded","projectIds","periodStart","periodEnd"]},"InvoiceDraftLine":{"type":"object","properties":{"position":{"type":"integer","example":1},"kind":{"type":"string","enum":["time","fixed_price"],"description":"`time` for a line built from tracked time or a monthly time allowance, `fixed_price` for one fixed-price item billed whole. A `fixed_price` line states no seconds: `quantitySeconds` and `quantityHours` are `null`, and `fixedPriceItemId` names the item.","example":"time"},"description":{"type":"string","example":"Website Relaunch — Frontend"},"quantityHundredths":{"type":"integer","description":"The quantity as printed and priced, in hundredths of `unit` — `150` is `1,50 h`. Every line states one, and `unitRateMinor` is the price per `unit`. For a line built from tracked time it is `quantitySeconds` rounded to hundredths of an hour. `amountMinor` stays authoritative: it is quantity × price for invoices created since ADR-2026-10-08-14AU2, while an older invoice was priced from its exact seconds and may sit cents apart.","example":150},"unit":{"type":"string","description":"The unit `quantityHundredths` and `unitRateMinor` are stated in; `h` for every line built from tracked time or a monthly time allowance, the item’s own unit (`Tag`, `pauschal`) for a fixed-price line.","example":"h"},"quantitySeconds":{"type":["integer","null"],"description":"The exact seconds a `time` line’s claims are measured against. `null` for a `fixed_price` line.","example":5400},"quantityHours":{"type":["string","null"],"description":"Decimal hours of a `time` line; `null` for a `fixed_price` line.","example":"1.50"},"fixedPriceItemId":{"type":["string","null"],"format":"uuid","description":"The fixed-price item a `fixed_price` line bills; `null` for a `time` line.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"unitRateMinor":{"type":"integer","description":"The price per `unit` in minor units. For a `time` line the hourly rate, exactly as it was frozen on the claims it is made of: the time entries for tracked hours, and its own claim for a monthly time allowance („Pauschale\"); a line that is an allowance alone — what `entry` grouping produces for one — therefore states a rate no time entry backs. For a `fixed_price` line the item’s unit price, per day or per piece and not per hour.","example":8500},"amountMinor":{"type":"integer","example":12750},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"allowanceSeconds":{"type":"integer","description":"How many of `quantitySeconds` are a monthly time allowance („Pauschale\") rather than tracked time — `0` for a line built from entries alone. At one resolved rate an allowance joins the line the person’s tracked hours are already on, so that line bills hours no time entry backs and the entries behind it are short by exactly this figure; under `entry` grouping it is instead a line of its own, where this equals `quantitySeconds` and `entryCount` is `0`. `entryCount` stays a count of entries either way.","example":0},"entryCount":{"type":"integer","description":"How many time entries this line is made of. `0` is a real answer since a monthly time allowance („Pauschale\") can be billed: under `entry` grouping a line stands for one entry and a month is not one, so an allowance gets a line of its own that no time entry backs — `allowanceSeconds` is then the whole of `quantitySeconds`."}},"required":["position","kind","description","quantityHundredths","unit","quantitySeconds","quantityHours","fixedPriceItemId","unitRateMinor","amountMinor","currency","projectId","taskId","personId","allowanceSeconds","entryCount"]},"InvoiceExclusion":{"type":"object","properties":{"reason":{"type":"string","enum":["not-billable","fixed-price","missing-rate","already-invoiced","outside-selection","outside-period","other-currency"],"description":"Why time or a fixed-price item inside the period is not on the invoice: the task is not billable, the task is billed through a fixed-price agreement (`fixed-price`: its hours are billed as the agreement’s items and never by the hour), no rate prices those seconds, another live invoice already bills them, a narrowed selection left them out, the period does not contain the whole month a monthly time allowance („Pauschale\") is agreed for or the last day of a fixed-price item (`outside-period`), or a fixed-price item is in another currency than the invoice (`other-currency`). `missing-rate` is per second rather than per entry — hours a budget rule prices are billed while hours no rule prices are reported, for the same entry. `outside-selection` is nothing to fix: it is what a `budgetIds`/`taskIds` selection did not ask about, reported so that the hours still to be billed are visible.","example":"missing-rate"},"entryCount":{"type":"integer","description":"How many entries contributed, counted once each. An entry only part of which a rate prices — hours held by two budgets, one of which no rule names — is on a line *and* here, for the seconds on either side; `durationSeconds` is the figure that adds up."},"durationSeconds":{"type":"integer"},"allowanceSeconds":{"type":"integer","description":"How many of `durationSeconds` are allowance months rather than tracked time. Beside `entryCount` rather than inside it: a month is not a row anybody can go and look at."},"fixedPriceItemCount":{"type":"integer","description":"How many fixed-price items were left out for this reason. Items are neither entries nor seconds, so they add to neither `entryCount` nor `durationSeconds`; `0` for every reason no item reached."},"fixedPriceAmountMinor":{"type":"integer","description":"What those items would have billed, in the invoice’s currency and in minor units. Items in another currency add nothing, whatever the reason: currencies of their own are not added up."},"tasks":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceExclusionTask"},"description":"The same figures per task, project then task, for `not-billable` (the task’s flag), `fixed-price` (its billing mode) and `missing-rate` (the project’s rules) — so the hours can be checked without opening the entries. Empty for every other reason."}},"required":["reason","entryCount","durationSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","tasks"]},"InvoiceExclusionTask":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskName":{"type":"string"},"entryCount":{"type":"integer"},"durationSeconds":{"type":"integer"},"allowanceSeconds":{"type":"integer"}},"required":["projectId","projectName","taskId","taskName","entryCount","durationSeconds","allowanceSeconds"]},"PreviewInvoice":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[]},"budgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[],"description":"The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped."},"taskIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[],"description":"The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`."},"from":{"type":["string","null"],"format":"date-time","description":"Inclusive lower bound on the entry start, or null for no lower bound.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive upper bound on the entry start.","example":"2026-07-29T09:00:00.000Z"},"grouping":{"type":"string","enum":["member","project","task","entry"],"description":"What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates.","example":"project"}},"required":["customerId","from","until","grouping"]},"RecordedOutstanding":{"type":"object","properties":{"openMinor":{"type":"integer","example":1250000},"openCount":{"type":"integer","example":5},"overdueMinor":{"type":"integer","example":340000},"overdueCount":{"type":"integer","example":1},"withheldCount":{"type":"integer","example":0}},"required":["openMinor","openCount","overdueMinor","overdueCount","withheldCount"]},"EasybillOutstanding":{"type":"object","properties":{"openMinor":{"type":"integer","example":1428500},"openCount":{"type":"integer","example":7},"overdueMinor":{"type":"integer","example":357000},"overdueCount":{"type":"integer","example":2},"withheldCount":{"type":"integer","example":0},"oldestFetchedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["openMinor","openCount","overdueMinor","overdueCount","withheldCount","oldestFetchedAt"]},"UnbilledSummary":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"throughMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last month these figures counted — `null` when nothing was left out, which is the default. Resolved in the organization’s time zone, so a client states the period it is looking at instead of deriving a month boundary of its own (ADR-2026-09-07-15TM4)."},"total":{"$ref":"#/components/schemas/UnbilledTotal"},"customers":{"type":"array","items":{"$ref":"#/components/schemas/UnbilledCustomer"}}},"required":["currency","throughMonth","total","customers"]},"UnbilledTotal":{"type":"object","properties":{"seconds":{"type":"integer","description":"Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from.","example":10800},"amountMinor":{"type":"integer","description":"Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view.","example":25500},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"unratedSeconds":{"type":"integer","description":"Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale\") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire.","example":3600},"allowanceSeconds":{"type":"integer","description":"How many of `seconds` are monthly time allowances („Pauschale\") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says.","example":7200},"fixedPriceItemCount":{"type":"integer","description":"Fixed-price items („Posten\") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it.","example":2},"fixedPriceAmountMinor":{"type":"integer","description":"The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19).","example":710000},"upcomingFixedPriceItemCount":{"type":"integer","description":"How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it.","example":1},"upcomingFixedPriceAmountMinor":{"type":"integer","description":"The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed.","example":350000},"otherCurrencyFixedPriceItemCount":{"type":"integer","description":"Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them.","example":0}},"required":["seconds","amountMinor","currency","unratedSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","upcomingFixedPriceItemCount","upcomingFixedPriceAmountMinor","otherCurrencyFixedPriceItemCount"]},"UnbilledCustomer":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"icon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"archived":{"type":"boolean"},"seconds":{"type":"integer","description":"Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from.","example":10800},"amountMinor":{"type":"integer","description":"Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view.","example":25500},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"unratedSeconds":{"type":"integer","description":"Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale\") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire.","example":3600},"allowanceSeconds":{"type":"integer","description":"How many of `seconds` are monthly time allowances („Pauschale\") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says.","example":7200},"fixedPriceItemCount":{"type":"integer","description":"Fixed-price items („Posten\") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it.","example":2},"fixedPriceAmountMinor":{"type":"integer","description":"The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19).","example":710000},"upcomingFixedPriceItemCount":{"type":"integer","description":"How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it.","example":1},"upcomingFixedPriceAmountMinor":{"type":"integer","description":"The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed.","example":350000},"otherCurrencyFixedPriceItemCount":{"type":"integer","description":"Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them.","example":0},"oldestEntryAt":{"type":["string","null"],"format":"date-time","description":"Start of the oldest still-open entry at this level — the signal, not a sum. `null` when nothing open here is an entry, which is what a level whose only open items are monthly time allowances or fixed-price items looks like: neither has an instant, and naming one would point at a day nothing happened on.","example":"2026-07-29T09:00:00.000Z"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/UnbilledProject"}}},"required":["customerId","customerName","icon","archived","seconds","amountMinor","currency","unratedSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","upcomingFixedPriceItemCount","upcomingFixedPriceAmountMinor","otherCurrencyFixedPriceItemCount","oldestEntryAt","projects"]},"UnbilledProject":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string","example":"Website Relaunch"},"archived":{"type":"boolean","description":"An archived project always stays listed while it still owes hours — there is no way to narrow it out. Write off its hours instead (ADR-2026-09-21-108YB)."},"joinableRules":{"type":"array","items":{"type":"object","properties":{"ruleId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"ruleName":{"type":"string"}},"required":["ruleId","ruleName"]},"description":"The customer’s active selected-projects billing rules this project could join over a gap in its coverage that holds at least one of its open services — an entry portion or an allowance month. Empty when no such gap holds any, or the customer has no such rule (ADR-2026-10-07-119RC decision 6)."},"seconds":{"type":"integer","description":"Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from.","example":10800},"amountMinor":{"type":"integer","description":"Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view.","example":25500},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"unratedSeconds":{"type":"integer","description":"Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale\") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire.","example":3600},"allowanceSeconds":{"type":"integer","description":"How many of `seconds` are monthly time allowances („Pauschale\") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says.","example":7200},"fixedPriceItemCount":{"type":"integer","description":"Fixed-price items („Posten\") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it.","example":2},"fixedPriceAmountMinor":{"type":"integer","description":"The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19).","example":710000},"upcomingFixedPriceItemCount":{"type":"integer","description":"How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it.","example":1},"upcomingFixedPriceAmountMinor":{"type":"integer","description":"The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed.","example":350000},"otherCurrencyFixedPriceItemCount":{"type":"integer","description":"Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them.","example":0},"oldestEntryAt":{"type":["string","null"],"format":"date-time","description":"Start of the oldest still-open entry at this level — the signal, not a sum. `null` when nothing open here is an entry, which is what a level whose only open items are monthly time allowances or fixed-price items looks like: neither has an instant, and naming one would point at a day nothing happened on.","example":"2026-07-29T09:00:00.000Z"}},"required":["projectId","projectName","archived","joinableRules","seconds","amountMinor","currency","unratedSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","upcomingFixedPriceItemCount","upcomingFixedPriceAmountMinor","otherCurrencyFixedPriceItemCount","oldestEntryAt"]},"Divergence":{"type":"object","properties":{"claimId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["invoice","external","write-off"],"description":"What settled these seconds. `invoice` is a Timjami invoice, `external` a document in another system (the Harvest era), `write-off` waived hours. Every live claim is compared, whatever its kind — the owner/admin override can move facts under any of them. See ADR-2026-08-24-11D67.","example":"invoice"},"invoiceId":{"type":["string","null"],"format":"uuid","description":"The Timjami invoice this claim belongs to — `null` for every other kind, which is the same statement as `kind !== \"invoice\"`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"invoiceStatus":{"type":["string","null"],"enum":["draft","transferred","withdrawn",null]},"externalDocumentRef":{"type":["string","null"],"description":"What the other system calls the document these hours were billed on — for Harvest, `harvest:invoice:<id>:<number>`. `null` for every other kind, and for an `external` claim whose source named no document.","example":"harvest:invoice:13150403:1001"},"customerName":{"type":"string","example":"Acme GmbH"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"customerId":{"type":"string","format":"uuid","description":"The customer `customerName` names — the invoice’s where there is one, the entry’s own chain otherwise. The two are the same row for almost every claim, and where they differ this pair still names one customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"entryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"The project of the entry’s task as it now stands — the **entry’s** chain, never the invoice’s, and where the dated rate rules behind a `rate` divergence are written.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string","example":"Website Relaunch"},"taskName":{"type":"string","example":"Frontend"},"kinds":{"type":"array","items":{"type":"string","enum":["window","task","person","rate","partly-unpriced","currency","over-billed"]},"description":"Which facts disagree. `rate` means the number over the claimed seconds is no longer the frozen one — a different rate, no rate at all, or two rates over stretches this claim covers (see `rateMixed`). `partly-unpriced` is not a changed number: some of the claimed seconds are priced by nothing today while the rest may still resolve to exactly the billed rate, which is reachable when a rule names one budget and not the next one the hours spill into. `over-billed` means the claim reaches past the entry’s current duration — somebody shortened it after it was billed; it is not reported for an entry that was reopened and is simply still running. `currency` means the organization changed its currency after the invoice froze one.","example":["window"]},"effect":{"type":"string","enum":["billing","relocation"],"description":"Whether what disagrees changed what the customer was billed, or only where the hours now hang. `relocation` means one thing and only that thing: the task is the **only** fact that disagrees, and the customer these seconds were settled for — `billed.customerId` — is still the one the entry hangs under today. The same hours, at the same rate, in the same currency, for the same person, over the same interval, booked on a different task, which is what restructuring a customer’s projects produces for every invoiced entry. A claim with no invoice of its own is answered the same way rather than refused: an `external` claim and a write-off are compared against the customer of the project the claim froze, so they are classified exactly like an invoice’s claim. Everything else is `billing` — above all a bare task move that carried the hours onto **another customer’s** project, where `billed.customerId` and `current.customerId` then disagree, and a comparison that cannot be made at all, which is a claim with no invoice whose frozen project has since been deleted. A label on a row that is reported either way: nothing is filtered out of this answer, and a client that reads only `kinds` is unaffected. See ADR-2026-09-07-15WGW.","example":"billing"},"billed":{"type":"object","properties":{"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"rateMinor":{"type":["integer","null"],"description":"What was billed per hour. `null` only for an `external` claim whose source states no rate — an invoice’s and a write-off’s is required. A `null` here is never reported as a `rate` divergence: „the other system did not say\" is a gap, not a disagreement."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"claimedSeconds":{"type":"integer"},"customerId":{"type":["string","null"],"format":"uuid","description":"The customer these seconds were settled for — the invoice’s own where there is one, otherwise the customer of the project the claim froze. `null` only for a claim with no invoice whose frozen project has since been deleted. Compare it with `current.customerId`: where the two disagree, invoiced hours have moved to another customer’s project, which is always reported as `effect: \"billing\"`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":["string","null"]}},"required":["startedAt","endedAt","personId","rateMinor","currency","claimedSeconds","customerId","customerName"],"description":"What the claim snapshotted when the invoice was created."},"current":{"type":"object","properties":{"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"rateMinor":{"type":["integer","null"],"description":"What the seconds this claim bills would be worth now, resolved from the project’s dated rules — the claimed stretch of the entry, not the whole of it. Read off the priced stretches, exactly as the entries list reads an entry: `null` when nothing prices them, and when two budgets price them differently, which `rateMixed` tells apart. A claim one budget prices and another does not keeps that one rate and reports the gap as `partly-unpriced`."},"rateMixed":{"type":"boolean","description":"True when more than one budget holds the claimed seconds and prices them at two different rates, so there is no single current rate to compare the billed one with — never merely because part of them is unpriced, which is `partly-unpriced`. A claim covering only one budget’s stretch of such an entry has a single rate and is not a divergence."},"isBillable":{"type":"boolean"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"durationSeconds":{"type":"integer"},"customerId":{"type":"string","format":"uuid","description":"The customer the entry’s task chain names **today**. The row’s top-level `customerId` is the invoice’s wherever there is one, so that a customer’s name and the invoice linked beside it are one customer; where these two disagree, invoiced hours have moved to a different customer’s project and this is the one they arrived at.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Beispiel GmbH"}},"required":["startedAt","endedAt","personId","rateMinor","rateMixed","isBillable","currency","durationSeconds","customerId","customerName"],"description":"The entry as it now stands."},"override":{"type":["object","null"],"properties":{"reason":{"type":"string","example":"Doppelbuchung, Stunden auf das richtige Projekt"},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"actorName":{"type":["string","null"]}},"required":["reason","recordedAt","actorName"],"description":"Why somebody changed these hours after they were billed, most recently — the reason an owner or admin had to state to get past the freeze (`editReason` on `PATCH /time-entries/{id}`). `null` where no override caused this: a rate rule rewritten, the organization’s currency changed, an import that went wrong, or a correction made before reasons were recorded."}},"required":["claimId","kind","invoiceId","invoiceStatus","externalDocumentRef","customerName","customerIcon","customerId","entryId","projectId","projectName","taskName","kinds","effect","billed","current","override"]},"AllowanceDivergence":{"type":"object","properties":{"claimId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"invoiceId":{"type":"string","format":"uuid","description":"Never `null`, unlike a time-entry claim’s: `invoice_allowance_claim.invoice_id` is `not null`, because a write-off cannot settle a month (invariant 63).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"invoiceStatus":{"type":"string","enum":["draft","transferred","withdrawn"]},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"allowanceId":{"type":"string","format":"uuid","description":"The agreement this month was billed against — still there whatever has changed, since a billed agreement cannot be deleted (`on delete restrict`). A supersession leaves this pointing at the predecessor, which is what an `outside-span` row usually is.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-07","description":"A calendar month, `YYYY-MM`."},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":["string","null"]},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string","example":"Website Relaunch"},"taskName":{"type":"string","example":"Slack-Begleitung"},"kinds":{"type":"array","items":{"type":"string","enum":["seconds","outside-span"]},"description":"What disagrees. `seconds` means the agreement plus this month’s entered delta no longer answers what the invoice froze — somebody corrected the Pauschale, or entered a delta for a month already billed. `outside-span` means the agreement no longer covers the month at all, which is what shortening it or superseding it from an earlier month leaves behind: the document bills a month no agreement states, and every figure derived from the agreement has stopped counting it. The two are never reported together — outside the span there is no figure to disagree with. **Rate and currency drift is not compared here**; that needs a month’s current rate per pot and is a separate question.","example":["seconds"]},"billed":{"type":"object","properties":{"seconds":{"type":"integer","description":"What the invoice froze for this month, and what it still bills. Never recomputed (ADR-0037)."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"}},"required":["seconds","currency"],"description":"What the claim snapshotted when the invoice was created."},"current":{"type":"object","properties":{"seconds":{"type":["integer","null"],"description":"What the month is worth today — `seconds_per_month` plus its entered delta, floored at nothing. `null` when the agreement no longer covers the month, which is the `outside-span` case: „this contract says nothing about that month any more\" rather than „that month is now worth nothing\"."},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"Inclusive. `null` is an open-ended agreement, so no upper bound at all."}},"required":["seconds","validFromMonth","validUntilMonth"],"description":"The agreement as it now stands."}},"required":["claimId","invoiceId","invoiceStatus","customerId","customerName","customerIcon","allowanceId","month","personId","personName","projectId","projectName","taskName","kinds","billed","current"]},"ExternallyBilledSummary":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"total":{"$ref":"#/components/schemas/ExternallyBilledTotal"},"documents":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledDocument"},"description":"The „no document\" group first — it is the one to act on — then the numbered documents by invoice number descending, and last a document whose number the source left empty, which there is no number to order by. Numbers compare numerically, so 1042 sorts above 999."},"customers":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledCustomer"},"description":"The same seconds by customer and project, most hours first."}},"required":["currency","total","documents","customers"]},"ExternallyBilledTotal":{"type":"object","properties":{"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42},"documentCount":{"type":"integer","description":"Distinct documents, counting the „no document\" group as one where it has hours."},"withoutDocument":{"type":"object","properties":{"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42}},"required":["seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount"],"description":"The `documentRef: null` group on its own — hours the source called billed while naming no document. Zero everywhere when there are none."}},"required":["seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount","documentCount","withoutDocument"]},"ExternallyBilledDocument":{"type":"object","properties":{"documentRef":{"type":["string","null"],"description":"The other system’s document, verbatim: `harvest:invoice:<id>:<number>`. Split on the first three colons — everything after them is the number, the only segment that may contain one. `null` is not missing data: the source called these hours billed while naming no document, which is how hours nobody will ever be paid for were got out of Harvest’s uninvoiced report. That group is the one to act on, and `total.withoutDocument` states it on its own.","example":"harvest:invoice:19783512:1042"},"claimCount":{"type":"integer","description":"How many claims the document holds. More than one per entry is the ordinary extended-entry case, not a duplicate."},"rates":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledRate"},"description":"The distinct rates these hours were billed at, largest share first — so a client says one rate or „gemischt\" rather than an average nothing was billed at (invariant 18)."},"people":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledPerson"},"description":"Whose hours are on the document, in seconds. Deliberately no amount per person: that is a different question, and invariant 19 wants a monetary total to cover one person by intent."},"projects":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledProject"},"description":"The projects the document billed, most hours first."},"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42},"billedFrom":{"type":"string","format":"date-time","description":"Start of the earliest billed second.","example":"2026-07-29T09:00:00.000Z"},"billedUntil":{"type":"string","format":"date-time","description":"End of the latest billed second.","example":"2026-07-29T09:00:00.000Z"}},"required":["documentRef","claimCount","rates","people","projects","seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount","billedFrom","billedUntil"]},"ExternallyBilledRate":{"type":"object","properties":{"rateMinor":{"type":["integer","null"],"description":"What the other system billed per hour, or `null` where it stated no rate."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"seconds":{"type":"integer"}},"required":["rateMinor","currency","seconds"]},"ExternallyBilledPerson":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":"string","example":"Max Mustermann"},"seconds":{"type":"integer"}},"required":["personId","personName","seconds"]},"ExternallyBilledProject":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string","example":"Website Relaunch"},"archived":{"type":"boolean","description":"An archived project stays listed: archiving never hides historical money or names (invariant 54), and an abandoned project is the ordinary case here."},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"customerArchived":{"type":"boolean"},"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42},"billedFrom":{"type":"string","format":"date-time","description":"Start of the earliest billed second.","example":"2026-07-29T09:00:00.000Z"},"billedUntil":{"type":"string","format":"date-time","description":"End of the latest billed second.","example":"2026-07-29T09:00:00.000Z"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]}},"required":["projectId","projectName","archived","customerId","customerName","customerArchived","seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount","billedFrom","billedUntil","customerIcon"]},"ExternallyBilledCustomer":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"icon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"archived":{"type":"boolean"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/ExternallyBilledProjectRollup"}},"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42},"billedFrom":{"type":"string","format":"date-time","description":"Start of the earliest billed second.","example":"2026-07-29T09:00:00.000Z"},"billedUntil":{"type":"string","format":"date-time","description":"End of the latest billed second.","example":"2026-07-29T09:00:00.000Z"}},"required":["customerId","customerName","icon","archived","projects","seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount","billedFrom","billedUntil"]},"ExternallyBilledProjectRollup":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string","example":"Website Relaunch"},"archived":{"type":"boolean","description":"An archived project stays listed: archiving never hides historical money or names (invariant 54), and an abandoned project is the ordinary case here."},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"customerArchived":{"type":"boolean"},"seconds":{"type":"integer","description":"Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`.","example":36000},"pricedSeconds":{"type":"integer","description":"The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency.","example":32400},"unratedSeconds":{"type":"integer","description":"Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed\" is worth recording without a price. Never valued at zero.","example":3600},"otherCurrencySeconds":{"type":"integer","description":"Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one.","example":0},"amountMinor":{"type":"integer","description":"Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent.","example":480000},"entryCount":{"type":"integer","description":"How many time entries contribute. An entry is counted once.","example":42},"billedFrom":{"type":"string","format":"date-time","description":"Start of the earliest billed second.","example":"2026-07-29T09:00:00.000Z"},"billedUntil":{"type":"string","format":"date-time","description":"End of the latest billed second.","example":"2026-07-29T09:00:00.000Z"},"documentRefs":{"type":"array","items":{"type":["string","null"]},"description":"The documents this project’s hours sit on, one entry per document, `null` included — the answer to „auf welchen Rechnungen steht dieses Projekt\"."}},"required":["projectId","projectName","archived","customerId","customerName","customerArchived","seconds","pricedSeconds","unratedSeconds","otherCurrencySeconds","amountMinor","entryCount","billedFrom","billedUntil","documentRefs"]},"ExternalClaimRepair":{"type":"object","properties":{"claims":{"type":"integer","description":"How many claims were written."},"entries":{"type":"integer","description":"How many time entries they belong to."},"seconds":{"type":"integer","description":"The seconds those claims cover — summed from what was written, not from the request."}},"required":["claims","entries","seconds"]},"AssignExternalDocument":{"type":"object","properties":{"selector":{"$ref":"#/components/schemas/ExternalClaimSelector"},"invoice":{"type":["object","null"],"properties":{"harvestInvoiceId":{"type":"string","minLength":1,"maxLength":64,"description":"Harvest’s own invoice id — the segment that identifies the document. Opaque; nothing does arithmetic on it.","example":"19783512"},"harvestInvoiceNumber":{"type":["string","null"],"maxLength":64,"description":"The number a person recognises, or `null` for an invoice Harvest gave none.","example":"1042"}},"required":["harvestInvoiceId","harvestInvoiceNumber"],"description":"The invoice to attribute these hours to, or `null` to clear the reference — the undo of a wrong repair."}},"required":["selector","invoice"]},"ExternalClaimSelector":{"type":"object","properties":{"documentRef":{"type":["string","null"],"description":"The document these claims carry today, verbatim — `null` for the group that names none. Required, so no request can select the whole history by omission.","example":null},"projectId":{"type":"string","format":"uuid","description":"Narrows to one project inside that document — the row under it.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["documentRef"]},"ReleaseExternalSettlement":{"type":"object","properties":{"selector":{"$ref":"#/components/schemas/ExternalClaimSelector"}},"required":["selector"]},"InvoiceWithLines":{"allOf":[{"$ref":"#/components/schemas/Invoice"},{"type":"object","properties":{"servicePeriodStart":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"servicePeriodEnd":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lines":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceLine"}},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]}},"budgets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]}},"tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"projectName":{"type":"string"}},"required":["id","name","projectName"]}}},"required":["servicePeriodStart","servicePeriodEnd","lines","projects","budgets","tasks"]}]},"InvoiceLine":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"position":{"type":"integer","example":1},"kind":{"type":"string","enum":["time","fixed_price"],"description":"`time` for a line built from tracked time or a monthly time allowance, `fixed_price` for one fixed-price item billed whole. A `fixed_price` line states no seconds: `quantitySeconds` and `quantityHours` are `null`, and `fixedPriceItemId` names the item.","example":"time"},"description":{"type":"string","example":"Website Relaunch — Frontend"},"quantityHundredths":{"type":"integer","description":"The quantity as printed and priced, in hundredths of `unit` — `150` is `1,50 h`. Every line states one, and `unitRateMinor` is the price per `unit`. For a line built from tracked time it is `quantitySeconds` rounded to hundredths of an hour. `amountMinor` stays authoritative: it is quantity × price for invoices created since ADR-2026-10-08-14AU2, while an older invoice was priced from its exact seconds and may sit cents apart.","example":150},"unit":{"type":"string","description":"The unit `quantityHundredths` and `unitRateMinor` are stated in; `h` for every line built from tracked time or a monthly time allowance, the item’s own unit (`Tag`, `pauschal`) for a fixed-price line.","example":"h"},"quantitySeconds":{"type":["integer","null"],"description":"The exact seconds a `time` line’s claims are measured against. `null` for a `fixed_price` line.","example":5400},"quantityHours":{"type":["string","null"],"description":"Decimal hours of a `time` line; `null` for a `fixed_price` line.","example":"1.50"},"fixedPriceItemId":{"type":["string","null"],"format":"uuid","description":"The fixed-price item a `fixed_price` line bills; `null` for a `time` line.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"unitRateMinor":{"type":"integer","description":"The price per `unit` in minor units. For a `time` line the hourly rate, exactly as it was frozen on the claims it is made of: the time entries for tracked hours, and its own claim for a monthly time allowance („Pauschale\"); a line that is an allowance alone — what `entry` grouping produces for one — therefore states a rate no time entry backs. For a `fixed_price` line the item’s unit price, per day or per piece and not per hour.","example":8500},"amountMinor":{"type":"integer","example":12750},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"entryCount":{"type":"integer","description":"How many time entries this line is made of. `0` is a real answer since a monthly time allowance („Pauschale\") can be billed: under `entry` grouping a line stands for one entry and a month is not one, so an allowance gets a line of its own that no time entry backs — `allowanceSeconds` is then the whole of `quantitySeconds`.","example":3},"allowanceSeconds":{"type":"integer","description":"How many of `quantitySeconds` are a monthly time allowance („Pauschale\") rather than tracked time — `0` for a line built from entries alone. At one resolved rate an allowance joins the line the person’s tracked hours are already on, so that line bills hours no time entry backs and the entries behind it are short by exactly this figure; under `entry` grouping it is instead a line of its own, where this equals `quantitySeconds` and `entryCount` is `0`. `entryCount` stays a count of entries either way.","example":0}},"required":["id","position","kind","description","quantityHundredths","unit","quantitySeconds","quantityHours","fixedPriceItemId","unitRateMinor","amountMinor","currency","projectId","taskId","personId","entryCount","allowanceSeconds"]},"Invoice":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["draft","transferred","withdrawn"],"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","example":"draft"},"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"}},"required":["id","name"]},"grouping":{"type":"string","enum":["member","project","task","entry"],"description":"What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates.","example":"project"},"periodStart":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"periodEnd":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"subtotalMinor":{"type":"integer","example":372500},"notes":{"type":["string","null"]},"transferredAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"withdrawnAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"export":{"$ref":"#/components/schemas/InvoiceExport"},"transferredByHand":{"type":"boolean"},"document":{"$ref":"#/components/schemas/InvoiceDocument"},"recordedDocument":{"$ref":"#/components/schemas/InvoiceRecordedDocument"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAtPrecise":{"type":"string"}},"required":["id","status","customer","grouping","periodStart","periodEnd","currency","subtotalMinor","notes","transferredAt","withdrawnAt","createdByPersonId","export","transferredByHand","document","recordedDocument","createdAt","updatedAt","updatedAtPrecise"]},"CreateInvoice":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[]},"budgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[],"description":"The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped."},"taskIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"default":[],"description":"The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`."},"from":{"type":["string","null"],"format":"date-time","description":"Inclusive lower bound on the entry start, or null for no lower bound.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive upper bound on the entry start.","example":"2026-07-29T09:00:00.000Z"},"grouping":{"type":"string","enum":["member","project","task","entry"],"description":"What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates.","example":"project"},"notes":{"type":["string","null"],"maxLength":2000,"description":"Free text for the invoice. Never filled from a time entry’s notes, which may be internal."}},"required":["customerId","from","until","grouping"]},"UpdateInvoice":{"type":"object","properties":{"notes":{"type":["string","null"],"maxLength":2000}},"required":["notes"]},"TransferInvoice":{"type":"object","properties":{"recordedDocument":{"$ref":"#/components/schemas/RecordedDocumentInput"}}},"RecordedDocumentInput":{"type":"object","properties":{"number":{"type":["string","null"],"minLength":1,"maxLength":100},"documentDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"dueDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"url":{"type":["string","null"],"maxLength":2000},"sentDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"paidDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["number","documentDay","dueDay","url","sentDay","paidDay"]},"InvoicingProviderConnection":{"type":"object","properties":{"provider":{"anyOf":[{"$ref":"#/components/schemas/InvoicingProvider"},{"type":"null"}],"description":"The invoicing tool this organization has connected, or `null` when it bills by hand. At most one."}},"required":["provider"]},"ExportedInvoice":{"allOf":[{"$ref":"#/components/schemas/InvoiceWithLines"},{"type":"object","properties":{"exportNotice":{"type":["string","null"],"description":"Set when sevdesk’s draft has a net total or a position count other than the invoice’s: the export is recorded, and this says what to check in sevdesk before finalizing. `null` otherwise, and always for Easybill.","example":"Der Rechnungsentwurf liegt in sevdesk, weicht aber ab: Netto in sevdesk 1652.37 EUR, in Timjami 1652.38 EUR."}},"required":["exportNotice"]}]},"ExportDocumentInput":{"type":"object","properties":{"documentId":{"type":"string","minLength":1,"maxLength":100,"description":"The document's id in the invoicing tool the export went to."}},"required":["documentId"]},"WriteOffList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WriteOff"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the list is exhausted."}},"required":["data","nextCursor"]},"WriteOff":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"reason":{"type":"string","description":"Why these hours were waived. Required when the batch is created.","example":"Projekt nach Vorarbeit abgesagt — Kulanz"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"releasedAt":{"type":["string","null"],"format":"date-time","description":"Set once the write-off was taken back. The record stays and stays listed; its seconds went back to „Abzurechnen\".","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Null once the person row itself was deleted — the record outlives them. Deleting the *account* leaves the person, and this id, in place.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"createdByName":{"type":["string","null"]},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"The claims’ frozen currency, not the organization’s today: changing the currency revalues un-invoiced hours and leaves settled records where they are.","example":"EUR"},"seconds":{"type":"integer"},"amountMinor":{"type":"integer"},"entryCount":{"type":"integer"},"claimCount":{"type":"integer"},"rates":{"type":"array","items":{"$ref":"#/components/schemas/WriteOffRateTotal"},"description":"What was waived per rate. The list carries no per-claim rows — a batch over an abandoned project can hold hundreds, and the figures a screen shows are these."}},"required":["id","reason","createdAt","releasedAt","createdByPersonId","createdByName","currency","seconds","amountMinor","entryCount","claimCount","rates"]},"PreviewWriteOff":{"type":"object","properties":{"entryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":500,"description":"The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404."}},"required":["entryIds"]},"CreatedWriteOff":{"type":"object","properties":{"writeOff":{"$ref":"#/components/schemas/WriteOff"},"cut":{"$ref":"#/components/schemas/WriteOffCut"}},"required":["writeOff","cut"]},"CreateWriteOff":{"type":"object","properties":{"entryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":500,"description":"The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404."},"reason":{"type":"string","minLength":1,"maxLength":2000,"description":"Why these hours will never be billed. **Required**: a batch with no reason is exactly the unexplained flag write-offs exist to replace.","example":"Projekt nach Vorarbeit abgesagt — Kulanz"}},"required":["entryIds","reason"]},"PreviewAllowanceWriteOff":{"type":"object","properties":{"months":{"type":"array","items":{"$ref":"#/components/schemas/AllowanceWriteOffMonthRequest"},"minItems":1,"description":"The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422."}},"required":["months"]},"AllowanceWriteOffMonthRequest":{"type":"object","properties":{"allowanceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["allowanceId","month"]},"CreatedAllowanceWriteOff":{"type":"object","properties":{"writeOff":{"$ref":"#/components/schemas/AllowanceWriteOff"},"cut":{"$ref":"#/components/schemas/AllowanceWriteOffCut"}},"required":["writeOff","cut"]},"AllowanceWriteOff":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"reason":{"type":"string"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"releasedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"createdByName":{"type":["string","null"]},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"seconds":{"type":"integer"},"amountMinor":{"type":"integer"},"monthCount":{"type":"integer"}},"required":["id","reason","createdAt","releasedAt","createdByPersonId","createdByName","currency","seconds","amountMinor","monthCount"]},"CreateAllowanceWriteOff":{"type":"object","properties":{"months":{"type":"array","items":{"$ref":"#/components/schemas/AllowanceWriteOffMonthRequest"},"minItems":1,"description":"The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422."},"reason":{"type":"string","minLength":1,"maxLength":2000,"description":"Why these months will never be billed. Required."}},"required":["months","reason"]},"HistoricalPeriodPreview":{"type":"object","properties":{"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"outstandingSeconds":{"type":"integer","description":"Open seconds a history mode would take on, allowance months included."},"fixedPriceItemCount":{"type":"integer","description":"Completed fixed-price items it would take on. They carry no seconds, so a period can hold these alone."}},"required":["periodStartMonth","outstandingSeconds","fixedPriceItemCount"]},"BillingRulePeriodPreview":{"type":"object","properties":{"currency":{"type":"string"},"periods":{"type":"array","items":{"$ref":"#/components/schemas/PricedPreviewPeriod"}},"history":{"type":"array","items":{"$ref":"#/components/schemas/PricedPreviewHistory"},"description":"What `historyMode` makes of the services before the first period. Empty without one, for `handle-later`, and with the first period after the running month."}},"required":["currency","periods","history"]},"PricedPreviewPeriod":{"type":"object","properties":{"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"lastMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The period’s last month, inclusive."},"dueDay":{"type":"string","format":"date"},"state":{"type":"string","enum":["ready","running","upcoming"],"description":"`ready`: ended, becomes a proposal once the rule is saved if `proposes` says so. `running`: holds the current month. `upcoming`: starts within the next twelve months."},"seconds":{"type":["integer","null"],"description":"Seconds the proposal would hold, priced or not — everybody’s, organization-wide. A `running` period counts up to now. `null` when `upcoming`."},"amountMinor":{"type":["integer","null"],"description":"That invoice’s subtotal in minor units, rounded once per line as the invoice would be, and provisional until an invoice claims the seconds. `null` when `upcoming`: a future period is never priced."},"missingRateSeconds":{"type":["integer","null"],"description":"Billable seconds in the period that no rate prices. `null` when `upcoming`."},"otherCurrencyItemCount":{"type":["integer","null"],"description":"Completed fixed-price items in another currency, which the proposal holds and `amountMinor` leaves out. `null` when `upcoming`."},"proposes":{"type":"boolean","description":"Whether the rule leads to a proposal for this period. A period that ended before the rule exists gets one only when it holds something to bill; a later one always does."}},"required":["periodStartMonth","lastMonth","dueDay","state","seconds","amountMinor","missingRateSeconds","otherCurrencyItemCount","proposes"]},"PricedPreviewHistory":{"type":"object","properties":{"kind":{"type":"string","enum":["catch-up","period"],"description":"`catch-up`: the one proposal `collect-history` makes. `period`: one of the proposals `separate-periods` makes."},"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first period with something open."},"lastMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The range’s last month, inclusive."},"seconds":{"type":"integer","description":"Seconds the proposal would hold, priced or not — everybody’s, organization-wide. 0 when it holds only fixed-price items."},"amountMinor":{"type":["integer","null"],"description":"The invoice’s subtotal in minor units, rounded once per line and provisional until invoiced. Only what setup takes on counts, so months another rule owns stay out. `null` when more than 24 periods would have to be priced."},"missingRateSeconds":{"type":["integer","null"],"description":"Billable seconds in the range no rate prices. `null` with `amountMinor`."},"otherCurrencyItemCount":{"type":["integer","null"],"description":"Completed fixed-price items in another currency, which the proposal holds and `amountMinor` leaves out. `null` with `amountMinor`."}},"required":["kind","periodStartMonth","lastMonth","seconds","amountMinor","missingRateSeconds","otherCurrencyItemCount"]},"PlannedPreviewPeriod":{"type":"object","properties":{"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"plannedSeconds":{"type":"integer","description":"Planned on projects the rule covers, budgets whose every project it covers, or — for an all-projects rule — the customer itself. Intent, never recorded time (ADR-2026-09-18-13VXS)."},"unassignedSeconds":{"type":"integer","description":"Planned on this customer but not attributable to the rule’s projects: a customer-level plan under a selected-projects rule, or a budget that also reaches other projects."}},"required":["periodStartMonth","plannedSeconds","unassignedSeconds"]},"BillingRuleListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["active","paused"]},"pausedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"projectMode":{"type":"string","enum":["selected","all"],"description":"`selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later."},"recurrence":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"dueDayOfMonth":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstPeriodStart":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"historyMode":{"type":"string","enum":["separate-periods","collect-history","handle-later"],"description":"The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month."},"catchUpThroughPeriod":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"hasProposals":{"type":"boolean","description":"Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused."},"responsiblePersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people who receive due/escalation email for this rule."},"coverage":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"source":{"type":"string","enum":["explicit","dynamic-all","history"],"description":"`history`: a closed row over months before the first period that the rule took on at setup, starting at the sentinel month `1900-01`."},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["projectId","projectName","source","validFromMonth","validUntilMonth"]}},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]},"nextPeriod":{"type":["object","null"],"properties":{"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"periodMonthCount":{"type":"integer"},"dueDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["periodStartMonth","periodMonthCount","dueDay"],"description":"The period the rule proposes next: the one still running, or its first if that lies ahead. `null` while paused."},"latestProposal":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["periodic","catch-up"]},"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"periodMonthCount":{"type":"integer"},"state":{"type":"string","enum":["actionable","nothing-to-bill","needs-rate","transfer-pending","complete"],"description":"The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read."},"allocationCount":{"type":"integer"}},"required":["id","kind","periodStartMonth","periodMonthCount","state","allocationCount"],"description":"The proposal whose period ends last, or `null` before the first one."}},"required":["id","customerId","customerName","name","status","pausedAt","projectMode","recurrence","dueDayOfMonth","selectionMode","defaultGrouping","firstPeriodStart","historyMode","catchUpThroughPeriod","hasProposals","responsiblePersonIds","coverage","createdAt","updatedAt","customerIcon","nextPeriod","latestProposal"]},"CreateBillingRule":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"The customer this rule bills.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"description":"A human-readable name for the rule."},"projectMode":{"type":"string","enum":["selected","all"],"description":"`selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later."},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Required, non-empty, when `projectMode` is `selected`; must be empty for `all`."},"recurrence":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"dueDayOfMonth":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstPeriodStart":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"Normalized to a quarter start when `recurrence` is `quarterly`."},"historyMode":{"type":"string","enum":["separate-periods","collect-history","handle-later"],"description":"The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month."},"catchUpThroughPeriod":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"Only meaningful, and required, when `historyMode` is `collect-history`."},"responsiblePersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people who receive due/escalation email for this rule."}},"required":["customerId","name","projectMode","projectIds","recurrence","dueDayOfMonth","selectionMode","defaultGrouping","firstPeriodStart","historyMode","responsiblePersonIds"]},"UpdateBillingRuleSchedule":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"A human-readable name for the rule."},"dueDayOfMonth":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"responsiblePersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The people who receive due/escalation email for this rule."}}},"ChangeBillingRuleCoverage":{"type":"object","properties":{"projectMode":{"type":"string","enum":["selected","all"],"description":"`selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later."},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Required, non-empty, when `projectMode` is `selected`; must be empty for `all`."},"effectiveFromPeriod":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A future period. Existing proposals before it keep their prior coverage."}},"required":["projectMode","projectIds","effectiveFromPeriod"]},"JoinableProjectList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"earliestMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"latestMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["projectId","projectName","earliestMonth","latestMonth"]}}},"required":["data"]},"ProjectAdditionPreview":{"type":"object","properties":{"currency":{"type":"string"},"earliestMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The earliest month the project can join from. A join fills one gap in the project’s coverage on every rule, from the rule’s first period on: the latest gap that has begun, or the first one while all lie ahead."},"latestMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The gap’s last month, inclusive, when coverage follows it; the join ends there. `null` when none does."},"fromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month this preview starts from."},"suggestedFromMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The oldest month between `earliestMonth` and `latestMonth` with open time on the project; `null` when there is none."},"olderOutstandingSeconds":{"type":"integer","description":"The project’s open seconds before `earliestMonth`, entries and allowance months alike. This join cannot take them on; they need an invoice of their own."},"olderOutstandingFixedPriceItemCount":{"type":"integer","description":"The project’s completed fixed-price items, by the month of their last day, before `earliestMonth`. Like the seconds, this join cannot take them on."},"periods":{"type":"array","items":{"$ref":"#/components/schemas/ProjectAdditionPeriod"}}},"required":["currency","earliestMonth","latestMonth","fromMonth","suggestedFromMonth","olderOutstandingSeconds","olderOutstandingFixedPriceItemCount","periods"]},"ProjectAdditionPeriod":{"type":"object","properties":{"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"lastMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The period’s last month, inclusive."},"state":{"type":"string","enum":["ready","running"],"description":"`ready`: ended, its hours can be billed now in a draft of their own. `running`: the hours go with the period’s regular invoice."},"proposalId":{"type":["string","null"],"format":"uuid","description":"The proposal the joined hours land in; `null` while none exists yet.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectSeconds":{"type":"integer","description":"The joining project’s own open seconds in this period, from `fromMonth` on: its entries’ and its allowance months’."},"projectFixedPriceItemCount":{"type":"integer","description":"The joining project’s completed fixed-price items in this period that no invoice claims and no proposal holds. An item has no seconds: it is counted here, and its amount is in `amountMinor`."},"seconds":{"type":"integer","description":"Seconds the period’s draft would bill: everything still open on its proposal, organization-wide, plus the project’s. For `running`, the project’s alone, up to now. Fixed-price items are no seconds."},"amountMinor":{"type":"integer","description":"That draft’s subtotal in minor units, rounded once per line as the invoice would be, fixed-price items included, and provisional until an invoice claims what it bills."},"missingRateSeconds":{"type":"integer","description":"Billable seconds among them that no rate prices."},"createsInvoice":{"type":"boolean","description":"Whether a join with `createInvoices` drafts this period: it has ended and its draft would bill at least one line. A period whose services no rate prices gets none."}},"required":["periodStartMonth","lastMonth","state","proposalId","projectSeconds","projectFixedPriceItemCount","seconds","amountMinor","missingRateSeconds","createsInvoice"]},"ProjectAdditionResult":{"type":"object","properties":{"rule":{"$ref":"#/components/schemas/BillingRule"},"proposalIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The ended periods’ proposals that now hold the project’s open hours."},"invoiceIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The drafts created, in period order. Empty unless `createInvoices`."},"skippedProposalIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Proposals among `proposalIds` that got no draft although `createInvoices` asked for one: nothing on them could be billed (no rate prices it), or another invoice claimed it meanwhile. Their services stay open."}},"required":["rule","proposalIds","invoiceIds","skippedProposalIds"]},"AddProjectToBillingRule":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"fromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month the project joins from: a past, the current, or a future month between the preview’s `earliestMonth` and `latestMonth`."},"createInvoices":{"type":"boolean","description":"Also create one draft invoice per ended period that now holds the project’s hours, each billing everything open on that period’s proposal."}},"required":["projectId","fromMonth","createInvoices"]},"ResumeBillingRule":{"type":"object","properties":{"restartFromPeriod":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A chosen period to resume from, becoming the rule’s new generation anchor. Omitted resumes from where it left off."}}},"BillingProposalListItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"billingRuleId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"ruleName":{"type":"string"},"kind":{"type":"string","enum":["periodic","catch-up"]},"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The period’s first month. On a `catch-up` proposal the placeholder `1900-01`, never a date to show: it ends at `periodStartMonth` plus `periodMonthCount` months, and starts with its oldest service."},"periodMonthCount":{"type":"integer"},"dueDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstActionableAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"reopenedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"completedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"state":{"type":"string","enum":["actionable","nothing-to-bill","needs-rate","transfer-pending","complete"],"description":"The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read."},"unresolvedSeconds":{"type":"integer"},"unresolvedAllowanceMonths":{"type":"integer"},"unresolvedFixedPriceItems":{"type":"integer"},"invoiceIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"reopened":{"type":"boolean"},"allocationCount":{"type":"integer","description":"How many service portions it owns. Zero with `state: complete` is a period confirmed empty (`dismiss`), not one that was billed."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"]}},"required":["id","billingRuleId","customerId","customerName","ruleName","kind","periodStartMonth","periodMonthCount","dueDay","selectionMode","defaultGrouping","firstActionableAt","reopenedAt","completedAt","state","unresolvedSeconds","unresolvedAllowanceMonths","unresolvedFixedPriceItems","reopened","allocationCount","createdAt","customerIcon"]},"BillingProposalDetail":{"allOf":[{"$ref":"#/components/schemas/BillingProposal"},{"type":"object","properties":{"allocations":{"type":"array","items":{"$ref":"#/components/schemas/BillingAllocation"}}},"required":["allocations"]}]},"BillingAllocation":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["entry","allowance","item"]},"timeEntryId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"fromSecond":{"type":["integer","null"]},"toSecond":{"type":["integer","null"]},"allowanceId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"fixedPriceItemId":{"type":["string","null"],"format":"uuid","description":"The fixed-price item an `item` allocation names, billed whole on one invoice line; `null` for an entry or an allowance month. Its service month is the month of its last day.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"serviceMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"manuallyAssigned":{"type":"boolean"},"deferReason":{"type":["string","null"]},"deferredAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"resolved":{"type":"boolean"},"settledVia":{"type":["string","null"],"enum":["invoice","external","write-off",null]},"inDraft":{"type":"boolean","description":"Not resolved, but a stored invoice draft (or a transferred invoice not yet confirmed) already claims part of it — it can no longer be moved, and the draft is what bills it. Which draft is the session-only `GET /billing-proposals/{id}/services`’s answer."}},"required":["id","kind","timeEntryId","fromSecond","toSecond","allowanceId","month","fixedPriceItemId","serviceMonth","projectId","projectName","manuallyAssigned","deferReason","deferredAt","resolved","settledVia","inDraft"]},"BillingProposal":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"billingRuleId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"ruleName":{"type":"string"},"kind":{"type":"string","enum":["periodic","catch-up"]},"periodStartMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The period’s first month. On a `catch-up` proposal the placeholder `1900-01`, never a date to show: it ends at `periodStartMonth` plus `periodMonthCount` months, and starts with its oldest service."},"periodMonthCount":{"type":"integer"},"dueDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"selectionMode":{"type":"string","enum":["period-only","cumulative"],"description":"`period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule."},"defaultGrouping":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"firstActionableAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"reopenedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"completedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"state":{"type":"string","enum":["actionable","nothing-to-bill","needs-rate","transfer-pending","complete"],"description":"The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read."},"unresolvedSeconds":{"type":"integer"},"unresolvedAllowanceMonths":{"type":"integer"},"unresolvedFixedPriceItems":{"type":"integer"},"invoiceIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"reopened":{"type":"boolean"},"allocationCount":{"type":"integer","description":"How many service portions it owns. Zero with `state: complete` is a period confirmed empty (`dismiss`), not one that was billed."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","billingRuleId","customerId","customerName","ruleName","kind","periodStartMonth","periodMonthCount","dueDay","selectionMode","defaultGrouping","firstActionableAt","reopenedAt","completedAt","state","unresolvedSeconds","unresolvedAllowanceMonths","unresolvedFixedPriceItems","reopened","allocationCount","createdAt"]},"MoveBillingAllocation":{"type":"object","properties":{"toProposalId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"reason":{"type":["string","null"],"minLength":1,"maxLength":500}},"required":["toProposalId","reason"]},"ProposalInvoicePreview":{"type":"object","properties":{"proposal":{"$ref":"#/components/schemas/BillingProposalDetail"},"draft":{"$ref":"#/components/schemas/InvoiceDraft"},"period":{"$ref":"#/components/schemas/ProposalPeriod"}},"required":["proposal","draft","period"]},"ProposalPeriod":{"type":"object","properties":{"lastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The last day this invoice reads, inclusive — the day a close would have to reach."},"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"closedThroughDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["projectId","projectName","closedThroughDay"]},"description":"The projects the rule covered over the period (and any an open service names beyond them), with how far each is closed. Asked, never acted on: nothing closes a period as a side effect."},"notBillable":{"anyOf":[{"$ref":"#/components/schemas/InvoiceExclusion"},{"type":"null"}],"description":"The not-billable time booked on those projects within the proposal’s own period, per task — `null` when there is none. The draft’s own `not-billable` group reaches back over every month before it."},"uncoveredProjects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"openSeconds":{"type":"integer"},"openFixedPriceItemCount":{"type":"integer","description":"Completed fixed-price items on the project, by the month of their last day within the period, that no invoice claims and no proposal holds. They have no seconds."},"joinable":{"type":"boolean","description":"Whether this proposal’s rule can take the project over a month of the period — `POST /billing-rules/{id}/project-additions`."}},"required":["projectId","projectName","openSeconds","openFixedPriceItemCount","joinable"]},"description":"Other projects of the customer with open billable hours, or completed fixed-price items, in the period that no rule covers. Empty for a catch-up proposal."},"unallocatedSeconds":{"type":"integer","description":"Billable seconds in the period on projects the rule covers that no proposal holds yet, so this invoice cannot bill them. The invoice routes reconcile first, so this is what that pass could not reach. Zero for a catch-up proposal."}},"required":["lastDay","projects","notBillable","uncoveredProjects","unallocatedSeconds"]},"BillingAllocationService":{"type":"object","properties":{"allocationId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":"string"},"taskName":{"type":"string"},"startedAt":{"type":["string","null"],"format":"date-time","description":"`null` for an allowance month and for an item.","example":"2026-07-29T09:00:00.000Z"},"notes":{"type":["string","null"]},"fixedPriceItem":{"anyOf":[{"type":"object","properties":{"description":{"type":"string"},"kind":{"type":"string","enum":["service","expense"]},"amountMinor":{"type":"integer"},"currency":{"type":"string"},"days":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}}},"required":["description","kind","amountMinor","currency","days"]},{"type":"null"}],"description":"What an `item` allocation bills: its description, kind, amount (in its own currency, which may differ from the organization’s) and service days. `null` for an entry or an allowance month. For an item `personName` lists the people sharing it, empty for an expense, and `taskName` is its agreement, empty when it stands alone."},"allowanceSeconds":{"type":["integer","null"],"description":"The allowance month’s agreed seconds; `null` for an entry."},"invoiceId":{"type":["string","null"],"format":"uuid","description":"The invoice that settled it, or the draft that already claims it; `null` while open, and for an external claim or a write-off.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["allocationId","personName","taskName","startedAt","notes","fixedPriceItem","allowanceSeconds","invoiceId"]},"CreateProposalInvoice":{"type":"object","properties":{"notes":{"type":["string","null"],"maxLength":2000},"grouping":{"type":"string","enum":["member","project","task","entry"],"description":"Overrides the proposal’s own default grouping for this one invoice."}}},"BudgetReportChannel":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"isPrivate":{"type":"boolean"},"isMember":{"type":"boolean","description":"Whether the Timjami app is in the channel. A public channel works either way; a private one only once the app was invited."}},"required":["id","name","isPrivate","isMember"]},"BudgetReports":{"type":"object","properties":{"subscriptions":{"type":"array","items":{"$ref":"#/components/schemas/BudgetReportSubscription"}},"slackConnected":{"type":"boolean","description":"Whether this organization has a Slack workspace connected to post into."},"canManageSlack":{"type":"boolean","description":"Whether the caller may add, reschedule or remove Slack reports of this budget (`budget.write`)."}},"required":["subscriptions","slackConnected","canManageSlack"]},"BudgetReportSubscription":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"channel":{"type":"string","enum":["email","slack"],"description":"`email` goes to the caller’s own address and is listed to them alone; `slack` posts to a channel and is listed to everybody who can see the budget."},"slackChannelId":{"type":["string","null"]},"slackChannelName":{"type":["string","null"],"description":"The channel’s name when it was chosen — a label, never used to address the post."},"createdByName":{"type":"string","description":"Who set it up. For a Slack report, that person’s current authority is what each post rides on: once they leave or lose `budget.write`, posts stop."},"sendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7,"description":"ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed.","example":[1,4]},"sendHour":{"type":"integer","minimum":0,"maximum":23,"description":"The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day.","example":8},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"latestDelivery":{"type":["object","null"],"properties":{"occurrenceDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"sentAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastError":{"type":["string","null"],"description":"Why the latest attempt failed, `null` when it went out; always a code, never a provider’s own text. `not-a-member`, `not-authorized` and `budget-hidden` name the configuring person’s lost authority; `unbuildable` a report that could not be built; `slack-not-connected` a missing workspace; `slack-credential-unreadable` a workspace token no configured key decrypts, fixed by reconnecting. `email: unreachable`, `email: rejected` and `email: not-configured` are the mail provider’s answer; `slack: ` followed by Slack’s own code is its refusal — `slack: missing_scope` a workspace connected before reports existed, which must be reconnected once — and `slack: unreachable` a Slack that was never reached. A value starting `outcome-unknown` is a report that may have gone out: a Slack post, whose day is not retried, or a mail the provider already holds a request for, whose retry it deduplicates."}},"required":["occurrenceDay","sentAt","lastError"]}},"required":["id","budgetId","channel","slackChannelId","slackChannelName","createdByName","sendWeekdays","sendHour","createdAt","latestDelivery"]},"BudgetReportSchedule":{"type":"object","properties":{"sendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7,"description":"ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed.","example":[1,4]},"sendHour":{"type":"integer","minimum":0,"maximum":23,"description":"The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day.","example":8}},"required":["sendWeekdays","sendHour"]},"CreateBudgetReportSlack":{"type":"object","properties":{"slackChannelId":{"type":"string","minLength":1,"maxLength":64,"description":"The Slack channel id, as `GET /budgets/report-channels` lists it."},"sendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7,"description":"ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed.","example":[1,4]},"sendHour":{"type":"integer","minimum":0,"maximum":23,"description":"The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day.","example":8}},"required":["slackChannelId","sendWeekdays","sendHour"]},"Budget":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"name":{"type":"string","description":"Named by a person: a budget outlives the composition it started with.","example":"Acme Retainer"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"Inclusive end. `null` is open-ended; set when the budget is superseded."},"budgetedSeconds":{"type":"integer","exclusiveMinimum":0,"description":"The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`.","example":540000},"kind":{"type":"string","enum":["monthly","total"],"description":"How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded.","example":"monthly"},"overflowIntoId":{"type":["string","null"],"format":"uuid","description":"Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"overflowIntoName":{"type":["string","null"],"description":"The overflow target’s name, so a screen needs no second lookup.","example":"Server-Umzug"},"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"archived":{"type":"boolean"}},"required":["projectId","projectName","archived"]}},"archivedAt":{"type":["string","null"],"format":"date-time","description":"Hidden from lists. Different from `validUntilMonth`, which means \"was superseded\".","example":"2026-07-29T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","customerId","customerName","name","validFromMonth","validUntilMonth","budgetedSeconds","kind","overflowIntoId","overflowIntoName","projects","archivedAt","createdAt","updatedAt"]},"CreateBudget":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"description":"A human-readable name for the agreement; it remains stable when the project composition changes.","example":"Acme Retainer"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first calendar month covered by the budget, inclusive."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last calendar month covered by the budget, inclusive. `null` keeps it open-ended."},"budgetedSeconds":{"type":"integer","exclusiveMinimum":0,"description":"The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`.","example":540000},"kind":{"type":"string","enum":["monthly","total"],"default":"monthly","description":"How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded.","example":"monthly"},"overflowIntoId":{"type":["string","null"],"format":"uuid","default":null,"description":"Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"overflowFromId":{"type":["string","null"],"format":"uuid","description":"Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"description":"One or more project IDs covered by the budget. Every project must belong to `customerId`."}},"required":["customerId","name","validFromMonth","validUntilMonth","budgetedSeconds","projectIds"]},"BudgetMonth":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"budgets":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"kind":{"type":"string","enum":["monthly","total"],"description":"For `monthly`, the figures describe this row’s month. For `total`, they describe the budget’s whole span and repeat identically in every month it covers — a total budget is one figure, and a monthly share of it would be a proration nobody agreed.","example":"monthly"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The span, so a screen can label what a `total` figure covers."},"overflowIntoId":{"type":["string","null"],"format":"uuid","description":"Where hours beyond capacity count instead, when declared.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"overflowIntoName":{"type":["string","null"]},"budgetedSeconds":{"type":"integer","description":"What was agreed, without this month’s adjustment."},"adjustmentSeconds":{"type":"integer","description":"This month’s manual adjustment, `0` when there is none. Beside `budgetedSeconds` rather than added into it: their sum is a figure nobody agreed to, so a screen has to be able to show both parts.","example":46800},"adjustmentNote":{"type":["string","null"],"description":"Why this month was adjusted, when somebody said.","example":"Übertrag aus Juli"},"consumedSeconds":{"type":"integer"},"differenceSeconds":{"type":"integer","description":"Consumed minus budgeted *and* this month’s adjustment. Positive means over.","example":-18000},"consumedPercent":{"type":["integer","null"],"description":"How much of `budgetedSeconds` plus `adjustmentSeconds` has been consumed, in whole percent, floored — `null` when that capacity is zero or less. Floored so that it reaches a percentage in the same reading a budget alert threshold on it fires.","example":81},"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"consumedSeconds":{"type":"integer"}},"required":["projectId","projectName","consumedSeconds"]}},"overlapping":{"type":"boolean","description":"One of its projects is also in another budget this month, with no overflow order between the two."}},"required":["budgetId","name","customerId","customerName","kind","validFromMonth","validUntilMonth","overflowIntoId","overflowIntoName","budgetedSeconds","adjustmentSeconds","adjustmentNote","consumedSeconds","differenceSeconds","consumedPercent","projects","overlapping"]}},"unbudgeted":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"consumedSeconds":{"type":"integer"}},"required":["projectId","projectName","customerId","customerName","consumedSeconds"]}},"overlaps":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"budgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}}},"required":["projectId","projectName","budgetIds"]}}},"required":["month","budgets","unbudgeted","overlaps"]},"BudgetTrajectories":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The organization’s current month."},"today":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Today, in the organization’s time zone."},"monthWorkdays":{"type":"integer","minimum":0,"description":"Monday-to-Friday days in `month`."},"elapsedMonthWorkdays":{"type":"integer","minimum":0,"description":"Monday-to-Friday days in `month` up to and including `today`."},"budgets":{"type":"array","items":{"$ref":"#/components/schemas/BudgetTrajectory"}}},"required":["month","today","monthWorkdays","elapsedMonthWorkdays","budgets"]},"BudgetTrajectory":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["monthly","total"],"description":"How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded.","example":"monthly"},"periodFirstDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The month for `monthly`; the whole span for `total`."},"periodLastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"basis":{"type":"string","enum":["plan","pace"],"description":"`plan` when work is planned against the budget in its period: consumed plus remaining planned work. `pace` otherwise: the average per elapsed working day, carried over the remaining ones. Always `pace` while the organization has planning switched off."},"status":{"type":"string","enum":["on-track","running-out","left-over","too-early"],"description":"`running-out` or `left-over` when the forecast deviates from capacity by at least `toleranceSeconds`; `too-early` for a `pace` forecast before the third working day."},"periodWorkdays":{"type":"integer","minimum":0},"elapsedWorkdays":{"type":"integer","minimum":0,"description":"Today included."},"capacitySeconds":{"type":"integer","description":"Agreed plus this month’s adjustment, as in `differenceSeconds`."},"consumedSeconds":{"type":"integer"},"targetSeconds":{"type":"integer","description":"Capacity × elapsed working days ÷ the period’s working days."},"remainingPlannedSeconds":{"type":["integer","null"],"description":"The shared forecast’s remaining planned work; null for `pace`."},"pacePerWorkdaySeconds":{"type":"integer"},"forecastSeconds":{"type":"integer"},"deviationSeconds":{"type":"integer","description":"Forecast minus capacity. Positive runs out, negative is left over."},"toleranceSeconds":{"type":"integer","description":"The larger of 10 % of capacity and eight hours."},"exhaustsOnDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"For `running-out`: the working day the forecast reaches capacity. Null when it already has."},"requiredPerWorkdaySeconds":{"type":["integer","null"],"description":"For `left-over`: seconds per remaining working day that would use the rest."}},"required":["budgetId","kind","periodFirstDay","periodLastDay","basis","status","periodWorkdays","elapsedWorkdays","capacitySeconds","consumedSeconds","targetSeconds","remainingPlannedSeconds","pacePerWorkdaySeconds","forecastSeconds","deviationSeconds","toleranceSeconds","exhaustsOnDay","requiredPerWorkdaySeconds"]},"BudgetTrajectoryDetail":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"today":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"trajectory":{"$ref":"#/components/schemas/BudgetTrajectory"},"days":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"targetSeconds":{"type":"integer","description":"Capacity spread evenly over the working days, up to this one."},"consumedSeconds":{"type":["integer","null"],"description":"Consumed up to and including this day, by the day its entries started; null after today. The last elapsed working day equals `trajectory.consumedSeconds`."},"forecastSeconds":{"type":["integer","null"],"description":"The forecast from today to the end of the period at its own daily rate; null before today and while `too-early`."}},"required":["day","targetSeconds","consumedSeconds","forecastSeconds"]},"description":"Every working day (Monday to Friday) of the period."}},"required":["month","today","trajectory","days"]},"SupersedeBudget":{"type":"object","properties":{"replaceIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"description":"The budgets this one replaces. Each is ended the month before the successor starts, in the same transaction. Several IDs allow a fan-in replacement. All must belong to the successor’s customer."},"successor":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200,"description":"A human-readable name for the agreement; it remains stable when the project composition changes.","example":"Acme Retainer"},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first calendar month covered by the budget, inclusive."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last calendar month covered by the budget, inclusive. `null` keeps it open-ended."},"budgetedSeconds":{"type":"integer","exclusiveMinimum":0,"description":"The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`.","example":540000},"kind":{"type":"string","enum":["monthly","total"],"default":"monthly","description":"How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded.","example":"monthly"},"overflowIntoId":{"type":["string","null"],"format":"uuid","default":null,"description":"Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"overflowFromId":{"type":["string","null"],"format":"uuid","description":"Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"description":"One or more project IDs covered by the budget. Every project must belong to `customerId`."}},"required":["customerId","name","validFromMonth","validUntilMonth","budgetedSeconds","projectIds"],"description":"The new budget that replaces every `replaceIds` budget in the same transaction."}},"required":["replaceIds","successor"]},"BudgetAlerts":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"availableScopes":{"type":"array","items":{"type":"string","enum":["month","total"],"description":"What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else.","example":"month"},"description":"The scopes this budget can carry, in the order to offer them. A `total` budget is one figure for its whole span and has no monthly share; a monthly budget with no end month has no total. A span longer than 24 months has no readable total either."},"thresholds":{"type":"array","items":{"$ref":"#/components/schemas/BudgetAlertThreshold"}}},"required":["budgetId","availableScopes","thresholds"]},"BudgetAlertThreshold":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"metric":{"type":"string","enum":["consumption"],"description":"What is measured. Only the budget’s own consumption today; a forecast threshold would be a second value here."},"scope":{"type":"string","enum":["month","total"],"description":"What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else.","example":"month"},"percent":{"type":"integer","minimum":1,"maximum":1000,"description":"The share of the agreed hours that makes this threshold fire, as a whole percent from 1 to 1000. Free-form within that range rather than a fixed set: 80 and 100 are the usual pair, 50 and 120 are as valid. Above 100 watches an overrun.","example":80},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"triggers":{"type":"array","items":{"$ref":"#/components/schemas/BudgetAlertTrigger"},"description":"The crossings this threshold has recorded — empty until it fires. A `month`-scope threshold records one per month, and a threshold whose reading fell back below it and rose again records another, so this is a list rather than a boolean. „Reached\" means a crossing with a `null` `resolvedAt`."}},"required":["id","budgetId","metric","scope","percent","createdAt","triggers"]},"BudgetAlertTrigger":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month that was watched, or `null` for a `total`-scope threshold."},"firedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"resolvedAt":{"type":["string","null"],"format":"date-time","description":"When a later reading fell back below the threshold, `null` while the crossing still stands. A resolved crossing stays readable as history, and its threshold is armed again: the next crossing records and reports itself anew.","example":"2026-07-29T09:00:00.000Z"},"consumedSeconds":{"type":"integer","description":"What had been consumed when the threshold was reached."},"budgetedSeconds":{"type":"integer","description":"What was agreed for the watched window."},"adjustmentSeconds":{"type":"integer","description":"The entered adjustment of the watched window, `0` when there was none. Beside `budgetedSeconds` rather than added into it, like everywhere else (invariant 47)."}},"required":["id","month","firedAt","resolvedAt","consumedSeconds","budgetedSeconds","adjustmentSeconds"]},"UpdateBudget":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"budgetedSeconds":{"type":"integer","exclusiveMinimum":0,"description":"The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`.","example":540000},"kind":{"type":"string","enum":["monthly","total"],"description":"How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded.","example":"monthly"},"overflowIntoId":{"type":["string","null"],"format":"uuid","description":"Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1}}},"BudgetAdjustment":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"The monthly budget whose adjustment is being changed.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The single calendar month to adjust, formatted `YYYY-MM`; the budget must cover it."},"adjustmentSeconds":{"type":"integer","example":46800,"description":"A signed delta in whole seconds, added to the budget’s agreed capacity for this month alone. Use a negative value when earlier work consumed capacity; this is not a replacement total and zero must be removed instead."},"note":{"type":["string","null"],"maxLength":200,"example":"Übertrag aus Juli","description":"Optional explanation for why this month differs. Free text; `null` clears the note."}},"required":["budgetId","month","adjustmentSeconds","note"]},"SetBudgetAdjustment":{"type":"object","properties":{"adjustmentSeconds":{"type":"integer","example":46800,"description":"A signed delta in whole seconds, added to the budget’s agreed capacity for this month alone. Use a negative value when earlier work consumed capacity; this is not a replacement total and zero must be removed instead."},"note":{"type":["string","null"],"maxLength":200,"example":"Übertrag aus Juli","description":"Optional explanation for why this month differs. Free text; `null` clears the note."}},"required":["adjustmentSeconds"]},"CreateBudgetAlert":{"type":"object","properties":{"scope":{"type":"string","enum":["month","total"],"description":"What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else.","example":"month"},"percent":{"type":"integer","minimum":1,"maximum":1000,"description":"The share of the agreed hours that makes this threshold fire, as a whole percent from 1 to 1000. Free-form within that range rather than a fixed set: 80 and 100 are the usual pair, 50 and 120 are as valid. Above 100 watches an overrun.","example":80}},"required":["scope","percent"]},"PlanningOverview":{"type":"object","properties":{"currentWeekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"The Monday of the ISO week containing „now\", in the organization’s time zone — what the grid tints as the current week and where the forecast starts crediting tracked work against the plan."},"weeks":{"type":"array","items":{"type":"object","properties":{"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["weekStartDay","month"]},"description":"Every week in the resolved range, in order, each tagged with the calendar month its Thursday falls in (`monthOfWeek`) — the same rule the budget figures below use, so the grid’s header and the arithmetic never disagree about the same week."},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weeks":{"type":"array","items":{"type":"object","properties":{"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"capacitySeconds":{"type":"integer","minimum":0,"description":"What `expectedWeekSeconds` asks of this week for this person — agreed demand minus absences (while the organization has them on), exactly what the week page and the sidebar meter already show. Never a planning-specific target."},"plannedSeconds":{"type":"integer","minimum":0},"unassignedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds this week with no unique budget assignment (customer targets, and projects without or with several covering budgets) — not seconds without a planning target."},"hiddenPlannedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds this week on budgets or projects the caller cannot see. Already included in plannedSeconds; never attributed to a target. Zero for a caller who sees every project."},"derived":{"type":"boolean","description":"True when any day in this week needed a monthly working-time model’s pro-rata share rather than a stated weekly figure (ADR-2026-08-18-063RA decision 4) — the same flag those screens carry."},"absences":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["vacation","sick","holiday","other"],"description":"Only for a caller holding `absence.readKinds` (owner, admin, or the planning role with absence details). Without it the week carries one entry listing every absent day, and no kind."},"days":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"description":"Working days this week that the absence covers — a weekend day is never listed."}},"required":["days"]},"description":"The working days this week lost to absence. Always empty while the organization has absences off: a stored absence then lowers no capacity (ADR-2026-10-25-09PNG)."}},"required":["weekStartDay","capacitySeconds","plannedSeconds","unassignedSeconds","hiddenPlannedSeconds","derived","absences"]}}},"required":["personId","weeks"]}},"allocations":{"type":"array","items":{"$ref":"#/components/schemas/PlanningOverviewAllocation"},"description":"Every stored allocation whose week falls in the requested range, any person or project."},"tracked":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"trackedSeconds":{"type":"integer","minimum":0,"description":"Finished entries’ seconds for this person, project and week, bucketed by `started_at` in the organization’s time zone. Never a plan figure, and a plan figure never adds to it (invariant 70)."}},"required":["personId","projectId","weekStartDay","trackedSeconds"]}},"customers":{"type":"array","items":{"type":"object","properties":{"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["color","icon","id","name","archivedAt"]}},"budgetWeeks":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"plannedSeconds":{"type":"integer","minimum":0}},"required":["budgetId","weekStartDay","plannedSeconds"]},"description":"Weekly forecast allocations: direct budget intent plus project intent uniquely assigned to this budget. Not the sum of direct editable rows alone."},"customerWeeks":{"type":"array","items":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"plannedSeconds":{"type":"integer","minimum":0},"trackedSeconds":{"type":"integer","minimum":0},"unassignedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds for this customer with no unique budget assignment."}},"required":["customerId","weekStartDay","plannedSeconds","trackedSeconds","unassignedSeconds"]}},"trackedCustomers":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"trackedSeconds":{"type":"integer","minimum":0}},"required":["personId","customerId","weekStartDay","trackedSeconds"]}},"projects":{"type":"array","items":{"type":"object","properties":{"icon":{"$ref":"#/components/schemas/ResourceIcon"},"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"Set when the project or its customer is archived. Still named here: archiving prevents new booking, never historical naming (invariant 54), and an archived project may still carry allocations.","example":"2026-07-29T09:00:00.000Z"}},"required":["icon","id","name","customerId","customerName","color","archivedAt"]},"description":"Every project named by an allocation, a tracked row or a budget above — archived projects included, because their names must still travel (invariant 54)."},"budgets":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["monthly","total"]},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"months":{"type":"array","items":{"type":"object","properties":{"remainingPlannedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds still to do for this budget: future weeks in full, the current week minus actuals credited to it (or its manual correction), past weeks nothing. Not free capacity, and not the budget’s remainder — that is `remainingSeconds`."},"remainingSeconds":{"type":"integer","description":"The budget’s remainder after planning: `budgetedSeconds + adjustmentSeconds − consumedSeconds − remainingPlannedSeconds`. Positive means hours left, negative means over — the opposite sign of the budget list’s `differenceSeconds`."},"overBudgetSeconds":{"type":"integer","minimum":0,"description":"`max(0, −remainingSeconds)`: how far the plan overruns this budget."},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"budgetedSeconds":{"type":"integer","description":"What was agreed, without this month’s adjustment."},"adjustmentSeconds":{"type":"integer","description":"This month’s manual adjustment, `0` when there is none."},"consumedSeconds":{"type":"integer","description":"Billable, finished seconds tracked this month."},"plannedSeconds":{"type":"integer","minimum":0,"description":"Direct budget allocations and project allocations uniquely covered by this budget whose week belongs to this month (the Thursday rule, `monthOfWeek`) — for `kind: total`, the whole span’s total, repeated in every covered month exactly as `budgetedSeconds` and `consumedSeconds` already are."},"plannedOpenSeconds":{"type":"integer","minimum":0,"description":"Original planned seconds from the current week onward, retained for compatibility. Despite the name it is not what is still open: it ignores work already tracked this week, so subtracting it double-counts. The forecast subtracts `remainingPlannedSeconds`."}},"required":["remainingPlannedSeconds","remainingSeconds","overBudgetSeconds","month","budgetedSeconds","adjustmentSeconds","consumedSeconds","plannedSeconds","plannedOpenSeconds"]}},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["budgetId","name","customerId","kind","projectIds","validFromMonth","validUntilMonth","months"]}}},"required":["currentWeekStartDay","weeks","people","allocations","tracked","customers","budgetWeeks","customerWeeks","trackedCustomers","projects","budgets"]},"PlanningOverviewAllocation":{"allOf":[{"$ref":"#/components/schemas/PlanningAllocation"},{"type":"object","properties":{"forecastBudgetId":{"type":["string","null"],"format":"uuid","description":"Uniquely assigned forecast budget for this week, or null. Never changes the stored planning target or actual consumption.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"matchedActualSeconds":{"type":"integer","minimum":0,"description":"Finished billable seconds this person tracked in this (current) week that are credited to this plan, each second at most once."},"remainingPlannedSeconds":{"type":"integer","minimum":0,"description":"What is still to do on this plan: the whole plan for a future week, plan minus `matchedActualSeconds` (or the manual `expectedRemainingSeconds`) for the current week, `0` for a past week."},"unassignedReason":{"type":["string","null"],"enum":["customer-target","missing-target","archived-target","outside-agreement","no-budget","ambiguous-budget",null]},"createdByName":{"type":["string","null"]},"updatedByName":{"type":["string","null"]}},"required":["forecastBudgetId","matchedActualSeconds","remainingPlannedSeconds","unassignedReason","createdByName","updatedByName"]}]},"PlanningAllocation":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"plannedSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":604800,"description":"Whole seconds planned for this person, target and week. Always positive — a cell set to `0:00` removes the row instead of storing it, so „nothing planned\" is a missing allocation rather than one saying nothing.","example":72000},"note":{"type":["string","null"]},"expectedRemainingSeconds":{"type":["integer","null"],"minimum":0,"maximum":604800,"description":"A person’s manual correction of what is still to do in the current week, or null (usually). Only the input: the effective figure, corrected or derived, is `remainingPlannedSeconds`. `0` means done. The name predates TIM-234."},"expectedRemainingUpdatedAt":{"type":["string","null"],"format":"date-time","description":"When `expectedRemainingSeconds` was last set; null without a correction.","example":"2026-07-29T09:00:00.000Z"},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Who first set this cell. Null once that person was deleted — the row outlives them.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"updatedByPersonId":{"type":["string","null"],"format":"uuid","description":"Who last wrote this cell — the same as `createdByPersonId` until a second write changes it. Null once that person was deleted.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","personId","weekStartDay","plannedSeconds","note","expectedRemainingSeconds","expectedRemainingUpdatedAt","createdByPersonId","updatedByPersonId","updatedAt"]},"SetPlanningAllocation":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Who this plans for. Must be a person of the active organization who still holds a membership.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"The project target. Keep this field for compatibility with existing clients.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"The direct customer target. Exactly one target id is required.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"The direct budget target. Exactly one target id is required.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"plannedSeconds":{"type":"integer","minimum":0,"maximum":604800,"description":"Whole seconds planned for this week. `0` removes the row — the only way an allocation is deleted.","example":72000},"note":{"type":["string","null"],"maxLength":500}},"required":["personId","weekStartDay","plannedSeconds"]},"RefinePlanningAllocation":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"source":{"type":"object","properties":{"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"description":"The existing target whose hours are reduced."},"destination":{"type":"object","properties":{"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"budgetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"description":"The target receiving the hours. Must belong to the same customer; a project and budget must be linked."},"moveSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":604800,"description":"Whole seconds to move, at most the source hours and at most 604800 seconds at the destination in total."},"expectedSourceSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":604800,"description":"The source plannedSeconds from the last overview read."},"sourceAllocationId":{"type":"string","format":"uuid","description":"The id of the source allocation from the last overview read.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"expectedSourceUpdatedAt":{"type":"string","format":"date-time","description":"The source allocation updatedAt from the last overview read; a changed revision is refused.","example":"2026-07-29T09:00:00.000Z"}},"required":["personId","weekStartDay","source","destination","moveSeconds","expectedSourceSeconds","sourceAllocationId","expectedSourceUpdatedAt"]},"PlanningForecast":{"type":"object","properties":{"currentWeekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"fromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"untilMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"weeks":{"type":"array","items":{"type":"object","properties":{"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings."},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["weekStartDay","month"]}},"customers":{"type":"array","items":{"type":"object","properties":{"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"icon":{"$ref":"#/components/schemas/ResourceIcon"},"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["color","icon","id","name","archivedAt"]}},"projects":{"type":"array","items":{"type":"object","properties":{"icon":{"$ref":"#/components/schemas/ResourceIcon"},"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"color":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$","example":"#228be6"},"archivedAt":{"type":["string","null"],"format":"date-time","description":"Set when the project or its customer is archived. Still named here: archiving prevents new booking, never historical naming (invariant 54), and an archived project may still carry allocations.","example":"2026-07-29T09:00:00.000Z"}},"required":["icon","id","name","customerId","customerName","color","archivedAt"]}},"budgets":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["monthly","total"]},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"months":{"type":"array","items":{"type":"object","properties":{"remainingPlannedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds still to do for this budget: future weeks in full, the current week minus actuals credited to it (or its manual correction), past weeks nothing. Not free capacity, and not the budget’s remainder — that is `remainingSeconds`."},"remainingSeconds":{"type":"integer","description":"The budget’s remainder after planning: `budgetedSeconds + adjustmentSeconds − consumedSeconds − remainingPlannedSeconds`. Positive means hours left, negative means over — the opposite sign of the budget list’s `differenceSeconds`."},"overBudgetSeconds":{"type":"integer","minimum":0,"description":"`max(0, −remainingSeconds)`: how far the plan overruns this budget."},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"budgetedSeconds":{"type":"integer","description":"What was agreed, without this month’s adjustment."},"adjustmentSeconds":{"type":"integer","description":"This month’s manual adjustment, `0` when there is none."},"consumedSeconds":{"type":"integer","description":"Billable, finished seconds tracked this month."},"plannedSeconds":{"type":"integer","minimum":0,"description":"Direct budget allocations and project allocations uniquely covered by this budget whose week belongs to this month (the Thursday rule, `monthOfWeek`) — for `kind: total`, the whole span’s total, repeated in every covered month exactly as `budgetedSeconds` and `consumedSeconds` already are."},"plannedOpenSeconds":{"type":"integer","minimum":0,"description":"Original planned seconds from the current week onward, retained for compatibility. Despite the name it is not what is still open: it ignores work already tracked this week, so subtracting it double-counts. The forecast subtracts `remainingPlannedSeconds`."}},"required":["remainingPlannedSeconds","remainingSeconds","overBudgetSeconds","month","budgetedSeconds","adjustmentSeconds","consumedSeconds","plannedSeconds","plannedOpenSeconds"]}},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"customerName":{"type":"string"},"total":{"type":"object","properties":{"remainingPlannedSeconds":{"type":"integer","minimum":0,"description":"Planned seconds still to do for this budget: future weeks in full, the current week minus actuals credited to it (or its manual correction), past weeks nothing. Not free capacity, and not the budget’s remainder — that is `remainingSeconds`."},"remainingSeconds":{"type":"integer","description":"The budget’s remainder after planning: `budgetedSeconds + adjustmentSeconds − consumedSeconds − remainingPlannedSeconds`. Positive means hours left, negative means over — the opposite sign of the budget list’s `differenceSeconds`."},"overBudgetSeconds":{"type":"integer","minimum":0,"description":"`max(0, −remainingSeconds)`: how far the plan overruns this budget."},"budgetedSeconds":{"type":"integer","description":"What was agreed, without this month’s adjustment."},"adjustmentSeconds":{"type":"integer","description":"This month’s manual adjustment, `0` when there is none."},"consumedSeconds":{"type":"integer","description":"Billable, finished seconds tracked this month."},"plannedSeconds":{"type":"integer","minimum":0,"description":"Direct budget allocations and project allocations uniquely covered by this budget whose week belongs to this month (the Thursday rule, `monthOfWeek`) — for `kind: total`, the whole span’s total, repeated in every covered month exactly as `budgetedSeconds` and `consumedSeconds` already are."},"plannedOpenSeconds":{"type":"integer","minimum":0,"description":"Original planned seconds from the current week onward, retained for compatibility. Despite the name it is not what is still open: it ignores work already tracked this week, so subtracting it double-counts. The forecast subtracts `remainingPlannedSeconds`."}},"required":["remainingPlannedSeconds","remainingSeconds","overBudgetSeconds","budgetedSeconds","adjustmentSeconds","consumedSeconds","plannedSeconds","plannedOpenSeconds"]}},"required":["budgetId","name","customerId","kind","projectIds","validFromMonth","validUntilMonth","months","customerName","total"]}},"allocations":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/PlanningOverviewAllocation"},{"type":"object","properties":{"personName":{"type":"string"},"targetName":{"type":"string"},"resolvedCustomerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."}},"required":["personName","targetName","resolvedCustomerId","customerName","month"]}]}},"issues":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["over-budget","ending-agreement","uncovered-work"]},"budgetId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"allocationId":{"type":["string","null"],"format":"uuid","description":"For `uncovered-work`, its one allocation; otherwise null. Redundant with `allocationIds`, kept for existing clients (TIM-234).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"allocationIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"The allocations whose remaining planned seconds make up `seconds`: for an ending agreement, the weeks planned after it ends; for uncovered work, its one allocation; empty for over-budget."},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"seconds":{"type":"integer","minimum":0}},"required":["kind","budgetId","allocationId","allocationIds","month","seconds"]}}},"required":["currentWeekStartDay","fromMonth","untilMonth","weeks","customers","projects","budgets","allocations","issues"]},"PlanningContextList":{"type":"object","properties":{"contexts":{"type":"array","items":{"$ref":"#/components/schemas/PlanningContext"}}},"required":["contexts"]},"PlanningContext":{"type":"object","properties":{"targetType":{"type":"string","enum":["customer","person","project","budget"]},"targetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"targetName":{"type":"string"},"notes":{"type":"string"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["targetType","targetId","targetName","notes","updatedAt","updatedByPersonId"]},"SetPlanningContext":{"type":"object","properties":{"targetType":{"type":"string","enum":["customer","person","project","budget"]},"targetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"notes":{"type":"string","maxLength":10000,"description":"Free-form planning context. Empty or whitespace-only text clears it. Treated as data, never executable instructions."}},"required":["targetType","targetId","notes"]},"TimeAllowance":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personId":{"type":"string","format":"uuid","description":"The person the allowance is **for** — usually not the person who agreed it.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":"string","example":"Frieda Sowade"},"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskName":{"type":"string","example":"Slack-Begleitung"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"secondsPerMonth":{"type":"integer","exclusiveMinimum":0,"description":"The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance\" is no agreement rather than one of zero.","example":7200},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-09","description":"The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":null,"description":"The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October."},"note":{"type":["string","null"]},"projectArchivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"customerArchivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"adjustments":{"type":"array","items":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"deltaSeconds":{"type":"integer","description":"A signed delta in whole seconds on this month alone, never a replacement total. Negative for a month that was shorter than agreed. Never zero — no adjustment is a missing row.","example":-3600},"note":{"type":["string","null"]}},"required":["month","deltaSeconds","note"]},"description":"Every month of this agreement somebody corrected, oldest first."},"billedMonths":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"description":"The months a **live** invoice already bills, oldest first. A month in here is not freely correctable any more: the document froze what it was worth, so the way back is to withdraw that invoice — which releases the month rather than deleting the record.","example":["2026-08"]},"createdByPersonId":{"type":["string","null"],"format":"uuid","description":"Who agreed it. Null once that person was deleted — the row outlives them.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"updatedByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","personId","personName","taskId","taskName","projectId","projectName","customerId","customerName","secondsPerMonth","validFromMonth","validUntilMonth","note","projectArchivedAt","customerArchivedAt","adjustments","billedMonths","createdByPersonId","updatedByPersonId","createdAt","updatedAt"]},"CreateTimeAllowance":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Who the allowance is for. Must be a person of the active organization who still holds a membership.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":"string","format":"uuid","description":"The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"secondsPerMonth":{"type":"integer","exclusiveMinimum":0,"description":"The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance\" is no agreement rather than one of zero.","example":7200},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-09","description":"The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":null,"description":"The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October."},"note":{"type":["string","null"],"maxLength":500,"description":"Why this was agreed, in free text. `null` clears it. „2 h/Monat\" without a note is a riddle in half a year.","example":"Slack-Begleitung, vereinbart mit Frau Meier"}},"required":["personId","taskId","secondsPerMonth","validFromMonth","validUntilMonth"]},"SupersedeTimeAllowance":{"type":"object","properties":{"replaceIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"description":"The agreements this one replaces. Each is ended the month **before** the successor starts, in the same transaction — „ab Oktober sind es zwei Stunden\" is one thing somebody agreed. All must be for the successor’s own person and task."},"successor":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Who the allowance is for. Must be a person of the active organization who still holds a membership.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"taskId":{"type":"string","format":"uuid","description":"The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"secondsPerMonth":{"type":"integer","exclusiveMinimum":0,"description":"The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance\" is no agreement rather than one of zero.","example":7200},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-09","description":"The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":null,"description":"The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October."},"note":{"type":["string","null"],"maxLength":500,"description":"Why this was agreed, in free text. `null` clears it. „2 h/Monat\" without a note is a riddle in half a year.","example":"Slack-Begleitung, vereinbart mit Frau Meier"}},"required":["personId","taskId","secondsPerMonth","validFromMonth","validUntilMonth"],"description":"The agreement that takes over, written in the same transaction."}},"required":["replaceIds","successor"]},"UpdateTimeAllowance":{"type":"object","properties":{"secondsPerMonth":{"type":"integer","exclusiveMinimum":0,"description":"The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance\" is no agreement rather than one of zero.","example":7200},"validFromMonth":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-09","description":"The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on."},"validUntilMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":null,"description":"The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October."},"note":{"type":["string","null"],"maxLength":500,"description":"Why this was agreed, in free text. `null` clears it. „2 h/Monat\" without a note is a riddle in half a year.","example":"Slack-Begleitung, vereinbart mit Frau Meier"}}},"TimeAllowanceAdjustment":{"type":"object","properties":{"allowanceId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"deltaSeconds":{"type":"integer","description":"A signed delta in whole seconds on this month alone — what somebody decided, never a recomputed total. Negative for a month that was shorter than agreed. Zero is refused: no adjustment is the absence of a row, so use `DELETE`.","example":-3600},"note":{"type":["string","null"]}},"required":["allowanceId","month","deltaSeconds","note"]},"SetTimeAllowanceAdjustment":{"type":"object","properties":{"deltaSeconds":{"type":"integer","description":"A signed delta in whole seconds on this month alone — what somebody decided, never a recomputed total. Negative for a month that was shorter than agreed. Zero is refused: no adjustment is the absence of a row, so use `DELETE`.","example":-3600},"note":{"type":["string","null"],"maxLength":500,"description":"Why this was agreed, in free text. `null` clears it. „2 h/Monat\" without a note is a riddle in half a year.","example":"Slack-Begleitung, vereinbart mit Frau Meier"}},"required":["deltaSeconds"]},"FixedPriceAgreement":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","example":"React-Workshop Okt 2026"},"tasks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"billingMode":{"type":"string","enum":["hourly","fixed_price","productive","internal"],"description":"How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it."},"archivedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","projectId","billingMode","archivedAt"]},"description":"The tasks whose hours are spent on this agreement: `fixed_price` ones carry the sold work, `internal` ones the investment around it."},"items":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceItem"}},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","customerId","name","tasks","items","createdAt","updatedAt"]},"FixedPriceItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"agreementId":{"type":["string","null"],"format":"uuid","description":"The agreement the item belongs to. `null` only for a standalone `expense`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"The project the item is billed under. Its customer is the agreement’s.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["service","expense"],"description":"`service` is revenue that people share by weight (a workshop’s day fee) and always belongs to an agreement. `expense` is passed through and counts as no revenue (travel costs); it may stand alone on a project."},"description":{"type":"string","description":"Becomes the invoice line’s description, together with the service days.","example":"Workshop-Tag"},"quantityHundredths":{"type":"integer","exclusiveMinimum":0,"description":"The quantity in hundredths of `unit`: 200 is two days. Independent of how many days the item has.","example":200},"unit":{"type":"string","example":"Tag"},"unitPriceMinor":{"type":"integer","minimum":0,"description":"The price per unit in minor units, entered per item.","example":180000},"amountMinor":{"type":"integer","minimum":0,"description":"`round(quantity × unit price)` in minor units, rounded once when the item was written and stored.","example":360000},"currency":{"type":"string","minLength":3,"maxLength":3,"description":"The organization’s currency when the item was written.","example":"EUR"},"easybillPositionId":{"type":["string","null"],"description":"The Easybill article this item’s invoice line references, overriding the kind’s own (it carries the VAT rate and the ledger account). `null` means the kind’s article.","example":"236898474"},"days":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"minItems":1,"description":"The service days — a set of calendar days in the organization’s time zone, ascending. Friday and Monday are two days, not four."},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weight":{"type":"integer","exclusiveMinimum":0,"description":"The share of the item’s revenue, relative to the other people. Equal by default.","example":1}},"required":["personId","weight"]},"description":"Who shares a `service` item’s revenue. Empty for an `expense`."},"policy":{"$ref":"#/components/schemas/FixedPriceItemPolicy"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","agreementId","projectId","kind","description","quantityHundredths","unit","unitPriceMinor","amountMinor","currency","easybillPositionId","days","people","policy","createdAt","updatedAt"]},"FixedPriceItemPolicy":{"type":"object","properties":{"state":{"type":"string","enum":["free","closed","billed"],"description":"`billed` — a live invoice claim holds the item. `closed` — a day of the item lies behind a period close or the tracking deadline. `free` — neither. A live claim wins over a closed day."},"update":{"type":"string","enum":["free","reason","locked"],"description":"`locked` for **everybody** while `billed` — the invoice line states the item’s quantity, price and days. `reason` for an owner or admin on a `closed` item."},"remove":{"type":"string","enum":["free","reason","locked"],"description":"Like `update`, and `locked` for everybody while **any** invoice claim names the item, a withdrawn one included — delete that invoice first."},"blockers":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceItemBlocker"},"description":"Why the state is `closed`; empty otherwise."}},"required":["state","update","remove","blockers"],"description":"What may still be done with this item, decided on the server. It informs and grants nothing: every write is refused by the same facts again."},"FixedPriceItemBlocker":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["period-closed"]},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A closed day the item is on."},"closedThroughDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"How far the project is closed — inclusive, so `day <= closedThroughDay`."}},"required":["kind","day","closedThroughDay"]},{"type":"object","properties":{"kind":{"type":"string","enum":["backdating-limit"]},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A frozen day the item is on."},"earliestOpenDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The first day the organization still records on — **exclusive** of the refusal, so `day < earliestOpenDay`."}},"required":["kind","day","earliestOpenDay"]}]},"CreateFixedPriceAgreement":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string","minLength":1,"maxLength":200}},"required":["customerId","name"]},"UpdateFixedPriceAgreement":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200}}},"FixedPriceRevision":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"subject":{"type":"string","enum":["agreement","item"]},"subjectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"agreementId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"action":{"type":"string","enum":["created","updated","deleted"]},"actorPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"recordedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"snapshot":{"type":"object","additionalProperties":{},"description":"The subject as it stood **before** this write; for `created`, its initial values."},"reason":{"type":["string","null"]}},"required":["id","subject","subjectId","agreementId","projectId","action","actorPersonId","recordedAt","snapshot","reason"]},"FixedPriceControlling":{"type":"object","properties":{"agreementId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"currency":{"type":"string","minLength":3,"maxLength":3,"description":"The organization’s currency; every amount here is in it."},"from":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"until":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"hours":{"type":"object","properties":{"fixedPriceSeconds":{"type":"integer","minimum":0,"description":"Hours on the agreement’s `fixed_price` tasks — the sold work."},"investmentSeconds":{"type":"integer","minimum":0,"description":"Hours on the agreement’s `productive` and `internal` tasks — our own investment around it."},"totalSeconds":{"type":"integer","minimum":0}},"required":["fixedPriceSeconds","investmentSeconds","totalSeconds"],"description":"Finished hours of everybody who worked on the agreement, split by kind."},"people":{"type":"array","items":{"type":"object","properties":{"fixedPriceSeconds":{"type":"integer","minimum":0,"description":"Hours on the agreement’s `fixed_price` tasks — the sold work."},"investmentSeconds":{"type":"integer","minimum":0,"description":"Hours on the agreement’s `productive` and `internal` tasks — our own investment around it."},"totalSeconds":{"type":"integer","minimum":0},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["fixedPriceSeconds","investmentSeconds","totalSeconds","personId"]},"description":"The same split per person, most hours first."},"revenue":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The `service` items’ amounts in the organization’s currency, spread over their service days (and so cut to the window by days) — taken from the items, never from the hours."},"itemCount":{"type":"integer","minimum":0,"description":"Items counted in the amount."},"otherCurrencyItemCount":{"type":"integer","minimum":0,"description":"Items in another currency: counted here, never summed into the amount."}},"required":["amountMinor","itemCount","otherCurrencyItemCount"]},"rates":{"type":"object","properties":{"perAllHours":{"$ref":"#/components/schemas/FixedPriceControllingRate"},"perFixedPriceHours":{"$ref":"#/components/schemas/FixedPriceControllingRate"}},"required":["perAllHours","perFixedPriceHours"],"description":"Two effective rates side by side, each stating its divisor. `perAllHours` is revenue ÷ **all** hours — what the fixed price earned per hour, investment included. `perFixedPriceHours` is revenue ÷ **fixed-price** hours — how well it was sold, without our own investment."},"rounding":{"type":"string","enum":["half-up-minor-unit-per-hour"],"description":"How the rates are rounded: once, half up, to a whole minor unit per hour. Seconds and amounts are never rounded."},"scope":{"type":"string","enum":["organization"],"description":"Whose work the figures cover: everybody who booked on the agreement, not one person."}},"required":["agreementId","currency","from","until","hours","people","revenue","rates","rounding","scope"]},"FixedPriceControllingRate":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The revenue the rate divides, in minor units of `currency`."},"seconds":{"type":"integer","minimum":0,"description":"The hours the rate divides by, in seconds — exactly the ones it names."},"rateMinorPerHour":{"type":["integer","null"],"description":"`amountMinor` per hour, rounded **once** to a whole minor unit, half up. `null` without hours — no hours is not a rate of zero."}},"required":["amountMinor","seconds","rateMinorPerHour"],"description":"An effective hourly rate: revenue ÷ the hours it names, with that divisor stated."},"CreateFixedPriceItem":{"type":"object","properties":{"agreementId":{"type":["string","null"],"format":"uuid","description":"Required for a `service`; omit or `null` for a standalone `expense`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"kind":{"type":"string","enum":["service","expense"],"description":"`service` is revenue that people share by weight (a workshop’s day fee) and always belongs to an agreement. `expense` is passed through and counts as no revenue (travel costs); it may stand alone on a project."},"description":{"type":"string","minLength":1,"maxLength":500},"quantityHundredths":{"type":"integer","exclusiveMinimum":0,"maximum":100000000},"unit":{"type":"string","minLength":1,"maxLength":40},"unitPriceMinor":{"type":"integer","minimum":0,"maximum":2000000000},"days":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"minItems":1,"maxItems":366},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weight":{"type":"integer","exclusiveMinimum":0,"description":"The share of the item’s revenue, relative to the other people. Equal by default.","example":1}},"required":["personId","weight"]},"maxItems":50,"default":[],"description":"At least one for a `service`, each with a weight; none for an `expense`. Each must be a live member of the organization."},"easybillPositionId":{"type":["string","null"],"pattern":"^[0-9]{1,18}$","description":"The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export.","example":"236898474"},"editReason":{"type":"string","maxLength":500,"description":"Why an item is being created on a closed day anyway. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends."}},"required":["projectId","kind","description","quantityHundredths","unit","unitPriceMinor","days"]},"UpdateFixedPriceItem":{"type":"object","properties":{"agreementId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":{"type":"string","minLength":1,"maxLength":500},"quantityHundredths":{"type":"integer","exclusiveMinimum":0,"maximum":100000000},"unit":{"type":"string","minLength":1,"maxLength":40},"unitPriceMinor":{"type":"integer","minimum":0,"maximum":2000000000},"easybillPositionId":{"type":["string","null"],"pattern":"^[0-9]{1,18}$","description":"The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export.","example":"236898474"},"days":{"type":"array","items":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"minItems":1,"maxItems":366},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"weight":{"type":"integer","exclusiveMinimum":0,"description":"The share of the item’s revenue, relative to the other people. Equal by default.","example":1}},"required":["personId","weight"]},"maxItems":50},"editReason":{"type":"string","maxLength":500,"description":"Why an item is being changed on a closed day anyway. The days it is on now **and** the days it would move to must be open. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends."}}},"HintWithSuggestion":{"allOf":[{"$ref":"#/components/schemas/Hint"},{"type":"object","properties":{"taskSuggestion":{"$ref":"#/components/schemas/HintTaskSuggestion"},"classifications":{"type":"array","items":{"$ref":"#/components/schemas/HintClassification"}}},"required":["taskSuggestion","classifications"]}]},"HintPath":{"type":"array","items":{"$ref":"#/components/schemas/HintPathNode"},"minItems":1,"maxItems":20},"HintPathNode":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":200,"description":"Stable id from the source system — never renamed, unlike the label.","example":"thymian"},"label":{"type":"string","minLength":1,"maxLength":500,"example":"Timjami"}},"required":["id","label"]},"HintTaskSuggestion":{"type":["object","null"],"properties":{"taskId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"origin":{"type":"string","enum":["rule","history"],"example":"history"},"lastBookedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"acceptedCount":{"type":["integer","null"],"example":3}},"required":["taskId","origin","lastBookedAt","acceptedCount"]},"HintClassification":{"type":"object","properties":{"kind":{"type":"string","enum":["presence","task","ignore","customer","project","absence"],"description":"`presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`.","example":"task"},"taskId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["kind","taskId","projectId","customerId"]},"Hint":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string"},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"signal":{"type":"string","enum":["presence","absence"]},"externalId":{"type":["string","null"]},"clientHintId":{"type":["string","null"]},"path":{"$ref":"#/components/schemas/HintPath"},"details":{"type":["string","null"]},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"durationSeconds":{"type":["integer","null"],"example":5700},"occurrenceCount":{"type":"integer","example":12},"version":{"type":"integer","example":1},"timeEntryId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"dismissedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"isResolved":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","userId","source","signal","externalId","clientHintId","path","details","startedAt","endedAt","durationSeconds","occurrenceCount","version","timeEntryId","dismissedAt","isResolved","createdAt","updatedAt"]},"ReportHint":{"type":"object","properties":{"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"externalId":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"Stable id from the collector, e.g. a session id."},"clientHintId":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"The reporting app’s own idempotency key for this report."},"sessionKey":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"Groups this report with the same session’s other reports; every report is its own observation."},"subject":{"type":"string","minLength":1,"maxLength":500,"description":"Short label: what the collector thinks the person was working on."},"path":{"$ref":"#/components/schemas/HintPath"},"details":{"type":["string","null"],"maxLength":4000,"description":"Optional longer context — a URL, the files touched, the prompt."},"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"signal":{"type":"string","enum":["presence","absence"]}},"required":["source","subject"]},"HintEvaluation":{"type":"object","properties":{"suggestions":{"type":"array","items":{"type":"object","properties":{"hintIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"description":"Which of the requested hints this suggestion covers."},"taskId":{"type":["string","null"],"format":"uuid","description":"The suggested task, or `null` where nothing matched confidently."},"summary":{"type":"string","minLength":1,"maxLength":240,"description":"A short, human-readable description of the covered hints."},"confidence":{"type":"string","enum":["low","medium","high"],"description":"The model’s own self-assessment — not validated against anything."}},"required":["hintIds","taskId","summary","confidence"]}}},"required":["suggestions"]},"EvaluateHints":{"type":"object","properties":{"hintIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":50,"description":"This person’s own hints to evaluate — up to 50 per request."}},"required":["hintIds"]},"HintSubject":{"type":"object","properties":{"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"subject":{"type":"string","example":"Ruhrstraße 70"},"path":{"$ref":"#/components/schemas/HintPath"},"hintCount":{"type":"integer","example":23},"occurrenceCount":{"type":"integer","example":41},"observedSeconds":{"type":"integer","example":52200},"firstSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastSeenAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["source","subject","path","hintCount","occurrenceCount","observedSeconds","firstSeenAt","lastSeenAt"]},"DismissHint":{"type":"object","properties":{"dismissed":{"type":"boolean","description":"true = not worth tracking. false takes the dismissal back."}},"required":["dismissed"]},"LinkHint":{"type":"object","properties":{"timeEntryId":{"type":["string","null"],"format":"uuid","description":"The time entry this hint became, or null to unlink.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["timeEntryId"]},"HintRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"path":{"$ref":"#/components/schemas/HintPath"},"kind":{"type":"string","enum":["presence","task","ignore","customer","project","absence"],"description":"`presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`.","example":"task"},"taskId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","source","path","kind","taskId","projectId","customerId","createdAt","updatedAt"]},"HintRuleInput":{"type":"object","properties":{"source":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"subject":{"type":"string","minLength":1,"maxLength":500},"path":{"$ref":"#/components/schemas/HintPath"},"kind":{"type":"string","enum":["presence","task","ignore","customer","project","absence"],"description":"`presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`.","example":"task"},"taskId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":["string","null"],"format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["source","subject","kind"]},"DemoDataStatus":{"type":"object","properties":{"exists":{"type":"boolean"},"createdAt":{"type":["string","null"],"format":"date-time"},"counts":{"$ref":"#/components/schemas/DemoDataCounts"},"canManage":{"type":"boolean"}},"required":["exists","createdAt","counts","canManage"]},"DemoDataCounts":{"type":"object","properties":{"customers":{"type":"integer","minimum":0},"projects":{"type":"integer","minimum":0},"tasks":{"type":"integer","minimum":0},"timeEntries":{"type":"integer","minimum":0}},"required":["customers","projects","tasks","timeEntries"]},"DemoDataCleanup":{"type":"object","properties":{"deleted":{"$ref":"#/components/schemas/DemoDataCounts"},"preserved":{"$ref":"#/components/schemas/DemoDataCounts"}},"required":["deleted","preserved"]},"OrganizationDomain":{"type":["object","null"],"properties":{"domain":{"type":"string"},"verificationHost":{"type":"string"},"verificationValue":{"type":"string"},"verifiedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastCheckedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"verificationLostAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"autoJoinEnabled":{"type":"boolean"}},"required":["domain","verificationHost","verificationValue","verifiedAt","lastCheckedAt","verificationLostAt","autoJoinEnabled"]},"OrganizationFeatureSwitches":{"type":"object","properties":{"planning":{"type":"boolean"},"customer-portal":{"type":"boolean"},"tickets":{"type":"boolean"},"absences":{"type":"boolean"}},"required":["planning","customer-portal","tickets","absences"],"description":"Each organization-switched feature: `true` while the organization has it on.","example":{"planning":true,"customer-portal":false}},"PlanningReadersAwaitingAbsences":{"type":"object","properties":{"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]}}},"required":["persons"],"description":"The persons whose connected client reads planning and would reach absences once they are switched back on. Empty while absences are on."},"UpdateOrganizationFeatureSwitches":{"type":"object","properties":{"planning":{"type":"boolean"},"customer-portal":{"type":"boolean"},"tickets":{"type":"boolean"},"absences":{"type":"boolean"},"planningReadConsent":{"type":"string","enum":["ask-again","extend"],"description":"What switching `absences` back on does to the connected clients approved with `thymian:planning.read`, which then reach absences. `ask-again` (the default) withdraws their tokens, so each is answered 401 and asked to approve again; `extend` lets their approval reach absences as it is. Either way the persons concerned, other than the caller, are told by email. Ignored unless absences actually come back on."}},"additionalProperties":false},"OrganizationLoginPolicy":{"type":"object","properties":{"enabled":{"type":"boolean"},"enabledAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"enabledByPersonId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"lastDisabledAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastDisabledReason":{"type":["string","null"],"enum":["owner","domain-verification-lost",null]},"domainVerified":{"type":"boolean"},"viewerHasGoogleAccount":{"type":"boolean"}},"required":["enabled","enabledAt","enabledByPersonId","lastDisabledAt","lastDisabledReason","domainVerified","viewerHasGoogleAccount"]},"UpdateOrganizationSettings":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"rateDisplay":{"type":"string","enum":["hourly","daily"],"description":"How rates are presented in the UI. Does not affect stored data.","example":"hourly"},"hoursPerDay":{"type":"string","pattern":"^\\d{1,2}(\\.\\d{1,2})?$"},"timeZone":{"type":"string","minLength":1},"weekStartsOn":{"type":"integer","minimum":1,"maximum":7},"hintRetentionDays":{"type":"integer","minimum":1,"maximum":3650,"description":"Days a hint is kept before `POST /hints` prunes it. Enforced when a collector reports, not by a scheduler.","example":30},"runningTimerAvatarsEnabled":{"type":"boolean"},"tourEnabled":{"type":"boolean"},"jamiEnabled":{"type":"boolean"},"llmHintEvaluationEnabled":{"type":"boolean"}}},"MemberAvatars":{"type":"object","properties":{"avatars":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"avatarUrl":{"type":"string"},"avatar":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"deprecated":true,"description":"Deprecated uploaded-avatar metadata. Use `avatarUrl`, which also falls back to the identity-provider image."}},"required":["personId","avatarUrl","avatar"]}}},"required":["avatars"]},"OrganizationMembers":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"memberId":{"type":["string","null"],"description":"better-auth’s membership id, which a role change addresses. `null` for a person who holds no access grant in this organization — somebody an import created, or somebody who has left. `role` and `joinedAt` are `null` with it."},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":["string","null"],"pattern":"^[A-Za-z0-9]{32}$","description":"The **account** this person signs in with — the join key for the account-keyed reads (`GET /organization/member-google-accounts`). Never a subject: pass `personId` for that. `null` for a person with no account, whether it was deleted or never existed.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"role":{"type":["string","null"],"description":"May be comma-joined (e.g. \"admin,member\") — never compare with equality. `null` when `memberId` is."},"name":{"type":"string"},"email":{"type":"string"},"joinedAt":{"type":["string","null"],"format":"date-time","description":"When the membership began. `null` when `memberId` is."}},"required":["memberId","personId","userId","role","name","email","joinedAt"]}}},"required":["data"]},"MemberGoogleAccounts":{"type":"object","properties":{"userIds":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"}}},"required":["userIds"]},"OrganizationRunningTimers":{"type":"object","properties":{"entries":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"task":{"type":["object","null"],"properties":{"name":{"type":"string"},"displayName":{"type":"string","minLength":1,"description":"Server-selected task label, composed from displayNameParts in the same priority order.","example":"Acme GmbH · Frontend"},"displayNameParts":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":3,"description":"One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them.","example":["Acme GmbH","Frontend"]}},"required":["name","displayName","displayNameParts"]},"project":{"type":["object","null"],"properties":{"name":{"type":"string"}},"required":["name"]},"customer":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}},"required":["personId","task","project","customer"]}}},"required":["entries"]},"MemberDailyReport":{"type":"object","properties":{"dailyOrgReportEmailEnabled":{"type":"boolean","description":"Whether a current holder of report.readOthers receives the daily organization revenue digest.","example":false},"dailyOrgReportSendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7,"description":"ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately.","example":[1,2,3,4,5]},"dailyOrgReportSendHour":{"type":"integer","minimum":0,"maximum":23,"description":"The hour, in the organization's own time zone, the digest is sent at on a scheduled day. Only delays the send within that day — never changes which days it covers.","example":7}},"required":["dailyOrgReportEmailEnabled","dailyOrgReportSendWeekdays","dailyOrgReportSendHour"]},"UpdateMemberDailyReport":{"type":"object","properties":{"dailyOrgReportEmailEnabled":{"type":"boolean"},"dailyOrgReportSendWeekdays":{"type":"array","items":{"type":"integer","minimum":1,"maximum":7},"minItems":1,"maxItems":7},"dailyOrgReportSendHour":{"type":"integer","minimum":0,"maximum":23}},"additionalProperties":false},"UpdateTrackingWindow":{"type":"object","properties":{"trackingOpenMonths":{"type":["integer","null"],"minimum":1,"maximum":120,"description":"`null` removes the limit; the setting is off by default.","example":2}},"required":["trackingOpenMonths"]},"UpdateBillingEscalation":{"type":"object","properties":{"delayDays":{"type":"integer","minimum":1,"maximum":365,"description":"Calendar days after a proposal’s due date before the global recipient joins the rule’s own responsible people, and before weekly repeats begin.","example":7},"recipientPersonId":{"type":["string","null"],"format":"uuid","description":"The second-level recipient every escalated or weekly-repeated proposal email also reaches. `null` leaves the obligation visibly unset rather than silently dropped.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["delayDays","recipientPersonId"]},"OrganizationIcon":{"type":"object","properties":{"icon":{"$ref":"#/components/schemas/ResourceIcon"}},"required":["icon"]},"OrganizationDashboard":{"type":"object","properties":{"asOf":{"type":"string","format":"date-time","description":"The single instant running timers were measured at.","example":"2026-07-29T09:00:00.000Z"},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"weekFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"monthFromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"revenue":{"type":"object","properties":{"today":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"week":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"month":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]}},"required":["today","week","month"]},"attendanceToday":{"type":"object","properties":{"trackedSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0}},"required":["trackedSeconds","billableSeconds"],"description":"Today's tracked and billable seconds summed across every member — the same organization-wide scope as `people`, computed once here rather than by every client."},"customers":{"type":"array","items":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"revenue":{"type":"object","properties":{"today":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"week":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"month":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]}},"required":["today","week","month"]}},"required":["customerId","customerName","revenue"]}},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"],"description":"The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture."},"image":{"type":["string","null"],"deprecated":true,"description":"Deprecated compatibility alias for `avatarUrl`."},"scheduledSeconds":{"type":"integer","minimum":0,"description":"The work schedule demand before absences are applied."},"absentSeconds":{"type":"integer","minimum":0},"expectedSeconds":{"type":"integer","minimum":0,"description":"Today's target after absences are applied."},"trackedSeconds":{"type":"integer","minimum":0,"description":"Tracked seconds including a running timer measured at `asOf`."},"billableSeconds":{"type":"integer","minimum":0,"description":"Of `trackedSeconds`, the seconds on billable tasks — task billability, not whether a rate resolved."},"hasAbsence":{"type":"boolean"},"isRunning":{"type":"boolean"},"tasks":{"type":"array","items":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid"},"taskName":{"type":"string"},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"trackedSeconds":{"type":"integer","minimum":0},"isRunning":{"type":"boolean"}},"required":["taskId","taskName","projectId","projectName","customerId","customerName","trackedSeconds","isRunning"]}}},"required":["personId","name","avatarUrl","image","scheduledSeconds","absentSeconds","expectedSeconds","trackedSeconds","billableSeconds","hasAbsence","isRunning","tasks"]}},"notStartedPersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Scheduled members with zero tracked seconds and no absence covering the day."}},"required":["asOf","day","weekFromDay","monthFromDay","currency","revenue","attendanceToday","customers","people","notStartedPersonIds"]},"DashboardWidgets":{"type":"object","properties":{"asOf":{"type":"string","format":"date-time","description":"The single instant running timers were measured at.","example":"2026-07-29T09:00:00.000Z"},"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"results":{"type":"array","items":{"$ref":"#/components/schemas/DashboardWidgetResult"},"description":"One result per request, in request order."}},"required":["asOf","day","currency","results"]},"DashboardWidgetResult":{"oneOf":[{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."},"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["revenue"]},"window":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Inclusive: entries starting at or after it count.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive.","example":"2026-07-29T09:00:00.000Z"}},"required":["from","until"]},"customers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."},"comparison":{"type":["object","null"],"properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."},"window":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Inclusive: entries starting at or after it count.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive.","example":"2026-07-29T09:00:00.000Z"}},"required":["from","until"]},"changePercent":{"type":["integer","null"],"description":"The change against the comparison, rounded to a whole percent; `null` when the comparison earned nothing."}},"required":["amountMinor","unratedSeconds","window","changePercent"]}},"required":["amountMinor","unratedSeconds","id","kind","window","customers","persons","comparison"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["hours"]},"window":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Inclusive: entries starting at or after it count.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive.","example":"2026-07-29T09:00:00.000Z"}},"required":["from","until"]},"trackedSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0,"description":"Seconds of entries valuation calls billable, as in `team-day`."},"billableRatePercent":{"type":["integer","null"],"description":"Billable of tracked seconds, rounded to a whole percent; `null` when nothing was tracked."},"customers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."},"comparison":{"type":["object","null"],"properties":{"window":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Inclusive: entries starting at or after it count.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive.","example":"2026-07-29T09:00:00.000Z"}},"required":["from","until"]},"trackedSeconds":{"type":"integer","minimum":0},"changePercent":{"type":["integer","null"],"description":"The change against the comparison, rounded to a whole percent; `null` when nothing was tracked in it."}},"required":["window","trackedSeconds","changePercent"]}},"required":["id","kind","window","trackedSeconds","billableSeconds","billableRatePercent","customers","persons","comparison"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["revenue-by-customer"]},"customers":{"type":"array","items":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"description":"The customer’s logo; `null` when it has none."},"revenue":{"type":"object","properties":{"today":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"week":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"month":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"last-week":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]},"last-month":{"type":"object","properties":{"amountMinor":{"type":"integer","description":"The sum of valued entry amounts. Current rates, provisional until an invoice is transferred."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds for which no rate resolved; reported instead of valued at zero."}},"required":["amountMinor","unratedSeconds"]}},"description":"One amount per requested period."}},"required":["customerId","customerName","customerIcon","revenue"]}},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","customers","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["team-day"]},"people":{"type":"array","items":{"$ref":"#/components/schemas/DashboardWidgetPerson"}},"attendance":{"type":"object","properties":{"trackedSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0},"billableRatePercent":{"type":["integer","null"],"description":"Billable of tracked seconds, rounded to a whole percent; `null` when nothing was tracked."}},"required":["trackedSeconds","billableSeconds","billableRatePercent"]},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","people","attendance","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["not-started"]},"people":{"type":"array","items":{"$ref":"#/components/schemas/DashboardWidgetPerson"},"description":"Scheduled people with zero tracked seconds and no absence covering the day."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","people","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["absent"]},"periods":{"type":"object","properties":{"today":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The person's first absent day within the period: its first day when the absence began earlier, so a later start can be told from one already running."},"lastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The latest last day of the absences overlapping the period, so contiguous absences read as one."},"partial":{"type":"boolean","description":"On the person's first absent day of the period, every covering absence states its own seconds and together they leave part of the scheduled day expected."}},"required":["personId","name","avatarUrl","fromDay","lastDay","partial"]}},"week":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The person's first absent day within the period: its first day when the absence began earlier, so a later start can be told from one already running."},"lastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The latest last day of the absences overlapping the period, so contiguous absences read as one."},"partial":{"type":"boolean","description":"On the person's first absent day of the period, every covering absence states its own seconds and together they leave part of the scheduled day expected."}},"required":["personId","name","avatarUrl","fromDay","lastDay","partial"]}}},"description":"Per requested period, the current members with an absence overlapping it, by name. Never the absence kind."},"windows":{"type":"object","properties":{"today":{"type":"object","properties":{"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["fromDay","untilDay"]},"week":{"type":"object","properties":{"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["fromDay","untilDay"]}},"description":"The inclusive days each requested period covers; `week` is the whole natural week."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","periods","windows","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["daily"]},"window":{"type":"object","properties":{"from":{"type":"string","format":"date-time","description":"Inclusive: entries starting at or after it count.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"Exclusive.","example":"2026-07-29T09:00:00.000Z"}},"required":["from","until"]},"isoWeek":{"type":["integer","null"],"description":"The ISO week number of the period's middle day for a week period, so a week starting on any weekday reads the week most of it lies in; `null` for a month."},"days":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"trackedSeconds":{"type":"integer","minimum":0},"amountMinor":{"type":"integer"},"unratedSeconds":{"type":"integer","minimum":0},"complete":{"type":"boolean","description":"False for the requested day, which is not over, and for later days."}},"required":["day","trackedSeconds","amountMinor","unratedSeconds","complete"]},"description":"Every day of the whole period, in order. An entry counts on the organization-local day it starts; days after the requested one are zeros."},"customers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","window","isoWeek","days","customers","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["unbilled"]},"total":{"$ref":"#/components/schemas/UnbilledTotal"},"throughMonth":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last month counted, as `GET /invoices/unbilled?includeCurrentMonth=false` states it."},"customers":{"type":"array","items":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string","example":"Acme GmbH"},"archived":{"type":"boolean"},"seconds":{"type":"integer","description":"Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from.","example":10800},"amountMinor":{"type":"integer","description":"Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view.","example":25500},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"unratedSeconds":{"type":"integer","description":"Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale\") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire.","example":3600},"allowanceSeconds":{"type":"integer","description":"How many of `seconds` are monthly time allowances („Pauschale\") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says.","example":7200},"fixedPriceItemCount":{"type":"integer","description":"Fixed-price items („Posten\") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it.","example":2},"fixedPriceAmountMinor":{"type":"integer","description":"The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19).","example":710000},"upcomingFixedPriceItemCount":{"type":"integer","description":"How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it.","example":1},"upcomingFixedPriceAmountMinor":{"type":"integer","description":"The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed.","example":350000},"otherCurrencyFixedPriceItemCount":{"type":"integer","description":"Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them.","example":0},"oldestEntryAt":{"type":["string","null"],"format":"date-time","description":"Start of the oldest still-open entry at this level — the signal, not a sum. `null` when nothing open here is an entry, which is what a level whose only open items are monthly time allowances or fixed-price items looks like: neither has an instant, and naming one would point at a day nothing happened on.","example":"2026-07-29T09:00:00.000Z"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"description":"The customer’s logo; `null` when it has none."},"oldestEntryDay":{"type":["string","null"],"pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The organization-local day `oldestEntryAt` falls on; `null` when it is `null`."}},"required":["customerId","customerName","archived","seconds","amountMinor","currency","unratedSeconds","allowanceSeconds","fixedPriceItemCount","fixedPriceAmountMinor","upcomingFixedPriceItemCount","upcomingFixedPriceAmountMinor","otherCurrencyFixedPriceItemCount","oldestEntryAt","customerIcon","oldestEntryDay"]},"description":"Every customer with open work, oldest open entry first, without its projects."}},"required":["id","kind","total","throughMonth","customers"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["billing-proposals-open"]},"count":{"type":"integer","minimum":0,"description":"Open proposals that are actionable, need a rate or wait for their transfer."}},"required":["id","kind","count"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["invoices-outstanding"]},"easybill":{"type":["object","null"],"properties":{"openCount":{"type":"integer","minimum":0},"openMinor":{"type":"integer","description":"Summed in `currency`; an open invoice in another currency is counted and left out of the amounts."},"overdueCount":{"type":"integer","minimum":0},"overdueMinor":{"type":"integer","description":"The part of `openMinor` past its due day."},"withheldCount":{"type":"integer","minimum":0,"description":"Open invoices counted in `openCount` but left out of the amounts, because their amount is not in `currency`."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"}},"required":["openCount","openMinor","overdueCount","overdueMinor","withheldCount","currency"],"description":"Easybill’s open documents, **gross**; `null` without an Easybill connection."},"recorded":{"type":["object","null"],"properties":{"openCount":{"type":"integer","minimum":0},"openMinor":{"type":"integer","description":"Summed in `currency`; an open invoice in another currency is counted and left out of the amounts."},"overdueCount":{"type":"integer","minimum":0},"overdueMinor":{"type":"integer","description":"The part of `openMinor` past its due day."},"withheldCount":{"type":"integer","minimum":0,"description":"Open invoices counted in `openCount` but left out of the amounts, because their amount is not in `currency`."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"}},"required":["openCount","openMinor","overdueCount","overdueMinor","withheldCount","currency"],"description":"Invoices billed by hand, their **net** totals; `null` when none is open. Never added to `easybill`."}},"required":["id","kind","easybill","recorded"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["budget-warnings"]},"budgets":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid"},"budgetName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"customerIcon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"description":"The customer’s logo; `null` when it has none."},"trajectory":{"$ref":"#/components/schemas/BudgetTrajectory"}},"required":["budgetId","budgetName","customerId","customerName","customerIcon","trajectory"]},"description":"Only budgets whose forecast is a warning (running out, or left over according to the plan), the largest deviation first. Budgets covering a project the viewer cannot see, and archived ones, are left out."}},"required":["id","kind","budgets"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["plan-week"]},"week":{"type":"integer","exclusiveMinimum":0,"description":"The ISO week number."},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]},"plannedSeconds":{"type":"integer","minimum":0,"description":"The staff plan for the week: intent, never recorded time."},"trackedSeconds":{"type":"integer","minimum":0,"description":"Entries starting in the ISO week, running timers measured at `asOf`."},"expectedSeconds":{"type":"integer","minimum":0,"description":"The week’s expected working time minus absences."},"absentAllWeek":{"type":"boolean","description":"Nothing is expected of the person this week and an absence is why."}},"required":["personId","name","avatarUrl","plannedSeconds","trackedSeconds","expectedSeconds","absentAllWeek"]},"description":"Current members the staff plan lists, by name, then those away all week by name. The kind of an absence is never part of it."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","week","people","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["todo"]},"unratedSeconds":{"type":["integer","null"],"minimum":0,"description":"Billable seconds in „Abzurechnen“ (the running month left out) that no rate prices; `null` without `invoice.read`."},"divergences":{"type":["boolean","null"],"description":"Whether the divergences page opens with a row that changed an invoice — an allowance divergence, or a claim divergence that is not a relocation. No count: that page reads only the newest claims. `null` without `invoice.read`."},"missingYesterday":{"type":["object","null"],"properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The latest previous working day among the people listed; each person is read against their own."},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]}},"required":["personId","name","avatarUrl"]},"description":"Current members with nothing tracked on their own previous working day: the latest of the fourteen days before `day` with expected time once absences are taken off. By name."}},"required":["day","people"],"description":"`null` when no member has expected time on any of the fourteen days."},"overBudgetCount":{"type":["integer","null"],"minimum":0,"description":"Visible budgets whose consumption already exceeds their capacity; `null` without `budget.readForecast`."},"unclosed":{"type":["object","null"],"properties":{"month":{"type":"string","description":"The month before `day`’s, as `YYYY-MM`."},"count":{"type":"integer","minimum":0,"description":"Visible projects with an entry starting in `month` that are neither closed through its last day nor behind the tracking window."}},"required":["month","count"],"description":"`null` without `period.close`."}},"required":["id","kind","unratedSeconds","divergences","missingYesterday","overBudgetCount","unclosed"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["working-now"]},"groupBy":{"type":"string","enum":["customer","project"]},"groups":{"type":"array","items":{"type":"object","properties":{"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"color":{"type":"string"},"icon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"description":"The customer’s logo; `null` when it has none."}},"required":["id","name","color","icon"]},"project":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"color":{"type":["string","null"],"description":"The project’s own colour; `null` when it uses the customer’s."},"icon":{"type":["object","null"],"properties":{"url":{"type":"string","format":"uri","description":"Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one.","example":"https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q"},"contentType":{"type":"string","enum":["image/webp","image/png"]},"width":{"type":"integer","example":256},"height":{"type":"integer","example":256},"byteSize":{"type":"integer","example":8452},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"checksum":{"type":"string","example":"a3c9…"}},"required":["url","contentType","width","height","byteSize","updatedAt","checksum"],"description":"The project’s own logo, else its customer’s; `null` when neither has one."}},"required":["id","name","color","icon"],"description":"Always `null` grouped by customer. Grouped by project, `null` for the people on a project the caller may not see, who are grouped under their customer."},"people":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"]},"projectName":{"type":["string","null"],"description":"`null` when the caller may not see the project."},"taskName":{"type":["string","null"],"description":"`null` when the caller may not see the project."}},"required":["personId","name","avatarUrl","projectName","taskName"]},"description":"By name."}},"required":["customer","project","people"]},"description":"Current members with a running timer, whenever it started. The largest group first, then by name."},"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}},"required":["id","name"]},"description":"The filter actually applied, named; empty when there was none."}},"required":["id","kind","groupBy","groups","persons"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["withheld"]},"requested":{"type":"string","description":"The kind the request asked for."},"reason":{"type":"string","enum":["capability","feature"],"description":"`capability` when the caller’s role does not hold what the kind needs, `feature` when the organization has the feature it needs switched off."}},"required":["id","kind","requested","reason"],"description":"The answer to a request the caller may not read. Its source was not read (ADR-2026-10-27-10CQS)."}]},"DashboardWidgetPerson":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"avatarUrl":{"type":["string","null"],"description":"The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture."},"scheduledSeconds":{"type":"integer","minimum":0,"description":"The work schedule demand before absences are applied."},"absentSeconds":{"type":"integer","minimum":0},"expectedSeconds":{"type":"integer","minimum":0,"description":"Today's target after absences are applied."},"trackedSeconds":{"type":"integer","minimum":0,"description":"Tracked seconds including a running timer measured at `asOf`."},"billableSeconds":{"type":"integer","minimum":0,"description":"Of `trackedSeconds`, the seconds on billable tasks — task billability, not whether a rate resolved."},"hasAbsence":{"type":"boolean"},"isRunning":{"type":"boolean"},"tasks":{"type":"array","items":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid"},"taskName":{"type":"string"},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"trackedSeconds":{"type":"integer","minimum":0},"isRunning":{"type":"boolean"}},"required":["taskId","taskName","projectId","projectName","customerId","customerName","trackedSeconds","isRunning"]}}},"required":["personId","name","avatarUrl","scheduledSeconds","absentSeconds","expectedSeconds","trackedSeconds","billableSeconds","hasAbsence","isRunning","tasks"]},"DashboardWidgetsRequest":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The organization-local day the periods are read for. Defaults to today in the organization's time zone."},"requests":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["revenue"]},"period":{"type":"string","enum":["today","week","month","last-week","last-month"],"description":"An organization-local period: `today`, the running `week` and `month`, or the finished `last-week` and `last-month`. Weeks start on the organization's `weekStartsOn`."},"customerIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"Only these customers. Ids the organization does not have are ignored; a filter left with none is dropped."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."},"compare":{"type":"boolean","description":"Also value the previous period: up to the same wall-clock point while `period` is running, whole otherwise."}},"required":["id","kind","period"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["hours"]},"period":{"type":"string","enum":["today","week","month","last-week","last-month"],"description":"An organization-local period: `today`, the running `week` and `month`, or the finished `last-week` and `last-month`. Weeks start on the organization's `weekStartsOn`."},"customerIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"Only these customers. Ids the organization does not have are ignored; a filter left with none is dropped."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."},"compare":{"type":"boolean","description":"Also count the previous period: up to the same wall-clock point while `period` is running, whole otherwise."}},"required":["id","kind","period"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["revenue-by-customer"]},"periods":{"type":"array","items":{"type":"string","enum":["today","week","month","last-week","last-month"],"description":"An organization-local period: `today`, the running `week` and `month`, or the finished `last-week` and `last-month`. Weeks start on the organization's `weekStartsOn`."},"minItems":1,"maxItems":5,"description":"Every period to value per customer; customers are sorted by the first."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind","periods"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["team-day"]},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["not-started"]},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["absent"]},"periods":{"type":"array","items":{"type":"string","enum":["today","week"]},"minItems":1,"maxItems":2,"description":"`today` is the requested day. `week` is the whole natural week the day falls in, Monday or the organization's `weekStartsOn` through the following six days, not only the days up to the requested one."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind","periods"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["daily"]},"period":{"type":"string","enum":["week","month","last-week","last-month"],"description":"The period one bar is drawn for per day. A running `week` or `month` runs to its natural end, not to the requested day; later days are zero. Weeks start on the organization's `weekStartsOn`."},"customerIds":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"Only these customers. Ids the organization does not have are ignored; a filter left with none is dropped."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind","period"]},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["unbilled"]}},"required":["id","kind"],"description":"What „Abzurechnen\" opens with: open billable work before the running month. Withheld without `invoice.read`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["billing-proposals-open"]}},"required":["id","kind"],"description":"How many billing proposals wait for somebody, as the recurring tab counts them. Withheld without `invoice.read`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["invoices-outstanding"]}},"required":["id","kind"],"description":"Open and overdue invoices, Easybill’s and those billed by hand apart. Withheld without `invoice.read`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["budget-warnings"]}},"required":["id","kind"],"description":"The budgets whose month-end forecast is a warning. Withheld without `budget.readForecast`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["plan-week"]},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind"],"description":"Planned, tracked and expected time per person for the ISO week the requested day falls in. Withheld with `reason: \"feature\"` while the organization has planning off, and with `reason: \"capability\"` without `planning.manage`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["todo"]}},"required":["id","kind"],"description":"What somebody should see to: unpriced billable time, invoice divergences, people without entries on their previous working day, overrun budgets and last month’s unclosed projects. Never withheld; each figure the caller’s role does not cover is `null`."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","enum":["working-now"]},"groupBy":{"type":"string","enum":["customer","project"],"description":"Whether one group holds a customer or one of its projects."},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":200,"description":"Only these persons. Ids the organization does not have are ignored; a filter left with none is dropped."}},"required":["id","kind","groupBy"],"description":"Who has a timer running now, grouped. Everybody running is listed with their customer; project and task only where the caller may see the project (ADR-2026-10-07-07T4Z)."},{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Chosen by the caller and echoed on the result; unique within one batch."},"kind":{"type":"string","minLength":1,"maxLength":64}},"required":["id","kind"],"description":"A request of a kind this server does not know yet. It gets no result."}]},"maxItems":100}},"required":["requests"]},"CompanyMetrics":{"type":"object","properties":{"window":{"type":"object","properties":{"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["fromDay","untilDay"],"description":"The four finished weeks the figures cover. Both bounds inclusive, organization-local."},"previousWindow":{"type":"object","properties":{"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["fromDay","untilDay"],"description":"The same window shifted back one week — what the previous post covered."},"fte":{"type":"number","description":"Full-time-equivalent sold capacity over `window`: every internal person's dated working time, resolved per day and scaled by their capacity share, averaged into a week and measured against a full-time week of the organization's default model. The five weekdays weigh the same, so the period's weekday composition does not enter it; absences never reduce it. Absent below `report.readOthers`."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"catchAllRate":{"type":"object","properties":{"value":{"type":["number","null"]},"previous":{"type":["number","null"]},"trend":{"type":["string","null"],"enum":["up","down","flat",null]}},"required":["value","previous","trend"],"description":"Percent of the measured group's tracked seconds on tasks flagged as the catch-all."},"billableRate":{"type":"object","properties":{"value":{"type":["number","null"]},"previous":{"type":["number","null"]},"trend":{"type":["string","null"],"enum":["up","down","flat",null]}},"required":["value","previous","trend"],"description":"Percent of the measured group's tracked seconds on billable tasks."},"revenuePerFte":{"type":"object","properties":{"value":{"type":["number","null"]},"previous":{"type":["number","null"]},"trend":{"type":["string","null"],"enum":["up","down","flat",null]}},"required":["value","previous","trend"],"description":"Minor units of recorded revenue per FTE of sold capacity. Counts every internal person and excludes freelancers."},"targets":{"type":["object","null"],"properties":{"billableRateMin":{"type":"number"},"billableRateMax":{"type":"number"},"revenuePerFteMinor":{"type":"integer"}},"required":["billableRateMin","billableRateMax","revenuePerFteMinor"],"description":"What this organization measures itself against over four weeks, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure converted to this window; the rate corridor is unconverted."},"warnings":{"type":"object","properties":{"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them."},"unclassifiedPersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"People with tracked time in the period this read covers and no classification. They count in no figure."},"withoutSchedule":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0."},"scheduleStartsInWindow":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window."}},"required":["unratedSeconds","unclassifiedPersonIds","withoutSchedule","scheduleStartsInWindow"],"description":"Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty."}},"required":["window","previousWindow","currency","catchAllRate","billableRate","revenuePerFte","targets"]},"CompanyMetricMonths":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"targets":{"type":["object","null"],"properties":{"billableRateMin":{"type":"number"},"billableRateMax":{"type":"number"},"revenuePerFteMinor":{"type":"integer"}},"required":["billableRateMin","billableRateMax","revenuePerFteMinor"],"description":"What this organization measures itself against over one calendar month, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure itself — every row here is a month, so nothing is converted."},"months":{"type":"array","items":{"$ref":"#/components/schemas/CompanyMetricMonth"},"description":"Newest first, the running month included and marked `complete: false`. One row per calendar month."}},"required":["currency","targets","months"]},"CompanyMetricMonth":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-01","description":"The period's first day, organization-local."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-31","description":"The period's last day, inclusive."},"complete":{"type":"boolean","description":"False for the period the request falls in: its figures cover the days so far, not a whole period, and are not comparable with the finished ones below them."},"fte":{"type":"number","description":"Full-time-equivalent sold capacity over this period alone, resolved over its own days so a contract change lands on the right rows. Measured against a full-time week rather than the period's own weekday count, so a weekly working-time model reads the same figure in February as in January while it is unchanged; time agreed per month is still spread over that month's own working days, which a shorter month divides differently. Absent below `report.readOthers`."},"catchAllRate":{"type":["number","null"],"description":"Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing."},"billableRate":{"type":["number","null"],"description":"Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice."},"billableRateDeviation":{"type":["number","null"],"description":"How far `billableRate` sits outside the target corridor, in percentage points: negative below the floor, positive above the ceiling, exactly 0 inside it. Null without a rate or without targets."},"revenuePerFteMinor":{"type":["integer","null"],"description":"Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers."},"revenueMinor":{"type":"integer","description":"The undivided total the figure above comes from, in minor units. Absent below `report.readOthers`."},"targetRevenueMinor":{"type":["integer","null"],"description":"What the per-FTE target for this period asks of its capacity: `targets.revenuePerFteMinor` times `fte`, rounded once. Null without targets, absent below `report.readOthers`."},"revenueDeviationMinor":{"type":["integer","null"],"description":"`revenueMinor` minus `targetRevenueMinor` — positive above the target, negative below it. Null without targets."},"measuredSeconds":{"type":"integer","minimum":0,"description":"The measured group's tracked seconds, the denominator both rates share. Absent below `report.readOthers`."},"warnings":{"type":"object","properties":{"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them."},"unclassifiedPersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"People with tracked time in the period this read covers and no classification. They count in no figure."},"withoutSchedule":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0."},"scheduleStartsInWindow":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window."}},"required":["unratedSeconds","unclassifiedPersonIds","withoutSchedule","scheduleStartsInWindow"],"description":"Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty."}},"required":["month","fromDay","untilDay","complete","catchAllRate","billableRate","billableRateDeviation","revenuePerFteMinor","revenueDeviationMinor"]},"CompanyMetricWeeks":{"type":"object","properties":{"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"targets":{"type":["object","null"],"properties":{"billableRateMin":{"type":"number"},"billableRateMax":{"type":"number"},"revenuePerFteMinor":{"type":"integer"}},"required":["billableRateMin","billableRateMax","revenuePerFteMinor"],"description":"What this organization measures itself against over one whole week, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure at one week — `× 12/52`, the same conversion the four-week window uses at four weeks; the rate corridor is unconverted."},"weeks":{"type":"array","items":{"$ref":"#/components/schemas/CompanyMetricWeek"},"description":"Newest first, the running week included and marked `complete: false`. One row per whole week."}},"required":["currency","targets","weeks"]},"CompanyMetricWeek":{"type":"object","properties":{"weekStartDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-10","description":"The day this week starts on — its identity. Follows the organization's `weekStartsOn` and is therefore not necessarily a Monday, unlike the ISO `weekStartDay` a plan allocation carries. Equal to `fromDay`."},"fromDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-10","description":"The period's first day, organization-local."},"untilDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-16","description":"The period's last day, inclusive."},"complete":{"type":"boolean","description":"False for the period the request falls in: its figures cover the days so far, not a whole period, and are not comparable with the finished ones below them."},"fte":{"type":"number","description":"Full-time-equivalent sold capacity over this period alone, resolved over its own days so a contract change lands on the right rows. Measured against a full-time week rather than the period's own weekday count, so a weekly working-time model reads the same figure in February as in January while it is unchanged; time agreed per month is still spread over that month's own working days, which a shorter month divides differently. Absent below `report.readOthers`."},"catchAllRate":{"type":["number","null"],"description":"Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing."},"billableRate":{"type":["number","null"],"description":"Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice."},"billableRateDeviation":{"type":["number","null"],"description":"How far `billableRate` sits outside the target corridor, in percentage points: negative below the floor, positive above the ceiling, exactly 0 inside it. Null without a rate or without targets."},"revenuePerFteMinor":{"type":["integer","null"],"description":"Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers."},"revenueMinor":{"type":"integer","description":"The undivided total the figure above comes from, in minor units. Absent below `report.readOthers`."},"targetRevenueMinor":{"type":["integer","null"],"description":"What the per-FTE target for this period asks of its capacity: `targets.revenuePerFteMinor` times `fte`, rounded once. Null without targets, absent below `report.readOthers`."},"revenueDeviationMinor":{"type":["integer","null"],"description":"`revenueMinor` minus `targetRevenueMinor` — positive above the target, negative below it. Null without targets."},"measuredSeconds":{"type":"integer","minimum":0,"description":"The measured group's tracked seconds, the denominator both rates share. Absent below `report.readOthers`."},"warnings":{"type":"object","properties":{"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them."},"unclassifiedPersonIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"People with tracked time in the period this read covers and no classification. They count in no figure."},"withoutSchedule":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0."},"scheduleStartsInWindow":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"description":"Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window."}},"required":["unratedSeconds","unclassifiedPersonIds","withoutSchedule","scheduleStartsInWindow"],"description":"Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty."}},"required":["weekStartDay","fromDay","untilDay","complete","catchAllRate","billableRate","billableRateDeviation","revenuePerFteMinor","revenueDeviationMinor"]},"ProjectHours":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"totalSeconds":{"type":"integer"}},"required":["projectId","projectName","customerId","customerName","totalSeconds"]},"UserHours":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"totalSeconds":{"type":"integer"},"lastEntryAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["personId","totalSeconds","lastEntryAt"]},"UserProjectCount":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectCount":{"type":"integer"}},"required":["personId","projectCount"]},"PersonActivity":{"type":"object","properties":{"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"from":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"totalSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0},"entryCount":{"type":"integer","minimum":0},"daysWorked":{"type":"integer","minimum":0},"perDay":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityDay"}},"projects":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityProject"}}},"required":["personId","from","until","totalSeconds","billableSeconds","entryCount","daysWorked","perDay","projects"]},"PersonActivityDay":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"totalSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0},"projects":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityDayProject"}},"tasks":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityDayTask"}}},"required":["day","totalSeconds","billableSeconds","projects","tasks"]},"PersonActivityDayProject":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid"},"totalSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0}},"required":["projectId","totalSeconds","billableSeconds"]},"PersonActivityDayTask":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid"},"projectId":{"type":"string","format":"uuid"},"totalSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0}},"required":["taskId","projectId","totalSeconds","billableSeconds"]},"PersonActivityProject":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"totalSeconds":{"type":"integer","minimum":0},"billableSeconds":{"type":"integer","minimum":0},"tasks":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityTask"}}},"required":["customerId","customerName","projectId","projectName","totalSeconds","billableSeconds","tasks"]},"PersonActivityTask":{"type":"object","properties":{"taskId":{"type":"string","format":"uuid"},"taskName":{"type":"string"},"isBillable":{"type":"boolean"},"totalSeconds":{"type":"integer","minimum":0},"noteCount":{"type":"integer","minimum":0},"notesTruncated":{"type":"boolean"},"notes":{"type":"array","items":{"$ref":"#/components/schemas/PersonActivityNote"}}},"required":["taskId","taskName","isBillable","totalSeconds","noteCount","notesTruncated","notes"]},"PersonActivityNote":{"type":"object","properties":{"text":{"type":"string"},"entryCount":{"type":"integer","minimum":0},"totalSeconds":{"type":"integer","minimum":0},"firstDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"lastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."}},"required":["text","entryCount","totalSeconds","firstDay","lastDay"]},"HoursOverTime":{"type":"object","properties":{"bucket":{"type":"string","enum":["week","month","quarter"],"description":"The grain, chosen from the range: weeks up to 92 days, months up to 24 months, quarters beyond."},"series":{"type":"array","items":{"$ref":"#/components/schemas/HoursOverTimeSeries"},"description":"Ordered by all-time seconds on this subject, whatever the window — so a colour stays with its series as the period changes. Settlement is always its four statuses in a fixed order; `self` comes before `others`."},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/HoursOverTimeBucket"},"description":"Every bucket of the range, an empty one as zero. Empty when `from` is omitted and the subject has no hours."}},"required":["bucket","series","buckets"]},"HoursOverTimeSeries":{"type":"object","properties":{"key":{"type":"string","description":"A person id, a project id, a settlement status (`billed`, `open`, `written-off`, `not-hourly`), or `self` and `others` for a viewer without `report.readOthers`."},"label":{"type":["string","null"],"description":"The person’s or project’s name; `null` for settlement statuses and for `self`/`others`, which the client words."},"color":{"type":["string","null"],"description":"The project’s own colour; `null` everywhere else."}},"required":["key","label","color"]},"HoursOverTimeBucket":{"type":"object","properties":{"firstDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"lastDay":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"totalSeconds":{"type":"integer","minimum":0},"seconds":{"type":"object","additionalProperties":{"type":"integer","minimum":0},"description":"Seconds per series key; a key without seconds in this bucket is absent. Every key is one of `series`."}},"required":["firstDay","lastDay","totalSeconds","seconds"]},"TicketDayReportRow":{"type":"object","properties":{"day":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"The calendar day the entry started on, read in the organization’s time zone — which the response repeats in `timeZone`, so a client labels the column with the zone the buckets were cut in.","example":"2026-09-30"},"provider":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"key":{"type":"string","example":"TIM-199"},"url":{"type":["string","null"]},"title":{"type":["string","null"]},"totalSeconds":{"type":"integer","minimum":0},"carriedMinutes":{"type":["integer","null"],"minimum":0},"entryCount":{"type":"integer","minimum":0},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}}},"required":["day","provider","key","url","title","totalSeconds","carriedMinutes","entryCount","personIds"]},"TicketReportRow":{"type":"object","properties":{"provider":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"key":{"type":"string","example":"TIM-199"},"url":{"type":["string","null"]},"title":{"type":["string","null"]},"totalSeconds":{"type":"integer","minimum":0},"entryCount":{"type":"integer","minimum":0},"personIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"firstStartedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastStartedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["provider","key","url","title","totalSeconds","entryCount","personIds","firstStartedAt","lastStartedAt"]},"PersonRevenue":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"from":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"until":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"amountMinor":{"type":"integer","description":"The sum of every priced billable slice, at current rates — provisional until an invoice is transferred, and zero rather than omitted when nothing was priced."},"unratedSeconds":{"type":"integer","minimum":0,"description":"Billable seconds no rate resolved for; reported rather than valued at zero."},"runningIncluded":{"type":"boolean","description":"True when `includeRunning` was asked for and the subject's own running entry falls inside this window."}},"required":["personId","from","until","currency","amountMinor","unratedSeconds","runningIncluded"]},"ProjectionChange":{"type":"object","properties":{"id":{"type":"string"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"changedAt":{"type":"string","format":"date-time","description":"When this document last changed — the stamp of the run that changed it.","example":"2026-07-29T09:00:00.000Z"},"deleted":{"type":"boolean","description":"A tombstone. **The sync job must act on this**: delete the id from the index, or entries deleted in Timjami stay in Kibana forever."},"document":{"$ref":"#/components/schemas/ProjectionDocument"}},"required":["id","month","changedAt","deleted","document"]},"ProjectionDocument":{"type":["object","null"],"properties":{"id":{"type":"string","description":"`<month>:<entryId>:<fromSecond>`. Stable in its start, its month and its uniqueness — not in its end: a moved allocation boundary is a tombstone plus a new id.","example":"2026-07:0f4d…:0"},"month":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The entry's own month in the organization's time zone. It is in the id so a re-dated entry is a tombstone in the old month and a document in the new one."},"entryId":{"type":"string","format":"uuid"},"fromSecond":{"type":"integer","minimum":0,"description":"Start offset within the entry, in seconds — the coordinate an invoice claim uses, so it survives an admin moving `startedAt`."},"toSecond":{"type":"integer","minimum":0,"description":"Exclusive end offset."},"seconds":{"type":"integer","minimum":0},"startedAt":{"type":"string","format":"date-time","description":"This stretch's start, not the entry's.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"This stretch's end, not the entry's.","example":"2026-07-29T09:00:00.000Z"},"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"personName":{"type":"string"},"taskId":{"type":"string","format":"uuid"},"taskName":{"type":"string"},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"},"billable":{"type":"boolean","description":"From the **task**, not from the entry — an entry stores no billability of its own (invariant 1). Non-billable time is exported too: the billable-rate metric's denominator needs it."},"budgetId":{"type":["string","null"],"format":"uuid","description":"The pot the allocation put these seconds in, or `null` when no budget covers them."},"budgetName":{"type":["string","null"]},"rateMinor":{"type":["integer","null"],"description":"Hourly rate in minor units. `null` means nothing priced these seconds — never `0` (invariant 19)."},"amountMinor":{"type":["integer","null"],"description":"This stretch's value, rounded over its own seconds. Summing `final` money therefore approximates the invoiced total rather than reproducing it — an invoice line rounds once over its whole quantity (invariant 16)."},"currency":{"type":"string","minLength":3,"maxLength":3,"pattern":"^[A-Z]{3}$","description":"ISO-4217 currency code.","example":"EUR"},"status":{"type":"string","enum":["provisional","final","written-off"],"description":"`final` is a stretch a **transferred** invoice claims, or one an `external` claim covers — money that has left, through the invoicing tool or through another system. `written-off` is a stretch a live write-off waived, valued at the rate it recorded: money that never arrived and never will, which is why it is neither of the other two. Everything else is `provisional`, a draft's claim included. Not monotonic: withdrawing an invoice or taking a write-off back puts its seconds back to `provisional`. Facet on this field; a dashboard summing these into one number without saying so is the failure invariant 19 exists to prevent.","example":"provisional"},"invoiceId":{"type":["string","null"],"format":"uuid","description":"Which **Timjami** invoice froze this stretch. `null` for everything provisional — and also for a `final` stretch an `external` claim settled and for every `written-off` one, whose document is not an invoice. Key revenue by `status`, not by the presence of this field."}},"required":["id","month","entryId","fromSecond","toSecond","seconds","startedAt","endedAt","personId","personName","taskId","taskName","projectId","projectName","customerId","customerName","billable","budgetId","budgetName","rateMinor","amountMinor","currency","status","invoiceId"],"description":"The document to upsert. `null` exactly when `deleted` is true."},"Dashboards":{"type":"object","properties":{"dashboards":{"type":"array","items":{"$ref":"#/components/schemas/Dashboard"}}},"required":["dashboards"]},"Dashboard":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"visibility":{"type":"string","enum":["private","shared"],"description":"private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing."},"layout":{"$ref":"#/components/schemas/DashboardLayout"},"ownerPersonId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"ownerName":{"type":"string","description":"The author’s display name, so a shared dashboard says whose it is."},"isOwn":{"type":"boolean","description":"Whether the caller owns it — and therefore whether they may change it."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"updatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["id","name","visibility","layout","ownerPersonId","ownerName","isOwn","createdAt","updatedAt"]},"DashboardLayout":{"type":"object","properties":{"widgets":{"type":"array","items":{"$ref":"#/components/schemas/DashboardWidgetPlacement"},"maxItems":100,"description":"The placements, in the order they were composed. At most 100, each with an id no other placement in this layout uses."}},"required":["widgets"]},"DashboardWidgetPlacement":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"description":"Client-assigned identity of this placement, unique within the layout.","example":"billable-hours-1"},"widget":{"type":"string","minLength":1,"maxLength":64,"description":"Which widget to render. Opaque to the server — the web app resolves it against its own catalogue and skips what it does not know.","example":"billable-hours"},"x":{"type":"integer","minimum":0,"maximum":1000},"y":{"type":"integer","minimum":0,"maximum":1000},"w":{"type":"integer","minimum":1,"maximum":1000},"h":{"type":"integer","minimum":1,"maximum":1000},"settings":{"type":"object","additionalProperties":{},"description":"How this placement is configured. Opaque to the server exactly like `widget` — checked to be a JSON object of at most 4096 bytes and never for its fields; the web app parses it and falls back to defaults for what it cannot read.","example":{"period":"month","compare":true}},"title":{"type":"string","minLength":1,"maxLength":80,"description":"A title chosen for this placement, shown instead of the widget’s own.","example":"Umsatz Kunde A"}},"required":["id","widget","x","y","w","h"]},"CreateDashboard":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"visibility":{"type":"string","enum":["private","shared"],"default":"private","description":"private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing."},"layout":{"$ref":"#/components/schemas/DashboardLayout"}},"required":["name","layout"]},"UpdateDashboard":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120},"visibility":{"type":"string","enum":["private","shared"],"description":"private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing."},"layout":{"$ref":"#/components/schemas/DashboardLayout"}}},"AgentUsageSummary":{"type":"object","properties":{"month":{"type":"string","example":"2026-10"},"day":{"type":["string","null"],"example":"2026-10-02","description":"The day the figures are narrowed to, or `null` for the whole month."},"personId":{"type":"string","format":"uuid"},"totals":{"$ref":"#/components/schemas/AgentTokenSums"},"bySource":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["claude-code","codex"],"example":"claude-code","description":"The coding agent a usage key reports for."},"tokens":{"$ref":"#/components/schemas/AgentTokenSums"}},"required":["source","tokens"]},"description":"Each agent’s own sums. A kind means one thing only within a source: Codex’s input already contains its cached input, its output its reasoning."},"byAttribution":{"type":"array","items":{"type":"object","properties":{"attribution":{"$ref":"#/components/schemas/AgentUsageAttribution"},"tokens":{"$ref":"#/components/schemas/AgentTokenSums"}},"required":["attribution","tokens"]}},"byModel":{"type":"array","items":{"type":"object","properties":{"source":{"type":"string","enum":["claude-code","codex"],"example":"claude-code","description":"The coding agent a usage key reports for."},"model":{"type":"string"},"tokens":{"$ref":"#/components/schemas/AgentTokenSums"}},"required":["source","model","tokens"]}},"byDay":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","example":"2026-10-02"},"tokens":{"$ref":"#/components/schemas/AgentTokenSums"}},"required":["day","tokens"]}},"unattributedPaths":{"type":["array","null"],"items":{"type":"object","properties":{"source":{"type":"string","enum":["claude-code","codex"],"example":"claude-code","description":"The coding agent a usage key reports for."},"pathIds":{"type":["array","null"],"items":{"type":"string"},"description":"The ids `PUT /hint-rules` names, parallel to `pathLabels`."},"pathLabels":{"type":["array","null"],"items":{"type":"string"}},"sessionCount":{"type":"integer"},"tokens":{"$ref":"#/components/schemas/AgentTokenSums"}},"required":["source","pathIds","pathLabels","sessionCount","tokens"]},"description":"Your own unattributed sessions by path. `null` whenever `personId` names somebody."}},"required":["month","day","personId","totals","bySource","byAttribution","byModel","byDay","unattributedPaths"]},"AgentTokenSums":{"type":"object","properties":{"input":{"type":"integer"},"output":{"type":"integer"},"cacheRead":{"type":"integer"},"cacheWrite":{"type":"integer"},"reasoning":{"type":"integer"},"total":{"type":"integer","description":"By each source’s own counting: Claude Code adds all four kinds; Codex adds input and output, which already contain cached input and reasoning."}},"required":["input","output","cacheRead","cacheWrite","reasoning","total"]},"AgentUsageAttribution":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","enum":["task"]},"taskId":{"type":"string","format":"uuid"},"taskName":{"type":"string"},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"}},"required":["kind","taskId","taskName","projectId","projectName","customerId","customerName"]},{"type":"object","properties":{"kind":{"type":"string","enum":["project"]},"projectId":{"type":"string","format":"uuid"},"projectName":{"type":"string"},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"}},"required":["kind","projectId","projectName","customerId","customerName"]},{"type":"object","properties":{"kind":{"type":"string","enum":["customer"]},"customerId":{"type":"string","format":"uuid"},"customerName":{"type":"string"}},"required":["kind","customerId","customerName"]},{"type":"object","properties":{"kind":{"type":"string","enum":["hidden"]}},"required":["kind"]},{"type":"object","properties":{"kind":{"type":"string","enum":["unattributed"]}},"required":["kind"]}],"description":"The accepted entry’s task, else the person’s longest matching hint rule’s target, else `unattributed`. `hidden` is attributed to something the caller may not see."},"CreatedCollectorKey":{"allOf":[{"$ref":"#/components/schemas/CollectorKey"},{"type":"object","properties":{"key":{"type":"string","example":"thy_9f8c…"}},"required":["key"]}]},"CollectorKey":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"],"example":"Laptop"},"start":{"type":["string","null"],"example":"thy_a1"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastRequestAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"requestCount":{"type":"integer","example":42}},"required":["id","name","start","createdAt","lastRequestAt","requestCount"]},"CreateCollectorKey":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":32,"description":"How this key is labelled in the list, e.g. the machine it lives on.","example":"Laptop"}}},"CreatedUsageKey":{"allOf":[{"$ref":"#/components/schemas/UsageKey"},{"type":"object","properties":{"key":{"type":"string","description":"The key itself. Shown once; only a hash is stored."}},"required":["key"]}]},"UsageKey":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"],"example":"pasture"},"start":{"type":["string","null"],"example":"thyu_a1"},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastRequestAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"source":{"type":"string","enum":["claude-code","codex"],"example":"claude-code","description":"The coding agent a usage key reports for."},"device":{"type":"string","example":"pasture"}},"required":["id","name","start","createdAt","lastRequestAt","source","device"]},"CreateUsageKey":{"type":"object","properties":{"source":{"type":"string","enum":["claude-code","codex"],"example":"claude-code","description":"The coding agent a usage key reports for."},"device":{"type":"string","minLength":1,"maxLength":64,"description":"The machine the agent runs on, as the key is labelled in the list.","example":"pasture"}},"required":["source","device"]},"CreatedAccessToken":{"allOf":[{"$ref":"#/components/schemas/AccessToken"},{"type":"object","properties":{"token":{"type":"string","example":"thyp_9f8c…"}},"required":["token"]}]},"AccessToken":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"],"example":"Reporting script"},"start":{"type":["string","null"],"example":"thyp_a1"},"scopes":{"type":"array","items":{"type":"string","enum":["thymian:read","thymian:track","thymian:timer","thymian:master-data.read","thymian:master-data.write","thymian:project-members.read","thymian:project-members.write","thymian:project-rates.read","thymian:project-rates.write","thymian:time.read-own","thymian:time.write-own","thymian:time.read-others","thymian:budgets.read","thymian:invoice-drafts.write","thymian:time-allowances.read","thymian:time-allowances.write","thymian:fixed-price.read","thymian:fixed-price.write","thymian:projects.write","thymian:customers.write","thymian:project-icons.write","thymian:budgets.write","thymian:invoices.read","thymian:invoices.write","thymian:billing.read","thymian:billing-proposals.write","thymian:billing-rules.write","thymian:invoice-status.read","thymian:organization.write","thymian:reports.read","thymian:hints.write","thymian:hints.read","thymian:absence-subjects.read","thymian:persons.read","thymian:planning.read","thymian:planning.write"],"description":"What a credential may do. One catalogue for every mechanism: thymian:read, thymian:track, thymian:timer, thymian:master-data.read, thymian:master-data.write, thymian:project-members.read, thymian:project-members.write, thymian:project-rates.read, thymian:project-rates.write, thymian:time.read-own, thymian:time.write-own, thymian:time.read-others, thymian:budgets.read, thymian:invoice-drafts.write, thymian:time-allowances.read, thymian:time-allowances.write, thymian:fixed-price.read, thymian:fixed-price.write, thymian:projects.write, thymian:customers.write, thymian:project-icons.write, thymian:budgets.write, thymian:invoices.read, thymian:invoices.write, thymian:billing.read, thymian:billing-proposals.write, thymian:billing-rules.write, thymian:invoice-status.read, thymian:organization.write, thymian:reports.read, thymian:hints.write, thymian:hints.read, thymian:absence-subjects.read, thymian:persons.read, thymian:planning.read, thymian:planning.write.","example":"thymian:read"},"description":"What this token was granted, intersected with what its owner may do — a scope never widens a role."},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"expiresAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastRequestAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"requestCount":{"type":"integer","example":42}},"required":["id","name","start","scopes","createdAt","expiresAt","lastRequestAt","requestCount"]},"CreateAccessToken":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64,"description":"How this token is labelled in the list, e.g. what uses it.","example":"Reporting script"},"scopes":{"type":"array","items":{"type":"string","enum":["thymian:read","thymian:track","thymian:timer","thymian:master-data.read","thymian:master-data.write","thymian:project-members.read","thymian:project-members.write","thymian:project-rates.read","thymian:project-rates.write","thymian:time.read-own","thymian:time.write-own","thymian:time.read-others","thymian:budgets.read","thymian:invoice-drafts.write","thymian:time-allowances.read","thymian:time-allowances.write","thymian:fixed-price.read","thymian:fixed-price.write","thymian:projects.write","thymian:customers.write","thymian:project-icons.write","thymian:budgets.write","thymian:invoices.read","thymian:invoices.write","thymian:billing.read","thymian:billing-proposals.write","thymian:billing-rules.write","thymian:invoice-status.read","thymian:organization.write","thymian:reports.read","thymian:hints.write","thymian:hints.read","thymian:absence-subjects.read","thymian:persons.read","thymian:planning.read","thymian:planning.write"],"description":"What a credential may do. One catalogue for every mechanism: thymian:read, thymian:track, thymian:timer, thymian:master-data.read, thymian:master-data.write, thymian:project-members.read, thymian:project-members.write, thymian:project-rates.read, thymian:project-rates.write, thymian:time.read-own, thymian:time.write-own, thymian:time.read-others, thymian:budgets.read, thymian:invoice-drafts.write, thymian:time-allowances.read, thymian:time-allowances.write, thymian:fixed-price.read, thymian:fixed-price.write, thymian:projects.write, thymian:customers.write, thymian:project-icons.write, thymian:budgets.write, thymian:invoices.read, thymian:invoices.write, thymian:billing.read, thymian:billing-proposals.write, thymian:billing-rules.write, thymian:invoice-status.read, thymian:organization.write, thymian:reports.read, thymian:hints.write, thymian:hints.read, thymian:absence-subjects.read, thymian:persons.read, thymian:planning.read, thymian:planning.write.","example":"thymian:read"},"minItems":1,"description":"What the token may do. Required and never defaulted: a token whose permissions nobody chose is the one nobody can reason about later.","example":["thymian:read"]},"expiresInDays":{"type":"integer","minimum":1,"maximum":365,"description":"Optional lifetime in days. Omit for a token that does not expire — revoking it is then the only way it stops working.","example":90}},"required":["name","scopes"]},"SlackConnection":{"type":"object","properties":{"configured":{"type":"boolean"},"workspace":{"type":["object","null"],"properties":{"teamId":{"type":"string","example":"T0123ABCD"},"teamName":{"type":"string","example":"Suora"},"connectedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["teamId","teamName","connectedAt"]},"identity":{"type":["object","null"],"properties":{"slackUserId":{"type":"string","example":"U0123ABCD"},"includeMessageText":{"type":"boolean"},"statusEnabled":{"type":"boolean"},"statusTemplate":{"type":["string","null"],"example":"⏱️ {project}"},"statusEmoji":{"type":["string","null"],"example":":stopwatch:"},"presenceEnabled":{"type":"boolean"},"huddleEnabled":{"type":"boolean"}},"required":["slackUserId","includeMessageText","statusEnabled","statusTemplate","statusEmoji","presenceEnabled","huddleEnabled"]}},"required":["configured","workspace","identity"]},"SlackAuthorizeUrl":{"type":"object","properties":{"url":{"type":"string","format":"uri","example":"https://slack.com/oauth/v2/authorize?…"}},"required":["url"]},"UpdateSlackStatus":{"type":"object","properties":{"statusTemplate":{"type":["string","null"],"maxLength":100,"description":"Placeholders `{customer}`, `{project}` and `{task}` are filled in. `null` restores the neutral default, which names no customer.","example":"⏱️ {project}"},"statusEmoji":{"type":["string","null"],"maxLength":64,"pattern":"^:[^:\\s]+:$","description":"`null` restores `:stopwatch:`.","example":":stopwatch:"}}},"UpdateSlackIdentity":{"type":"object","properties":{"includeMessageText":{"type":"boolean","description":"Whether a hint may carry the first line of what you wrote. Off by default; turning it off also clears the text already stored."}},"required":["includeMessageText"]},"GoogleCalendarConnection":{"type":"object","properties":{"configured":{"type":"boolean"},"connection":{"type":["object","null"],"properties":{"googleAccountEmail":{"type":"string","format":"email","example":"anna@suora.com"},"calendarIds":{"type":"array","items":{"type":"string"},"example":["anna@suora.com"]},"includeDetails":{"type":"boolean"},"connectedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastSyncedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"authorizationFailedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["googleAccountEmail","calendarIds","includeDetails","connectedAt","lastSyncedAt","authorizationFailedAt"]}},"required":["configured","connection"]},"GoogleCalendarAuthorizeUrl":{"type":"object","properties":{"url":{"type":"string","format":"uri","example":"https://accounts.google.com/o/oauth2/v2/auth?…"}},"required":["url"]},"GoogleCalendarList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"anna@suora.com"},"name":{"type":"string","example":"Anna Wolf"},"primary":{"type":"boolean"},"selected":{"type":"boolean"}},"required":["id","name","primary","selected"]}}},"required":["data"]},"UpdateGoogleCalendar":{"type":"object","properties":{"calendarIds":{"type":"array","items":{"type":"string","minLength":1},"maxItems":50,"description":"Which calendars are observed. An empty array is valid and means nothing is read — there is no value meaning \"all\".","example":["anna@suora.com"]},"includeDetails":{"type":"boolean","description":"Whether a hint may carry the calendar's name, the event's location and the first line of its description. Off by default; turning it off also clears what is already stored."}}},"GoogleCalendarSync":{"type":"object","properties":{"reported":{"type":"integer","minimum":0,"example":3},"status":{"type":"string","enum":["reported","not-configured","not-connected","no-calendars","authorization-failed","outside-retention","future-window","throttled","failed"],"description":"Why nothing was reported, when nothing was. `failed` is a bad minute at Google and heals by itself; `authorization-failed` never does and needs reconnecting."}},"required":["reported","status"]},"EasybillConnection":{"type":"object","properties":{"configured":{"type":"boolean"},"easybillServicePositionId":{"type":["string","null"],"example":"236898474"},"easybillServiceLabel":{"type":["string","null"],"example":"1 · Softwareentwicklung"},"easybillExpensePositionId":{"type":["string","null"],"example":"236898475"},"easybillExpenseLabel":{"type":["string","null"],"example":"2 · Reisekosten"},"connectedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["configured","easybillServicePositionId","easybillServiceLabel","easybillExpensePositionId","easybillExpenseLabel","connectedAt"]},"ConnectEasybill":{"type":"object","properties":{"apiKey":{"type":"string","minLength":1,"description":"Easybill's Bearer API key, from the account's own settings."},"easybillServicePositionId":{"type":"string","minLength":1,"description":"The Easybill article's id.","example":"236898474"}},"required":["apiKey"]},"SetEasybillService":{"type":"object","properties":{"easybillServicePositionId":{"type":"string","minLength":1,"description":"The Easybill article's id.","example":"236898474"}},"required":["easybillServicePositionId"]},"SetEasybillExpense":{"type":"object","properties":{"easybillExpensePositionId":{"type":["string","null"],"minLength":1,"description":"The Easybill article's id.","example":"236898474"}},"required":["easybillExpensePositionId"]},"EasybillServiceList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"236898474"},"number":{"type":["string","null"],"example":"1"},"description":{"type":["string","null"],"example":"Softwareentwicklung"},"unit":{"type":["string","null"],"example":"Std."},"vatPercent":{"type":["number","null"],"example":19}},"required":["id","number","description","unit","vatPercent"]}},"page":{"type":"integer","example":1},"pages":{"type":"integer","example":1}},"required":["data","page","pages"]},"EasybillCustomerMappingList":{"type":"object","properties":{"data":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/EasybillCustomerMapping"},{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","example":"7f9b2c1e-0000-4000-8000-000000000001"}},"required":["customerId"]}]}}},"required":["data"]},"EasybillCustomerList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"4711"},"number":{"type":["string","null"],"example":"10023"},"companyName":{"type":["string","null"],"example":"Muster GmbH"},"firstName":{"type":["string","null"],"example":"Anna"},"lastName":{"type":["string","null"],"example":"Muster"},"zipCode":{"type":["string","null"],"example":"20095"},"city":{"type":["string","null"],"example":"Hamburg"}},"required":["id","number","companyName","firstName","lastName","zipCode","city"]}},"page":{"type":"integer","example":1},"pages":{"type":"integer","example":1}},"required":["data","page","pages"]},"SetEasybillCustomerMapping":{"type":"object","properties":{"easybillCustomerId":{"type":"string","minLength":1,"example":"4711"}},"required":["easybillCustomerId"]},"SevdeskConnection":{"type":"object","properties":{"configured":{"type":"boolean"},"servicePartId":{"type":["string","null"],"example":"900000007"},"servicePartLabel":{"type":["string","null"],"example":"TJ-1 · Beratung"},"expensePartId":{"type":["string","null"],"example":"900000008"},"expensePartLabel":{"type":["string","null"],"example":"TJ-2 · Auslage"},"contactPersonId":{"type":["string","null"],"example":"900000001"},"contactPersonLabel":{"type":["string","null"],"example":"Max Mustermann"},"taxRuleId":{"anyOf":[{"type":"number","enum":[1]},{"type":"number","enum":[11]},{"type":"null"}],"description":"The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19).","example":1},"connectedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["configured","servicePartId","servicePartLabel","expensePartId","expensePartLabel","contactPersonId","contactPersonLabel","taxRuleId","connectedAt"]},"SevdeskConnectionResult":{"allOf":[{"$ref":"#/components/schemas/SevdeskConnection"},{"type":"object","properties":{"suggestedTaxRuleId":{"anyOf":[{"type":"number","enum":[1]},{"type":"number","enum":[11]}],"description":"The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19).","example":1},"taxRuleWarning":{"type":["string","null"],"example":"sevdesk meldet für dieses Konto die Kleinunternehmerregelung (§19), gewählt ist aber Regel 1."}},"required":["suggestedTaxRuleId","taxRuleWarning"]}]},"ConnectSevdesk":{"type":"object","properties":{"apiToken":{"type":"string","minLength":1,"description":"The account's sevdesk API token, from the settings of the sevdesk user."},"servicePartId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"},"expensePartId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"},"contactPersonId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"},"taxRuleId":{"anyOf":[{"type":"number","enum":[1]},{"type":"number","enum":[11]}],"description":"The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19).","example":1}},"required":["apiToken"]},"SevdeskAccount":{"type":"object","properties":{"suggestedTaxRuleId":{"anyOf":[{"type":"number","enum":[1]},{"type":"number","enum":[11]}],"description":"The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19).","example":1},"taxRuleWarning":{"type":["string","null"]},"smallSettlement":{"type":"boolean"}},"required":["suggestedTaxRuleId","taxRuleWarning","smallSettlement"]},"SevdeskContactPersonList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"900000001"},"name":{"type":["string","null"],"example":"Max Mustermann"},"role":{"type":["string","null"],"example":"admin"}},"required":["id","name","role"]}}},"required":["data"]},"SevdeskPartList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"900000007"},"partNumber":{"type":["string","null"],"example":"TJ-1"},"name":{"type":["string","null"],"example":"Beratung"},"vatPercent":{"type":["number","null"],"example":19}},"required":["id","partNumber","name","vatPercent"]}},"mayHaveMore":{"type":"boolean"}},"required":["data","mayHaveMore"]},"SetSevdeskServicePart":{"type":"object","properties":{"servicePartId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"}},"required":["servicePartId"]},"SetSevdeskExpensePart":{"type":"object","properties":{"expensePartId":{"type":["string","null"],"minLength":1,"description":"sevdesk's own id.","example":"900000007"}},"required":["expensePartId"]},"SetSevdeskContactPerson":{"type":"object","properties":{"contactPersonId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"}},"required":["contactPersonId"]},"SetSevdeskTaxRule":{"type":"object","properties":{"taxRuleId":{"anyOf":[{"type":"number","enum":[1]},{"type":"number","enum":[11]}],"description":"The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19).","example":1}},"required":["taxRuleId"]},"SevdeskCustomerMappingList":{"type":"object","properties":{"data":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/SevdeskCustomerMapping"},{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","example":"7f9b2c1e-0000-4000-8000-000000000001"}},"required":["customerId"]}]}}},"required":["data"]},"SevdeskCustomerMapping":{"type":"object","properties":{"sevdeskContactId":{"type":"string","example":"900000003"},"sevdeskContactName":{"type":["string","null"],"example":"Muster GmbH"}},"required":["sevdeskContactId","sevdeskContactName"]},"SevdeskContactList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"900000003"},"name":{"type":["string","null"],"example":"Muster GmbH"},"customerNumber":{"type":["string","null"],"example":"1000"},"categoryId":{"type":["string","null"],"example":"3","description":"sevdesk’s category of the contact: 3 is a customer, 2 a supplier; others are not captured."}},"required":["id","name","customerNumber","categoryId"]}},"page":{"type":"integer","example":1},"pages":{"type":"integer","example":1}},"required":["data","page","pages"]},"SetSevdeskCustomerMapping":{"type":"object","properties":{"sevdeskContactId":{"type":"string","minLength":1,"description":"sevdesk's own id.","example":"900000007"}},"required":["sevdeskContactId"]},"PersonioAttendanceExport":{"type":"object","properties":{"active":{"type":"boolean","description":"Whether the export writes attendances: its sync target exists and is enabled."}},"required":["active"]},"ActivatePersonioAttendanceExport":{"type":"object","properties":{"personioClientId":{"type":"string","minLength":1,"maxLength":500,"description":"The client id of a Personio API credential (Personio: Einstellungen → Integrationen → API-Zugangsdaten)."},"personioClientSecret":{"type":"string","minLength":1,"maxLength":500,"description":"Its client secret. Passed to the sync service and never stored, logged or returned by Timjami. Sending it again replaces it; Personio checks a replacement only at the next sync."},"exportFrom":{"type":"string","format":"date","description":"The first day whose tracked time is exported, read in the organization’s time zone. Time before this day is never sent to Personio. Timjami stores nothing of this field — it is converted and forwarded to the sync service on every activation, so changing it later neither re-exports earlier time nor removes what Personio already has."}},"required":["personioClientId","personioClientSecret","exportFrom"]},"PersonioPersonMappings":{"type":"object","properties":{"persons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"]},"status":{"type":"string","enum":["assigned","open","ambiguous"]},"assignment":{"type":["object","null"],"properties":{"id":{"type":"string"},"version":{"type":"integer","minimum":0},"confirmed":{"type":"boolean"},"person":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"]}},"required":["id","version","confirmed","person"]},"suggestions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"]}}},"required":["id","name","email","status","assignment","suggestions"]}},"personioPersons":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"]},"assignedTo":{"type":["string","null"]}},"required":["id","name","email","assignedTo"]}},"openCount":{"type":"integer","minimum":0,"description":"Persons read by the sync service without a confirmed, live counterpart. Includes ambiguous suggestions; these persons cannot resolve in the export."}},"required":["persons","personioPersons","openCount"]},"ChangePersonioPersonMapping":{"type":"object","properties":{"personioPersonId":{"type":["string","null"],"format":"uuid","description":"The Personio mapping item to confirm, or null to revoke."},"expectedAssignment":{"type":["object","null"],"properties":{"id":{"type":"string"},"version":{"type":"integer","minimum":0}},"required":["id","version"],"description":"The assignment and version displayed to the administrator, or null for an unassigned person."}},"required":["personioPersonId","expectedAssignment"]},"HarvestConnection":{"type":"object","properties":{"configured":{"type":"boolean"},"connection":{"type":["object","null"],"properties":{"accountId":{"type":"string","example":"1234567"},"harvestUserEmail":{"type":"string","format":"email","example":"anna@suora.com"},"harvestUserName":{"type":"string","example":"Anna Wolf"},"harvestUserTimezone":{"type":"string","example":"Berlin"},"timestampMode":{"type":"boolean"},"connectedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"lastVerifiedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"authorizationFailedAt":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["accountId","harvestUserEmail","harvestUserName","harvestUserTimezone","timestampMode","connectedAt","lastVerifiedAt","authorizationFailedAt"]}},"required":["configured","connection"]},"ConnectHarvest":{"type":"object","properties":{"accountId":{"type":"string","minLength":1,"description":"Harvest's numeric account id, from the account's own settings.","example":"1234567"},"accessToken":{"type":"string","minLength":1,"description":"A personal access token, from the account's own settings."},"confirmDifferentEmail":{"type":"boolean"}},"required":["accountId","accessToken"]},"HarvestExportResult":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["created","updated","failed"]},"harvestTimeEntryId":{"type":"string"},"reason":{"type":"string","enum":["foreign-user","running","spans-midnight","shorter-than-a-minute","missing-project-link","missing-task-link","inconsistent-task-link","foreign-account-link","in-flight","harvest-deleted","harvest-locked","ambiguous","rejected","aborted"]},"detail":{"type":"string"}},"required":["timeEntryId","status"]}},"created":{"type":"integer","minimum":0},"updated":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0}},"required":["results","created","updated","failed"]},"HarvestExportRequest":{"type":"object","properties":{"timeEntryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":50}},"required":["timeEntryIds"]},"HarvestCompareResult":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["matches","diverged","harvest-deleted","failed"]},"harvestTimeEntryId":{"type":"string"},"differences":{"type":"array","items":{"type":"string","enum":["date","start","end","notes","project","task"]}},"differenceDetails":{"type":"array","items":{"$ref":"#/components/schemas/HarvestCompareFieldDifferenceDetail"}},"detail":{"type":"string"},"localUpdatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"remoteUpdatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["timeEntryId","status"]}}},"required":["results"]},"HarvestCompareFieldDifferenceDetail":{"type":"object","properties":{"field":{"type":"string","enum":["date","start","end","notes","project","task"]},"expected":{"type":"string"},"remote":{"type":"string"}},"required":["field","expected","remote"]},"HarvestCompareRequest":{"type":"object","properties":{"timeEntryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":2000,"description":"The entries to compare, at most 2000. Far more than an export batch accepts, because a comparison reads Harvest’s copies as one range rather than one request per entry — see ADR-2026-08-18-14GLB. Equal to what one import takes over, so a whole reconciled period fits in both directions (ADR-2026-08-27-15EG4)."},"personId":{"type":"string","format":"uuid","description":"Whose Harvest period this is about. Omit for the caller’s own. Naming somebody else requires `time.readOthers`, and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true}},"required":["timeEntryIds"]},"HarvestAdoptResult":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"status":{"type":"string","enum":["adopted","unchanged","failed"]},"harvestTimeEntryId":{"type":"string"},"adopted":{"type":"array","items":{"type":"string","enum":["date","start","end","notes","task"]}},"reason":{"type":"string","enum":["foreign-user","running","not-synchronised","foreign-account-link","harvest-deleted","missing-project-link","missing-task-link","inconsistent-task-link","ambiguous-task-link","no-clock-times","ends-before-start","archived-chain","not-assigned","billed","rejected","aborted"]},"detail":{"type":"string"}},"required":["timeEntryId","status"]}},"adopted":{"type":"integer","minimum":0},"unchanged":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0}},"required":["results","adopted","unchanged","failed"]},"HarvestAdoptRequest":{"type":"object","properties":{"timeEntryIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1,"maxItems":50,"description":"The entries to correct. The export’s cap rather than the comparison’s: this spends one Harvest request per entry and follows each with a local transaction."},"personId":{"type":"string","format":"uuid","description":"Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true}},"required":["timeEntryIds"]},"HarvestImportResult":{"type":"object","properties":{"preview":{"type":"boolean"},"results":{"type":"array","items":{"type":"object","properties":{"harvestTimeEntryId":{"type":"string","example":"987654321"},"status":{"type":"string","enum":["imported","skipped"]},"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"reason":{"type":"string","enum":["not-in-range","already-linked","thymian-deleted","thymian-origin","missing-project-link","missing-task-link","inconsistent-task-link","ambiguous-task-link","foreign-account-link","no-clock-times","harvest-running","ends-before-start","double-billed","target-unassigned"]},"detail":{"type":"string"},"archived":{"type":"string","enum":["task","project","customer"],"description":"The innermost archived level of the Timjami chain this entry lands on — absent for a live one. An archived chain is booked rather than refused here, unlike every other way of creating time: the hours were tracked elsewhere, on a task somebody chose while the chain was live, so the row states what it landed on instead of stopping. Present on an `imported` row in a preview exactly as in a run, and named like the `archived` member of the 409 the other booking paths still answer. See ADR-2026-08-24-11766.","example":"project"},"plannedSeconds":{"type":["integer","null"],"minimum":0,"example":5436,"description":"The seconds this run books for the entry, whatever its status. Harvest publishes a second-precise duration only as decimal `hours` and its boundaries only as `HH:mm`, so the import places a pair inside those two minutes that is `hours` long — this is that length. It falls back to the clock span when Harvest states no hours, or when hours and clock times disagree by a minute or more so that no such pair exists, and is `null` when Harvest states no clock times at all. Summing it is what a period adds up to here after an import; the difference against `hours` is the residue no placement can hold. See ADR-2026-08-20-07UAD."},"plannedStartedAt":{"type":["string","null"],"format":"date-time","description":"The instant `plannedSeconds` is placed to start at. `null` alongside `plannedSeconds`. The offset inside the published minute carries the rule that an entry starts no earlier than the previous one of that Harvest day ended, which is a property of the whole day — so an entry whose own length already matches can still be one the placement moves. See ADR-2026-08-20-07ME9."},"plannedEndedAt":{"type":["string","null"],"format":"date-time","description":"The instant `plannedSeconds` is placed to end at. `null` alongside it."},"harvest":{"type":"object","properties":{"spentDate":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"startedTime":{"type":["string","null"],"example":"09:00"},"endedTime":{"type":["string","null"],"example":"10:30"},"hours":{"type":["number","null"],"example":1.5,"description":"Harvest's own decimal hours for this entry, or `null` when Harvest did not say. Summed for what Harvest says a period adds up to, rather than re-deriving it from the clock times — see ADR-2026-08-19-08F3B."},"notes":{"type":["string","null"]},"projectName":{"type":["string","null"]},"taskName":{"type":["string","null"]}},"required":["spentDate","startedTime","endedTime","hours","notes","projectName","taskName"]}},"required":["harvestTimeEntryId","status","plannedSeconds","plannedStartedAt","plannedEndedAt","harvest"]}},"imported":{"type":"integer","minimum":0},"skipped":{"type":"integer","minimum":0},"claimsWritten":{"type":"integer","minimum":0,"description":"How many of these entries this run marked as already billed in Harvest — an `external` claim over the whole entry, so the hours leave „Abzurechnen\" and can never be billed a second time here. Counted for freshly imported entries and for already-linked ones this run caught up with, which is why it is its own number rather than part of `imported`: an entry that was already here stays `skipped` and the claim is what changed about it. In a preview it counts what *would* be written. See ADR-2026-08-24-11D67.","example":3},"truncated":{"type":"boolean","description":"`true` when the range holds more than 2000 entries; the rest were not examined."}},"required":["preview","results","imported","skipped","claimsWritten","truncated"]},"HarvestImportRequest":{"type":"object","properties":{"from":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's."},"to":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive, and never before `from`."},"preview":{"type":"boolean","description":"When `true`, nothing is written and every result says what would happen."},"harvestTimeEntryIds":{"type":"array","items":{"type":"string","minLength":1},"minItems":1,"maxItems":2000,"description":"Harvest time entry ids to take over, out of the range. Omit for the whole range. Given, the answer holds exactly one result per requested id, in this order — an id the range does not hold answers `not-in-range`. The range is still read whole, because an entry's reconstructed placement depends on the previous entry of its Harvest day. At most 2000.","example":["987654321"]},"personId":{"type":"string","format":"uuid","description":"Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true}},"required":["from","to","preview"]},"HarvestSecondsResult":{"type":"object","properties":{"preview":{"type":"boolean"},"results":{"type":"array","items":{"type":"object","properties":{"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"harvestTimeEntryId":{"type":"string","example":"987654321"},"status":{"type":"string","enum":["corrected","unchanged","failed"]},"reason":{"type":"string","enum":["no-placement","foreign-account-link","foreign-user","running","not-minute-precise","thymian-origin","billed"]},"detail":{"type":"string"},"fromSeconds":{"type":"integer","minimum":0},"toSeconds":{"type":"integer","minimum":0}},"required":["timeEntryId","harvestTimeEntryId","status"]}},"corrected":{"type":"integer","minimum":0},"unchanged":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"gainedSeconds":{"type":"integer","description":"Seconds the corrections added to the period's total — what the difference against Harvest's own report shrinks by. Negative where Harvest's hours are below its published minutes."},"openSeams":{"type":"integer","minimum":0,"description":"Places where one entry was placed to end after the next one could possibly start. Not a defect: an entry's length is Harvest's own and its boundaries may not leave the minutes Harvest published, and those two outrank „no overlap\". Where Harvest's hours for a stretch add up to more than its own minutes hold, some overlap is forced — reported here rather than hidden behind a shortened duration. Always under two minutes per seam."},"openSeamSeconds":{"type":"integer","minimum":0,"description":"How much those seams overlap in total, seconds."},"harvestOverlaps":{"type":"integer","minimum":0,"description":"Of `openSeams`, the ones where **Harvest's own published windows** already overlap — two Harvest entries whose `HH:mm` ranges cross. No placement of any kind could have avoided those, and a shortened duration would not close them either."},"truncated":{"type":"boolean","description":"`true` when Harvest holds more than 2000 entries in the range; the rest were not examined."}},"required":["preview","results","corrected","unchanged","failed","gainedSeconds","openSeams","openSeamSeconds","harvestOverlaps","truncated"]},"HarvestSecondsRequest":{"type":"object","properties":{"from":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"to":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"preview":{"type":"boolean","default":false},"personId":{"type":"string","format":"uuid","description":"Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"userId":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true}},"required":["from","to"]},"HarvestPeriodSummary":{"type":"object","properties":{"totalSeconds":{"type":"integer","minimum":0,"description":"Harvest's `total_hours`, as seconds. Harvest's own quantum is 0.01 h, so this figure is 36-second-grained — it is Harvest's answer, not a recomputation of it.","example":329040},"billableSeconds":{"type":"integer","minimum":0,"description":"Harvest's `billable_hours`, as seconds. What *Harvest* holds for sale, which is its own task-assignment setting and not Timjami's `task.is_billable`.","example":288000},"amounts":{"type":"array","items":{"type":"object","properties":{"currency":{"type":"string","example":"EUR"},"billableAmountMinor":{"type":"integer","example":300000}},"required":["currency","billableAmountMinor"]},"description":"One entry per currency, because Harvest reports one row per person **and** currency. Never added together: a sum over two currencies is not a number. Empty when Harvest named no billable amount at all."},"rowsWithoutAmount":{"type":"integer","minimum":0,"description":"Report rows that state billable hours but no amount — counted and named rather than summed as zero (invariant 19)."}},"required":["totalSeconds","billableSeconds","amounts","rowsWithoutAmount"]},"HarvestProjectList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"20"},"name":{"type":"string","example":"Website"}},"required":["id","name"]}}},"required":["data"]},"HarvestTaskList":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","example":"30"},"name":{"type":"string","example":"Design"}},"required":["id","name"]}}},"required":["data"]},"HarvestProjectLink":{"type":["object","null"],"properties":{"harvestProjectId":{"type":"string","example":"20"},"linkedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["harvestProjectId","linkedAt"]},"HarvestProjectMapping":{"type":"object","properties":{"harvestProjectId":{"type":"string","example":"20"}},"required":["harvestProjectId"]},"HarvestProjectTeamImportResult":{"type":"object","properties":{"preview":{"type":"boolean"},"members":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"harvestMatch":{"type":"string","enum":["matched","unmatched","ambiguous","denied"],"description":"`matched` ran an ordinary import for this person; every other status wrote nothing. `unmatched`/`ambiguous` mean no or no unambiguous Harvest account shares this person’s email. `denied` means Harvest refused this one person’s hours to the importer’s own token — reported rather than ending the run for everybody else."},"imported":{"type":"integer","minimum":0},"skipped":{"type":"integer","minimum":0},"claimsWritten":{"type":"integer","minimum":0},"truncated":{"type":"boolean"},"results":{"type":"array","items":{"type":"object","properties":{"harvestTimeEntryId":{"type":"string","example":"987654321"},"status":{"type":"string","enum":["imported","skipped"]},"timeEntryId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"reason":{"type":"string","enum":["not-in-range","already-linked","thymian-deleted","thymian-origin","missing-project-link","missing-task-link","inconsistent-task-link","ambiguous-task-link","foreign-account-link","no-clock-times","harvest-running","ends-before-start","double-billed","target-unassigned"]},"detail":{"type":"string"},"archived":{"type":"string","enum":["task","project","customer"],"description":"The innermost archived level of the Timjami chain this entry lands on — absent for a live one. An archived chain is booked rather than refused here, unlike every other way of creating time: the hours were tracked elsewhere, on a task somebody chose while the chain was live, so the row states what it landed on instead of stopping. Present on an `imported` row in a preview exactly as in a run, and named like the `archived` member of the 409 the other booking paths still answer. See ADR-2026-08-24-11766.","example":"project"},"plannedSeconds":{"type":["integer","null"],"minimum":0,"example":5436,"description":"The seconds this run books for the entry, whatever its status. Harvest publishes a second-precise duration only as decimal `hours` and its boundaries only as `HH:mm`, so the import places a pair inside those two minutes that is `hours` long — this is that length. It falls back to the clock span when Harvest states no hours, or when hours and clock times disagree by a minute or more so that no such pair exists, and is `null` when Harvest states no clock times at all. Summing it is what a period adds up to here after an import; the difference against `hours` is the residue no placement can hold. See ADR-2026-08-20-07UAD."},"plannedStartedAt":{"type":["string","null"],"format":"date-time","description":"The instant `plannedSeconds` is placed to start at. `null` alongside `plannedSeconds`. The offset inside the published minute carries the rule that an entry starts no earlier than the previous one of that Harvest day ended, which is a property of the whole day — so an entry whose own length already matches can still be one the placement moves. See ADR-2026-08-20-07ME9."},"plannedEndedAt":{"type":["string","null"],"format":"date-time","description":"The instant `plannedSeconds` is placed to end at. `null` alongside it."},"harvest":{"type":"object","properties":{"spentDate":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"A calendar day, `YYYY-MM-DD`."},"startedTime":{"type":["string","null"],"example":"09:00"},"endedTime":{"type":["string","null"],"example":"10:30"},"hours":{"type":["number","null"],"example":1.5,"description":"Harvest's own decimal hours for this entry, or `null` when Harvest did not say. Summed for what Harvest says a period adds up to, rather than re-deriving it from the clock times — see ADR-2026-08-19-08F3B."},"notes":{"type":["string","null"]},"projectName":{"type":["string","null"]},"taskName":{"type":["string","null"]}},"required":["spentDate","startedTime","endedTime","hours","notes","projectName","taskName"]}},"required":["harvestTimeEntryId","status","plannedSeconds","plannedStartedAt","plannedEndedAt","harvest"]}}},"required":["personId","harvestMatch","imported","skipped","claimsWritten","truncated","results"]}},"imported":{"type":"integer","minimum":0},"skipped":{"type":"integer","minimum":0},"claimsWritten":{"type":"integer","minimum":0},"truncated":{"type":"boolean","description":"`true` when any one member's own range was truncated."},"otherTrackers":{"type":"array","items":{"type":"object","properties":{"harvestUserId":{"type":"string"},"harvestUserName":{"type":["string","null"]},"harvestUserEmail":{"type":["string","null"],"description":"`null` when no `GET /users` row matches this id — a deactivated Harvest account, or Harvest never listed it."},"status":{"type":"string","enum":["unassigned-member","ambiguous","no-thymian-account"],"description":"`unassigned-member`: the email matches exactly one organization member who is not a project member — assign them to pick these hours up on the next run. `ambiguous`: the email matches more than one Harvest or Timjami account. `no-thymian-account`: no organization member shares this email at all."},"personId":{"type":"string","format":"uuid","description":"Present only for `unassigned-member` — the Timjami person to assign.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["harvestUserId","harvestUserName","harvestUserEmail","status"]},"description":"Everybody who tracked hours on this Harvest project in the range and is not among `members` — see the field descriptions above. See ADR-2026-08-27-13NS7."},"otherTrackersTruncated":{"type":"boolean","description":"`true` when the project-wide read behind `otherTrackers` held more than the cap."}},"required":["preview","members","imported","skipped","claimsWritten","truncated","otherTrackers","otherTrackersTruncated"]},"HarvestProjectTeamImportRequest":{"type":"object","properties":{"from":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's."},"to":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive, and never before `from`."},"preview":{"type":"boolean","description":"When `true`, nothing is written and every result says what would happen."}},"required":["from","to","preview"]},"HarvestTaskMapping":{"type":"object","properties":{"harvestProjectId":{"type":"string","example":"20"},"harvestTaskId":{"type":"string","example":"30"}},"required":["harvestProjectId","harvestTaskId"]}},"parameters":{}},"paths":{"/api/v1/auth/providers":{"get":{"tags":["Authentication"],"summary":"Available interactive sign-in providers","responses":{"200":{"description":"Enabled providers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProviders"}}}}}}},"/api/v1/client-errors":{"post":{"tags":["Diagnostics"],"summary":"Report a browser error","description":"Write-only, unauthenticated diagnostics for configured web origins, including before sign-in. No account or organization is inferred. Accepts at most 8 KiB and 120 requests per minute per API process. Client fields are untrusted; errorId is independent of the server-generated requestId. Free-form messages, URLs, and application data are not accepted.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"errorId":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["react","route","error","unhandledrejection"]},"name":{"type":"string","enum":["Error","TypeError","ReferenceError","SyntaxError","RangeError","URIError","EvalError","AggregateError","UnknownError"]},"frames":{"type":"array","items":{"type":"string","maxLength":180,"pattern":"^[A-Za-z0-9_.\\-$]+\\.(?:[cm]?js|[jt]sx?):\\d+:\\d+$"},"maxItems":12},"route":{"type":"string","maxLength":180,"pattern":"^[A-Za-z0-9_/$.-]*$"},"build":{"type":"string","maxLength":100,"pattern":"^[A-Za-z0-9_.+-]+$"}},"required":["errorId","kind","name","frames","route","build"],"additionalProperties":false}}}},"responses":{"200":{"description":"Report logged; requestId also appears in X-Request-Id.","content":{"application/json":{"schema":{"type":"object","properties":{"errorId":{"type":"string","format":"uuid"},"requestId":{"type":"string","format":"uuid"}},"required":["errorId","requestId"]}}}},"403":{"description":"Origin not allowed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"413":{"description":"Report exceeds 8 KiB.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Invalid report.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/app-errors":{"post":{"tags":["Diagnostics"],"summary":"Report a desktop, CLI or Raycast error","description":"Write-only diagnostics from the Timjami desktop app, command-line tool and Raycast extension. An optional bearer session token or OAuth access token attributes the report to its account; without one, or with any other credential, the report is accepted anonymously. No organization is inferred and no Origin is required. Accepts at most 8 KiB, 30 reports per minute per account and 120 anonymous reports per minute per API process. Client fields and installation headers are untrusted; errorId is independent of the server-generated requestId. Free-form messages, URLs, absolute paths and application data are not accepted.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"errorId":{"type":"string","format":"uuid"},"source":{"type":"string","enum":["panic","webview","upload","collector","api","command"]},"kind":{"type":"string","enum":["Panic","UnexpectedStatus","InvalidResponse","Refused","UploadFailing","UnreadableInput","UnreadableLocalState","Error","TypeError","ReferenceError","SyntaxError","RangeError","URIError","EvalError","AggregateError","UnknownError"]},"status":{"type":"integer","minimum":400,"maximum":599,"description":"The HTTP status; only for api, upload and collector reports."},"operation":{"type":"string","pattern":"^[A-Za-z0-9_ /$.-]{1,80}$"},"frames":{"type":"array","items":{"type":"string","maxLength":180,"description":"file:line:column — a JavaScript file basename, or a Rust source path that is relative and contains no \"..\"."},"maxItems":12},"build":{"type":"string","maxLength":100,"pattern":"^[A-Za-z0-9_.+-]+$"}},"required":["errorId","source","kind","frames","build"],"additionalProperties":false}}}},"responses":{"200":{"description":"Report logged; requestId also appears in X-Request-Id.","content":{"application/json":{"schema":{"type":"object","properties":{"errorId":{"type":"string","format":"uuid"},"requestId":{"type":"string","format":"uuid"}},"required":["errorId","requestId"]}}}},"413":{"description":"Report exceeds 8 KiB.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Invalid report.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/access/invite-codes/check":{"post":{"tags":["Access"],"summary":"Check whether an invite code is usable","description":"Public and unauthenticated: whether a code is unknown, expired, revoked, or exhausted, it answers the same 422 either way. A usable code sets a short-lived, unsigned cookie the sign-up gate re-validates against the database on every use.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteCodeCheckRequest"}}}},"responses":{"200":{"description":"The code is usable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteCodeCheckResult"}}}},"403":{"description":"Origin not allowed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"The code is unusable.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/access-requests":{"post":{"tags":["Access"],"summary":"Request access to Timjami","description":"Public and unauthenticated. Always answers 202 once the body validates, whether or not the address is already known — a stranger probing for known addresses learns nothing from the response. A repeated request while one is still pending updates it rather than filing a second one.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessRequest"}}}},"responses":{"202":{"description":"The request was recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessRequestAccepted"}}}},"403":{"description":"Origin not allowed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Invalid request.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customer-portal/{organizationSlug}/branding":{"get":{"tags":["Customer portal"],"summary":"Get public customer portal branding","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":255},"required":true,"name":"organizationSlug","in":"path"}],"responses":{"200":{"description":"Portal branding.","content":{"application/json":{"schema":{"type":"object","properties":{"organizationSlug":{"type":"string"},"displayName":{"type":"string"},"accentColor":{"type":"string"},"welcomeMessage":{"type":["string","null"]},"logoUrl":{"type":["string","null"],"format":"uri"}},"required":["organizationSlug","displayName","accentColor","welcomeMessage","logoUrl"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/events":{"get":{"tags":["Events"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Live change notifications (Server-Sent Events)","description":"A stream of notifications that something relevant to the authenticated client changed,\nso it can refresh without polling.\n\n**An organization- or user-scoped frame carries no domain data** — only `kind`,\n`organizationId` when applicable, and `audienceUserId`. Read the change through the\nordinary endpoints afterwards.\n\nFrames are `time-entries.changed`, `hints.changed`, `invoices.changed`,\n`sessions.changed`, and `release.available`. `invoices.changed` reaches only members\nwhose role may read invoices, and says that an invoice changed without a write through\nthis API — Easybill reported news about its exported document — so refetch invoices.\n`sessions.changed` is addressed to the authenticated user rather\nthan an organization and causes clients to refetch their Better Auth session inventory.\n`release.available` carries `{kind, clientType, version}` and is deployment-wide rather\nthan organization-scoped —\nsent only to a connection that opened with `x-thymian-client: desktop`). Comment\nlines beginning with `:` are keep-alives and can be ignored. There is no `id:` and\n`Last-Event-ID` is not honoured: nothing is buffered for a disconnected client, so\n**refetch on every reconnect** rather than assuming continuity.\n\nThe server closes the connection after 15 minutes so that membership is re-checked;\nreconnecting is expected and is not an error.","responses":{"200":{"description":"The event stream. Stays open until the client or the server closes it.","content":{"text/event-stream":{"schema":{"type":"string","example":"event: time-entries.changed\ndata: {\"kind\":\"time-entries.changed\",\"organizationId\":\"org_1\",\"audienceUserId\":null}\n\n"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/timer/events":{"get":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Live timer change notifications for restricted clients","description":"The restricted counterpart to `/api/v1/events` for a credential holding\n`thymian:timer`, or `thymian:read` and `thymian:track` together. It emits\n`time-entries.changed` and never hint events or domain data.\n\nComment lines are keep-alives. Refetch `/api/v1/timer` after every connection and\nevent because notifications are not durable or replayable.","responses":{"200":{"description":"The event stream. Stays open until the client or the server closes it.","content":{"text/event-stream":{"schema":{"type":"string","example":"event: time-entries.changed\ndata: {\"kind\":\"time-entries.changed\",\"organizationId\":\"org_1\",\"audienceUserId\":null}\n\n"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/mcp":{"post":{"tags":["MCP"],"security":[{"McpBearer":[]}],"summary":"Model Context Protocol endpoint (Streamable HTTP)","description":"A Model Context Protocol server, so an assistant can look up projects and record\ntime on behalf of the person who connected it.\n\n**Authentication is OAuth 2.1**, with Timjami as the authorization server. A client\ndiscovers it from `/.well-known/oauth-protected-resource`, or from the\n`WWW-Authenticate` header on a 401 here. PKCE is required.\n\n**The organization is not a parameter.** It is chosen by the person on the approval\npage and stored with the grant. Scopes: thymian:master-data.read, thymian:master-data.write, thymian:project-members.read, thymian:project-members.write, thymian:project-rates.read, thymian:project-rates.write, thymian:time.read-own, thymian:time.write-own, thymian:time.read-others, thymian:budgets.read, thymian:invoice-drafts.write, thymian:time-allowances.read, thymian:time-allowances.write, thymian:fixed-price.read, thymian:fixed-price.write, thymian:persons.read, thymian:budgets.write, thymian:billing.read, thymian:billing-proposals.write, thymian:billing-rules.write, thymian:invoice-status.read, thymian:invoices.read, thymian:reports.read, thymian:hints.write, thymian:hints.read, thymian:planning.read, thymian:planning.write.\n\nTools: `list_customers`, `list_projects`, `list_tasks`, `create_customer`, `update_customer`, `set_customer_icon`, `remove_customer_icon`, `set_project_icon`, `remove_project_icon`, `create_project`, `update_project`, `create_task`, `update_task`, `list_persons`, `list_project_members`, `assign_project_member`, `remove_project_member`, `get_project_rates`, `set_project_rates`, `list_time_entries`, `list_team_time_entries`, `get_running_timer`, `summarize_time`, `start_timer`, `stop_timer`, `log_time`, `update_time_entry`, `remove_time_entry_reference`, `list_hints`, `report_hint`, `list_budgets`, `get_budget_consumption`, `create_budget`, `supersede_budgets`, `set_budget_adjustment`, `remove_budget_adjustment`, `list_billing_rules`, `get_billing_rule`, `list_billing_proposals`, `get_billing_proposal`, `dismiss_empty_billing_proposal`, `create_billing_rule`, `update_billing_rule_schedule`, `change_billing_rule_coverage`, `move_billing_allocation`, `list_invoices`, `get_invoice`, `preview_invoice`, `create_invoice`, `delete_invoice_draft`, `list_time_allowances`, `get_time_allowance`, `create_time_allowance`, `supersede_time_allowances`, `correct_time_allowance`, `delete_time_allowance`, `set_time_allowance_adjustment`, `remove_time_allowance_adjustment`, `list_fixed_price_agreements`, `get_fixed_price_agreement`, `create_fixed_price_agreement`, `rename_fixed_price_agreement`, `delete_fixed_price_agreement`, `list_fixed_price_items`, `get_fixed_price_item`, `create_fixed_price_item`, `change_fixed_price_item`, `delete_fixed_price_item`, `set_task_fixed_price_agreement`, `get_company_metrics`, `list_company_metric_months`, `list_company_metric_weeks`, `set_planning_context`, `get_planning_context`, `set_planning_allocation`, `refine_planning_allocation`, `get_planning_overview`, `get_work_history`. Tools unavailable\nbecause of role capability or live grant are hidden; a missing OAuth scope remains\ndiscoverable so the client can request step-up authorization.\n\nThe transport is stateless and answers `application/json`; it opens no stream, so\nthere are no server-initiated notifications.","requestBody":{"required":true,"description":"A JSON-RPC 2.0 request, notification or batch, as defined by MCP.","content":{"application/json":{"schema":{"type":"object","example":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}}}},"responses":{"200":{"description":"A JSON-RPC response.","content":{"application/json":{"schema":{"type":"object","example":{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}}}}},"401":{"description":"No or invalid access token. Carries a `WWW-Authenticate` challenge naming the protected-resource metadata (RFC 9728).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"No approved organization, membership lost, or `insufficient_scope` for the tool that was called.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/icons/{token}":{"get":{"tags":["Icons"],"summary":"Serve a customer or project icon","description":"Returns the image bytes for a signed token, as found in a resource’s `icon.url`.\n\nDeliberately not authenticated with a session or a bearer token: the URL is meant\nto be usable directly as an image source. The token is signed, names the exact\nbytes it grants access to, and expires — so it stops working when the icon is\nreplaced or removed.\n\nDo not build these URLs; read them from the resource. Do not store them either —\na stale one answers 404, which is the signal to refetch the resource.\n\nAn image stored as WebP is sent as WebP only when `Accept` names `image/webp`\nexplicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients\n(watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be\nre-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always\nsent as PNG.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Opaque signed token from the resource’s `icon.url`."},"required":true,"description":"Opaque signed token from the resource’s `icon.url`.","name":"token","in":"path"}],"responses":{"200":{"description":"The image.","headers":{"ETag":{"schema":{"type":"string","description":"Identifies the image and the format it is sent in."},"required":true,"description":"Identifies the image and the format it is sent in."},"Cache-Control":{"schema":{"type":"string"},"required":true},"Vary":{"schema":{"type":"string","description":"`Accept` whenever the format depends on it."},"required":false,"description":"`Accept` whenever the format depends on it."}},"content":{"image/webp":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}}}},"304":{"description":"Not modified — the client already has these bytes."},"404":{"description":"No such icon: the token is invalid, expired, or names bytes that have since been replaced.","content":{"application/problem+json":{"schema":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"}},"required":["type","title","status"]}}}}}}},"/api/v1/avatars/{token}":{"get":{"tags":["Avatars"],"summary":"Serve a person's avatar","description":"Returns the image bytes for a signed token, as found in `user.avatar.url`.\n\nDeliberately not authenticated with a session or a bearer token, for the same\nreason a customer or project icon is not — see `GET /icons/{token}`.\n\nDo not build these URLs; read them from `/me` or the organization member list.\nDo not store them either — a stale one answers 404, which is the signal to\nrefetch.\n\nAn image stored as WebP is sent as WebP only when `Accept` names `image/webp`\nexplicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients\n(watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be\nre-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always\nsent as PNG.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Opaque signed token from `user.avatar.url`."},"required":true,"description":"Opaque signed token from `user.avatar.url`.","name":"token","in":"path"}],"responses":{"200":{"description":"The image.","headers":{"ETag":{"schema":{"type":"string","description":"Identifies the image and the format it is sent in."},"required":true,"description":"Identifies the image and the format it is sent in."},"Cache-Control":{"schema":{"type":"string"},"required":true},"Vary":{"schema":{"type":"string","description":"`Accept` whenever the format depends on it."},"required":false,"description":"`Accept` whenever the format depends on it."}},"content":{"image/webp":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}}}},"304":{"description":"Not modified — the client already has these bytes."},"404":{"description":"No such avatar: the token is invalid, expired, or names bytes that have since been replaced.","content":{"application/problem+json":{"schema":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"}},"required":["type","title","status"]}}}}}}},"/api/v1/budget-report-charts/{token}":{"get":{"tags":["Budgets"],"summary":"Serve a budget report’s chart","description":"The PNG chart a scheduled budget report carried, for a signed token found in that\nreport’s mail or Slack post. The image is the snapshot that was sent; it never changes\nand never shows anything newer.\n\nDeliberately not authenticated with a session or a bearer token, for the reason\n`GET /avatars/{token}` is not. Invalid, expired and unknown tokens all answer 404.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Opaque signed token, with a `.png` suffix, from a report."},"required":true,"description":"Opaque signed token, with a `.png` suffix, from a report.","name":"token","in":"path"}],"responses":{"200":{"description":"The chart.","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"No such chart: the token is invalid or expired, or the report is gone.","content":{"application/problem+json":{"schema":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"}},"required":["type","title","status"]}}}}}}},"/api/v1/organization-icons/{token}":{"get":{"tags":["Organization"],"summary":"Serve an organization's icon","description":"Returns the image bytes for a signed token, as found in `organization.icon.url`.\n\nDeliberately not authenticated with a session or a bearer token, for the same\nreason a customer or project icon is not — see `GET /icons/{token}`.\n\nDo not build these URLs; read them from `GET /organization/icon`. Do not store\nthem either — a stale one answers 404, which is the signal to refetch.\n\nAn image stored as WebP is sent as WebP only when `Accept` names `image/webp`\nexplicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients\n(watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be\nre-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always\nsent as PNG.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Opaque signed token from `organization.icon.url`."},"required":true,"description":"Opaque signed token from `organization.icon.url`.","name":"token","in":"path"}],"responses":{"200":{"description":"The image.","headers":{"ETag":{"schema":{"type":"string","description":"Identifies the image and the format it is sent in."},"required":true,"description":"Identifies the image and the format it is sent in."},"Cache-Control":{"schema":{"type":"string"},"required":true},"Vary":{"schema":{"type":"string","description":"`Accept` whenever the format depends on it."},"required":false,"description":"`Accept` whenever the format depends on it."}},"content":{"image/webp":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}}}},"304":{"description":"Not modified — the client already has these bytes."},"404":{"description":"No such icon: the token is invalid, expired, or names bytes that have since been replaced.","content":{"application/problem+json":{"schema":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"}},"required":["type","title","status"]}}}}}}},"/api/v1/otlp/v1/logs":{"post":{"tags":["Collectors"],"security":[{"UsageKey":[]}],"summary":"Export coding-agent telemetry (OTLP/HTTP logs)","description":"The OpenTelemetry logs endpoint Claude Code and Codex export to, set up by\n`thymian collector install claude-code|codex`. OTLP/JSON only, uncompressed, at most\n5 MiB. Authenticated by a usage key (`X-API-Key`), whose source decides which records\nare read: `claude_code.api_request` for Claude Code, a completed `codex.sse_event` for\nCodex. Every other record is discarded unread. Of a Claude Code record, only\n`vcs.repository.name` is read beyond session, model, time and token counts: it names\na session no hint has reported, until one does.\n\nAnswers `{}` whenever the body parses, including when records were dropped, so an\nexporter does not resend. A resent batch is counted once. Over the key’s limit of\n20,000 requests per clock hour, counted on each API instance, the answer is 429 with\n`Retry-After` until the hour ends.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtlpLogsRequest"}}}},"responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtlpExportResponse"}}}},"400":{"description":"The body is not valid JSON.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"No usage key, or one that is not valid.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"The key’s owner is no longer a member of its organization, or the organization does not have the agent-usage feature.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"413":{"description":"The body is larger than 5 MiB.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"415":{"description":"The body is not OTLP/JSON.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"The body is not an OTLP logs request.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"The key is over its hourly limit; retry after `Retry-After` seconds.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"agent-usage"}},"/api/v1/downloads/cli/install/{ticket}":{"get":{"tags":["Downloads"],"summary":"Native CLI installer at its former address","description":"The same POSIX `sh` installer as `GET /downloads/cli-native/install/{ticket}`: it installs the native CLI, and fetching it does not redeem the ticket. It once streamed the Node.js CLI installer, which has ended.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128},"required":true,"name":"ticket","in":"path"}],"responses":{"200":{"description":"POSIX shell installer for the native CLI.","content":{"text/x-shellscript":{"schema":{"type":"string"}}}},"404":{"description":"The capability is unknown, expired, used, or unavailable."}}}},"/api/v1/downloads/cli-native/install/{ticket}":{"get":{"tags":["Downloads"],"summary":"Native CLI installer for Linux and macOS","description":"A POSIX `sh` script that detects the platform, downloads the matching binary through this ticket, and installs it as `timjami`. Fetching it does not redeem the ticket.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128},"required":true,"name":"ticket","in":"path"}],"responses":{"200":{"description":"POSIX shell installer for the native CLI.","content":{"text/x-shellscript":{"schema":{"type":"string"}}}},"404":{"description":"The capability is unknown, expired, used, or unavailable."}}}},"/api/v1/downloads/cli-native/install/{ticket}/powershell":{"get":{"tags":["Downloads"],"summary":"Native CLI installer for Windows","description":"A PowerShell script, for `irm … | iex`, that downloads the Windows binary through this ticket and installs it as `timjami.exe`. Fetching it does not redeem the ticket.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128},"required":true,"name":"ticket","in":"path"}],"responses":{"200":{"description":"PowerShell installer for the native CLI.","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"The capability is unknown, expired, used, or unavailable."}}}},"/api/v1/downloads/cli-native/install/{ticket}/{kind}":{"get":{"tags":["Downloads"],"summary":"Redeem a one-time native CLI download","description":"The binary the installer scripts download. Redeems the ticket: it answers once, then 404.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":128},"required":true,"name":"ticket","in":"path"},{"schema":{"type":"string","enum":["cli-linux-x86_64","cli-linux-aarch64","cli-macos-aarch64","cli-macos-x86_64","cli-windows-x86_64"]},"required":true,"name":"kind","in":"path"}],"responses":{"200":{"description":"The native CLI binary for this platform, streamed through Timjami.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true},"Content-Length":{"schema":{"type":"string"},"required":true},"X-Thymian-Version":{"schema":{"type":"string"},"required":true}},"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"The capability is unknown, expired, used, or unavailable."}}}},"/api/v1/downloads/firefox-extension/updates.json":{"get":{"tags":["Downloads"],"summary":"Firefox extension update manifest","responses":{"200":{"description":"Mozilla's update manifest for the self-distributed Firefox extension: the newest signed version, or no update when none is published yet.","content":{"application/json":{"schema":{"type":"object","properties":{"addons":{"type":"object","additionalProperties":{"type":"object","properties":{"updates":{"type":"array","items":{"type":"object","properties":{"version":{"type":"string"},"update_link":{"type":"string","format":"uri"},"applications":{"type":"object","properties":{"gecko":{"type":"object","properties":{"strict_min_version":{"type":"string"}},"required":["strict_min_version"]}},"required":["gecko"]}},"required":["version","update_link","applications"]}}},"required":["updates"]}}},"required":["addons"]}}}},"404":{"description":"Downloads are not configured."}}}},"/api/v1/downloads/firefox-extension/xpi/{tag}":{"get":{"tags":["Downloads"],"summary":"Download the signed Firefox extension","parameters":[{"schema":{"type":"string","pattern":"^(?:firefox-)?extension-v\\d+\\.\\d+\\.\\d+$"},"required":true,"name":"tag","in":"path"}],"responses":{"200":{"description":"The package Mozilla signed for this release.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true},"Content-Length":{"schema":{"type":"string"},"required":true},"X-Thymian-Version":{"schema":{"type":"string"},"required":true}},"content":{"application/x-xpinstall":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Downloads are not configured, or this release has no signed package or is older than the newest two that have one."}}}},"/api/v1/customer-portal/{organizationSlug}":{"get":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get the invited customer portal overview","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":255},"required":true,"name":"organizationSlug","in":"path"}],"responses":{"200":{"description":"Customer and currently active projects.","content":{"application/json":{"schema":{"type":"object","properties":{"customer":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"color":{"type":"string"}},"required":["id","name","color"]},"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"color":{"type":["string","null"]}},"required":["id","name","color"]}}},"required":["customer","projects"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customer-portal/{organizationSlug}/budgets":{"get":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get the shared budgets of the invited customer","description":"Hours only, for the current and the previous month in the organization time zone. Lists only budgets the organization shared whose every project the portal shows (ADR-2026-09-28-22UCR).","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":255},"required":true,"name":"organizationSlug","in":"path"}],"responses":{"200":{"description":"Shared budget consumption, oldest month first.","content":{"application/json":{"schema":{"type":"object","properties":{"months":{"type":"array","items":{"type":"object","properties":{"month":{"type":"string"},"budgets":{"type":"array","items":{"type":"object","properties":{"budgetId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"kind":{"type":"string","enum":["monthly","total"],"description":"`monthly`: the figures describe the row month. `total`: they describe the whole span, repeated in every month it covers."},"validFromMonth":{"type":"string","description":"First covered month, `YYYY-MM`."},"validUntilMonth":{"type":["string","null"],"description":"Last covered month, `YYYY-MM`; `null` is open-ended."},"budgetedSeconds":{"type":"integer","description":"The agreed hours, in seconds."},"adjustmentSeconds":{"type":"integer","description":"This month's adjustment in seconds; `0` when there is none."},"consumedSeconds":{"type":"integer"},"differenceSeconds":{"type":"integer","description":"Consumed minus the sum of agreed and adjustment; positive means over."},"consumedPercent":{"type":["integer","null"]},"projects":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"projectName":{"type":"string"},"consumedSeconds":{"type":"integer"}},"required":["projectId","projectName","consumedSeconds"]}}},"required":["budgetId","name","kind","validFromMonth","validUntilMonth","budgetedSeconds","adjustmentSeconds","consumedSeconds","differenceSeconds","consumedPercent","projects"]}}},"required":["month","budgets"]}}},"required":["months"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/sessions":{"get":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List every active session belonging to the current user","description":"Includes browser, native, CLI, and operator support sessions.\nAn impersonated support session cannot inspect the target account security inventory.","responses":{"200":{"description":"Every active session owned by the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSessions"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/sessions/{sessionId}":{"delete":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke one session belonging to the current user","description":"Uses the opaque inventory identifier. The reusable bearer credential is never exposed to another session.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"sessionId","in":"path"}],"responses":{"204":{"description":"The session was revoked."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/impersonation-session":{"delete":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"End the current support session","description":"Revokes only the impersonation session presented as a bearer token. The operator\nsession cookie and any other support session are never changed.","responses":{"204":{"description":"The current support session was revoked."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/google-provision":{"post":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Join the organization claimed by the signed-in Google Workspace domain","description":"Idempotent and session-only. It trusts the verified Google ID token hosted-domain claim, never an email suffix.","responses":{"200":{"description":"The provisioning outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleProvisionResult"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me":{"get":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":[]}],"summary":"Current user, preferences and effective display settings","description":"One call that gives a client everything it needs to render rates consistently.\n\n`effectiveRateDisplay` is resolved server-side (user override, else organization default) so that\nweb and native clients cannot disagree about which mode is in effect.\n\nUnlike the other routes here, this one does not require an active organization —\na freshly registered user has none yet and still needs to load the app.","responses":{"200":{"description":"The current user and settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/invite-code":{"post":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Redeem an invite code for a signed-in account without an organization","description":"Turns an admitted-but-unspent invitation into the same standing a code gives at\nsign-up: this account may then create one organization (invariant 52). Refused once\nthe account already belongs to an organization — a redemption would spend a use for\nnothing — or has already redeemed a code.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemInviteCodeRequest"}}}},"responses":{"204":{"description":"The code was redeemed."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/avatar":{"put":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Set your own avatar","description":"Replaces the avatar, or sets the first one. Idempotent, hence PUT.\n\nThe image must arrive ready to store: a **square** PNG or WebP within the\ndocumented size limits — identical rule to a customer or project icon. The\nserver neither crops nor resizes; the client does that\n(docs/decisions/0020-icons-are-normalised-in-the-browser.md).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutIcon"}}}},"responses":{"200":{"description":"The new avatar.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceIcon"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Remove your own avatar","description":"Idempotent: having no avatar already still answers 204.","responses":{"204":{"description":"No avatar is set."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/preferences":{"patch":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Update personal preferences","description":"Setting `rateDisplay` to `null` means \"follow the organization\". This changes presentation only — no stored rate is touched (ADR 0012). `dayTab` is stored only when it differs from the remembered one, and a support session’s `dayTab` is ignored (ADR-2026-11-02-15KRK).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserPreferences"}}}},"responses":{"200":{"description":"The updated preferences.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPreferences"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/assistant-tours":{"get":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Where you stand in Jami’s guided tours","description":"One entry per tour this account has been offered or has started. An `onboarding` with\nno entry has never been offered — the web app starts it on arrival, while the tour is\nenabled for the caller (`effectiveTourEnabled` on `GET /me`). A `menu-tabs` with no\nentry is never shown.\n\nPer account, not per organization (ADR-2026-09-28-12X97).","responses":{"200":{"description":"The tours this account has progress in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssistantTours"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Reached with an access token rather than a signed-in session.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/assistant-tours/{tour}":{"put":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Record where you stand in one of Jami’s tours","description":"`active` with a `step` starts, resumes or restarts the tour there; `completed` and\n`dismissed` end it. Idempotent, hence PUT. `offered` is not writable — only the\nserver offers a tour, when it becomes available to an organization that already has\nmembers.\n\nRefused in a support session: what the account has been shown is the account\nholder’s, not the support person’s.","parameters":[{"schema":{"type":"string","enum":["onboarding","menu-tabs"],"description":"Which of Jami’s tours this is: `onboarding`, the tour itself, or `menu-tabs`, his note that the main menu now groups pages as tabs.","example":"onboarding"},"required":true,"description":"Which of Jami’s tours this is: `onboarding`, the tour itself, or `menu-tabs`, his note that the main menu now groups pages as tabs.","name":"tour","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutAssistantTourProgress"}}}},"responses":{"200":{"description":"The recorded progress.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssistantTourProgress"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Reached with an access token rather than a signed-in session, or in a support session, which cannot move the account holder’s tours.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/me/watch-credential":{"post":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Replace the current user’s Apple Watch credential","responses":{"201":{"description":"The credential, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchCredential"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Me"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":[]}],"summary":"Revoke Apple Watch credentials","responses":{"204":{"description":"The credential or credentials were revoked."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/downloads/cli/install-tickets":{"post":{"tags":["Downloads"],"summary":"Create a one-time CLI installer URL","responses":{"201":{"description":"A ten-minute, single-use installer capability.","content":{"application/json":{"schema":{"type":"object","properties":{"installUrl":{"type":"string","format":"uri"},"expiresAt":{"type":"string","format":"date-time"}},"required":["installUrl","expiresAt"]}}}},"404":{"description":"CLI downloads are not configured."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/downloads/cli-native/install-tickets":{"post":{"tags":["Downloads"],"summary":"Create one-time installer URLs for the native CLI","description":"Mints the same ten-minute, single-use capability as `POST /downloads/cli/install-tickets` and answers two installer addresses for it: a POSIX `sh` script for Linux and macOS, and a PowerShell script for Windows. Fetching either script does not use the capability up; downloading the binary the script selects does.","responses":{"201":{"description":"A ten-minute, single-use installer capability, as two scripts.","content":{"application/json":{"schema":{"type":"object","properties":{"installUrl":{"type":"string","format":"uri"},"powershellUrl":{"type":"string","format":"uri"},"expiresAt":{"type":"string","format":"date-time"}},"required":["installUrl","powershellUrl","expiresAt"]}}}},"404":{"description":"CLI downloads are not configured."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/downloads":{"get":{"tags":["Downloads"],"summary":"List client downloads","responses":{"200":{"description":"The currently published CLI, macOS app and browser extension releases — the CLI as the Node.js bundle and as one native binary per platform, the extension in each of its Chrome, Firefox and Safari shapes.","content":{"application/json":{"schema":{"type":"object","properties":{"downloads":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["cli","cli-linux-x86_64","cli-linux-aarch64","cli-macos-aarch64","cli-macos-x86_64","cli-windows-x86_64","desktop","extension","firefox-extension","safari-extension"]},"version":{"type":"string"},"platform":{"type":"string"},"byteSize":{"type":"integer","minimum":0},"downloadUrl":{"type":"string"},"fileName":{"type":"string"}},"required":["kind","version","platform","byteSize","downloadUrl","fileName"]}}},"required":["downloads"]}}}},"404":{"description":"Downloads are not configured or no matching releases exist."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/downloads/{kind}":{"get":{"tags":["Downloads"],"summary":"Download a client","parameters":[{"schema":{"type":"string","enum":["cli","cli-linux-x86_64","cli-linux-aarch64","cli-macos-aarch64","cli-macos-x86_64","cli-windows-x86_64","desktop","extension","firefox-extension","safari-extension"]},"required":true,"name":"kind","in":"path"}],"responses":{"200":{"description":"Release asset bytes, streamed through Timjami.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true},"Content-Length":{"schema":{"type":"string"},"required":true},"X-Thymian-Version":{"schema":{"type":"string"},"required":true},"X-Thymian-Signature":{"schema":{"type":"string","description":"Present for the native CLI kinds (`cli-linux-x86_64`, …) only: the canonical base64 of the Ed25519 signature over the binary, its version and its kind, which `timjami update` verifies before writing anything."},"required":false,"description":"Present for the native CLI kinds (`cli-linux-x86_64`, …) only: the canonical base64 of the Ed25519 signature over the binary, its version and its kind, which `timjami update` verifies before writing anything."}},"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Downloads are not configured or this product has no release."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion}":{"get":{"tags":["Downloads"],"summary":"Desktop app update check (Tauri dynamic update server)","parameters":[{"schema":{"type":"string"},"required":true,"name":"target","in":"path"},{"schema":{"type":"string"},"required":true,"name":"arch","in":"path"},{"schema":{"type":"string"},"required":true,"name":"currentVersion","in":"path"},{"schema":{"type":"string","description":"Self-reported client preference (`stable` or `beta`), stored device-locally in the desktop app and never persisted server-side. Omitted or anything but exactly `beta` means `stable` — not rejected."},"required":false,"description":"Self-reported client preference (`stable` or `beta`), stored device-locally in the desktop app and never persisted server-side. Omitted or anything but exactly `beta` means `stable` — not rejected.","name":"x-thymian-update-channel","in":"header"}],"responses":{"200":{"description":"A newer desktop build is published.","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"string"},"notes":{"type":"string","minLength":1},"url":{"type":"string","format":"uri"},"signature":{"type":"string"}},"required":["version","notes","url","signature"]}}}},"204":{"description":"Nothing newer than the installed build."},"404":{"description":"Downloads are not configured, this platform is not built, or no release exists."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/downloads/desktop/updater-artifact/{tag}":{"get":{"tags":["Downloads"],"summary":"Download the desktop app updater artifact","parameters":[{"schema":{"type":"string"},"required":true,"name":"tag","in":"path"},{"schema":{"type":"string","description":"Must match the channel sent to the manifest request that named this tag (`stable` or `beta`) — a caller still on stable cannot download a beta-only artifact by tag. Omitted or anything but exactly `beta` means `stable`, same as the manifest endpoint."},"required":false,"description":"Must match the channel sent to the manifest request that named this tag (`stable` or `beta`) — a caller still on stable cannot download a beta-only artifact by tag. Omitted or anything but exactly `beta` means `stable`, same as the manifest endpoint.","name":"x-thymian-update-channel","in":"header"}],"responses":{"200":{"description":"The signed updater bundle, streamed through Timjami.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true},"Content-Length":{"schema":{"type":"string"},"required":true},"X-Thymian-Version":{"schema":{"type":"string"},"required":true}},"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Downloads are not configured or this release has no updater artifact."}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/changelog/{kind}/status":{"get":{"tags":["Changelog"],"summary":"Whether a product has unreleased changes","description":"A single boolean, so a client that only needs to know whether to mark its version label does not have to download every release. Only web can be true: it is the one product whose unreleased changesets are deployed with the API.","parameters":[{"schema":{"type":"string","enum":["cli","cli-native","desktop","extension","firefox-extension","safari-extension","web","ios"]},"required":true,"name":"kind","in":"path"}],"responses":{"200":{"description":"Whether the deployed build has changes not yet in a release.","content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["cli","cli-native","desktop","extension","firefox-extension","safari-extension","web","ios"]},"hasUnreleased":{"type":"boolean"}},"required":["kind","hasUnreleased"]}}}}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/changelog/{kind}":{"get":{"tags":["Changelog"],"summary":"A product's changelog","parameters":[{"schema":{"type":"string","enum":["cli","cli-native","desktop","extension","firefox-extension","safari-extension","web","ios"]},"required":true,"name":"kind","in":"path"}],"responses":{"200":{"description":"Every version this product's CHANGELOG.md records, newest first. Only web includes unreleased changesets already deployed in this build; unreleased is empty for every other product.","content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["cli","cli-native","desktop","extension","firefox-extension","safari-extension","web","ios"]},"entries":{"type":"array","items":{"type":"object","properties":{"version":{"type":"string"},"changes":{"type":"array","items":{"type":"object","properties":{"type":{"type":["string","null"],"enum":["major","minor","patch",null]},"sha":{"type":["string","null"]},"headline":{"type":["string","null"]},"note":{"type":"string"}},"required":["type","sha","headline","note"]}}},"required":["version","changes"]}},"unreleased":{"type":"array","items":{"type":"object","properties":{"type":{"type":["string","null"],"enum":["major","minor","patch",null]},"headline":{"type":["string","null"]},"note":{"type":"string"}},"required":["type","headline","note"]}}},"required":["kind","entries","unreleased"]}}}}},"security":[{"SessionCookie":[]},{"BearerAuth":[]}]}},"/api/v1/app-installations/check-in":{"post":{"tags":["App installations"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":[]}],"summary":"Report the running version of this app installation","deprecated":true,"description":"Deprecated: only older versions of the Timjami apps call this. It is removed once\nnone of them are in use any more.\n\nRecords one latest sighting for the authenticated account and pseudonymous\ninstallation id. The server never accepts a user or organization id from the client.\nA successful response says only that the sighting was stored, not that the app is\nknown to remain installed. See ADR-2026-08-24-15MW4.\n\nBesides a session, any OAuth or personal access token may check in, whatever its\nscopes. See ADR-2026-09-18-10DRH.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppInstallationCheckIn"}}}},"responses":{"204":{"description":"The latest installation sighting was stored."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/operator-security":{"get":{"tags":["Operator security"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List your operator passkeys and approval status","responses":{"200":{"description":"Own keys; never public keys or challenges.","content":{"application/json":{"schema":{"type":"object","properties":{"ready":{"type":"boolean"},"passkeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"approved":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","approved","createdAt"]}}},"required":["ready","passkeys"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many passkey challenges.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/operator-security/authentication-options":{"post":{"tags":["Operator security"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Request a one-use passkey confirmation for a specific operator action","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"action":{"type":"string","enum":["user.delete"]},"organizationId":{"type":"string","minLength":1,"maxLength":128},"userId":{"type":"string","minLength":1,"maxLength":128},"confirmation":{"type":"string","minLength":1,"maxLength":320}},"required":["action","organizationId","userId","confirmation"]},{"type":"object","properties":{"action":{"type":"string","enum":["organization.delete"]},"organizationId":{"type":"string","minLength":1,"maxLength":128},"confirmation":{"type":"string","minLength":1,"maxLength":320}},"required":["action","organizationId","confirmation"]},{"type":"object","properties":{"action":{"type":"string","enum":["user.impersonate"]},"userId":{"type":"string","minLength":1,"maxLength":128}},"required":["action","userId"]},{"type":"object","properties":{"action":{"type":"string","enum":["passkey.add"]},"name":{"type":"string","minLength":1,"maxLength":80}},"required":["action","name"]},{"type":"object","properties":{"action":{"type":"string","enum":["passkey.delete"]},"id":{"type":"string","format":"uuid"}},"required":["action","id"]}]}}}},"responses":{"200":{"description":"Session-bound challenge, expires after five minutes; user verification required.","content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"options":{"type":"object","properties":{"challenge":{"type":"string"},"rpId":{"type":"string"},"timeout":{"type":"number"},"userVerification":{"type":"string","enum":["required","preferred","discouraged"]},"allowCredentials":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["public-key"]}},"required":["id","type"]}}},"required":["challenge"]}},"required":["challengeId","options"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many passkey challenges.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/operator-security/registration-options":{"post":{"tags":["Operator security"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Prepare an operator passkey; an existing approved key must authorize additional keys","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"proof":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"authenticatorData":{"type":"string","minLength":1,"maxLength":32768},"signature":{"type":"string","minLength":1,"maxLength":32768},"userHandle":{"type":"string","maxLength":1024}},"required":["clientDataJSON","authenticatorData","signature"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["challengeId","response"]}},"required":["name"]}}}},"responses":{"200":{"description":"First key remains pending until manually approved.","content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"options":{"type":"object","properties":{"challenge":{"type":"string"},"rp":{"type":"object","properties":{"name":{"type":"string"},"id":{"type":"string"}},"required":["name"]},"user":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"displayName":{"type":"string"}},"required":["id","name","displayName"]},"pubKeyCredParams":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["public-key"]},"alg":{"type":"number"}},"required":["type","alg"]}},"timeout":{"type":"number"},"attestation":{"type":"string","enum":["none","indirect","direct","enterprise"]},"authenticatorSelection":{"type":"object","properties":{"residentKey":{"type":"string","enum":["discouraged","preferred","required"]},"requireResidentKey":{"type":"boolean"},"userVerification":{"type":"string","enum":["required","preferred","discouraged"]}}},"excludeCredentials":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["public-key"]}},"required":["id","type"]}}},"required":["challenge","rp","user","pubKeyCredParams"]}},"required":["challengeId","options"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many passkey challenges.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/operator-security/passkeys":{"post":{"tags":["Operator security"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Finish registering an operator passkey with required user verification","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":80},"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"attestationObject":{"type":"string","minLength":1,"maxLength":32768},"transports":{"type":"array","items":{"type":"string","enum":["ble","cable","hybrid","internal","nfc","smart-card","usb"]}}},"required":["clientDataJSON","attestationObject"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["name","challengeId","response"]}}}},"responses":{"204":{"description":"Passkey registered."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many passkey challenges.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/operator-security/passkeys/{id}":{"delete":{"tags":["Operator security"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Remove your operator passkey after fresh confirmation; keeps the last approved key","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"authenticatorData":{"type":"string","minLength":1,"maxLength":32768},"signature":{"type":"string","minLength":1,"maxLength":32768},"userHandle":{"type":"string","maxLength":1024}},"required":["clientDataJSON","authenticatorData","signature"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["challengeId","response"]}}}},"responses":{"204":{"description":"Passkey removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many passkey challenges.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/users/{userId}/impersonation-sessions":{"post":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Open a short-lived support session (Suora staff only)","description":"Requires a fresh, session-bound, user-verified operator passkey assertion for this\ntarget. Creates an independent Better Auth impersonation session without changing the\noperator session cookie. The returned token is valid for at most one hour and must\nbe sent as a bearer token. It is never repeated and the response is not cacheable.","parameters":[{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"proof":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"authenticatorData":{"type":"string","minLength":1,"maxLength":32768},"signature":{"type":"string","minLength":1,"maxLength":32768},"userHandle":{"type":"string","maxLength":1024}},"required":["clientDataJSON","authenticatorData","signature"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["challengeId","response"]}},"required":["proof"]}}}},"responses":{"201":{"description":"The new support session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonationSession"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/app-installations":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Recently reported app versions (Suora staff only)","description":"Current version sightings grouped by app and version. \"Recent\" means seen within\n30 days; silence never proves uninstall.\nReachable only by a platform operator and recorded in the access log.","responses":{"200":{"description":"The current app-version distribution.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformAppVersions"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/usage":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Feature usage per organization (Suora staff only)","description":"Per organization and usage area: `activity` areas count rows of the last\n30 days, `setup` areas the configuration live now, `held` areas\nthe rows held now. Counts per organization only, never a person and never money; an\narea whose feature is not in effect is listed in `notInEffect`. The summary in `areas`\ncounts an organization as using an area with activity in the window, with a dated\narea (an app, a calendar or Harvest connection, planning, hints) seen or changed in\nit, or with any other setup area configured now. An organization an operator excluded\nfrom the analytics is not listed and counts in no summary (ADR-2026-11-02-11QNV).\nReachable only by a platform operator and recorded in the access log.","responses":{"200":{"description":"Every organization, most recent time entry first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformUsage"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Every organization on this deployment (Suora staff only)","description":"Suora's own cross-organization admin view — metadata only, never money and never\na customer, project or task belonging to any organization. Reachable only by a\nperson granted `platform_operator` (ADR-2026-08-18-18HXT); every call is recorded\nin the platform admin access log.\n\nAn organization left out of the platform analytics is still listed, marked with\n`excludedFromAnalytics` (ADR-2026-11-02-11QNV).","responses":{"200":{"description":"Every organization, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformOrganizations"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/users/{userId}/push-logs":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"One person's recent push delivery logs (Suora staff only)","description":"A bounded, newest-first projection of this person’s structured APNs events from\nLoki. This is deliberately not a general LogQL proxy: the server fixes the stream\nselector, user filter, provider, time ranges and result limit. Reachable only by a\nperson granted `platform_operator`; every call is recorded in the platform admin\naccess log. See ADR-2026-08-21-075DF.","parameters":[{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"},{"schema":{"type":"string","enum":["24h","7d","30d"],"default":"7d","description":"How far back Loki should be queried.","example":"7d"},"required":false,"description":"How far back Loki should be queried.","name":"range","in":"query"}],"responses":{"200":{"description":"Recent structured APNs events, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformPushLogs"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Loki is temporarily unavailable.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations/{id}":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"One organization's detail (Suora staff only)","description":"The same metadata as the list, plus this organization's roster — accepted members\nand still-pending invitations alike, each row saying which it is — and the features\nit switched off for itself, without who or when (ADR-2026-10-31-12YXA). Still never\nmoney and never a customer, project or task belonging to the organization.\nReachable only by a person granted `platform_operator` (ADR-2026-08-18-18HXT);\nevery call is recorded in the platform admin access log.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The organization and its members.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformOrganizationDetail"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete an organization without members (operators only)","description":"Requires a fresh, session-bound, user-verified operator passkey assertion and the organization slug as confirmation. Refuses organizations with members, including concurrent joins. Permanently deletes remaining organization data and invitations. Every attempt is rate limited; successful deletion is audited atomically.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"proof":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"authenticatorData":{"type":"string","minLength":1,"maxLength":32768},"signature":{"type":"string","minLength":1,"maxLength":32768},"userHandle":{"type":"string","maxLength":1024}},"required":["clientDataJSON","authenticatorData","signature"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["challengeId","response"]},"confirmation":{"type":"string","minLength":1,"maxLength":320}},"required":["proof","confirmation"]}}}},"responses":{"204":{"description":"Organization and remaining data deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many deletion attempts.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations/{id}/features/{feature}":{"put":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Switch a feature on for one organization (operators only)","description":"Idempotent: switching on a feature that is already on answers the same. Every call is\nrecorded in the platform admin access log in the same transaction\n(ADR-2026-09-29-17JKR). Reachable only by a person granted `platform_operator`.\n\nA feature that requires another (`jami` requires `tour`) is stored without it, but\ntakes effect for the organization only once its prerequisite is on as well\n(ADR-2026-09-30-17KCB).\n\nSwitching `tour` on when it was off offers the tour to the organization’s members who\nhave no progress in it yet, in the same transaction: they already use the application,\nso they are asked rather than walked through it (ADR-2026-09-28-12X97).","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"$ref":"#/components/schemas/OperatorFeature"},"required":true,"description":"A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG).","name":"feature","in":"path"}],"responses":{"204":{"description":"The feature is on for this organization."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Switch a feature off for one organization (operators only)","description":"Idempotent. Leaves the data behind the feature in place, so switching it on again\nrestores access to all of it. Every call is recorded in the platform admin access log\nin the same transaction (ADR-2026-09-29-17JKR). Reachable only by a person granted\n`platform_operator`.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"$ref":"#/components/schemas/OperatorFeature"},"required":true,"description":"A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG).","name":"feature","in":"path"}],"responses":{"204":{"description":"The feature is off for this organization."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations/{id}/analytics-exclusion":{"put":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Leave one organization out of the platform analytics (operators only)","description":"From now on the organization counts neither on the usage page nor in the platform\nfigures: not its entries, its members’ accounts and installations, its MCP grants or\nits integrations. Nothing of the organization changes and its members notice nothing\n(ADR-2026-11-02-11QNV).\n\nIdempotent. Every call is recorded in the platform admin access log in the same\ntransaction. Reachable only by a person granted `platform_operator`.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"The organization is left out of the analytics."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Count one organization in the platform analytics again (operators only)","description":"Idempotent. The organization counts again from the next read, its past included,\nsince an exclusion only filters reads and deletes nothing (ADR-2026-11-02-11QNV).\nEvery call is recorded in the platform admin access log in the same transaction.\nReachable only by a person granted `platform_operator`.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"The organization counts in the analytics."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations/{id}/users/{userId}/features/{feature}":{"put":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Switch a feature on for one member of an organization (operators only)","description":"Adds to what the organization has: it changes nothing for anybody else, and neither\nthis grant nor the organization’s can take the other away (ADR-2026-11-03-12TFG). It\nonly lifts the feature gate; the member’s role and a credential’s scopes apply as\nbefore.\n\nIdempotent, and every call is recorded in the platform admin access log in the same\ntransaction. Answers 404 unless the account is an accepted member of that\norganization. Reachable only by a person granted `platform_operator`.\n\nA feature that requires another (`jami` requires `tour`) is stored without it, and\ntakes effect for the member once its prerequisite is on for them, whichever level\nthat comes from.\n\nSwitching `tour` on for the member when it was off offers them the tour, unless they\nalready have progress in it, in the same transaction.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"},{"schema":{"$ref":"#/components/schemas/OperatorFeature"},"required":true,"description":"A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG).","name":"feature","in":"path"}],"responses":{"204":{"description":"The feature is on for this member."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Switch a member’s own grant of a feature off (operators only)","description":"Idempotent. Takes away this member’s own grant and nothing else: a feature the\norganization has stays on for them, and the data behind it stays in place\n(ADR-2026-11-03-12TFG). Every call is recorded in the platform admin access log in the\nsame transaction. Answers 404 unless the account is an accepted member of that\norganization. Reachable only by a person granted `platform_operator`.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"},{"schema":{"$ref":"#/components/schemas/OperatorFeature"},"required":true,"description":"A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG).","name":"feature","in":"path"}],"responses":{"204":{"description":"This member’s own grant is gone."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/audit-log":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Who opened the admin view, and what they read (Suora staff only)","description":"The platform admin access log itself — every call any operator has made against\nthis admin view, newest first, capped at the most recent\n200 rows. Reachable only by a person granted\n`platform_operator` (ADR-2026-08-18-18HXT); reading this log is itself recorded\nin it, like every other admin read.","responses":{"200":{"description":"The most recent access log rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformAccessLog"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/users/{userId}/push-tokens":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"One person's currently registered push tokens (Suora staff only)","description":"Every visible iOS notification token, widget/complication push token, Live Activity\npush-to-start token and Live Activity update token currently registered for this person\n— read from the same\nper-user repositories `apps/api` already calls to send a push, across every\norganization a stale token might still name. A forgotten token stops existing\nrather than being marked inactive, so every row here is, by construction,\ncurrently registered. The related delivery history is exposed separately from\nLoki — see ADR-2026-08-21-075DF. Reachable only by a person\ngranted `platform_operator` (ADR-2026-08-18-18HXT); every call is recorded in the\nplatform admin access log.","parameters":[{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"}],"responses":{"200":{"description":"This person's currently registered tokens.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformPushTokens"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/releases":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Every product’s release/deploy status (Suora staff only)","description":"One row per product — web, iOS, CLI, both browser extension shapes, the desktop app and the Raycast extension. iOS and Raycast have no GitHub Release, so their \"version\"/\"tag\"/\"releaseUrl\" are read off the repository's own tag list instead, gated on `GITHUB_ACTIONS_TOKEN` alone — unlike `cli`/`desktop`/`extension`/`safariExtension`, whose `version`/`tag`/`releaseUrl` come from an actual GitHub Release and are `null` without `GITHUB_DOWNLOAD_TOKEN` instead. `web` carries no version at all — its currently deployed commit is read client-side from the web bundle's own build-time SHA instead. Either way, the same field is `null` when GitHub answers that lookup with an error; `releaseWorkflow` is `null` the same two ways for `GITHUB_ACTIONS_TOKEN` — independently, per field, never failing the whole route or hiding a field the other token can still answer (ADR-2026-08-24-11FS5). Each product also carries `activeReleaseWorkflows` (`activeDeployWorkflows` for web) — every run of that workflow still queued or in progress, since `main` merges close together can leave more than one deploy building at once; always `[]`, never `null`, in both degrade cases above. `web` additionally carries `pendingCiWorkflows`: `deploy.yml` only triggers once `CI` finishes, so a commit whose tests are still running has no `deploy.yml` run yet even though it will deploy automatically the moment CI goes green — this is every such `CI` run still queued or in progress for a push to `main` (never a pull request), and `blockingCiWorkflow`: the `CI` run that made the latest `deploy.yml` run skip, since that run itself never says why. Reachable only by `platform_operator` (ADR-2026-08-18-18HXT); every call is logged.","responses":{"200":{"description":"Every product’s current release/deploy status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformReleases"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/organizations/{id}/users/{userId}":{"delete":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete an organization member’s login account (operators only)","description":"Requires a fresh, session-bound, user-verified operator passkey assertion and the target email as confirmation. Preserves person and time history, revokes account credentials and sessions. Self-deletion, operator accounts and audit actors are protected. Every attempt is rate limited; successful deletion is audited atomically.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17"},"required":true,"description":"better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID.","name":"userId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"proof":{"type":"object","properties":{"challengeId":{"type":"string","format":"uuid"},"response":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":32768},"rawId":{"type":"string","minLength":1,"maxLength":32768},"type":{"type":"string","enum":["public-key"]},"clientExtensionResults":{"type":"object","properties":{}},"authenticatorAttachment":{"type":"string","enum":["platform","cross-platform"]},"response":{"type":"object","properties":{"clientDataJSON":{"type":"string","minLength":1,"maxLength":32768},"authenticatorData":{"type":"string","minLength":1,"maxLength":32768},"signature":{"type":"string","minLength":1,"maxLength":32768},"userHandle":{"type":"string","maxLength":1024}},"required":["clientDataJSON","authenticatorData","signature"]}},"required":["id","rawId","type","clientExtensionResults","response"]}},"required":["challengeId","response"]},"confirmation":{"type":"string","minLength":1,"maxLength":320}},"required":["proof","confirmation"]}}}},"responses":{"204":{"description":"Account deleted; historical person data retained."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many deletion attempts.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/access-requests":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The access-request queue (operators only)","description":"Every access request, newest pending first, then decided requests newest first (`access.listRequests()`’s own order). `inviteCode` names the code an approval issued, and stays `null` while a request is pending or was declined.","responses":{"200":{"description":"The access-request queue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccessRequestList"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/access-requests/{id}/approve":{"post":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Approve a pending access request (operators only)","description":"Issues a single-use invite code bound to the requester’s address and emails it to them, then returns the code so the operator can hand it over even if the email failed (`emailSent: false`). Refuses a request that is no longer pending, including a second approval of the same request. The body names the features the organization founded with the code starts with (ADR-2026-09-30-17KCB); it is required, and `[]` issues a code without any.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveAccessRequestRequest"}}}},"responses":{"200":{"description":"The code just issued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveAccessRequestResult"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/access-requests/{id}/decline":{"post":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Decline a pending access request (operators only)","description":"Refuses a request that is no longer pending, including a second decline.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The request was declined."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/invite-codes":{"get":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Every invite code, with its redemptions (operators only)","description":"`status` is derived, never stored: `revoked` once `revokedAt` is set, else `used-up` once `uses` reaches `maxUses`, else `expired` once `expiresAt` has passed, else `active`.","responses":{"200":{"description":"Every invite code, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminInviteCodeList"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Mint an invite code (operators only)","description":"Both single- and multi-use codes: `maxUses: 1` for a single-use code, higher for a multi-use one shared with several strangers (ADR-2026-09-24-11NJJ). `email`, when given, binds the code to one address. `expiresAt` must be in the future. `features` names the features the organization the code founds starts with (ADR-2026-09-30-17KCB); it is required, and `[]` issues a code without any.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteCodeRequest"}}}},"responses":{"201":{"description":"The invite code just minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminInviteCode"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/admin/invite-codes/{id}/revoke":{"post":{"tags":["Admin"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke an invite code (operators only)","description":"A code already revoked, or unknown, answers 404 either way.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The code was revoked."},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/mcp/consent":{"get":{"tags":["MCP"],"summary":"Describe a pending MCP authorization request","description":"What the approval page shows. Everything is read from the signed authorization request the authorization server sent the page, and nothing unsigned is believed, which is why the page can be trusted to name the client.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"The approval page’s `oauth_query` parameter: the authorization request, as the authorization server signed it."},"required":true,"description":"The approval page’s `oauth_query` parameter: the authorization request, as the authorization server signed it.","name":"oauthQuery","in":"query"}],"responses":{"200":{"description":"The pending request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpConsentRequest"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["MCP"],"summary":"Approve or deny an MCP authorization request","description":"Records which organization the client may act in and completes better-auth’s consent step. Answers the URI the browser must be sent to.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"oauthQuery":{"type":"string","minLength":1},"accept":{"type":"boolean"},"organizationId":{"type":"string","minLength":1},"scopes":{"type":"array","items":{"type":"string"},"minItems":1},"hintsReadEnabled":{"type":"boolean"},"budgetWriteEnabled":{"type":"boolean"},"moneyReadEnabled":{"type":"boolean"},"billingWriteEnabled":{"type":"boolean"}},"required":["oauthQuery","accept"]}}}},"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpConsentDecision"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/mcp/grants":{"get":{"tags":["MCP"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List your connected MCP clients","description":"Session only. Lists the concrete grants you approved, the scopes each token carries, and their independently withdrawable hint-read, budget-write, money-read and billing-write switches.","responses":{"200":{"description":"The grants, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/McpGrant"}}},"required":["data"]}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/mcp/grants/{id}":{"patch":{"tags":["MCP"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Change a live switch of a connected client","description":"Session only. Send one switch per request.\nEnabling budget writing requires current membership and the `budget.write` capability; enabling money reading, billing writes or hint reading requires current membership.\nDisabling is always available to the grant owner, even after membership was lost.\nNo switch changes the scopes already carried by a token.\nEnabling applies to the organization the grant names when the request is read; if the client was re-approved for another organization in the meantime, the request conflicts rather than enabling anything.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"budgetWriteEnabled":{"type":"boolean"}},"required":["budgetWriteEnabled"],"additionalProperties":false},{"type":"object","properties":{"moneyReadEnabled":{"type":"boolean"}},"required":["moneyReadEnabled"],"additionalProperties":false},{"type":"object","properties":{"billingWriteEnabled":{"type":"boolean"}},"required":["billingWriteEnabled"],"additionalProperties":false},{"type":"object","properties":{"hintsReadEnabled":{"type":"boolean"}},"required":["hintsReadEnabled"],"additionalProperties":false}]}}}},"responses":{"200":{"description":"The updated grant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpGrant"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customers":{"get":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List customers","description":"Customers of the active organization, alphabetical. Archived ones are excluded by default. Paginate with `cursor` set to the previous page’s `nextCursor`, unchanged; omit `limit` for every matching customer in one call.","parameters":[{"schema":{"type":"string","default":false},"required":false,"name":"includeArchived","in":"query"},{"schema":{"type":"string","description":"Case-insensitive substring match against the customer's name, code or aliases."},"required":false,"description":"Case-insensitive substring match against the customer's name, code or aliases.","name":"search","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged."},"required":false,"description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The customers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:customers.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Create a customer","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomer"}}}},"responses":{"201":{"description":"The created customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:customers.write"}},"/api/v1/customers/setup":{"post":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Set up a customer with its first project and billing","description":"Creates the customer and, as asked, its first project with tasks, people and rates, a recurring billing rule over all of its projects, and the Easybill mapping — in one transaction, so any refusal leaves nothing behind. Every refusal names the body field it is about (`customer.name`, `project.tasks.2.name`, `billing.responsiblePersonIds`).\n\n`billing` needs `invoice.write` and `easybill` needs `easybill.map`; without them the request is refused with 403 naming the field, before anything is written. The Easybill customer is read from Easybill before the transaction; when that read fails, everything else is still created and `easybill` in the answer says why the mapping is missing.\n\nYou are assigned to the project. A rate template copies the version of its rates in force today as one version from the beginning; what cannot be copied is listed in `droppedRateRules`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSetup"}}}},"responses":{"201":{"description":"What was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSetupResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customers/setup/templates/{projectId}":{"get":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read what setting a customer up like a project fills in","description":"The tasks, people and privacy „Einrichten wie …\" fills in from a project for `POST /customers/setup`. The people leave out former members — somebody whose membership ended or whose account was deleted — exactly as the setup leaves out their person rates when the project is its `rateTemplateProjectId`.\n\n404 for a project you cannot see. An archived project answers like any other.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"projectId","in":"path"}],"responses":{"200":{"description":"What the project fills in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomerSetupTemplate"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customers/suggested-colors":{"post":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:customers.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Apply a suggested colour to many customers at once","description":"For each customer: extracts colours from its icon and applies the best suggestion, unless the customer already has an explicitly chosen colour (`colorSource: \"manual\"`), which is skipped rather than overwritten.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchSuggestColors"}}}},"responses":{"200":{"description":"What happened, per customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchSuggestColorsResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:customers.write"}},"/api/v1/customers/{id}/suggested-colors":{"get":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Suggest colours from a customer's icon","description":"Ranked colours extracted from the customer's stored icon, best first. 404 when the customer has no icon; an unsupported icon format or one with no usable colour answers an empty `suggestions` list rather than a 404.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The ranked suggestions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ColorSuggestions"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/customers/{id}":{"get":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get a customer","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:customers.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Update a customer","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCustomer"}}}},"responses":{"200":{"description":"The updated customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:customers.write"}},"/api/v1/customers/{id}/period":{"post":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Close a tracking period for every project of a customer","description":"The same movement `POST /projects/{id}/period` makes, applied to every project of this customer in one transaction — archived ones included, because archiving governs what may be *booked*, never what may be billed. `projectIds` narrows it to some of them, still in one transaction: the projects a recurring invoice bills, when its rule covers only those. Projects already standing on the stated day are reported as `moved: false`, which is the ordinary case rather than a failure.\n\nIf **any** of them would move backwards, the whole movement is an opening: it needs `period.open` and a `reason`, and the reason is stored on every project that actually moved.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveCustomerPeriodClose"}}}},"responses":{"200":{"description":"What moved, per project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PeriodCloseResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customers/{id}/archive":{"post":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Archive or restore a customer","description":"Customers are archived rather than deleted, because their projects may carry tracked time that must stay attributable.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"boolean"}},"required":["archived"]}}}},"responses":{"200":{"description":"The customer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/customers/{id}/icon":{"put":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:customers.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Set a customer's icon","description":"Replaces the icon, or sets the first one. Idempotent, hence PUT.\n\nThe image must arrive ready to store: a **square** PNG or WebP within the\ndocumented size limits. The server neither crops nor resizes — the client does\nthat, so the person uploading sees exactly what will be stored\n(docs/decisions/0020-icons-are-normalised-in-the-browser.md).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutIcon"}}}},"responses":{"200":{"description":"The customer, with its new icon.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:customers.write"},"delete":{"tags":["Customers"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:customers.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Remove a customer's icon","description":"Idempotent: a customer that has no icon still answers 204. A 404 means the customer itself is unknown.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The customer has no icon."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:customers.write"}},"/api/v1/projects":{"get":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List projects","description":"Sorted by last activity: a running timer first, then the most recent tracked time, a project nobody has ever tracked time on last. `orderBy=customer` sorts by customer name and then project name instead, so a client can group the page by customer without a group ever being split across two pages. Paginate with `cursor` set to the previous page’s `nextCursor`, unchanged — a cursor belongs to the order that issued it.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","description":"Comma-separated customer ids. A single-value query param is ambiguous between \"one id\" and \"one id, repeated\" across HTTP clients, so this is always a delimited string rather than a repeated key."},"required":false,"description":"Comma-separated customer ids. A single-value query param is ambiguous between \"one id\" and \"one id, repeated\" across HTTP clients, so this is always a delimited string rather than a repeated key.","name":"customerIds","in":"query"},{"schema":{"type":"string","default":false},"required":false,"name":"includeArchived","in":"query"},{"schema":{"type":"string","default":false,"description":"Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it."},"required":false,"description":"Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it.","name":"includeUnbilled","in":"query"},{"schema":{"type":"string","enum":["public","private"]},"required":false,"name":"visibility","in":"query"},{"schema":{"type":"string","description":"Only projects with no members at all — invariant 51’s \"unbesetzt\"."},"required":false,"description":"Only projects with no members at all — invariant 51’s \"unbesetzt\".","name":"unassigned","in":"query"},{"schema":{"type":"string","description":"Only projects you are a member of."},"required":false,"description":"Only projects you are a member of.","name":"mine","in":"query"},{"schema":{"type":"string","description":"Case-insensitive substring match against the project’s own name/aliases or its customer’s name/aliases."},"required":false,"description":"Case-insensitive substring match against the project’s own name/aliases or its customer’s name/aliases.","name":"search","in":"query"},{"schema":{"type":"string","enum":["activity","name","customer"]},"required":false,"name":"orderBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged."},"required":false,"description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The projects.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ProjectListItem"}},"nextCursor":{"type":["string","null"]}},"required":["data","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Create a project","description":"`rules` is what an hour on this project is worth. Each rule names zero or more axes\nexactly — a task, a person, a budget — and carries a rate from a month; the most\nspecific one matching an entry decides, and work no rule matches is billable with no\nrate, which is reported rather than filled in.\n\nA rule cannot name a budget here: a project that has just been created is in none yet,\nso every budget rule would price nothing. Add the project to a budget first, then\n`PUT /projects/{id}/rate-rules`.\n\nOmit for none. Afterwards the list is replaced whole through\n`PUT /projects/{id}/rate-rules`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProject"}}}},"responses":{"201":{"description":"The created project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/setup":{"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Set up a project with its tasks, people and rates","description":"Creates a project for an existing customer with tasks, people and rates — in one transaction, so any refusal leaves nothing behind. Every refusal names the body field it is about (`customerId`, `project.name`, `project.tasks.2.name`).\n\nYou are assigned to the project. A rate template copies the version of its rates in force today as one version from the beginning; what cannot be copied is listed in `droppedRateRules`. A billing rule over all of the customer’s projects covers the new project too, as it does after `POST /projects`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectSetup"}}}},"responses":{"201":{"description":"What was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectSetupResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/projects/{id}":{"get":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get a project","description":"Carries the same money/activity aggregates the list screen shows — see\n`projectDetailSchema` — so the detail page needs no second, per-project resolver\n(invariant 1). The unbilled slice is added only with `includeUnbilled=true`.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","default":false,"description":"Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it."},"required":false,"description":"Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it.","name":"includeUnbilled","in":"query"}],"responses":{"200":{"description":"The project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectDetail"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Update a project","description":"Rules are **not** editable here, the way a budget’s adjustments are not editable\nthrough `PATCH /budgets/{id}`: use `PUT /projects/{id}/rate-rules`, so one form\nsaving a name can never silently rewrite what an hour is worth.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProject"}}}},"responses":{"200":{"description":"The updated project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/archive":{"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Archive or restore a project","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"boolean"}},"required":["archived"]}}}},"responses":{"200":{"description":"The project.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/period":{"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Close a tracking period, or open one again","description":"Moves this project’s `closedThroughDay`. Hours on a day at or below it take no new bookings, no corrections and no deletions — the day an entry’s `startedAt` falls in decides, in the organization’s time zone, so an entry that begins at 23:55 on the last closed day is inside the period whatever hour it ends in.\n\nForwards is a **close**: owner, admin or `billing`, no reason. Backwards — `null` included — is an **opening**: owner or admin, and `reason` is required (422 with `reasonRequired` without one). Every movement is logged with its actor and is readable at `GET /projects/{id}/period-changes`.\n\n**A timer still running inside the period refuses the close** (409, `runningEntryIds`): hours are not final while a clock is ticking in them. Stop it and close afterwards.\n\nOwners and admins may still correct a single entry inside a closed period per request, with `editReason` — the watermark is moved back only when broad access *is* the intent.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MovePeriodClose"}}}},"responses":{"200":{"description":"What moved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PeriodCloseResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/projects/{id}/period-changes":{"get":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Who closed and opened this project’s periods","description":"Every movement of `closedThroughDay`, newest first — actor, time, from, to, and the reason a backwards movement had to state. Append-only: a movement is never edited or deleted, the way a time entry’s revisions are not.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The movements, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PeriodCloseChange"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/projects/{id}/icon":{"put":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:project-icons.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Set a project's icon","description":"Replaces the icon, or sets the first one. Idempotent, hence PUT.\n\nThe response's `icon` is the project's *resolved* icon — this upload if it is\naccepted, its customer's otherwise (ADR-2026-08-18-13UMG). `iconInheritedFrom` says\nwhich.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutIcon"}}}},"responses":{"200":{"description":"The project, with its new icon.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:project-icons.write"},"delete":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:project-icons.write"]},{"OAuth2":["thymian:projects.write"]}],"summary":"Remove a project's icon","description":"Idempotent: a project with no icon still answers 204. Afterwards `GET`/`PATCH` on the project answer the customer's icon again, with `iconInheritedFrom` naming it (ADR-2026-08-18-13UMG).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The project has no icon."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:project-icons.write"}},"/api/v1/projects/{id}/members":{"get":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List project members","description":"Who is assigned to the project. An assignment is what puts the project in somebody’s\npickers; it does **not** decide what their hour is worth — that is a rule naming\nthem, so unassigning a colleague never reprices the hours they booked.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The project members.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ProjectMember"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"put":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Assign a person to a project","description":"Idempotent: assigning an already-assigned person returns the existing assignment. PUT\nrather than POST for that reason.\n\nAny person **of this organization** may be named, whether or not they still have access:\nsomebody a Harvest import created without a login, or a colleague whose membership has\nended, is assignable like anybody else. An assignment says whose hours belong on this\nproject, which is a statement about the work and not a grant — a person with no live\nmembership cannot access this organization, so it hands them nothing (ADR-2026-09-22-09MER).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertProjectMember"}}}},"responses":{"200":{"description":"The project member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectMember"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/members/{personId}":{"delete":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Remove a person from a project","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Person identifier (UUID v4) — a person, not a login.","name":"personId","in":"path"}],"responses":{"204":{"description":"The assignment was removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/rate-rules":{"get":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List a project’s billing rules","description":"Every version, least specific first then by month. Group by `validFromMonth` to get the\nversions: from a version’s month onwards only its own rules apply. A project with no\nrules prices nothing on it.\n\n`exposureByMonth` is what a change to a version would revalue — tracked seconds per\nmonth, and how many of them a live invoice claims. A version prices from its month\nforward, so summing from that month on is the figure the editor states before saving,\nand a non-zero invoiced sum is what `PUT` refuses for a member.\n\n`pricingBudgets` is what a rule may name on the budget axis: the budgets covering this\nproject, plus everything downstream of them by overflow — the pots its hours can\nactually land in. Naming anything else is refused, because such a rule prices nothing.\n\n`namedBudgets` is the other half: the pots this project’s rules already name that a *new*\nrule may no longer name, because they were archived or their chain no longer reaches here.\nSuch a rule stays stored and prices nothing, so it is carried here for its name alone —\na rule nobody can read is a rule nobody can decide to delete.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The project’s rules.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateRules"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"put":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Replace a project’s billing rules","description":"The whole list — every version — in one transaction. „Ab Oktober: Standard 100,\nDesign 120, Anna 130\" is one version somebody agreed, and a half-applied billing\nchange is exactly the state the rules exist to prevent. Sending everything is also\nwhat lets one request correct an old version and add a new one.\n\nA version is the set of rules sharing a `validFromMonth`, and it **replaces** its\npredecessor: from its month onwards only its own rules apply, so a rule it does not\nrestate has ended. That is how „ab Juni hat diese Aufgabe keinen Sonderpreis mehr\" is\nsaid at all.\n\nRules that survive keep their ids. Within one version, two rules neither of which is\na special case of the other — „Aufgabe = Design\" against „Person = Anna\" — are refused\nwith 422: there is no derivable answer for Anna’s Design hour, so the case has to be\nspelled out as a third rule naming both. Rules in *different* versions never conflict,\nbecause they never both apply.\n\nA rule may name a **budget**, which prices the hours the allocation put in that pot —\nand only a budget that can hold this project’s hours: the ones covering it, plus\nanything downstream of them by overflow (`pricingBudgets` on the `GET`). Anything else\nis 422, because such a rule would match nothing at all.\n\nNothing about a time entry is frozen until an invoice bills it, so editing the list\ndoes change what existing entries are worth — which is the point of the dating. A\nversion that has already priced hours **an invoice claims** is therefore refused with\n409 for a member, naming the way forward; the `owner` and `admin` roles pass, and their\ncorrection is listed by `GET /invoices/divergences`.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutRateRules"}}}},"responses":{"200":{"description":"The project’s rules, after the change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateRules"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/write-offs/preview":{"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What writing off this project’s open hours and allowance months would waive","description":"Writes nothing. Resolves, server-side, exactly the two things „Abzurechnen\" already\ncounts for this project — its open, finished time entries and its open, unclaimed\nmonthly allowance months — and previews each through the same `POST /write-offs/preview`\nand `POST /write-offs/allowance/preview` arithmetic unchanged. Either half is `null` when\nthe project has nothing open in that family within `from`/`until`.\n\nThe same refusals as the two previews it delegates to: a **running** entry never blocks\nthis (it is excluded from the resolved selection rather than refused), but **unpriced**\nseconds or an unpriced month’s share still 422, naming the project and task, exactly as\nthe id-based and month-based dialogs already refuse them — a project write-off claims no\nmore, and under no looser rule, than either already allows.\n\n404 for a project this organization does not have or may not see. 422 when the window has\nnothing open at all, entries and allowance months alike. 409 on the rare race where an\nallowance month is settled by somebody else between resolving candidates and pricing them.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewProjectWriteOff"}}}},"responses":{"200":{"description":"What this project’s open work would waive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectWriteOffCut"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/projects/{id}/write-offs":{"post":{"tags":["Projects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Write off this project’s open hours and allowance months","description":"Records that this project’s currently open time entries and monthly allowance months\nwill never be billed, and why. The cut and the refusals are the preview’s; this is that\nrequest with a reason attached.\n\nWrites each family through its own existing mechanism — `POST /write-offs` and\n`POST /write-offs/allowance` — in **two separate transactions**, entries first. On the\nrare race where the allowance half loses one that the preview could not have seen a\nmoment earlier, the entries half **stays written off** and the response is a 409 naming\nthe write-off id that already committed, so nothing is silently lost.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectWriteOff"}}}},"responses":{"201":{"description":"What was waived, per family.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedProjectWriteOff"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/tasks":{"get":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List tasks","description":"Visible, not necessarily bookable: a public project’s tasks are readable by\neverybody, but starting a timer or logging time against one still answers 403\nunless the caller is assigned to its project (or holds `project.writeAny`).\n\n`bookable=true` asks the narrower question up front — the same one\n`GET /tasks/recent` already answers for the Schnellstart band — so a picker can\nleave unbookable tasks out instead of offering them and reporting the 403 on submit.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","default":false},"required":false,"name":"includeArchived","in":"query"},{"schema":{"type":"string","default":false},"required":false,"name":"bookable","in":"query"}],"responses":{"200":{"description":"The tasks.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Task"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Create a task","description":"`isBillable: false` wins over every rule: entries on such a task are never billable,\nwhatever the project’s rules say.\n\nA rate *for* this task is a rule on its project naming it —\n`PUT /projects/{id}/rate-rules`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTask"}}}},"responses":{"201":{"description":"The created task.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/tasks/bookable":{"get":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List every task the caller may book, ordered by personal frecency","description":"The complete read model for a manual task picker: every live task the caller may\nbook, with customer/project context and resolved visuals in one response.\n\nRows are ordered by the caller’s decaying booking frequency, then recency. A task\nwith no contributing history remains present and falls back to deterministic\ncustomer, project and task name order. Clients keep this order while filtering\n`searchText` locally; the endpoint takes no search query and has no result limit.","responses":{"200":{"description":"Every task the caller may book, in picker order.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BookableTask"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/tasks/suggested-names":{"get":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Suggest names for new tasks from the names already in use","description":"The task names the organization’s projects already carry, the name most projects\ncarry first — what a form creating tasks offers as one-click chips.\n\nNames are compared case-insensitively and come back in their most common spelling,\nwith the value state (`isBillable`, `isProductive`) most tasks of that name carry.\nRanked by the number of distinct projects carrying the name, then by the most\nrecently created task with it, then by name.\n\nOnly tasks the caller can see, on live projects of live customers, and never a\ncatch-all task. No time entry is read: a name counts by where it is used, not by how\nmuch time it holds.","parameters":[{"schema":{"type":"string","maxLength":200,"description":"Only names starting with this text, case-insensitively. `%`, `_` and `\\` are taken literally."},"required":false,"description":"Only names starting with this text, case-insensitively. `%`, `_` and `\\` are taken literally.","name":"prefix","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Leave out every name this project already carries, archived tasks included — a project cannot hold a name twice.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Leave out every name this project already carries, archived tasks included — a project cannot hold a name twice.","name":"projectId","in":"query"},{"schema":{"type":"array","items":{"type":"string","maxLength":200},"maxItems":50,"description":"Names the caller already holds, compared case-insensitively; repeat the key for several. They are left out so `limit` new suggestions still come back."},"required":false,"description":"Names the caller already holds, compared case-insensitively; repeat the key for several. They are left out so `limit` new suggestions still come back.","name":"exclude","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":10,"default":3,"description":"How many suggestions to answer at most."},"required":false,"description":"How many suggestions to answer at most.","name":"limit","in":"query"}],"responses":{"200":{"description":"The suggestions, most common first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TaskNameSuggestion"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/tasks/recent":{"get":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List the tasks the caller books time on, most recent first","description":"What the day screen offers as one-click starts. Ordered by the start of the\ncaller's most recent entry on each task, descending.\n\nThe rate fields are the rate **a timer started now would freeze** — resolved from the\nproject’s rules by the same function `POST /time-entries/start` uses, not read from\nthe last entry. A task whose rules changed since it was last booked reports the new\nanswer.\n\nResolved against the **current** month of the organization’s zone, because that is the\nmonth a press right now would land in. A rule row that starts next month is therefore\nnot what a tile advertises today.\n\nAlways the caller's own entries: this describes what one person was working on.\nArchived tasks and archived projects are left out.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":365,"default":30},"required":false,"name":"days","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":24,"default":6},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The tasks, most recent first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RecentTask"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/tasks/{id}":{"get":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get a task","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The task.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Update a task","description":"Rates are **not** editable here — they live on the project as rules, so saving a name\ncan never silently rewrite what an hour is worth.\n\n`isBillable` is the one field that revalues existing hours: billability is undated and\ncomes from the task, so toggling it bills or unbills every one of that task’s\nun-invoiced entries, in every month at once. Once an invoice claims any of them it is\ntherefore refused with 409 for a member, whose way forward is a new task; the `owner`\nand `admin` roles pass, and their change is listed by `GET /invoices/divergences`.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTask"}}}},"responses":{"200":{"description":"The updated task.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"},"delete":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Delete a task","description":"Only a task nothing records history on: it answers **409** while tracked time, a monthly\nallowance or an invoice names it — including an entry that was booked and deleted again, which\nthe revision log still shows under the task. Archive such a task instead\n(ADR-2026-10-18-14CLD). Rate rules, hint rules and Harvest links that name the task go with\nit. Deleting a fixed-price agreement’s last task is what lets the agreement be deleted.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The task is deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/tasks/{id}/archive":{"post":{"tags":["Tasks"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Archive or restore a task","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"boolean"}},"required":["archived"]}}}},"responses":{"200":{"description":"The task.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:projects.write"}},"/api/v1/search":{"get":{"tags":["Search"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Search customers, projects and tasks","description":"Three separately-capped, separately-ordered lists — never one merged, ranked list, because nothing here computes a relevance score to rank by.\n\nSpace-separated terms are matched case-insensitively as substrings; every term must\noccur in the resource context. Customer and project aliases are included. Projects\nalso include their customer name/code, and tasks include their project name. Archived\nrows are included, distinguished by their own\n`archivedAt` — archiving governs what may be booked, never what may be named.\nWithin each list, live rows come before archived ones; a project or task under an\narchived customer or project counts as archived.\n\nA private project, and its tasks, are excluded for a viewer not assigned to it, the\nsame visibility `GET /projects` and `GET /tasks` already apply.","parameters":[{"schema":{"type":"string","minLength":2,"maxLength":200,"description":"Space-separated terms are matched case-insensitively as substrings against a resource and its context (customer code/name/aliases, project name/aliases and task name). Every term must match.","example":"acme"},"required":true,"description":"Space-separated terms are matched case-insensitively as substrings against a resource and its context (customer code/name/aliases, project name/aliases and task name). Every term must match.","name":"q","in":"query"}],"responses":{"200":{"description":"Matching customers, projects and tasks.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List time entries","description":"Newest first. `orderBy: 'startedAt'` (the default) sorts by `startedAt` then `id`; `orderBy: 'updatedAt'` sorts by `updatedAt` then `id` instead, regardless of when the hours fall — page from the top, compare `updatedAtPrecise` (never `updatedAt` itself, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes. `orderBy: 'updatedAt:asc'` is the same column, oldest write first, and its `nextCursor` is a durable watermark rather than a within-poll stopping point: keep the last one you received and pass it back as `cursor` on your next poll to read only what changed since, instead of paging from the top again. All three are best-effort sorts, not a linearizable change feed, so a write that commits later than an already-observed watermark but is stamped earlier can still be missed, and none of them surface deletions, which `GET /time-entries/deleted` covers separately. Paginate with `cursor` set to the previous page's `nextCursor`, unchanged, together with the same `orderBy` — the cursor names both sort columns, so rows sharing an instant are not skipped at a page boundary; offsets are unstable while a timer is running. Omitting `personId` returns the whole organization's entries, readable by every member; naming a *specific other person's* `personId` requires the `owner` or `admin` role.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role.","name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"taskId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to the projects a budget covers. Narrows projects only — a budget's own figure additionally counts billable, finished entries inside its month, which is `isBillable=true` plus `from`/`until`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Filter to the projects a budget covers. Narrows projects only — a budget's own figure additionally counts billable, finished entries inside its month, which is `isBillable=true` plus `from`/`until`.","name":"budgetId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string"},"required":false,"name":"isBillable","in":"query"},{"schema":{"type":"string","maxLength":120,"description":"Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems.","example":"TIM-199"},"required":false,"description":"Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems.","name":"externalKey","in":"query"},{"schema":{"type":"string","enum":["linear","jira","azure_devops","github","generic"],"description":"Only entries carrying a live reference in this ticket system."},"required":false,"description":"Only entries carrying a live reference in this ticket system.","name":"externalProvider","in":"query"},{"schema":{"type":"string","description":"true = only entries with at least one live reference, false = only entries with none."},"required":false,"description":"true = only entries with at least one live reference, false = only entries with none.","name":"hasExternalReference","in":"query"},{"schema":{"type":"string","description":"true = only running entries."},"required":false,"description":"true = only running entries.","name":"running","in":"query"},{"schema":{"type":"string","enum":["synced","pending"],"description":"Filter by Harvest sync state. 'synced' means Harvest holds the entry, whether Timjami exported it or imported it. 'pending' includes an entry never claimed and one whose claim is still in flight — neither has actually reached Harvest."},"required":false,"description":"Filter by Harvest sync state. 'synced' means Harvest holds the entry, whether Timjami exported it or imported it. 'pending' includes an entry never claimed and one whose claim is still in flight — neither has actually reached Harvest.","name":"harvestExport","in":"query"},{"schema":{"type":"string","enum":["startedAt","updatedAt","updatedAt:asc"],"default":"startedAt","description":"'startedAt' (the default) is the ordinary list, newest tracked time first. 'updatedAt' orders by when an entry was last written instead, regardless of when its hours fall, and issues a different `nextCursor` shape that only this mode accepts back. To sync this way: page from the top (no `cursor`) on every poll, compare `updatedAtPrecise` (not `updatedAt`, which is millisecond-rounded and can tie two writes), and reprocess every row equal to the last value the previous poll saw before stopping below it. 'updatedAt:asc' is the same column, oldest write first, with its own `nextCursor` shape again — but unlike the other two, that cursor is a durable watermark: page forward once to the end, keep the last `nextCursor` you received, and pass it back as `cursor` on your *next* poll (whenever that is) to read only what has changed since, without paging from the top again. This is still a best-effort sort, not a linearizable change feed: `updated_at` is stamped when a write starts rather than when it commits, so a slow transaction can commit a value below a watermark a caller has already moved past and be missed permanently — periodic full re-reads from the top remain the only way to close that gap, and hard-deleted entries never appear under any `updatedAt` order at all; watch `GET /time-entries/deleted` separately for deletions."},"required":false,"description":"'startedAt' (the default) is the ordinary list, newest tracked time first. 'updatedAt' orders by when an entry was last written instead, regardless of when its hours fall, and issues a different `nextCursor` shape that only this mode accepts back. To sync this way: page from the top (no `cursor`) on every poll, compare `updatedAtPrecise` (not `updatedAt`, which is millisecond-rounded and can tie two writes), and reprocess every row equal to the last value the previous poll saw before stopping below it. 'updatedAt:asc' is the same column, oldest write first, with its own `nextCursor` shape again — but unlike the other two, that cursor is a durable watermark: page forward once to the end, keep the last `nextCursor` you received, and pass it back as `cursor` on your *next* poll (whenever that is) to read only what has changed since, without paging from the top again. This is still a best-effort sort, not a linearizable change feed: `updated_at` is stamped when a write starts rather than when it commits, so a slow transaction can commit a value below a watermark a caller has already moved past and be missed permanently — periodic full re-reads from the top remain the only way to close that gap, and hard-deleted entries never appear under any `updatedAt` order at all; watch `GET /time-entries/deleted` separately for deletions.","name":"orderBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`."},"required":false,"description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`.","name":"cursor","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The time entries.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntryList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Log a finished time entry","description":"For entering time after the fact. Both `startedAt` and `endedAt` are required — to start a running timer use POST /time-entries/start.\n\nAn archived task — or one whose project or customer is archived — answers **409** with `archived` naming the level, however long ago the entry itself is dated.\n\nA day at or below the project’s `closedThroughDay` answers **409** with `periodClosed` naming the day and the watermark: the tracking period is closed and takes no new hours. Owners and admins may book anyway and then **must say why** (`editReason`, else **422** with `reasonRequired`). The day is the one `startedAt` falls in, in the organization’s time zone — an entry that begins at 23:55 on the last closed day is inside the period whatever hour it ends in.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTimeEntry"}}}},"responses":{"201":{"description":"The created entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/running":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get the running time entry","description":"The current user's running entry in the active organization, or `null`. A timer running in a *different* organization is reported as `null` here but still blocks starting a new one — see POST /time-entries/start.","responses":{"200":{"description":"The running entry, or null.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/TimeEntryWithContext"},{"type":"null"}]}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/last-stopped":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get the caller’s most recently stopped time entry","description":"The current user's own most recently *stopped* entry in the active organization, or `null` — what `timjami resume` restarts, on the task it names. Ordered by `endedAt`, not `startedAt`: the two agree for anyone who only ever presses start and stop in order, and disagree the moment a backdated entry is booked after something newer already stopped, which is exactly the case this endpoint exists to get right rather than approximate (ADR-2026-09-12-06L4N). A currently running entry is never returned here — see GET /time-entries/running.","responses":{"200":{"description":"The most recently stopped entry, or null.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"anyOf":[{"$ref":"#/components/schemas/TimeEntryWithContext"},{"type":"null"}]}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/recent-notes":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Suggest notes for starting a timer","description":"The caller's own most-used notes, most suggestible first — what the timer-start\nfield offers as one-click text (ADR-2026-09-04-08MT6).\n\nRanked by frecency over the trailing window: how often a note was used and how\nrecently, so a habit from weeks ago fades out rather than permanently outranking\nsomething typed every day this week. Never scored across colleagues — this is what\none person tends to write, the same scope `/tasks/recent` uses for what they tend to\nbook.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":365,"default":30},"required":false,"name":"days","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":10,"default":4},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"The suggested notes, most suggestible first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/start":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Start a timer","description":"Resolves the billable rate from the project/task/member configuration and **freezes it** on the entry.\nLater rate changes do not affect entries that already exist.\n\nA user may have at most one running timer across all organizations. If one is already running,\nthis returns 409 with `runningEntryId`; pass `stopRunning: true` to stop it and start the new one.\n\nAn archived task — or one whose project or customer is archived — answers **409** with\n`archived` naming the level. Archiving takes something out of the way for new time; the\nhours already on it keep their place.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartTimeEntry"}}}},"responses":{"201":{"description":"The started entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/{id}/stop":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Stop a running timer","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StopTimeEntry"}}}},"responses":{"200":{"description":"The stopped entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/merge":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Merge time entries into one","description":"Folds a chain of entries that describe one stretch of work — the stopped-and-immediately-restarted timer — into a single entry. The **earliest** entry survives and its end reaches forward to the last one's; the others are deleted, and any hint linked to them moves to the survivor. `startedAt` never moves, so a merge is always an extension.\n\nThe chain must be **one person, one task and one calendar day** (the organization's timezone), and the seams between the entries are absorbed into the merged entry — so a seam wider than 15 minutes answers **422** rather than recording time nobody worked. A running entry may only be the last of the chain, and the merged entry then goes on running.\n\n**409** where something outside Timjami already knows about these hours: an entry any invoice has ever claimed — including a withdrawn one, and for owner and admin too — or a Harvest export on anything but the first entry, because absorbing an exported entry would leave Harvest billing its hours twice.\n\nWithout `notes`, the entries' distinct notes are joined with ` · ` in clock order.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MergeTimeEntries"}}}},"responses":{"200":{"description":"The merged entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/insert":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Insert a finished entry, cutting the entries it overlaps","description":"Books a finished entry for the caller and cuts the caller's own entries it overlaps, in one transaction. For every overlapping entry with `keep: false`: one that contains the window is **split** — it ends at `startedAt`, and a new entry on its task, notes and ticket references runs from `endedAt` to its old end (running again if it was running); one that overlaps the window's start is **shortened**, one that overlaps its end is **moved** to start at `endedAt`, and one entirely inside is **deleted**. An entry with `keep: true` is left alone and overlaps the new one.\n\nThe piece that keeps an entry's id is the one whose start does not move, so a split running timer goes on under a new id.\n\n`affected` must name exactly the overlapping entries — any other set answers **409** with `overlapping`. **409** too for an entry to be cut that any invoice has ever claimed (for owner and admin too), and for one to be deleted that was exported to Harvest. **422** for a window across midnight, more than 20 overlapping entries, or a cut of the running timer at an instant still in the future. The new entry is a booking: an archived or unassigned task is refused as on `POST /time-entries`, and a closed day as on every write, with `editReason` for owner and admin.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsertTimeEntry"}}}},"responses":{"201":{"description":"The inserted entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/{id}/split":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Split a finished entry into consecutive parts","description":"Splits one of the caller's own finished entries into at least two consecutive parts, each on its own task, in one transaction. The parts must fill the entry exactly, or **422**.\n\nThe first part keeps the entry's id, note and ticket references. A later part on the entry's own task copies the note and the references; a later part on another task starts empty. Hints linked to the entry follow the part they overlap most.\n\n**403** for a colleague's entry. **422** for a running entry or one across midnight. **409** for an entry any invoice has ever claimed (for owner and admin too), and when the entry changed in the meantime. A part on another task is a booking: an archived or unassigned task is refused as on `POST /time-entries`, and a closed day as on every write, with `editReason` for owner and admin.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitTimeEntry"}}}},"responses":{"200":{"description":"The pieces, in clock order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SplitTimeEntryResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"entry-split","x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/insert-break":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Record a break, cutting the entries it overlaps","description":"Stores a break for the caller and cuts every one of the caller's own entries it overlaps, in one transaction — the cut `POST /time-entries/insert` makes, with a break instead of a new entry and no entry kept. A break is not tracked time; it answers the stretch the cut leaves.\n\n`affected` must name exactly the overlapping entries — any other set answers **409** with `overlapping`. **409** too for an entry to be cut that any invoice has ever claimed (for owner and admin too), and for one to be deleted that was exported to Harvest. **422** for a window across midnight, more than 20 overlapping entries, or a cut of the running timer at an instant still in the future. A closed day refuses the cut as on every write, with `editReason` for owner and admin.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsertBreak"}}}},"responses":{"201":{"description":"The recorded break.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BreakPeriod"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/retask":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Move several time entries onto one task","description":"Moves every named entry onto `taskId` — customer and project follow the task, and what the hours are worth is resolved again from the target project’s rate rules, because an entry stores no money.\n\nPer entry this is `PATCH /time-entries/{id}` with `taskId` and nothing else, refusal for refusal. What is asked **once**, about the target rather than the selection, refuses the whole request: **403** where the caller may not book on that task, **409** where the task, its project or its customer is archived — no override for anybody.\n\nEverything else is per entry and reported in `results`: `done` moved it, `unchanged` means it already sat on that task (nothing written, no revision), and `failed` carries the problem document the single-entry `PATCH` would have answered with — the same `claimed`, `periodClosed`, `backdatingLimit` and `reasonRequired` members a client already renders. One refusal never stops the run.\n\nA repeated id is written once. Order is the run order.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetaskTimeEntries"}}}},"responses":{"200":{"description":"What each named entry did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchTimeEntryResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/delete":{"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Delete several time entries","description":"Deletes every named entry, and like `DELETE /time-entries/{id}` there is no restore — only the `deleted` revision each one leaves behind, and the day it is missing from.\n\nPer entry, reported in `results`: `done` deleted it, `failed` carries the problem document the single-entry `DELETE` would have answered with. An entry a **live claim** holds is refused for everybody, owner and admin included, and an entry in a **closed** period needs `editReason` from a caller who may write through — 422 with `reasonRequired` for that entry alone. One refusal never stops the run.\n\nA repeated id is deleted once. `unchanged` is never answered here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteTimeEntries"}}}},"responses":{"200":{"description":"What each named entry did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchTimeEntryResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/policy":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"What a booking or a move would be allowed to do","description":"Resolves the freezes for a write that has not happened: booking on `taskId` at `startedAt`, or — with `entryId` — moving that entry there. `permission` is `free`, `reason` (allowed, and the save must carry `editReason`) or `locked`, and `blockers` says why in a shape a screen can render.\n\nIt informs and never grants: the write path decides again, so a period closed between this answer and the save still refuses.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The task the hours would be booked on.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"The task the hours would be booked on.","name":"taskId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"When they would start. The **day** this falls in is what decides.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"When they would start. The **day** this falls in is what decides.","name":"startedAt","in":"query"},{"schema":{"type":"string","format":"uuid","description":"An existing entry this write would move, if any — its own day and project are then asked as well, because moving hours *out of* a closed period is refused like moving them in.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"An existing entry this write would move, if any — its own day and project are then asked as well, because moving hours *out of* a closed period is refused like moving them in.","name":"entryId","in":"query"}],"responses":{"200":{"description":"What this write may do.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WritePolicy"}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/summary":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Total tracked seconds in a window","description":"Used for the \"today\" figure in the UI. Running entries are excluded because their duration is not yet fixed — the client adds the live timer itself. Defaults to the caller; naming a *specific other person's* `personId` requires the `owner` or `admin` role.","parameters":[{"schema":{"type":"string","format":"date-time"},"required":true,"name":"from","in":"query"},{"schema":{"type":"string","format":"date-time"},"required":true,"name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Defaults to the caller.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Defaults to the caller.","name":"personId","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The total.","content":{"application/json":{"schema":{"type":"object","properties":{"totalSeconds":{"type":"integer"}},"required":["totalSeconds"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/progress":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Tracked time against its target, and what it is worth","description":"The sidebar week meter’s figures, for any period: tracked seconds, the target\n(a working time model, or hoursPerDay × Mo–Fr, less absences while the organization\nhas them on), and the billable\namount — both for the whole `[from, until)` and again restricted to \"through\ntoday\" in the organization’s own timezone. Running entries are excluded by default,\nthe same rule `/time-entries/summary` follows — add the live timer yourself from\n`GET /time-entries/running`, or pass `includeRunning` to have this fold the caller’s\nown running entry in instead, valued once as of now (see `runningIncluded` on the\nresult).\n\nDefaults to the caller; naming a *specific other* person’s `personId` requires\nthe owner or admin role, the same gate `/time-entries/summary` uses.","parameters":[{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Defaults to the caller. Naming a specific *other* person requires the owner or admin role.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Defaults to the caller. Naming a specific *other* person requires the owner or admin role.","name":"personId","in":"query"},{"schema":{"type":"string","default":false,"description":"Fold the caller's own running entry into trackedSeconds/billableSeconds and the money figures, valued as of now — see runningIncluded on the result. A caller that folds its own live timer in already (the web sidebar meter) leaves this at its default."},"required":false,"description":"Fold the caller's own running entry into trackedSeconds/billableSeconds and the money figures, valued as of now — see runningIncluded on the result. A caller that folds its own live timer in already (the web sidebar meter) leaves this at its default.","name":"includeRunning","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The range’s progress.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeProgress"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/revisions":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"What changed in one month","description":"One month of the time entry audit log — every `created`, `updated` and `deleted` write recorded in it (ADR-2026-08-18-09LXJ’s log, ADR-2026-08-26-20V59). „Protokoll\" reads this beside `GET /absences/revisions` and shows the two as one chronology.\n\nThe month bounds **when a change was recorded**, resolved in the organization’s own timezone — never the day the hours sit on, which can be months earlier. Inside the month the answer is one **page**: newest first, `limit` rows, `nextCursor` where more follow — pass it back unchanged together with the same `month` and `userId`. An organization’s month of writes is unbounded, which is what the page is for.\n\n**Merging this with `GET /absences/revisions` is only honest down to the older of the two frontiers.** Each log pages on its own, so after one request each you hold the entry log back to one instant and the absence log back to another. Show the merged chronology down to the *newer* of those two boundaries and keep the rest until the other log has been paged that far — below it, one log is complete and the other is not, and a chronology missing every change of one kind looks like a month in which none happened.\n\n**The order is the answer’s own.** Rows arrive grouped by entry, oldest write first inside each group — the order the writes happened. That is what makes a change derivable: a revision holds the state its write *replaced*, so what it changed is the step to the next snapshot of the same entry. Sort by `recordedAt` for display and never for pairing.\n\n`heads` closes that derivation at the edge of the **page**: for each entry, the state its newest write on this page produced — the next revision’s snapshot where the entry was changed again later (which, for an entry seen on the page before, is that page’s oldest row for it), the live entry where it was not, and **nothing at all** where the entry was deleted, which needs no such state. So each page derives on its own and a client merges the results; splicing one entry’s chain across two pages would need the write order, which does not travel.\n\nWhose changes: your own by default, and naming a *specific other person’s* `userId` requires the `owner` or `admin` role. An owner or admin who names nobody reads the whole organization’s; a member who names nobody reads their own. Deliberately not widened by project visibility, and not by having been the person who made the change — a member may delete a colleague’s entry and may not read that entry’s history. An empty month answers `200` with empty lists.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`time.readOthers`).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`time.readOthers`).","name":"personId","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`."},"required":false,"description":"Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`.","name":"cursor","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"One page of the month’s revisions, grouped by entry in write order, plus one head per entry that has one.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeEntryLogEntry"}},"heads":{"type":"array","items":{"$ref":"#/components/schemas/TimeEntryLogHead"}},"nextCursor":{"type":["string","null"]}},"required":["data","heads","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-entries/export":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Download the filtered entries list as CSV","description":"The **whole filtered window**, not a page of it — the same filters `GET /time-entries` accepts (minus `orderBy`, `limit`, `cursor`, `running` and `harvestExport`, which the screen this mirrors does not filter on either), turned into a file rather than a screen. Applies no filter logic of its own: the file can never disagree with the list it was pressed from about which entries qualify.\n\nAbove 10000 entries the request is refused with 422 rather than silently truncated — narrow the period or the filters.\n\n`Datum`, `Von`, `Bis`, `Dauer (h)`, `Person`, `Kunde`, `Projekt`, `Task`, `Abrechenbar`, `Notiz`, and — only once the filter names exactly one person, the same gate the list’s own footer uses for money — `Satz` and `Betrag` (invariant 19: no organization-wide amount, on screen or in a file). Duration is decimal hours, chosen over `H:MM` for a file that exists to be summed and pivoted; an individual entry keeps its own second precision in `Von`/`Bis`. `Satz` and `Betrag` read `gemischt` or `ohne Satz` where the entry itself does — never a hyphen, which a later import would read as zero.\n\n`Tickets` is always the **last** column, so the two money columns keep their positions: the keys of the entry’s live ticket references, in attach order, joined by `; ` — empty for an entry carrying none. The column is left out while the organization has tickets switched off.\n\nSemicolon-delimited, UTF-8 with a byte-order mark — Excel’s German-locale default, not RFC 4180’s comma.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role.","name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"taskId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to the projects a budget covers. Narrows projects only, exactly as the list's own `budgetId` does.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Filter to the projects a budget covers. Narrows projects only, exactly as the list's own `budgetId` does.","name":"budgetId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string"},"required":false,"name":"isBillable","in":"query"},{"schema":{"type":"string","maxLength":120,"description":"Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems.","example":"TIM-199"},"required":false,"description":"Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems.","name":"externalKey","in":"query"},{"schema":{"type":"string","enum":["linear","jira","azure_devops","github","generic"],"description":"Only entries carrying a live reference in this ticket system."},"required":false,"description":"Only entries carrying a live reference in this ticket system.","name":"externalProvider","in":"query"},{"schema":{"type":"string","description":"true = only entries with at least one live reference, false = only entries with none."},"required":false,"description":"true = only entries with at least one live reference, false = only entries with none.","name":"hasExternalReference","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The filtered entries, one row per entry, oldest filter match first.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true}},"content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/time-entries/deleted":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Deleted entries in a window of days","description":"The entries somebody deleted out of `[from, until)`, oldest first — read off the audit log’s `deleted` revisions (ADR-2026-08-18-09LXJ’s log, ADR-2026-08-26-20V59). The day and the week draw these as dimmed rows where hours are missing, which is the question this answers: **which day is short**.\n\nThe window is matched against the snapshot’s own `startedAt` — the day the hours were booked on, never when somebody deleted them, which can be months later. So an entry moved from Monday to Tuesday and then deleted appears on Tuesday.\n\n**A merged-away entry is not here.** A merge deletes the entries it absorbs and records the survivor on each, precisely so a bare deletion does not read as „these hours vanished\" when they moved — so those are filtered out rather than reported as missing.\n\nDefaults to the caller; naming a *specific other person’s* `personId` requires the `owner` or `admin` role, the same gate the entries list uses. Reading somebody else’s deletions is deliberately **not** widened by project visibility, and not by having been the person who deleted them.\n\nNothing here can be changed: the entry is gone, its id names no row, and re-entering the hours is an ordinary `POST /time-entries` with every refusal intact. An empty window answers `200` with an empty list.","parameters":[{"schema":{"type":"string","format":"date-time"},"required":true,"name":"from","in":"query"},{"schema":{"type":"string","format":"date-time"},"required":true,"name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose deleted entries. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose deleted entries. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role.","name":"personId","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The deleted entries, oldest first by the snapshot’s own `startedAt`.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DeletedTimeEntry"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-entries/{id}":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get a time entry","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The time entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Update a time entry","description":"Times, notes and task can be corrected — an entry on an archived task, or on a project you are not assigned to, included, as long as the request does not *move* it onto such a task, which answers **409** (archived) or **403** (not assigned) the way booking on it does. Sending the entry’s current `taskId` unchanged is not a move. Giving a **running** entry an `endedAt` stops it, at that time — which is how \"I went to lunch at 12:00 and forgot to stop the timer\" is recorded in one step rather than by stopping now and correcting afterwards. `endedAt: null` turns a finished entry back into a running one. The rate snapshot cannot be edited — it is the record of what was agreed when the work happened (ADR 0010).\n\nAn entry an invoice is currently billing answers **409** for `startedAt`, `endedAt` and `taskId` — the facts its line was built from — the way deleting one already does. Its `notes` stay editable. Cancelling and deleting the invoice releases the hours and the correction alike. That **409** carries `claimed` — the kinds holding the seconds, `invoice`, `external` and/or `write-off`, in that order — so a client can tell it from the archived-task **409** above and name the state in its own words instead of printing this one.\n\nOwners and admins (`time.editBilled`) may change those facts anyway, and then **must say why**: without `editReason` the request is **422** naming that field and carrying `reasonRequired: true`. The reason is kept with the revision this write appends — visible in the entry’s history and beside the entry in `GET /invoices/divergences`, which is where the correction shows up.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTimeEntry"}}}},"responses":{"200":{"description":"The updated entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeEntry"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"},"delete":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Delete a time entry","description":"Deleting hours out of a **closed** tracking period is refused with **409** and `periodClosed`, the way booking them into one is: an emptied day changes the period as much as a filled one. Owners and admins may delete anyway and then must say why — `editReason`, a query parameter because a `DELETE` carries no body — else **422** with `reasonRequired`. The sentence is kept on the `deleted` revision, which is the last thing this entry’s history says.\n\nAn entry a live claim holds cannot be deleted by anybody (409), including an owner.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","maxLength":500,"description":"Why hours are being deleted out of a **closed** tracking period anyway. Required — 422 with `reasonRequired` — when the caller gets through only because they hold `time.editBilled`; ignored otherwise. Kept on the `deleted` revision."},"required":false,"description":"Why hours are being deleted out of a **closed** tracking period anyway. Required — 422 with `reasonRequired` — when the caller gets through only because they hold `time.editBilled`; ignored otherwise. Kept on the `deleted` revision.","name":"editReason","in":"query"}],"responses":{"204":{"description":"The entry was deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/{id}/revisions":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read a time entry's history","description":"Who changed this entry, when, and what stood there before — one row per write, oldest first (ADR-2026-08-18-09LXJ).\nThe snapshot on each revision is the state the action **replaced**; for `created` it is the initial values, and the *current* state is always the live entry.\nOrdered oldest first, and `position` — not `recordedAt` — is that order: `recordedAt` is the writing transaction’s clock, so a transaction that begins first and writes second carries the earlier timestamp (ADR-2026-08-18-22UND).\n\nReadable by the person the hours belong to and by owners and admins — deliberately **narrower** than the entry itself, which a colleague can read through project visibility: a history can show more than the entry does, an earlier note above all.\n\nThe history outlives the entry, so a deleted one still answers here. **404** covers everything else without distinguishing: a history that is not yours, an entry older than the log, and an id that names nothing.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The entry's revisions, oldest first — `position` counts from 0 in that order.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeEntryRevision"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-entries/{id}/references":{"get":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List the tickets attached to a time entry","description":"Live references only, oldest attachment first. A detached one is gone from this list the moment it is detached — the tombstone it leaves is what the entry’s history reads, not this.\n\nEvery member may read the references of an entry they can read, which is the same reach the entries list has (invariant 51). The list is also embedded in every `TimeEntryWithContext`, so a client rendering a list of entries never needs this endpoint per row.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The tickets on this entry.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExternalReference"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"tickets","x-thymian-scope":"thymian:read"},"post":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Attach a ticket to a time entry","description":"**Idempotent by `(provider, key)`.** Attaching what is already attached answers **200** with the reference that is there and writes nothing — two clicks on one link are one act. A new attachment answers **201**.\n\nAllowed on hours an invoice already holds and inside a closed tracking period, **without a reason**: a reference changes neither a second nor a rate nor a task, so nothing a freeze protects can move under it (invariants 17, 64 and 73). The authority is the authority to edit the entry, and nothing further.\n\nTimjami never calls the ticket system. `url` and `title` are stored exactly as supplied, the title is a label that is allowed to go stale, and the server checks the **form** of both — `https` and a single-token key — and deliberately not that `provider` agrees with the URL’s host: a self-hosted Jira or Azure DevOps Server lands on a host nothing here can enumerate.\n\n**While the organization has tickets switched off** this answers **202** with `{ \"attached\": false }` and stores nothing. A client released before the switch attaches its ticket right after starting a timer, and that start should read as it always did; the other ticket routes answer `403 feature-not-enabled` (`tickets`).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachExternalReference"}}}},"responses":{"200":{"description":"This ticket was already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalReference"}}}},"201":{"description":"The ticket was attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalReference"}}}},"202":{"description":"Tickets are switched off for this organization; nothing was stored.","content":{"application/json":{"schema":{"type":"object","properties":{"attached":{"type":"boolean","enum":[false]}},"required":["attached"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/time-entries/{id}/references/{referenceId}":{"delete":{"tags":["Time entries"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Detach a ticket from a time entry","description":"The row is **tombstoned**, never deleted: „who took this ticket off these hours, and when\" is exactly what the entry’s history is kept for, and the `reference.detached` revision names the row by id.\n\nDetaching one that is already detached is the same **404** as one that never existed. Like attaching, it answers to no freeze and owes no reason.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"referenceId","in":"path"}],"responses":{"204":{"description":"The ticket was detached."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"tickets","x-thymian-scope":"thymian:track"}},"/api/v1/timer":{"get":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Get the caller’s own timer snapshot","responses":{"200":{"description":"The current timer snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimerSnapshot"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/start":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Start or switch the caller’s own timer","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimerStart"}}}},"responses":{"200":{"description":"The updated timer snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimerSnapshot"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/stop":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Stop the caller’s own timer","description":"Stops the caller’s own running timer and answers the snapshot afterwards, with `stopped`: the entry this request ended, or `null` when nothing was running — which is a success, not an error. See ADR-2026-10-08-00B4A.","responses":{"200":{"description":"The updated timer snapshot and the stopped entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimerStopResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/forgotten-timer-push-tokens":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Opt this iPhone installation into forgotten-timer notifications","description":"Registers the ordinary APNs device token only after the person explicitly enables the feature in the iOS app. Re-registering the installation rotates its token. See ADR-2026-08-24-15KNJ.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IosNotificationPushTokenRegistration"}}}},"responses":{"200":{"description":"The opted-in installation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IosNotificationPushToken"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"},"delete":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Opt this iPhone installation out of forgotten-timer notifications","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IosNotificationPushTokenForget"}}}},"responses":{"204":{"description":"The installation, if registered, was forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/widget-push-tokens":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Register a widget or complication kind for its own WidgetKit push channel","description":"Registers, or re-registers, one kind of the caller’s own widgets or complications — `ThymianWidgets` and each of `ThymianWatchComplications`’ three kinds report separately, from `WidgetPushHandler.pushTokenDidChange`. Re-registering an already-known kind just updates its token and when it was last seen. See ADR-2026-08-18-14XZ6.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WidgetPushTokenRegistration"}}}},"responses":{"200":{"description":"The registered widget kind.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WidgetPushToken"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/live-activity-start-token":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Register the caller’s own Live Activity push-to-start token","description":"The per-install token that can start a new Live Activity by push, without the app running — `Activity.pushToStartTokenUpdates`. Re-registering replaces whatever was there before. See ADR-2026-08-19-093A3.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LiveActivityStartTokenRegistration"}}}},"responses":{"200":{"description":"The registered push-to-start token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LiveActivityStartToken"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"},"delete":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Forget the caller’s own Live Activity push-to-start token","description":"Called when the Live Activity preference is turned off, or on sign-out — so the server can never push-start a new activity for this person again. Idempotent.","responses":{"204":{"description":"The token, if any, was forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/live-activity-tokens":{"post":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Register one running Live Activity’s own update token","description":"Observed from `Activity.pushTokenUpdates` — dies with the activity it names. An upsert on `(userId, activityId)`, so re-observing the same activity just refreshes its token. See ADR-2026-08-19-093A3.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LiveActivityUpdateTokenRegistration"}}}},"responses":{"200":{"description":"The registered update token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LiveActivityUpdateToken"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/timer/live-activity-tokens/{activityId}":{"delete":{"tags":["Timer"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read","thymian:track"]}],"summary":"Forget one running Live Activity’s own update token","description":"Called when that activity ends locally — `LiveActivityService.endCurrent()` — so a stale token cannot receive a later update meant for a different activity. Idempotent.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"activityId","in":"path"}],"responses":{"204":{"description":"The token, if any, was forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:timer"}},"/api/v1/break-periods":{"get":{"tags":["Break periods"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List explicit breaks","description":"Defaults to the caller. Naming a *specific other person's* `personId` requires the `owner` or `admin` role — the same gate `GET /time-entries` applies, because a break is the answer to a gap that role can already see.","parameters":[{"schema":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"ISO-8601 timestamp with offset.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"ISO-8601 timestamp with offset.","name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose breaks. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose breaks. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role.","name":"personId","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The breaks.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BreakPeriod"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Break periods"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Record an explicit break","description":"A break answers a gap in the UI. It is not tracked or billable time.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["startedAt","endedAt"]}}}},"responses":{"201":{"description":"The recorded break.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BreakPeriod"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/break-periods/{id}":{"delete":{"tags":["Break periods"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Remove one of your breaks","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The break was removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/break-dismissals":{"get":{"tags":["Break periods"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List your dismissed break questions","description":"Stretches inside an entry that nothing observed and that you said were work — the dismissed „Pause …?“ question on the day. Always your own: the question is never asked on somebody else’s day.","parameters":[{"schema":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"ISO-8601 timestamp with offset.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"ISO-8601 timestamp with offset.","name":"until","in":"query"}],"responses":{"200":{"description":"The dismissals.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BreakDismissal"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Break periods"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Dismiss a break question","description":"Records that a stretch nothing observed was work, so the day stops asking whether it was a break. It changes no entry and no total.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"startedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"endedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"}},"required":["startedAt","endedAt"]}}}},"responses":{"201":{"description":"The recorded dismissal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BreakDismissal"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:track"}},"/api/v1/absences":{"get":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List absences","description":"Owners and admins see the whole organization's; a member sees only their own, whether or not they pass `personId`. Both ends of the window are inclusive, and an absence is returned when its span overlaps the window at all — a fortnight of leave belongs to every week it touches. `orderBy: 'fromDay'` (the default) is this unbounded window read, and `nextCursor` is always `null`. `orderBy: 'updatedAt'` is a sync order instead, ordering by when an absence was last written rather than by the days it covers: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means unbounded."},"required":false,"description":"Inclusive. Omitted means unbounded.","name":"fromDay","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Omitted means unbounded."},"required":false,"description":"Inclusive. Omitted means unbounded.","name":"untilDay","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose absences. A member may only name themselves; owners and admins may name anybody, and reading without it answers with the whole organization’s.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose absences. A member may only name themselves; owners and admins may name anybody, and reading without it answers with the whole organization’s.","name":"personId","in":"query"},{"schema":{"type":"string","enum":["fromDay","updatedAt"],"default":"fromDay","description":"'fromDay' (the default) is the ordinary window read — unbounded, oldest first, no `cursor`. 'updatedAt' is a sync order instead, ordering by when an absence was last written rather than by the days it covers: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes — an HR import or bulk correction touching several absences at once ties on `updatedAt` the same way a bulk time-entry edit does."},"required":false,"description":"'fromDay' (the default) is the ordinary window read — unbounded, oldest first, no `cursor`. 'updatedAt' is a sync order instead, ordering by when an absence was last written rather than by the days it covers: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes — an HR import or bulk correction touching several absences at once ties on `updatedAt` the same way a bulk time-entry edit does.","name":"orderBy","in":"query"},{"schema":{"type":"string","description":"Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which is never paginated."},"required":false,"description":"Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which is never paginated.","name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50,"description":"Only meaningful with `orderBy: 'updatedAt'`; ignored by the default order."},"required":false,"description":"Only meaningful with `orderBy: 'updatedAt'`; ignored by the default order.","name":"limit","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The absences.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AbsenceList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:read"},"post":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Record an absence","description":"Leave, illness or a public holiday. Not tracked or billable time: an absence contributes to no total anywhere, it only changes what a day is expected to hold. `personId` defaults to the caller; naming somebody else needs owner or admin.\n\nA `fromDay` further back than the organization records time for answers **409** with `backdatingLimit` naming the day and the first day still open. `fromDay` decides the whole absence, `untilDay` included. Owners and admins (`absence.editLocked`, session only) may record it anyway and then **must say why** — `editReason`, else **422** with `reasonRequired`. The sentence is kept on the `created` revision and read at `GET /absences/{id}/revisions`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAbsence"}}}},"responses":{"201":{"description":"The recorded absence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Absence"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:track"}},"/api/v1/absences/external/{source}/{externalId}":{"put":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Import an absence","description":"Records or corrects the absence an external system knows by `externalId` — one call an importer may make **as often as it likes**. **201** when it recorded a new one, **200** when it found and wrote the existing one, and the body is the absence either way, so the caller learns the id it may have lost.\n\nThe key is `(organization, source, externalId)`, the organization coming from the credential. That is what makes this safe to retry: a `POST /absences` whose response never arrived cannot be sent again — the second send is a 409 — while this one finds the row the first send wrote and answers with the same id.\n\n**The whole absence, not a patch.** Every field this does not carry is cleared, `secondsPerDay` and `note` included, because a mirror that can only add fields drifts from its source the first time somebody empties one over there. A request that would change nothing writes nothing and appends no revision, so polling does not fill the history with rows saying nobody changed anything.\n\n`userId` may move an absence to another person here, unlike `PATCH /absences/{id}`: the external id names one absence in the source system, which already knows whose it is. Naming anybody but yourself — and touching an absence that is already somebody else’s — needs owner or admin.\n\nSomebody who has left the organization can no longer be **newly** named — a fresh absence for them, or a move onto them, is **422** — but the absences they already have keep importing, corrections included. An absence outlives its subject’s membership, so refusing those would leave a source that still sends them stuck on a 422 nothing can clear.\n\nBehind the organization’s tracking deadline this answers **409** with `backdatingLimit` exactly as recording one by hand does, judged on `fromDay` — the day the absence sits on today **and** the day this write would move it to. Owners and admins holding `absence.editLocked` (session only) may write through it with an `editReason`, else **422** with `reasonRequired`; the sentence lands on the revision this write appends.","parameters":[{"schema":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","example":"personio","description":"The importing system’s slug. `manual` is what a person typing an absence in leaves behind, so an importer names itself: `personio`."},"required":true,"description":"The importing system’s slug. `manual` is what a person typing an absence in leaves behind, so an importer names itself: `personio`.","name":"source","in":"path"},{"schema":{"type":"string","minLength":1,"maxLength":200,"description":"That system’s own id for this absence. Percent-encode it if it is not URL-safe.","example":"personio-absence-4711"},"required":true,"description":"That system’s own id for this absence. Percent-encode it if it is not URL-safe.","name":"externalId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertAbsence"}}}},"responses":{"200":{"description":"The absence this source already had, as it now stands.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Absence"}}}},"201":{"description":"The absence this source did not have yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Absence"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:track"}},"/api/v1/absences/revisions":{"get":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"What changed in one month","description":"One month of the absence audit log — every `created`, `updated` and `deleted` write recorded in it (ADR-2026-08-26-14VZ4’s log, ADR-2026-08-26-20V59). „Protokoll\" reads this beside `GET /time-entries/revisions` and shows the two as one chronology.\n\nThe month bounds **when a change was recorded**, resolved in the organization’s own timezone — never the days the absence covers. Inside the month the answer is one **page**: newest first, `limit` rows, `nextCursor` where more follow — pass it back unchanged together with the same `month` and `userId`.\n\n**Merging this with `GET /time-entries/revisions` is only honest down to the older of the two frontiers.** Each log pages on its own, so show the merged chronology down to the *newer* of the two boundaries you hold and keep the rest until the other log has been paged that far. Below it one log is complete and the other is not, and a chronology missing every change of one kind looks like a month in which none happened.\n\n**The order is the answer’s own.** Rows arrive grouped by absence, oldest write first inside each group — the order the writes happened, which is what makes a change derivable: a revision holds the state its write *replaced*, so what it changed is the step to the next snapshot of the same absence. Sort by `recordedAt` for display and never for pairing.\n\n`heads` closes that derivation at the edge of the **page**: for each absence, the state its newest write on this page produced — the next revision’s snapshot where it was changed again later (which, for an absence seen on the page before, is that page’s oldest row for it), the live absence where it was not, and **nothing at all** where it was deleted, which needs no such state. So each page derives on its own and a client merges the results.\n\nWhose changes: your own by default, and naming a *specific other person’s* `userId` requires the `owner` or `admin` role (`absence.manageOthers`). An owner or admin who names nobody reads the whole organization’s; a member who names nobody reads their own. An empty month answers `200` with empty lists.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`absence.manageOthers`).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`absence.manageOthers`).","name":"personId","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`."},"required":false,"description":"Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`.","name":"cursor","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"One page of the month’s revisions, grouped by absence in write order, plus one head per absence that has one.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AbsenceLogEntry"}},"heads":{"type":"array","items":{"$ref":"#/components/schemas/AbsenceLogHead"}},"nextCursor":{"type":["string","null"]}},"required":["data","heads","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences"}},"/api/v1/absences/{id}/revisions":{"get":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read an absence's history","description":"Who changed this absence, when, and what stood there before — one row per write, oldest first (ADR-2026-08-26-14VZ4).\nThe snapshot on each revision is the state the action **replaced**; for `created` it is the initial values, and the *current* state is always the live absence.\nOrdered oldest first, and `position` — not `recordedAt` — is that order: `recordedAt` is the writing transaction’s clock, so a transaction that begins first and writes second carries the earlier timestamp.\n\nReadable by the person the absence is about and by owners and admins (`absence.manageOthers`) — the same authority that decides whose absences somebody may see at all.\n\nThe history outlives the absence, so a deleted one still answers here. **404** covers everything else without distinguishing: a history that is not yours, an absence older than the log, and an id that names nothing.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The absence's revisions, oldest first — `position` counts from 0 in that order.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AbsenceRevision"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences"}},"/api/v1/absences/{id}":{"patch":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Correct an absence","description":"A correction, not a move: neither the person nor the source can be changed, because either would make this row a different fact while keeping its id.\n\nRefused with **409** and `backdatingLimit` when the absence sits further back than the organization records time for **or** would be moved there — both, so an absence cannot be moved out of the frozen range either. Owners and admins (`absence.editLocked`, session only) may correct it anyway with an `editReason`, else **422** with `reasonRequired`; the sentence lands on the revision this write appends. A request that changes nothing is answered with the row.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAbsence"}}}},"responses":{"200":{"description":"The corrected absence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Absence"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:track"},"delete":{"tags":["Absences"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:track"]}],"summary":"Remove an absence","description":"Deleted rather than archived: nothing is attributable to an absence, so a removed one leaves no figure behind needing an explanation — but the `deleted` revision outlives it, and is the last thing this absence’s history says.\n\nRefused with **409** and `backdatingLimit` when the absence is further back than the organization records time for: removing one gives a demand back exactly as recording it took one away. Owners and admins (`absence.editLocked`, session only) may remove it anyway and then must say why — `editReason`, a query parameter because a `DELETE` carries no body — else **422** with `reasonRequired`.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","maxLength":500,"description":"Why an absence behind the organization’s tracking deadline is being removed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history."},"required":false,"description":"Why an absence behind the organization’s tracking deadline is being removed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history.","name":"editReason","in":"query"}],"responses":{"204":{"description":"The absence was removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:track"}},"/api/v1/absence-subjects":{"get":{"tags":["Absence Subjects"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"List absence subjects","description":"Everybody in the active organization an absence may be recorded against — the durable, machine-readable roster a mapping integration such as Zynkey reads on a schedule, distinct from `organization/list-members`, which is for the app’s own interactive membership screens and carries no pagination or stability contract.\n\n**Scope.** Requires `thymian:absence-subjects.read` and nothing wider: a token minted for this alone cannot read customers, projects, tasks, time, or money.\n\n**Pagination.** Keyset, newest-membership-first is not the order — the order is join order, oldest first, which never reshuffles as people are renamed. `limit` is 1–100, default 50. Pass the previous page’s `nextCursor` back unchanged to continue; `null` means the page was the last one. The set is walked to completion without skipping or repeating a person as long as nobody new joins mid-walk — a join during a walk is placed by its own membership instant, so it can only ever appear ahead of where the walk already is.\n\n**Completeness.** Every subject in the organization is returned on every walk, active or not — `status` says `deactivated` for a banned account rather than the row disappearing, so an integration is never left believing somebody it once mapped stopped existing.\n\n**Fields.** Only what identity mapping needs: a stable id, a display name, an email where one exists, a lifecycle status, and a `kind` reserved for a future login-less subject. No role, no image, no organization settings.\n\n**Rate limiting.** Governed by the same per-credential API rate limit as every other `/api/v1` route; no endpoint-specific limit applies.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"At most 100 per page."},"required":false,"description":"At most 100 per page.","name":"limit","in":"query"},{"schema":{"type":"string","description":"The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody even while people join the organization."},"required":false,"description":"The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody even while people join the organization.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of absence subjects, in stable join order.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AbsenceSubject"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the roster is exhausted."}},"required":["data","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"absences","x-thymian-scope":"thymian:absence-subjects.read"}},"/api/v1/persons":{"get":{"tags":["Persons"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:persons.read"]}],"summary":"List persons","description":"Everybody the active organization knows as a person — with or without a login, current or former — keyed by the same `personId` time entries, absences and reports carry. The durable, machine-readable roster an integration reads to map Timjami people onto its own.\n\n**Scope.** Requires `thymian:persons.read` and nothing wider: a token minted for this alone cannot read customers, projects, tasks, time, or money.\n\n**Pagination.** Keyset, in creation order, oldest first — never by name, so a rename does not move anybody between pages. `limit` is 1–100, default 50. Pass the previous page’s `nextCursor` back unchanged to continue; `null` means the page was the last one.\n\n**Completeness.** Without `status`, a walk to the end returns every person of the organization. Nobody disappears for losing access: `status` turns `inactive` instead, so an integration can tell a deactivation from a person it has never seen.\n\n**Rate limiting.** Governed by the same per-credential API rate limit as every other `/api/v1` route; no endpoint-specific limit applies.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"At most 100 per page."},"required":false,"description":"At most 100 per page.","name":"limit","in":"query"},{"schema":{"type":"string","description":"The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody."},"required":false,"description":"The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody.","name":"cursor","in":"query"},{"schema":{"type":"string","enum":["active","inactive"],"description":"Only persons with this `status`. Omit it to walk every person of the organization — the complete set a mapping integration compares against."},"required":false,"description":"Only persons with this `status`. Omit it to walk every person of the organization — the complete set a mapping integration compares against.","name":"status","in":"query"}],"responses":{"200":{"description":"One page of persons, in stable creation order.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Person"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page, or null when the roster is exhausted."}},"required":["data","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:persons.read"}},"/api/v1/work-schedules":{"get":{"tags":["Work schedules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List working time model versions","description":"Owners and admins see anybody's; a member sees only their own, whether or not they pass `personId`. `personIds` is the roster's bulk read — several people in one call — and names the subject a second way, so it is **exclusive** with `personId` and its deprecated `userId` alias: sending both is a 422 rather than one of the two being quietly ignored. Every stored version travels — resolving a day, a week or a range is client-side arithmetic over the whole list (`@thymian/core`'s `work-schedule.ts`), not a single 'current version'.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Whose versions. Only an owner or admin may read anybody’s; a member may only name themselves, and reading without it answers with their own.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose versions. Only an owner or admin may read anybody’s; a member may only name themselves, and reading without it answers with their own.","name":"personId","in":"query"},{"schema":{"type":"string","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33,9a2b4c6d-8e0f-4a1b-9c3d-5e7f9a1b3c5d","description":"Comma-separated person ids. For reading several people’s versions in one call — the team roster’s case. Exclusive with `personId` and its deprecated `userId` alias: sending both asks two different questions and is a 422. Needs the same permission the moment it names anybody but the caller. Every value must be a person id; an account id here is a 422 rather than a query against a `uuid` column."},"required":false,"description":"Comma-separated person ids. For reading several people’s versions in one call — the team roster’s case. Exclusive with `personId` and its deprecated `userId` alias: sending both asks two different questions and is a 422. Needs the same permission the moment it names anybody but the caller. Every value must be a person id; an account id here is a 422 rather than a query against a `uuid` column.","name":"personIds","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The versions.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkSchedule"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Work schedules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Start a new working time model version","description":"A new agreement, never a correction — `PATCH` is for a typo in an existing version. Ending an agreement (parental leave, somebody leaving) is a version whose seven days are all `0`, never a deleted row: there is no `validUntilDay`, so the absence of a version means full time.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkSchedule"}}}},"responses":{"201":{"description":"The new version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkSchedule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:organization.write"}},"/api/v1/work-schedules/{id}":{"patch":{"tags":["Work schedules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Correct a working time model version","description":"A correction, not a move: the person cannot be changed, because that would make this row a different fact while keeping its id. Re-validated as a whole row — changing `kind` without also restating the weekday columns is refused the same way an insert missing them would be.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkSchedule"}}}},"responses":{"200":{"description":"The corrected version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkSchedule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:organization.write"}},"/api/v1/capacity-agreements":{"get":{"tags":["Capacity agreements"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Every stored capacity agreement version","description":"Requires the `owner` or `admin` role. Oldest first, per person.\n\nEvery stored version rather than „the one in effect\": resolving a day, a week or a\nwindow needs the whole list to pick from, and a period something changed inside needs\nthe versions beginning before it too. A person with no version at all is absent here —\n„unklassifiziert\" is a state somebody can be in, not a row with null fields, and they\ncount in no figure until a version covers the day in question.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Only this person’s versions. Omitted, every person’s.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only this person’s versions. Omitted, every person’s.","name":"personId","in":"query"}],"responses":{"200":{"description":"The stored versions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapacityAgreements"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:read"},"post":{"tags":["Capacity agreements"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Record a new capacity agreement version","description":"Requires the `owner` or `admin` role.\n\nA new agreement, never a correction — `PATCH` is for a typo in an existing version.\nThere is no `validUntilDay`: the next version ends the previous one.\n\n`kind` picks which side is stated. `share` is a percentage of contracted time and\nfollows a contract change by itself, which is what „we sell all of you\" means. `fixed`\nis an absolute sold week and survives one, which is what „the two of us are one FTE\ntogether\" means — recorded as the agreement rather than as the percentage it currently\nworks out to, so nobody has to recompute it when a contract moves.\n\nEnding somebody’s participation is a `share` version at `\"0.00\"` from the day it ends,\nnever a deleted row: the periods they were present must keep counting them.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCapacityAgreement"}}}},"responses":{"201":{"description":"The stored version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapacityAgreement"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:organization.write"}},"/api/v1/capacity-agreements/{id}":{"patch":{"tags":["Capacity agreements"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Correct one stored capacity agreement version","description":"Requires the `owner` or `admin` role. For a mistyped share, a wrong start day or a note —\na capacity that *changed* on a date is a new version, not a correction of the old one.\n\nSwitching `kind` is an ordinary correction: send the new kind with its own side, and the\nother side is cleared for you. The merged row is what is checked, so a patch leaving the\ntwo sides contradicting each other is a 422 rather than a stored row nobody can read.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCapacityAgreement"}}}},"responses":{"200":{"description":"The corrected version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapacityAgreement"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:organization.write"},"delete":{"tags":["Capacity agreements"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Remove one capacity agreement version","description":"Requires the `owner` or `admin` role. Removes **one version**; the previous one then\nreaches forward, and removing the only version returns the person to „unklassifiziert\",\ncounting in no figure and named in the report’s `warnings`.\n\nThis is a correction path, not how somebody leaves. Ending a participation is a `share`\nversion at `\"0.00\"` from the day it ends — a delete removes the agreement from the past\nas well, so the periods the person was present stop counting them.\n\nDeleting the earliest version is the one delete that moves published history, and nothing\nprevents it: everything before the next version becomes unclassified.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The version is gone."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:organization.write"}},"/api/v1/invoices":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"List invoices","description":"Newest first. `orderBy: 'createdAt'` (the default) paginates with `createdBefore` set to the previous page's `nextCursor`. `orderBy: 'updatedAt'` orders by when an invoice was last written instead — a status change or an Easybill export moves a row without changing its `createdAt` — and paginates with `cursor` instead: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes; like the entries list this order is under, this is a best-effort sort, not a linearizable change feed. Lines are on the individual invoice, not here.","parameters":[{"schema":{"type":"string","enum":["draft","transferred","withdrawn"],"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","example":"draft"},"required":false,"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","name":"status","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","description":"Comma-separated invoice ids: only these. The drafts one request created are listed together this way (ADR-2026-10-07-119RC)."},"required":false,"description":"Comma-separated invoice ids: only these. The drafts one request created are listed together this way (ADR-2026-10-07-119RC).","name":"ids","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset cursor from nextCursor, for the default `orderBy: 'createdAt'`.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Keyset cursor from nextCursor, for the default `orderBy: 'createdAt'`.","name":"createdBefore","in":"query"},{"schema":{"type":"string","enum":["createdAt","updatedAt"],"default":"createdAt","description":"'createdAt' (the default) is the ordinary list, newest invoice first, paginated with `createdBefore`. 'updatedAt' orders by when an invoice was last written instead — a status change (`transfer()`, `withdraw()`) or an Easybill export moves a row without changing its `createdAt` — and is paginated with `cursor` instead: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes."},"required":false,"description":"'createdAt' (the default) is the ordinary list, newest invoice first, paginated with `createdBefore`. 'updatedAt' orders by when an invoice was last written instead — a status change (`transfer()`, `withdraw()`) or an Easybill export moves a row without changing its `createdAt` — and is paginated with `cursor` instead: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes.","name":"orderBy","in":"query"},{"schema":{"type":"string","description":"Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which pages on `createdBefore` instead."},"required":false,"description":"Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which pages on `createdBefore` instead.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The invoices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"},"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Create a draft invoice","description":"Groups the billable time of one customer in a period into lines and **claims** the\nseconds behind them: an interval of an entry is on at most one live invoice, enforced by\na gist exclusion constraint rather than by a check. A 409 means somebody else billed some\nof these minutes in between. Each claim also snapshots what it billed — the window, the\ntask, the person and the rate — so the document keeps its meaning if the entry is later\ncorrected.\n\nThe result is a draft — editable and deletable, and the place an invoice is checked.\nIt leaves Timjami either through `POST /invoices/{id}/export`, which transfers\nand exports it in one request, or through `POST /invoices/{id}/transfer` for an\norganization billing elsewhere. Nothing is ever issued from Timjami itself.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInvoice"}}}},"responses":{"201":{"description":"The created draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/status":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoice-status.read"]}],"summary":"An invoice's status, customer, period and transfer state — no amount","description":"The same list as `GET /invoices`, narrowed to what carries no money: `id`, `status`,\n`customer`, `periodStart`/`periodEnd`, `transferredAt`, `withdrawnAt`, `export`\n(provider and time, no document) and `transferredByHand`. Never `currency`,\n`subtotalMinor`, the export's `documentId`, or the `document` mirror (invariant 82).\n\n`periodFrom`/`periodUntil` filter by the invoice’s own billed period touching that\nwindow — \"in August\" means the period overlaps August, not that `transferredAt` fell\nin it, since a monthly invoice for the *previous* month routinely transfers in a\ndifferent month than the one it bills, and `transferredAt` is `null` for a draft.\n\nPaginated exactly like `GET /invoices`’s default order: newest `createdAt` first,\n`limit` (default 50, capped 200) and `createdBefore`.","parameters":[{"schema":{"type":"string","enum":["draft","transferred","withdrawn"],"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","example":"draft"},"required":false,"description":"'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched.","name":"status","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Only invoices whose own billed period touches this window — the period the invoice states, not when it was transferred. Omit for no lower bound.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Only invoices whose own billed period touches this window — the period the invoice states, not when it was transferred. Omit for no lower bound.","name":"periodFrom","in":"query"},{"schema":{"type":"string","format":"date-time","description":"The other end of the period-overlap window. Required together with periodFrom.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"The other end of the period-overlap window. Required together with periodFrom.","name":"periodUntil","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset cursor from nextCursor: newest invoice first, paginated by created time.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Keyset cursor from nextCursor: newest invoice first, paginated by created time.","name":"createdBefore","in":"query"}],"responses":{"200":{"description":"The invoices.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceStatusList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoice-status.read"}},"/api/v1/invoices/preview":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What an invoice for this selection would contain","description":"Writes nothing. Returns the lines that `POST /invoices` would store, computed by the\nsame code — plus what inside the period is *not* on them and why: a non-billable task,\nan hour no rule prices, or seconds another live invoice already claims.\n\nA POST rather than a GET because the selection is a document with a list of projects in\nit, not a handful of query parameters.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewInvoice"}}}},"responses":{"200":{"description":"The lines this selection would produce.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceDraft"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/invoices/recorded-outstanding":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What the invoices billed by hand still have open","description":"The hand path’s counterpart of `GET /invoices/easybill-outstanding`: one figure over the\ninvoices written in a program Timjami is not connected to, by what their authors\nrecorded (ADR 2026-10-08-13YT8). Without `customerId` that is the whole organization;\nwith it, every figure covers that customer's invoices alone.\n\nAn invoice counts as open while it is transferred by hand, never exported, has\n**something** recorded and no paid day. One with nothing recorded is not counted: it\nsays as little about being paid as about anything else, and counting it would leave every\ninvoice ever billed by hand standing as open. A withdrawn invoice is not counted.\n\nThe amounts are the invoices’ own frozen **net** totals (`subtotalMinor`) — Timjami\nmodels no VAT, so this is not what the customer owes gross. Integer cents are summed with\nno further rounding, and an invoice in another currency than the organization’s is\ncounted in `openCount` and `withheldCount` and left out of the amounts. `overdueMinor` is\nthe part whose recorded due day has passed in the organization’s zone.\n\nShown, never obeyed: nothing in Timjami reads this figure.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"}],"responses":{"200":{"description":"The outstanding total.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordedOutstanding"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/invoices/easybill-outstanding":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What Easybill still has outstanding","description":"One organization-wide figure over the Easybill document mirror: the **gross** amounts\nEasybill has not recorded as paid, with the overdue part of them as a subset\n(ADR-2026-09-20-18BC8). Read with `invoice.read`, like every other invoice route.\nWithout `customerId` that is the whole organization; with it, every figure below\ncovers that customer's invoices alone.\n\nA document counts as open in `finalized`, `sent`, `partiallyPaid` and `overdue` —\na `draft` is not yet an invoice anybody owes, and `paid`, `cancelled` and `deleted` are\nfinished. `archived` is not a state and excludes nothing: an archived invoice is still\npaid or not. Each open document contributes `amountMinor - paidAmountMinor`, so a part\npayment leaves the rest standing, and the states come from the same derivation the\ninvoice resource's `document.state` does, against today in the organization's zone.\n\n`overdueMinor` is read off the **due date**, not off the `overdue` state: that state is\none label chosen by precedence and `partiallyPaid` outranks it, so a document half paid\nand two months late is labelled „Teilweise bezahlt\" and is still late money. A label has\nto pick one word; this total is splitting money.\n\nThese are **Easybill’s figures, gross**, including the VAT Timjami never models, and\nthey are shown, never obeyed (ADR-2026-09-18-21LNB): nothing in Timjami reads this\nnumber, and no comparison with the invoices’ own `subtotalMinor` is drawn. There is no\nrounding — integer cents are summed — and no period: the question is \"what is open\".\n\nAn open document whose amount cannot be stated in the organization’s currency — none\nfrom Easybill yet, or a currency changed by hand over there — is counted in `openCount`\nand in `withheldCount`, and left out of the amounts, so a screen can say what the total\ndoes not cover rather than quietly understating it.\n\n`oldestFetchedAt` is the **least** `fetchedAt` among the documents counted: a total is\nonly as current as its stalest member. An invoice withdrawn in Timjami still counts\nwhile its document is open in Easybill — withdrawing does not reach Easybill (ADR-0061),\nand \"laut Easybill\" is the whole claim this endpoint makes.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Narrows every figure in the answer to the invoices of this customer. Omitted, the total covers the whole organization. The same narrowing `GET /invoices` takes, so a filtered table and the figure above it agree.","example":"018f3b0c-2f31-7c4a-9a1d-4f2c1b9e77aa"},"required":false,"description":"Narrows every figure in the answer to the invoices of this customer. Omitted, the total covers the whole organization. The same narrowing `GET /invoices` takes, so a filtered table and the figure above it agree.","name":"customerId","in":"query"}],"responses":{"200":{"description":"The outstanding total.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillOutstanding"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/invoices/unbilled":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"Hours that are finished, billable and on no live invoice","description":"What is still to be billed, one row per customer with its projects inside. The exact\ninverse of the `already-invoiced` exclusion — finished, billable **seconds** no live\ninvoice claims, so an entry extended after it was billed owes its tail here — with **no\nperiod**: the question is \"what is open\", not \"what happened in July\".\n\nRead by every member, like the other invoice routes and unlike `reports/hours-by-user`:\nthe view does not break down by person on purpose, so it is not the management question\nthat gating exists for (ADR 0039).\n\nAmounts round per (project, rate) group from whole seconds, the way an invoice line does\n(invariant 16) — not per entry like the week view. Rates are resolved per month from the\nproject's dated rules, so a project repriced in September produces two figures for one\ntask rather than an average. Billable hours no rule prices travel as `unratedSeconds` and\nare never part of `amountMinor`. Archived customers and projects stay listed while they\nstill owe hours; running timers are absent.\n\n`total` is everything open over every customer, summed from the rows already rounded —\nso a client states what is outstanding without adding the rows up itself, and cannot\narrive at a figure a cent away from them.\n\n`includeCurrentMonth=false` is the one narrowing there is: it counts only entries that\nstarted before the running month, which is what somebody billing monthly is asking\nabout — nothing tracked this month is due yet. The boundary is a calendar one in the\norganization's zone, so it cuts where the rate resolution's own months cut, and the\nanswer states what it counted in `throughMonth` rather than leaving a client to derive\nit (ADR-2026-09-07-15TM4). Everything else is unchanged: still no lower bound, the same\nrounding, the same rollups.\n\nFixed-price items („Posten\") no live invoice holds are open too, from the moment they\nexist: each level states `fixedPriceItemCount` and `fixedPriceAmountMinor`, with the\nnot yet completed part named as `upcomingFixedPrice…`. An item has no seconds, so these\nfigures stand **beside** `amountMinor` and are never part of it, and an item in another\ncurrency is only counted (`otherCurrencyFixedPriceItemCount`), never summed\n(ADR-2026-10-18-07ZA3). `includeCurrentMonth=false` also leaves out items not completed\nby the end of `throughMonth`.","parameters":[{"schema":{"type":"string","default":"true","description":"false counts only entries that started before the running month — the reading a monthly biller wants, since nothing in the current month is due yet."},"required":false,"description":"false counts only entries that started before the running month — the reading a monthly biller wants, since nothing in the current month is due yet.","name":"includeCurrentMonth","in":"query"}],"responses":{"200":{"description":"Everything still to be billed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnbilledSummary"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/invoices/divergences":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Live claims that no longer match the time underneath them","description":"What an invoice billed, beside what its entries now say. A time entry is also the\nworking-time record, so it has to stay correctable after a document went out — the\n`owner` and `admin` roles may move times an invoice is billing, may change a task’s\nbillability, and may correct a rate-rule version that already priced invoiced hours.\nThis is the counterpart that makes each of those a reviewed state rather than a silent\ndrift.\n\nIt **reports and never repairs**. What the customer was billed is whatever the frozen\nline says; resolving a divergence means withdrawing and rebuilding the invoice, or accepting it.\n\n`kinds` names what disagrees: the window, the task, the person, the **rate** — the entry\nwould be valued differently today — or `over-billed`, a claim reaching past the entry’s\ncurrent duration because it was shortened after being billed.\n\n`invoiceId` asks the same comparison of **one invoice** and is what a notice band on that\ninvoice reads: the organization-wide set grows without bound, but one document’s claims\nare bounded by its own lines, so a narrowed request is normally a complete answer rather\nthan the newest page of everything. It narrows the scan, not the rows — an invoice whose\nclaims are older than one page is compared, not skipped — and answers **404** for an\ninvoice that does not exist, so a wrong id cannot read as „nothing has drifted\".\n\nNewest first and **bounded per request**, because a live claim is never cleaned up: the\nset grows with the whole invoicing history while the divergences in it stay a handful.\n`nextCursor` names the last claim *examined* — not the last one reported, since a page\nmay find nothing and still have older claims behind it — and `null` is the only thing\nthat means „every live claim was compared\". An empty `data` beside a non-null\n`nextCursor` means „nothing found in what was looked at\", never „nothing has drifted\";\npass the cursor back, with the same `limit`, until it comes back `null` to check them all.\n\n`scanned` is how many live claims this request compared — a page that reports nothing\nstill examined a page of them — and `totalClaims` how many a whole sweep would examine,\ncounted once on the first request (no `cursor`) and `null` on every continuation. Both\nare for a progress display and nothing else: claims are written and released while a\nsweep runs, so the end of one is `nextCursor: null`, never a counter reaching a total.\n\nSession-only: a leaked credential must not be able to learn what it is getting away with.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":2000,"description":"How many live claims to compare in this request. Defaults to the full per-request bound; a smaller page is what a client sweeping the whole history uses so its progress display moves.","example":500},"required":false,"description":"How many live claims to compare in this request. Defaults to the full per-request bound; a smaller page is what a client sweeping the whole history uses so its progress display moves.","name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged. It names the entry's instant, the entry, **and** the claim, so two claims on one entry are not skipped at a page boundary."},"required":false,"description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged. It names the entry's instant, the entry, **and** the claim, so two claims on one entry are not skipped at a page boundary.","name":"cursor","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Compare only the claims of this invoice. The organization-wide scan grows with the whole invoicing history and is therefore walked page by page; one invoice’s claims are bounded by its own lines, so a narrowed request is normally the complete answer — `nextCursor` still says whether it was. **404** when no such invoice exists, so a wrong id cannot read as „nothing has drifted\".","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Compare only the claims of this invoice. The organization-wide scan grows with the whole invoicing history and is therefore walked page by page; one invoice’s claims are bounded by its own lines, so a narrowed request is normally the complete answer — `nextCursor` still says whether it was. **404** when no such invoice exists, so a wrong id cannot read as „nothing has drifted\".","name":"invoiceId","in":"query"}],"responses":{"200":{"description":"Every live claim that disagrees with its entry. Empty is the normal answer.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Divergence"}},"nextCursor":{"type":["string","null"]},"scanned":{"type":"integer"},"totalClaims":{"type":["integer","null"]}},"required":["data","nextCursor","scanned","totalClaims"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/invoices/allowance-divergences":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Billed allowance months whose agreement has since changed","description":"What an invoice froze for a monthly time allowance („Pauschale\"), beside what the\nagreement behind it says today. The sibling of `GET /invoices/divergences`, for the\nunit that is a **month** rather than a stretch of an entry.\n\nIt exists because the two sides are read differently: an invoice freezes the month’s\nseconds, while budget consumption and Kennzahlen revenue derive the month from the\nagreement and its adjustments **live**. So correcting a Pauschale, entering a delta for\na month already billed, or superseding an agreement from an earlier month leaves the\ndocument and every figure beside it disagreeing — and until this endpoint existed,\nnothing said so.\n\nIt **reports and never repairs**, exactly like its sibling. The answer to a row is to\nwithdraw and rebuild the invoice — withdrawing releases the month, so rebuilding it is a\nreal option — or to accept it.\n\n`kinds` names what disagrees: `seconds` for a month whose value moved, `outside-span` for\none the agreement no longer covers at all. The two never appear together, since outside\nthe span there is no figure left to disagree with. **Rate and currency are not compared**\n— that needs a month’s current rate per pot, which is a separate question.\n\nAnswered **whole rather than paged**, unlike its sibling: the live set is bounded by\nagreements × months, so there is no cursor. `scanned` is how many live allowance months\nwere compared and `truncated` says whether a ceiling was reached — `false` is the only\nthing that means „every billed month was compared\", and it is the normal answer.\n\n`invoiceId` asks the same comparison of **one invoice**, which is what a notice band on\nthat invoice reads, and answers **404** for an invoice that does not exist so a wrong id\ncannot read as „nothing has drifted\".\n\nSession-only: a leaked credential must not be able to learn what it is getting away with.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Compare only the allowance months of this invoice. **404** for an invoice that does not exist, so a wrong id cannot read as „nothing has drifted\".","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Compare only the allowance months of this invoice. **404** for an invoice that does not exist, so a wrong id cannot read as „nothing has drifted\".","name":"invoiceId","in":"query"}],"responses":{"200":{"description":"Every live allowance month that disagrees with its agreement. Empty is the normal answer.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AllowanceDivergence"}},"scanned":{"type":"integer"},"truncated":{"type":"boolean"}},"required":["data","scanned","truncated"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/invoices/externally-billed":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"Hours another system has billed, grouped by its documents","description":"What was billed outside Timjami, read back out of the `external` claims the Harvest\nimport writes — today the only source of one. Two rollups of the same seconds in one\nresponse: `documents`, one row per document the source named, and `customers`, the same\nhours by customer and project. Both are sums of one set of already-rounded\n(document, project, rate) groups, so the two readings agree to the cent.\n\n**A row is not the invoice.** Harvest states an id and a number on a time entry and\nnothing else — no total, no issue date, no state — so a row is \"the hours carrying that\nnumber\", and `amountMinor` is reconstructed from rate times seconds. It will not match\nthe document: the other tool rounds its own way, and Pauschalen, Auslagen, discounts and\ntax are not hours and are absent here entirely.\n\n**`documentRef: null` is the group to look at.** The source called those hours billed\nwhile naming no document, which is how work nobody would ever be paid for was got out of\nHarvest's uninvoiced report — marking it invoiced, which is simply false. `total`\nrestates that group as `withoutDocument` so a client can say so without knowing which\nrow means it.\n\nSeconds come in three buckets that add up to `seconds`: priced, `unratedSeconds` (billed\nwith no rate stated, which for an `external` claim is a designed state rather than an\nerror) and `otherCurrencySeconds` (billed in a currency the organization has left).\nOnly the first is inside `amountMinor`.\n\nNo period and no cursor — the question is what another system has billed, which has no\nlower bound — and archived customers and projects stay listed: archiving never hides\nhistorical money or names, and an abandoned project is the ordinary case here.","responses":{"200":{"description":"Everything another system has billed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternallyBilledSummary"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/invoices/externally-billed/document":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Repair the invoice reference on hours another system billed","description":"Writes a Harvest invoice reference onto the live `external` claims a row of\n„Extern abgerechnet\" names — the repair for a reference **Harvest itself lost**. When an\ninvoice was split or two were merged, Harvest kept the association on some of the time\nentries on it and dropped it on the others; the document exists, and the entries that\nstill carry it are what say which one it is.\n\nThe invoice travels as its **two parts** and never as a finished reference: the server\ncomposes `harvest:invoice:<id>:<number>`, so a typo cannot turn one invoice into two\ndocuments differing by a colon. `invoice: null` clears the reference instead, which\nreturns those hours to the „Ohne Rechnung\" group — the undo of a wrong repair.\n\n**It moves no money and settles nothing.** The claim keeps its rate, its currency, its\nbilled window and its claimed seconds; only the document they are attributed to changes.\nWhich is why it is one update rather than a release and a re-claim: the seconds are never\nbriefly free, so nobody can bill them in between.\n\nOwner and admin only (`time.editBilled`), and session-only with it: a leaked token must\nnot be able to restate which document billed what. **422** when the selection holds no\nlive external claim any more — a screen left open, or a project that was already\nrepaired; it never answers 200 for a write that touched nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignExternalDocument"}}}},"responses":{"200":{"description":"What the repair wrote.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalClaimRepair"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/invoices/externally-billed/release":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Take back an external settlement, freeing its hours again","description":"Releases the live `external` claims a row of „Extern abgerechnet\" names: `released_at`\nis set, and nothing else changes. The answer for hours another system called billed that\nnobody was ever paid for — in Harvest, marking work invoiced was the only way to get it\nout of the uninvoiced report.\n\nThe seconds are free the instant this commits: they reappear in `GET /invoices/unbilled`\nand are offered to the invoice builder and to a write-off. What happens to them next is a\nsecond decision, made on those screens. **One thing to know before pressing it:** writing\nthem off needs a rate the project’s dated rules resolve, because a write-off’s amount is\nthe decision it records — Harvest’s own rate lives on the claim being released and does\nnot carry over.\n\n**Released, never deleted.** The claim row stays, so what the other system claimed remains\nreadable, and the entry stays undeletable and unmergeable. There is no endpoint that\ndeletes a claim.\n\nOwner and admin only (`time.editBilled`), session-only with it. **422** when the selection\nholds no live external claim any more, which is also how two simultaneous releases resolve\nto one winner.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseExternalSettlement"}}}},"responses":{"200":{"description":"What was released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalClaimRepair"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/invoices/{id}":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"Get an invoice with its lines","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The invoice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"},"patch":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Change a draft’s notes","description":"Drafts only. A transferred invoice is out of Timjami’s hands; correcting it means withdrawing it and building a new one (ADR 0037).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInvoice"}}}},"responses":{"200":{"description":"The updated draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"},"delete":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Delete a draft or a withdrawn invoice","description":"A transferred invoice must be withdrawn first. Deleting releases the time entries for good — including for deletion, which a live claim prevents.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The invoice was deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/{id}/transfer":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Mark a draft as transferred to the invoicing tool","description":"Records that this invoice has left Timjami: it freezes the invoice and stamps\n`transferredAt`. Timjami assigns no invoice number and never issues an invoice itself —\nthe invoicing tool does both, and what happens there is deliberately invisible here.\nSee ADR 0068 and ADR 2026-08-13-125XN.\n\nFor an organization that has connected an invoicing tool, `POST /invoices/{id}/export`\ndoes this itself, in the same request that creates the document — the export *is* the\ntransfer. This endpoint is what an organization billing elsewhere uses instead; nothing\nhere can verify the claim that the invoice is being billed somewhere.\n\nSets `transferredByHand` when no invoicing tool is connected: the transfer is then the\nwhole settlement. With a tool connected it records the transfer but not that, since the\ntool's export is what settles it.\n\nAn optional `recordedDocument` records, in the same step, what the person who wrote the\ninvoice knows about it — number, dates, a link. It is shown, never obeyed: nothing about\nthe invoice’s status, claims or money reads it (ADR 2026-10-08-13YT8).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferInvoice"}}}},"responses":{"200":{"description":"The transferred invoice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/{id}/recorded-document":{"put":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Record what is known about the document an invoice billed by hand became","description":"Replaces all six recorded fields — number, Rechnungsdatum, due day, link, sent day and\npaid day; `null` clears one. For an invoice written in a program Timjami is not connected\nto: the number arrives after the invoice was marked, the payment weeks later.\n\n**Shown, never obeyed.** Recording a payment completes nothing and frees nothing; the\ninvoice’s status, its claims and every figure derived from them stay as they are. The\n`state` it answers with is read off these days against today (ADR 2026-10-08-13YT8).\n\n409 unless the invoice is `transferred` and was never exported: an exported invoice’s\ndocument is the tool’s and reads back as `document`, and a withdrawn one no longer\nchanges.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordedDocumentInput"}}}},"responses":{"200":{"description":"The invoice, with the document recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/{id}/withdraw":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Withdraw a transferred invoice","description":"Takes the invoice back in Timjami. It keeps its lines, its total and its\n`transferredAt` — it is the record of what was billed — while its hours become\nbillable again, so they can go on a corrected invoice.\n\n**This is not a Storno.** A document already created in Easybill is untouched, and\nTimjami cannot reach it: cancelling or crediting it there is a separate, manual step.\nSee ADR 0061 and ADR 2026-08-13-125XN.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The withdrawn invoice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoicing-provider":{"get":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The connected invoicing tool","description":"Which invoicing tool exports go to: `provider` is `null` when none is connected, and the\ninvoice is then marked as transferred by hand. An organization has at most one\n(ADR-2026-09-25-14DWP). Connecting and mapping customers stay with each tool’s own routes.","responses":{"200":{"description":"The connected tool, if any.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoicingProviderConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/invoices/{id}/export":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Export an invoice to the connected invoicing tool","description":"**Exporting is what transfers an invoice out of Timjami**, so this takes a draft and\nleaves it `transferred` — there is no separate \"issue it first\" step. An invoice that is\nalready `transferred` (the escape hatch was used, or a previous export failed) is\nexported without changing its status.\n\nThe tool is the organization’s connected one (`GET /invoicing-provider`); the response\nnames it in `export.provider`. 404 if none is connected. 409 if the invoice was already\nexported — this never creates a second document for the same invoice — or if it has\nbeen withdrawn. A 409 about a customer that could not be resolved carries `customerId`\n(the Timjami customer) and `provider`, which says whose mapping resolves it. A definite\nfailure leaves a draft a draft, so nothing looks transferred that never left. An\nambiguous outcome (a timeout, an unreadable reply) keeps the invoice `transferred` and\nclaimed, `export.unclear` is `true`, and `PUT /invoices/{id}/export/document` or\n`DELETE /invoices/{id}/export` resolves it (ADR-2026-10-20-08BYN).\n\n`exportNotice` names a difference the export recorded anyway: sevdesk’s draft has a net\ntotal or a position count other than the invoice’s. The document exists, so the export\nis recorded and a retry cannot create a second one; the draft is to be checked in sevdesk\nbefore it is finalized. `null` when nothing differs, and always `null` for Easybill.\n\nThe document is created as a **draft** in the tool, deliberately: that is where the VAT\ntreatment and the customer address get checked before a Rechnungsnummer is assigned. So\nthis records that the tool received the invoice, not that anybody sent it — and Timjami\nnever learns the number. See ADR 0061, ADR 0068, ADR 2026-08-13-125XN and\nADR-2026-10-20-1522X.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The invoice, with the exported document recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportedInvoice"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"},"delete":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Release an unclear export that created nothing","description":"For an invoice whose export is **unclear** (see `PUT /invoices/{id}/export/document`),\nafter checking the tool and finding no document: releases the claim, so the invoice can be\nexported again, and puts it back to `draft` when the export itself transferred it.\nAn invoice transferred by hand stays `transferred`.\n\n409 for any other invoice. A claim the export marked unclear resolves at once; one\nwithout that marker only after 15 minutes, since it may still belong to a running\nexport. Releasing while the document exists makes the next export a duplicate — check\nfirst.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The invoice, with the claim released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/{id}/export/document":{"put":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Record the document found for an unclear export","description":"For an invoice whose export is **unclear**: its `export` is present with `documentId`\n`null`, because the tool answered ambiguously (a timeout, or a reply that could not be\nread) and may have created the document anyway. After finding it there, this records its\nid as the export’s own — the invoice then reads like any other export.\n\n409 for any other invoice. A claim the export marked unclear (`export.unclear`) resolves\nat once; one without that marker only after 15 minutes, since it may still belong to a\nrunning export. The id is not verified against the tool.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportDocumentInput"}}}},"responses":{"200":{"description":"The invoice, with the document recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/invoices/{id}/document-refresh":{"post":{"tags":["Invoices"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Read this invoice’s document back from the invoicing tool, now","description":"Asks the invoicing tool about the document this invoice was exported to, instead of\nwaiting for the hourly refresh, and answers the invoice as it stands afterwards. Meant to\nbe called when somebody opens the invoice, so the `document` object is current for the person\nlooking at it.\n\nThe tool asked is the one the invoice was exported to (`export.provider`): Easybill or\nsevdesk (ADR-2026-10-26-184PV).\n\n**It never fails for the tool’s sake.** An invoice that was never exported, or was\nexported to a connection that has since been replaced, is answered unchanged; so is one\nwhose reading is less than a minute old, and so is a refusal by the tool — Easybill’s\nrate limit is ten requests a minute on the smaller tier, shared with exports. Read\n`document.fetchedAt` to see how current the answer is. Only the tool’s own 404 changes\nanything on such a path: it marks the document deleted. A sevdesk cancellation is a\nsecond document and reaches the reading with the hourly refresh, not here.\n\nThis writes nothing of Timjami’s own: the mirror is shown, never obeyed, which is why\nreading an invoice is all the authority it takes. See ADR 2026-09-18-21LNB.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The invoice, with whatever the tool now says.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWithLines"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/write-offs":{"get":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"List write-offs","description":"Hours somebody decided will never be billed, newest first — the reason, who waived them\nand when, and what was waived per rate. Newest first, ordered by `createdAt` then `id`;\npaginate with `cursor` set to the previous page's `nextCursor`, unchanged — it names both,\nso two batches written inside one millisecond are not skipped at a page boundary.\n\n**Released batches stay listed, marked by `releasedAt`.** A write-off is *taken back*, never\ndeleted: releasing returns its seconds to „Abzurechnen\" and leaves the record of what was\nwaived standing. There is no delete endpoint, deliberately — a record a `DELETE` can erase\nis not one.\n\nAmounts are rounded **once per claim** — a claim being one stretch of one entry at one rate —\nand the per-rate rows are sums of those. That is the write-off’s own rounding rule and is\ndeliberately not an invoice line’s: a write-off predicts no document that could disagree\nwith it. A screen showing these figures has to say so (invariant 19).\n\nNo per-claim rows here: a batch over an abandoned project can hold hundreds, and the\nfigures a list shows are the per-rate ones. `POST /write-offs` returns the claims it wrote.","parameters":[{"schema":{"type":"string","enum":["live","released"],"description":"`live` still holds its seconds; `released` was taken back. Omitted means both."},"required":false,"description":"`live` still holds its seconds; `released` was taken back. Omitted means both.","name":"status","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"The previous page's `nextCursor`, passed back unchanged. It names the batch's creation instant **and** its id, so two batches written inside one millisecond are not skipped at a page boundary."},"required":false,"description":"The previous page's `nextCursor`, passed back unchanged. It names the batch's creation instant **and** its id, so two batches written inside one millisecond are not skipped at a page boundary.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The write-offs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteOffList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"},"post":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Write off hours that will never be billed","description":"Records that these hours are waived, and why. One batch, N **claims** — the same mechanism\nan invoice uses to say „these seconds are spoken for\", so a written-off second cannot\nafterwards be invoiced and an invoiced one cannot be written off: a gist exclusion\nconstraint says so rather than application code. A 409 means somebody settled some of\nthese minutes in between.\n\nThe cut, the refusals and the rounding are `POST /write-offs/preview`’s — this is that\nrequest with a reason attached, and the response carries the claims it wrote.\n\n`reason` is required. A batch with no reason is precisely the unexplained flag this\nendpoint exists to replace: in the tool Suora used before, the only way to get abandoned\nhours out of the uninvoiced report was to mark them invoiced — a statement that is simply\nfalse, and afterwards indistinguishable from hours that really were billed.\n\nThe entries become **frozen** in their times and their task, exactly as an invoiced entry\nis (their notes stay editable, and an owner or admin may still correct them — which then\nshows under „Abweichungen\"). They also stay undeletable and unmergeable, and releasing the\nwrite-off does not give that back: the record of what was waived would be worth little if\ndeleting its batch erased it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWriteOff"}}}},"responses":{"201":{"description":"The batch, with the claims it wrote.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedWriteOff"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/write-offs/preview":{"post":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What writing off this selection would waive","description":"Writes nothing. Returns the claims `POST /write-offs` would store, computed by the same\ncode — so the amount somebody is about to waive is on screen before they confirm it, and\nthe refusals below are met *before* a reason has been typed.\n\nA POST rather than a GET because the selection is a list of entry ids, not a handful of\nquery parameters — the same reason `POST /invoices/preview` is one.\n\n**What is claimed is the free seconds, cut per rate.** An entry partly on an invoice\ncontributes only its unbilled rest, and an entry whose hours two budgets price differently\nproduces two claims rather than one at an average nothing was ever billed at.\n\n**Every claimed second must have a rate**, and unpriced ones are a 422 that names the\nproject and task it is missing on. The waived amount *is* the decision being recorded, so\nwhoever waives unpriced work prices it first — a dated rate rule on the project. The\nrefusal carries the levels as `unpriced` beside the sentence, so a screen can name them.\n\nThree more refusals: a **running** entry (422 — it has no duration yet), a selection with\n**nothing free** in it (422 — every second is already settled), and an entry id this\norganization does not have or that sits on a project you may not see (404, never 403).\n\nArchiving is not filtered anywhere here: writing off is not booking, and the case this\nexists for is the abandoned — usually archived — project.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewWriteOff"}}}},"responses":{"200":{"description":"What this selection would waive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteOffCut"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/write-offs/allowance/preview":{"post":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"What writing off these allowance months would waive","description":"Writes nothing. A month is whole or not at all: any unpriced share refuses the whole month with 422, and an already-settled month (by an invoice or another write-off) is 409.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreviewAllowanceWriteOff"}}}},"responses":{"200":{"description":"What this selection would waive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllowanceWriteOffCut"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/write-offs/allowance":{"post":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Write off whole monthly time allowances","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAllowanceWriteOff"}}}},"responses":{"201":{"description":"The batch, with what it waived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedAllowanceWriteOff"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/write-offs/{id}/release":{"post":{"tags":["Write-offs"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Take a write-off back","description":"Releases the batch and every claim in it, in one transaction: the seconds go back to\n„Abzurechnen\" and are offered to the invoice builder again. **The only way back** — there\nis no delete endpoint — and the record stays, marked with `releasedAt`, so „was haben wir\nabgeschrieben\" still answers.\n\nIt returns the seconds and nothing else. The entries stay undeletable and unmergeable,\nbecause a claim row still names them; that is the one way a write-off is stricter than an\ninvoice, and it is deliberate.\n\n409 if it has already been released. 404 if this organization has no such write-off.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The released write-off.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteOff"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/billing-rules/preview-history":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Preview outstanding historical periods before a rule’s first period","description":"Section 5’s setup step: which of the rule-to-be’s own periods, before `firstPeriodStart`, hold something a history mode would take on: open time, allowance months or completed fixed-price items, in months no other rule owns. Prices nothing — only which periods have something in them.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","description":"Comma-separated project ids. Empty means every project of the customer."},"required":false,"description":"Comma-separated project ids. Empty means every project of the customer.","name":"projectIds","in":"query"},{"schema":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"required":true,"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.","name":"recurrence","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"firstPeriodStart","in":"query"}],"responses":{"200":{"description":"The periods.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/HistoricalPeriodPreview"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/preview-periods":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Preview a rule-to-be’s periods from its first one on","description":"Writes nothing. Every ended period (`ready`, a proposal once the rule is saved if `proposes` says so) and the running one are priced by the same computation a proposal’s invoice runs, over that period’s own months: organization-wide, rounded once per line, provisional until invoiced. Upcoming periods carry a due day and never an amount. With `historyMode`, `history` says what becomes of the hours before the first period. More than 24 ended periods answers 422. ADR-2026-09-18-13VXS.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","description":"Comma-separated project ids. Empty means every project of the customer."},"required":false,"description":"Comma-separated project ids. Empty means every project of the customer.","name":"projectIds","in":"query"},{"schema":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"required":true,"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.","name":"recurrence","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"firstPeriodStart","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"required":true,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","name":"dueDayOfMonth","in":"query"},{"schema":{"type":"string","enum":["member","project","task","entry"],"description":"The default invoice grouping a proposal generated under this rule freezes."},"required":true,"description":"The default invoice grouping a proposal generated under this rule freezes.","name":"grouping","in":"query"},{"schema":{"type":"string","enum":["separate-periods","collect-history","handle-later"],"description":"The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month."},"required":false,"description":"The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month.","name":"historyMode","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last period the catch-up proposal reaches, for `collect-history`."},"required":false,"description":"The last period the catch-up proposal reaches, for `collect-history`.","name":"catchUpThroughPeriod","in":"query"}],"responses":{"200":{"description":"The periods.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRulePeriodPreview"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/preview-planned":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Planned hours for a rule-to-be’s running and upcoming periods","description":"Staff plans, never recorded time and never priced. A week counts in the period holding its Thursday. Requires planning authority. ADR-2026-09-18-13VXS.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","description":"Comma-separated project ids. Empty means every project of the customer."},"required":false,"description":"Comma-separated project ids. Empty means every project of the customer.","name":"projectIds","in":"query"},{"schema":{"type":"string","enum":["monthly","quarterly"],"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct."},"required":true,"description":"How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.","name":"recurrence","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"firstPeriodStart","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":31,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","example":10},"required":true,"description":"The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month.","name":"dueDayOfMonth","in":"query"}],"responses":{"200":{"description":"The planned hours per period.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PlannedPreviewPeriod"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning"}},"/api/v1/billing-rules":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing.read"]}],"summary":"List recurring billing rules","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","enum":["active","paused"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"The rules.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BillingRuleListItem"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing.read"},"post":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing-rules.write"]}],"summary":"Create a recurring billing rule","description":"No overlapping active rule may already cover a named project, or the customer dynamically, for an overlapping period — answers 409. Validated under a per-customer lock, so two concurrent creates cannot both succeed. Older services the rule takes on normally get their proposals in this request. If that step fails, the answer is still 201 with the rule saved and `hasProposals` false, and the next reconciliation pass, within minutes, makes the proposals. So `hasProposals: false` says that no proposal exists yet, neither that nothing is open nor that the rule will stay deletable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBillingRule"}}}},"responses":{"201":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing-rules.write"}},"/api/v1/billing-rules/{id}":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing.read"]}],"summary":"One billing rule","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing.read"},"delete":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete a rule that has not produced a proposal yet","description":"Removes the rule with its project coverage and its responsible people. Once any proposal exists — periodic, catch-up or historical — the rule is part of what was billed and answers 409; pause it instead. A periodic proposal is generated only once its period has ended, so a rule whose first period is the current one stays deletable throughout that period, unless its setup collects older outstanding hours.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The rule was deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/{id}/schedule":{"patch":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing-rules.write"]}],"summary":"Change a rule’s live fields","description":"Name, due day, selection mode, default grouping and responsible people — takes effect immediately for proposals not yet generated. Recurrence and project coverage instead go through `PUT /billing-rules/{id}/coverage`, effective from a chosen future period.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBillingRuleSchedule"}}}},"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing-rules.write"}},"/api/v1/billing-rules/{id}/coverage":{"put":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing-rules.write"]}],"summary":"Change which projects a rule covers, from a future period","description":"Closes the rule’s current coverage the month before `effectiveFromPeriod` and opens the new one from it. Existing proposals before the boundary keep their prior coverage (section 6).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeBillingRuleCoverage"}}}},"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing-rules.write"}},"/api/v1/billing-rules/{id}/project-addition/candidates":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List the projects that can still join the rule","description":"The customer’s visible, unarchived projects with a gap in their coverage this rule can fill, each with the months it can join over — the same window the preview and the join use. An archived project with open hours joins from „Offen“ instead. ADR-2026-10-07-119RC.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The projects, by name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JoinableProjectList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/{id}/project-addition/preview":{"get":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Preview a project joining the rule from a past month","description":"Writes nothing. Groups the project’s open hours from `fromMonth` on by the period that would bill them. Each ended period is priced as the draft it would get — everything still open on its proposal plus the project’s hours — by the same computation a proposal’s invoice runs: organization-wide, rounded once per line, provisional until invoiced. The running period is priced on the project’s hours alone. The project joins over one gap in its coverage (`earliestMonth` … `latestMonth`); a `fromMonth` outside it, or more than 24 periods, answers 422. ADR-2026-10-07-119RC.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month the project joins from. Omitted, the preview starts at `suggestedFromMonth`."},"required":false,"description":"The month the project joins from. Omitted, the preview starts at `suggestedFromMonth`.","name":"fromMonth","in":"query"}],"responses":{"200":{"description":"The preview.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAdditionPreview"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/{id}/project-additions":{"post":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Add a project to an active rule from a past, current or future month","description":"Fills a gap in the project’s coverage: the rule covers it from `fromMonth` to the gap’s end, and no existing coverage, proposal or invoice changes. The project’s open hours are allocated to their periods’ proposals at once, reopening a completed one. With `createInvoices`, every ended period that now holds them gets one draft invoice, billing everything open on its proposal; a period whose draft would bill nothing is listed in `skippedProposalIds` instead, and the join stands. Refused before anything is written for a paused or all-projects rule, a project of another customer, a project with no free month, a month outside the gap, or more than 24 periods (ADR-2026-10-07-119RC).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddProjectToBillingRule"}}}},"responses":{"200":{"description":"The rule, its affected proposals and drafts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAdditionResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/{id}/pause":{"post":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Pause a rule","description":"Stops new periodic proposal generation. Existing proposals stay visible and keep their reminders; historical reconciliation for late services still runs.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-rules/{id}/resume":{"post":{"tags":["Billing rules"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Resume a paused rule","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResumeBillingRule"}}}},"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-proposals":{"get":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing.read"]}],"summary":"List billing proposals","description":"Customer/rule, period, due/overdue information and state — the entry point \"Abzurechnen\" links from. `openOnly=true` excludes complete proposals; a filter for \"Nichts abzurechnen\" is a read-time state check on the client.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"billingRuleId","in":"query"},{"schema":{"type":"string","description":"Only proposals not yet complete. Defaults to every proposal."},"required":false,"description":"Only proposals not yet complete. Defaults to every proposal.","name":"openOnly","in":"query"}],"responses":{"200":{"description":"The proposals.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BillingProposalListItem"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing.read"}},"/api/v1/billing-proposals/{id}":{"get":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing.read"]}],"summary":"One proposal, with its allocations","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The proposal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingProposalDetail"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing.read"}},"/api/v1/billing-proposals/{id}/dismiss":{"post":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing-proposals.write"]}],"summary":"Confirm an empty proposal needs no billing","description":"For a proposal with no allocations at all — a period audited and found genuinely empty. Runs the reconciliation pass first, so an hour booked since the last background pass counts. Refused 409 once the proposal has any allocation; a later service reopens it the same way it would reopen any other completed proposal.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The proposal.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingProposalDetail"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing-proposals.write"}},"/api/v1/billing-proposals/{id}/allocations/{allocationId}/move":{"post":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:billing-proposals.write"]}],"summary":"Move or defer a service portion to another open proposal of the same rule","description":"A `reason` marks it a deferral (visible, and the target’s due date now governs reminders); omitting it is a plain correction. Refuses 409 when the portion is already inside a stored invoice draft — delete the draft first — or already settled, and when it left this proposal after it was read: reconciliation released it because its entry, agreement or item changed, or someone moved it — read the proposal again. No cross-rule reassignment.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"allocationId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveBillingAllocation"}}}},"responses":{"204":{"description":"Moved."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:billing-proposals.write"}},"/api/v1/billing-proposals/{id}/invoices/preview":{"post":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Preview the invoice this proposal’s open allocations would produce","description":"Creates no invoice and claims nothing, but first runs the same reconciliation pass the background worker runs every five minutes — for the whole organization — so an hour booked or corrected since the last pass is allocated and can reappear, move or reopen proposals. Shares the exact code path `POST .../invoices` uses to create one. `period` answers for the proposal’s own period what the draft cannot, since the draft reads with no lower bound: how far the projects it bills are closed against the last day it reads, the not-billable time booked on them, and other projects of the customer with open billable hours that no rule covers.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The preview.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProposalInvoicePreview"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-proposals/{id}/services":{"get":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The people, tasks, days and notes behind a proposal’s service portions","description":"One row per allocation of `GET /billing-proposals/{id}`, joined by `allocationId`: whose time it is, on which task, when it started, what was noted on it, and which invoice settled it or which draft already claims it. For a fixed-price item, `fixedPriceItem` says what it bills — description, kind, amount in its own currency and days — and `personName` and `taskName` are the people sharing it and its agreement. Session-only — a colleague’s notes and an invoice reference are neither state, period nor project.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The services.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BillingAllocationService"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/billing-proposals/{id}/invoices":{"post":{"tags":["Billing proposals"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create the invoice for this proposal’s currently open allocations","description":"The precise allocation mask, never a wider date range — services other proposals own are never included. Runs the reconciliation pass first, so hours booked or corrected since the last background pass are included. Links the created invoice back to the proposal, which may link several over time (partial and supplementary billing).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProposalInvoice"}}}},"responses":{"201":{"description":"The invoice.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"],"additionalProperties":{}}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/budgets/report-channels":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Slack channels a budget report can be posted to","description":"Every unarchived public channel of the connected workspace, and the private channels\nthe Timjami app was invited to. 409 when no workspace is connected.","responses":{"200":{"description":"The channels.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BudgetReportChannel"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/reports":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"One budget’s scheduled reports","description":"The caller’s own e-mail report of this budget, if any, and every Slack report of it.\nSomebody else’s e-mail report is theirs alone and never listed here.\n\nA report states where the budget stands this month and both forecasts to the end of its\nperiod — by plan where one exists, and by the pace so far — with a chart. Hours only,\nnever money.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The reports.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReports"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/budgets/{id}/reports/email":{"put":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Subscribe to a budget’s report by e-mail","description":"Creates or reschedules the caller’s own e-mail report of this budget. It goes to the\ncaller’s account address — nobody subscribes a colleague. Whether the caller may still\nread the budget is checked again before every report.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReportSchedule"}}}},"responses":{"200":{"description":"The subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReportSubscription"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Unsubscribe from a budget’s e-mail report","description":"Removes the caller’s own e-mail report of this budget. 404 when there is none.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/budgets/{id}/reports/slack":{"post":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Post a budget’s report to a Slack channel","description":"Everybody in the channel reads what the report says, including people who could not\nopen the budget themselves — so this needs `budget.write`, and each post rides on the\nauthority of the person who set it up, checked again before every post.\n\nA public channel needs no invitation; a private one only works once the Timjami app was\ninvited. 409 when this channel already gets this budget’s report, or when no Slack\nworkspace is connected.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBudgetReportSlack"}}}},"responses":{"201":{"description":"The subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReportSubscription"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/reports/slack/{subscriptionId}":{"patch":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Reschedule a Slack report","description":"Changes the days and the hour a channel gets this budget’s report.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"subscriptionId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReportSchedule"}}}},"responses":{"200":{"description":"The subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetReportSubscription"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"},"delete":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Stop posting a budget’s report to a channel","description":"Removes the Slack report and the record of what it sent.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"subscriptionId","in":"path"}],"responses":{"204":{"description":"Removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"List budgets","description":"Every budget of the organization, or of one customer. Newest span first.\n\nArchived budgets are excluded by default. Archiving is *not* the same as having been\nsuperseded: a superseded budget keeps a `validUntilMonth` and stays listed, because it\nis what was agreed for those months. Archiving is for one created by mistake.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","name":"customerId","in":"query"},{"schema":{"type":"string","default":false,"description":"Whether archived budgets should be included. Defaults to false."},"required":false,"description":"Whether archived budgets should be included. Defaults to false.","name":"includeArchived","in":"query"}],"responses":{"200":{"description":"The budgets.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Budget"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:budgets.write"]}],"summary":"Agree a new budget","description":"For a budget that *replaces* an existing one, use `POST /budgets/supersede` instead:\nit ends the predecessors and writes the successor in one transaction, which is what\none agreed change should be.\n\nAnswers 409 when any of the projects already runs against a budget in an overlapping\nperiod — unless a declared overflow order links the two into one chain, which is the\nsanctioned overlap: hours fill the upstream budget first and everything beyond draws\ndown the pot it names. Nothing in the database enforces the refusal — this check is\nwhat does.\n\n`overflowFromId` points an existing budget’s overflow at the new one, in the same\ntransaction — the only way a supplementary pot covering the same projects can come\ninto being, since the pointer is what sanctions the overlap.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBudget"}}}},"responses":{"201":{"description":"The budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/consumption":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"What was agreed and what was tracked, per month","description":"One entry per month in `[fromMonth, untilMonth]`, both inclusive — a month with no\nbudget is returned with an empty list rather than omitted, because \"nothing was agreed\nfor October\" is an answer.\n\n`consumedSeconds` counts **billable, finished** entries on the budget’s projects.\nWhether an entry carries a rate is irrelevant: a budget is hours. Months are resolved\nin the organization’s own time zone.\n\nA budget in a declared overflow chain shows what the chronological allocation left in\nit: hours fill a pot up to its capacity — per month for `monthly`, per span for\n`total` — and everything beyond draws down the pot it names, exact to the second, the\nboundary entry included. A `total` budget’s figures describe its whole span and repeat\nidentically in every covered month.\n\nThe months come in calendar order, and inside one the budgets come **grouped by\ncustomer**: a budget belongs to exactly one customer, so that is the question this\nanswer is read with, and a client may rely on one customer’s budgets being\ncontiguous. Inside a customer the largest overrun comes first. `unbudgeted` is\nordered the same way.\n\nTwo things travel beside the numbers rather than being hidden: `unbudgeted` lists\nprojects whose billable hours no budget covers, and `overlaps` lists any project\nclaimed by two budgets in the same month with no fill order between them — a state the\nmodel allows and this endpoint is required to surface. A declared chain is the one\nsanctioned overlap and is not reported.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first consumption month to return, inclusive."},"required":true,"description":"The first consumption month to return, inclusive.","name":"fromMonth","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The final consumption month to return, inclusive. It cannot precede `fromMonth`; the range may cover at most 24 months."},"required":true,"description":"The final consumption month to return, inclusive. It cannot precede `fromMonth`; the range may cover at most 24 months.","name":"untilMonth","in":"query"},{"schema":{"type":"string","format":"uuid","description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer.","name":"customerId","in":"query"}],"responses":{"200":{"description":"The months.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BudgetMonth"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/budgets/trajectory":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Where each budget of the current month is heading","description":"One trajectory per budget covering the organization’s current month: what should be\nconsumed by today, a forecast for the end of the period, and whether it deviates enough\nto act on. A `monthly` budget’s period is the month; a `total` budget’s is its span, and\none without an end month has no trajectory.\n\nWorking days are Monday to Friday, today included. The forecast is consumed plus the\nremaining planned work when anything is planned against the budget in its period\n(`basis: plan`, the same figure as `GET /planning/forecast`; always `basis: pace` when the\norganization has switched planning off), and otherwise the average per\nelapsed working day carried over the remaining ones (`basis: pace`). A deviation under 10 %\nof capacity or under eight hours is `on-track`.\n\nRequires `budget.readForecast`, which every member holds: the answer is a per-budget\naggregate and names no person, week or capacity (ADR-2026-09-17-14UV6).","responses":{"200":{"description":"The current month’s trajectories.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetTrajectories"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/budgets/{id}/trajectory":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Where one budget is heading, day by day","description":"The budget’s trajectory as in `GET /budgets/trajectory`, and its burn-up over every working\nday of the period: consumed to date, placed on the day its entries started (an allowance\nmonth on its first day), the target spread evenly over the working days, and the forecast\nfrom today to the end at its own daily rate. The consumed line ends at the trajectory’s\n`consumedSeconds`.\n\n404 when the budget has no trajectory this month — no period covering today, an open-ended\ntotal budget, or a budget the caller cannot see (ADR-2026-09-17-14UV6).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The trajectory and its days.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetTrajectoryDetail"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/budgets/supersede":{"post":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:budgets.write"]}],"summary":"Replace budgets with the one agreed next","description":"Ends every budget in `replaceIds` the month **before** the successor starts and writes\nthe successor, in one transaction. That is the shape of the change people actually\nagree: \"from October we have 250 hours for both projects\" is one thing, even when it\nends two budgets and starts one.\n\nAnswers 422 when a predecessor belongs to another customer, starts *after* the successor\ndoes — which would ask for a span that closes before it opened — or has already ended\nearlier, since superseding only ever shortens a span and never extends one over months\nnobody agreed it for.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupersedeBudget"}}}},"responses":{"201":{"description":"The successor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/alerts":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Every watched budget’s thresholds and what they recorded","description":"One entry per budget that carries at least one threshold — a budget nobody configured\nan alert on is absent rather than present and empty, since „keine Alarme\" is the same\nanswer either way and listing budgets is what `GET /budgets` does.\n\nFor a screen that lists budgets and wants to mark the thresholds a reading reaches right\nnow — a crossing with a `null` `resolvedAt` — in one request instead of one per row.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Limit the answer to the budgets of one customer.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Limit the answer to the budgets of one customer.","name":"customerId","in":"query"}],"responses":{"200":{"description":"The watched budgets.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BudgetAlerts"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/budgets/{id}":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"One budget","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Correct a budget","description":"For correcting a mistake — a typo in the name, a mistyped number, the wrong project.\nA *change in what was agreed* belongs in `POST /budgets/supersede` instead, so that\nthe months before it keep saying what was true then.\n\nThe budget is validated as it would be **after** the patch, so a partial change cannot\nwalk it into an overlap — nor past a threshold it already carries: a correction that\nwould leave a configured alert this budget could no longer watch is refused, and the\nalert is removed first.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBudget"}}}},"responses":{"200":{"description":"The budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"},"delete":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Delete a budget nothing was measured against","description":"For one entered by mistake. Answers **409** as soon as a single billable hour has been\ntracked against it: from that moment it is the record of what those months were measured\nagainst, and the answer is to archive it instead.\n\nThe line is ADR-0077’s own — *a budget that was consumed is a record* — rather than a\nblanket refusal, because a budget nobody measured anything against is a record of\nnothing, and archiving it would leave a row that has to be explained forever.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/adjustments/{month}":{"put":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:budgets.write"]}],"summary":"Adjust one month of a budget","description":"For a carry-over from the month before, or any other one-off change to that single\nmonth. `adjustmentSeconds` is a **delta** on what was agreed and may be negative — a\nmonth worked over the agreement leaves the next one with less. It must not be zero:\nno adjustment is the absence of a row, so use `DELETE` for that.\n\nIdempotent. A month already adjusted is overwritten, because one month has one answer\nand correcting a carry-over is an edit rather than a second, compensating row. Omitting\n`note` therefore clears it, the way a `PUT` should.\n\nAnswers 422 for a month outside the budget’s span — there is nothing to adjust there,\nand a month with no budget at all is answered by agreeing a one-month budget for it. An\narchived budget is 422 as well rather than 404: it exists, it just covers no month. A\n`total` budget is also 422: its capacity is one figure for the whole span, and\ncorrecting *that* is what `PATCH` and superseding are for.\n\nNothing recomputes this: it is what somebody decided, so a later correction to the\nprevious month does not move it. Carrying a balance further means adjusting the next\nmonth too, and letting it lapse means not doing so — the arrangements behind that\n(carried over here, expired after three months there) are deliberately not modelled.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetBudgetAdjustment"}}}},"responses":{"200":{"description":"The adjustment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetAdjustment"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"},"delete":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:budgets.write"]}],"summary":"Remove a month’s adjustment","description":"The month goes back to exactly what the budget says.\n\nUnguarded, unlike `DELETE /budgets/{id}`: the budget is the record of what was agreed,\nwhile an adjustment is bookkeeping about one month — the thing somebody revises when\nthey realise they carried the wrong number over. Removing it restores a state that was\ntrue before it rather than losing history.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"path"}],"responses":{"204":{"description":"Removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/alerts":{"get":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"One budget’s alert thresholds","description":"The same shape the collection answers with, for one budget — including one that has no\nthreshold yet, which is exactly the budget somebody opens this for.\n\nA **crossing** reports itself once: a `total`-scope threshold has one standing crossing,\na `month`-scope one has one per month. A reading that falls back below the threshold\nresolves that crossing and arms the threshold again, so the next crossing records and\nreports itself anew. The figures on a crossing are the ones read at the moment it\nhappened and are never recomputed — what a later booking did to the budget is the\nbudget’s own answer, not this one’s.\n\n`availableScopes` is what this budget can carry, and a client offers nothing else: a\n`total` budget is one figure for its whole span and has no monthly share to be a\npercentage of, a monthly budget with no end month has no total, and a span longer than\n24 months has no total this system can read.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The thresholds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetAlerts"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"post":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Watch a percentage of a budget","description":"The percentage is free-form between 1 and 1000: 80 and 100 are the usual pair, and a\nvalue above 100 watches an overrun. Whoever subscribed to budget alerts in their profile\nis mailed once per crossing — and again once the hours have fallen back below the\nthreshold and later reach it anew.\n\nAnswers 422 for a scope this budget cannot carry, rather than storing a threshold that\ncould never fire — `availableScopes` on the `GET` says which ones it can. A percentage\nthis budget already watches in the same scope is 409: two identical thresholds would\nmail the same figure twice.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBudgetAlert"}}}},"responses":{"201":{"description":"The threshold.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BudgetAlertThreshold"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/alerts/{alertId}":{"delete":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Stop watching a percentage","description":"Removes the threshold and what it recorded. Unguarded, unlike deleting a budget: a\nthreshold is a standing request to be told something, not a record of what was agreed,\nand a mail already sent is recorded elsewhere.\n\nEntering the same percentage again therefore starts over — it may fire a second time\nfor a window it already fired for, which is what asking for it again means.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"alertId","in":"path"}],"responses":{"204":{"description":"Removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/budgets/{id}/archive":{"post":{"tags":["Budgets"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Archive or restore a budget","description":"Budgets are archived rather than deleted: one that was consumed is the record of what\nthe hours were measured against.\n\nThis is for a budget created by mistake. A budget that simply *ended* has a\n`validUntilMonth` and stays listed.\n\nRestoring one answers **409** when a live budget has taken over its name and start month\nin the meantime — which is a realistic sequence, because \"archive it and enter it again\"\nis the recommended flow. The message names the budget in the way, since archived budgets\nare what the person is looking at and the live one is filtered out of that list.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"archived":{"type":"boolean"}},"required":["archived"]}}}},"responses":{"200":{"description":"The budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Budget"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:budgets.write"}},"/api/v1/planning/overview":{"get":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.read"]}],"summary":"Read the staff plan","description":"One read for the whole screen: capacity per person and week (agreed working time\nmodel minus absences, while the organization has them on — `expectedWeekSeconds`,\nexactly what the week page and the\nsidebar meter already show), every stored allocation in range, tracked seconds by\nperson/project/week, every project any of those name (archived included — invariant\n54: archiving prevents new booking, never historical naming), and the budgets behind\nthe visible weeks.\n\n`fromWeek` and `untilWeek` must both be Mondays — an ISO week always starts there,\nregardless of the organization’s own `weekStartsOn` display setting — and are\nanswered as **422** otherwise. Omitting either resolves it: `fromWeek` defaults to the\ncurrent ISO week in the organization’s time zone, and `untilWeek` to seven weeks\nafter it (eight weeks total). The resolved range must not end before it starts and may\ncover at most 26 weeks, both **422**.\n\n**A week belongs to the month containing its Thursday** (`monthOfWeek`, the same rule\nISO 8601 uses to assign a week to a year) — one rule for both `weeks[].month` and the\nbudget arithmetic below, so what the grid groups by and what the numbers sum by never\ndisagree about the same week.\n\nEach budget month carries agreed, adjusted and consumed seconds plus original planned\nand open planned seconds. `remainingPlannedSeconds` excludes completed current-week\nwork, matching each billable actual second once, or uses an explicit remaining estimate.\n`remainingSeconds` is budgeted plus adjustment minus consumed minus remaining planned\nwork. For total budgets the complete agreement totals repeat in each covered month.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"The first week to include, inclusive. Defaults to the current ISO week in the organization’s time zone."},"required":false,"description":"The first week to include, inclusive. Defaults to the current ISO week in the organization’s time zone.","name":"fromWeek","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-09-14","description":"The last week to include, inclusive. Defaults to seven weeks after `fromWeek` (eight weeks total). The resolved range may cover at most 26 weeks and must not end before it starts."},"required":false,"description":"The last week to include, inclusive. Defaults to seven weeks after `fromWeek` (eight weeks total). The resolved range may cover at most 26 weeks and must not end before it starts.","name":"untilWeek","in":"query"}],"responses":{"200":{"description":"The staff plan for the requested weeks.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanningOverview"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.read"}},"/api/v1/planning/allocations":{"put":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.write"]}],"summary":"Plan one person’s hours for one customer, budget or project in one week","description":"Writes one cell, addressed by its natural key — person, target and ISO week.\nExactly one of `projectId`, `customerId` or `budgetId` is required, together with\n`weekStartDay` (a Monday). There is no cell-creation `POST` that could 409 on a repeat and no\n`PATCH` that could move a row to another person or target: a cell in a grid is\nwritten by its coordinates, and this endpoint speaks the same way. A second `PUT` on\nthe same key replaces `plannedSeconds` and `note` outright.\n\n`plannedSeconds: 0` **removes the allocation** and answers `{ \"allocation\": null }` —\n„nothing planned\" is a missing row, never a row saying nothing (invariant 70, the\nsame choice a budget adjustment makes). Any other value must be a whole number of\nseconds, at most 168 hours.\n\nA **positive** `plannedSeconds` is refused as **422** for a `weekStartDay` that is\nnot a Monday, and for a `personId` that does not name a person of the active\norganization who still holds a membership — that reads exactly like a `personId`\nnaming nobody at all, so a colleague from another organization cannot be told apart\nfrom a typo. Refused as **404** for a project, customer or budget target that is\nmissing, foreign or inaccessible. Refused as **409** while the target or its customer\nis archived, or the budget agreement does not cover the week’s Thursday month: planning new\nhours on it is „prevents new booking\" (invariant 54) applied to intent, and any\nallocation already stored on it stays visible either way.\n\nFor a valid cell key, `plannedSeconds: 0` bypasses membership, target availability,\narchive and budget-span checks and answers **200**\neither way — a removal only restores the state that held before the row existed, so\nrefusing one would strand an intent rather than protect anything. It is what clears\na departed colleague's weeks (a plan outlives the account since\nADR-2026-09-07-18ZRU), and a week planned on a target that has since been archived.\nA `personId`, target id and week that match no row remove nothing and still answer\n`{ \"allocation\": null }`. The same choice `budgets.removeAdjustment` makes.\n\n`createdByPersonId` is stamped once, from the caller, on the write that first inserts\nthe row; `updatedByPersonId` is stamped on every write, insert included. Neither is\nthe `personId` in the request body — that names the person being planned for, who may\nbe someone else entirely.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPlanningAllocation"}}}},"responses":{"200":{"description":"The allocation as it now stands, or `null` when this write removed it.","content":{"application/json":{"schema":{"type":"object","properties":{"allocation":{"anyOf":[{"$ref":"#/components/schemas/PlanningAllocation"},{"type":"null"}]}},"required":["allocation"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.write"}},"/api/v1/planning/allocations/refine":{"post":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.write"]}],"summary":"Move hours between compatible planning targets","description":"Atomically transfers hours for one person and ISO week. The source id, updatedAt and seconds must match the last read. Both targets must belong to the same customer; a project and budget must also be linked.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefinePlanningAllocation"}}}},"responses":{"200":{"description":"The refined source and destination cells.","content":{"application/json":{"schema":{"type":"object","properties":{"sourceRemainingSeconds":{"type":"integer","minimum":0},"allocation":{"anyOf":[{"$ref":"#/components/schemas/PlanningAllocation"},{"type":"null"}]}},"required":["sourceRemainingSeconds","allocation"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.write"}},"/api/v1/planning/forecast":{"get":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.read"]}],"summary":"Read the budget forecast and unresolved planning work","description":"Returns one to 24 months of agreed and consumed time alongside remaining planned work. Current-week billable actuals reduce a matching plan once. Total budgets retain their complete agreement totals. Defaults to the current month and the following five months.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":false,"description":"A calendar month, `YYYY-MM`.","name":"fromMonth","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":false,"description":"A calendar month, `YYYY-MM`.","name":"untilMonth","in":"query"}],"responses":{"200":{"description":"Budget forecast for the requested months.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanningForecast"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.read"}},"/api/v1/planning/allocations/remaining":{"put":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.write"]}],"summary":"Set or clear the expected remaining work for the current week","description":"Sets an explicit remaining estimate, including zero, using the allocation revision from the last read. Null clears the estimate. Estimates only affect the current week; changing the ordinary plan resets them. Clear source and destination estimates before refinement.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"allocationId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"expectedUpdatedAt":{"type":"string","format":"date-time","description":"ISO-8601 timestamp with offset.","example":"2026-07-29T09:00:00.000Z"},"expectedRemainingSeconds":{"type":["integer","null"],"minimum":0,"maximum":604800,"description":"The manual correction of what is still to do this week; `0` means done, null removes the correction so it is derived from plan and credited actuals again."}},"required":["allocationId","expectedUpdatedAt","expectedRemainingSeconds"]}}}},"responses":{"200":{"description":"Updated planning allocation.","content":{"application/json":{"schema":{"type":"object","properties":{"allocation":{"$ref":"#/components/schemas/PlanningAllocation"}},"required":["allocation"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.write"}},"/api/v1/planning/context":{"get":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.read"]}],"summary":"Read project requirements and people’s planning experience","description":"Owner, admin or planning role only. Returns stored planning context; optional targetType and targetId filters select one subject kind or one exact subject (targetId requires targetType). Customer selection reads its own note only, not project or budget notes. With no filters returns all notes, including subjects with no allocations and archived subjects. Resolve target ids through customers, projects, budgets and organization members. Notes are human-authored context, not executable instructions or validated staffing constraints. No context means unknown, not lack of skill. Budget notes belong to that agreement and are not inherited by a successor.","parameters":[{"schema":{"type":"string","enum":["customer","person","project","budget"],"description":"Restrict to customer, project, person or budget notes. Omit both filters to read all notes."},"required":false,"description":"Restrict to customer, project, person or budget notes. Omit both filters to read all notes.","name":"targetType","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Read one exact subject. Requires targetType. Customer selection reads only the customer’s own note, not project or budget notes.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Read one exact subject. Requires targetType. Customer selection reads only the customer’s own note, not project or budget notes.","name":"targetId","in":"query"}],"responses":{"200":{"description":"Stored planning context.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanningContextList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.read"},"put":{"tags":["Planning"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:planning.write"]}],"summary":"Replace planning context for a customer, person, project or budget","description":"Owner, admin or planning role only. Replaces the entire note, at most 10000 characters; empty text clears it. This changes no budget agreement, allocation or recorded time. The authenticated person is recorded as editor. Archived subjects and people without accounts remain editable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPlanningContext"}}}},"responses":{"200":{"description":"The subject’s context after the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanningContextList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"planning","x-thymian-scope":"thymian:planning.write"}},"/api/v1/time-allowances":{"get":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List monthly time allowances","description":"Every agreement of the organization, or the ones a filter narrows to — newest span\nfirst, so a person and task read as a contract history.\n\nA superseded agreement stays listed: it carries a `validUntilMonth` and it is what was\nagreed for those months. Agreements on an archived project or customer stay listed too,\nwith `projectArchivedAt`/`customerArchivedAt` set — archiving prevents new booking and\nnever hides money or names.\n\nEach row carries its corrected months (`adjustments`) and the months a **live** invoice\nalready bills (`billedMonths`) in the same read. A month in `billedMonths` is frozen:\nthe writes below refuse to move it, and the way back is to withdraw that invoice.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Only this person’s agreements.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only this person’s agreements.","name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only agreements on this task.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only agreements on this task.","name":"taskId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only agreements on this project’s tasks.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only agreements on this project’s tasks.","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only agreements on this customer’s projects.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only agreements on this customer’s projects.","name":"customerId","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"Only agreements whose span covers this month. Every parameter here narrows; none widens."},"required":false,"description":"Only agreements whose span covers this month. Every parameter here narrows; none widens.","name":"coveringMonth","in":"query"}],"responses":{"200":{"description":"The agreements.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TimeAllowance"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Agree a monthly time allowance","description":"For an agreement that *replaces* an existing one, use `POST /time-allowances/supersede`\ninstead: it ends the predecessors and writes the successor in one transaction, which is\nwhat one agreed change is.\n\nAnswers **409** when this person already has an agreement on this task in any of the\nmonths covered. Nothing in the application checks that — `time_allowance_no_overlap` is\na gist `EXCLUDE` constraint, and it is the authority: an overlap here would not\nmisreport a figure, it would bill the same month twice, and two writers racing into one\nmonth both reach the constraint where a preflight query would let one through.\n\nAnswers **422** for a task that is not billable — an allowance on work nothing prices\nis not a smaller number but a meaningless one — and **409** while the task, its project\nor its customer is archived. A `personId` naming nobody of this organization, or a task\nthis viewer may not see, is **404**.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTimeAllowance"}}}},"responses":{"201":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeAllowance"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-allowances/supersede":{"post":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Replace agreements with the one agreed next","description":"Ends every agreement in `replaceIds` the month **before** the successor starts and\nwrites the successor, in one transaction. „Ab Oktober sind es zwei Stunden\" is one\nthing somebody agreed, and the history is the audit trail: there is no revision log\nhere, because supersession *is* one.\n\nAnswers **422** when a predecessor is for another person or task, starts *after* the\nsuccessor does, or has already ended earlier — superseding only ever shortens a span\nand never extends one over months nobody agreed it for.\n\nAnswers **409** when a live invoice bills a month the shortened span would no longer\ncover: that month is frozen on a document, and the way back is to withdraw it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupersedeTimeAllowance"}}}},"responses":{"201":{"description":"The successor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeAllowance"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-allowances/{id}":{"get":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"One agreement","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeAllowance"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Correct an agreement","description":"For a **typo** — a mistyped number, the wrong start month, a note. A change in what was\nagreed belongs in `POST /time-allowances/supersede`, so that the months before it keep\nsaying what was true then.\n\nNeither the person nor the task can move: that is a different agreement, and moving one\nwould rewrite what a billed month was about.\n\nAnswers **409** when a live invoice bills any month of this agreement and the patch\nmoves `secondsPerMonth`, or when it moves the span off a billed month — a document\nfroze what those months were worth. The note stays correctable either way.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTimeAllowance"}}}},"responses":{"200":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeAllowance"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete an agreement nothing billed","description":"For one entered by mistake. An agreement that simply *ended* has a `validUntilMonth`\nand stays listed, because it is what was agreed for those months.\n\nAnswers **409** as soon as any month of it reached an invoice, live or withdrawn: from\nthat moment it is the record of what a document billed. `invoice_allowance_claim` holds\nthe agreement with `on delete restrict`, so the database refuses exactly those\ndeletions rather than a check having to remember to.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/time-allowances/{id}/adjustments/{month}":{"put":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Adjust one month of an agreement","description":"For the month that was different — three weeks of leave in August, or anything else\nabout that single month. `deltaSeconds` is a **delta** on what was agreed and is signed:\na shorter month is negative. It must not be zero, because no adjustment is the absence\nof a row; use `DELETE` for that.\n\n**Nothing recomputes this, and nothing derives it from an absence.** A dialog may look\nat the month’s absences to *suggest* a number, and what is stored is what somebody\naccepted or typed instead. A proration rule was refused deliberately: it would make an\ninvoice figure move because somebody entered a holiday.\n\nIdempotent. A month already adjusted is overwritten, because one month has one answer\nand correcting it is an edit rather than a second, compensating row. Omitting `note`\ntherefore clears it, the way a `PUT` should.\n\nAnswers **422** for a month outside the agreement’s span — there is nothing to adjust\nthere, and a month no agreement covers is answered by agreeing one. Answers **409** for\na month a live invoice bills: it froze what the month was worth, so moving the figure\nwould make the budget and the Kennzahlen disagree with the document about that month.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTimeAllowanceAdjustment"}}}},"responses":{"200":{"description":"The adjustment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeAllowanceAdjustment"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Time allowances"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Remove a month’s adjustment","description":"The month goes back to exactly what the agreement says.\n\nUnguarded by the span, unlike `PUT`: removing an adjustment restores a state that was\ntrue before the row existed, so it stays possible for a month the agreement has since\nstopped covering. A month a live invoice bills is still **409** — removing the delta\nmoves that month’s value exactly as much as writing one does.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"path"}],"responses":{"204":{"description":"Removed."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-agreements":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List fixed-price agreements","description":"Every agreement the caller may see, newest first, each with its tasks and items.\n\nAn agreement is **hidden whole** from somebody who cannot see a project it touches, through\na task or an item: seeing project A but not B means not seeing the agreement at all.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Only this customer’s agreements.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only this customer’s agreements.","name":"customerId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only agreements that touch this project, through a task or an item.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only agreements that touch this project, through a task or an item.","name":"projectId","in":"query"}],"responses":{"200":{"description":"The agreements.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceAgreement"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create a fixed-price agreement","description":"An agreement belongs to a customer and starts with no tasks and no items. Name it on a task with `fixedPriceAgreementId`, and add the amounts that are billed with `POST /fixed-price-items`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFixedPriceAgreement"}}}},"responses":{"201":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceAgreement"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-agreements/{id}":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get a fixed-price agreement","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceAgreement"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Rename a fixed-price agreement","description":"Only the name changes. The customer cannot: tasks and items are held to it, so a different customer is a different agreement.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFixedPriceAgreement"}}}},"responses":{"200":{"description":"The agreement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceAgreement"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete a fixed-price agreement","description":"Answers **409** while a task or an item still names the agreement: delete the items first, and delete the tasks (possible while nothing was booked on them) or move them to another agreement or detach them (archiving a task does not release it).","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-agreements/{id}/revisions":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read a fixed-price agreement’s history","description":"Every write to the agreement and to its items, newest first: the state each write replaced, who wrote it, when, and the reason where a closed day was written through.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The revisions, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceRevision"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-agreements/{id}/controlling":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read a fixed-price agreement’s controlling","description":"The hours spent on the agreement, in total and per person, split into sold work (`fixed_price` tasks) and investment (`productive` and `internal` tasks), and **two effective hourly rates side by side**, each with the hours it divides by: revenue ÷ all hours, and revenue ÷ fixed-price hours.\n\nRevenue is the `service` items’ amounts, spread over their service days — never derived from the hours. An item in another currency is counted, never summed. Rates are rounded once, half up, to a whole minor unit per hour; finished entries only. No estimate is read.\n\nOwner and admin only (`report.readOthers`): the per-person split names colleagues’ hours. Hidden whole, as a 404, from somebody who cannot see a project the agreement touches.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"First calendar day, in the organization’s zone, inclusive. Hours are those started on or after it, and revenue is the items’ shares on or after it. Omitted: from the start."},"required":false,"description":"First calendar day, in the organization’s zone, inclusive. Hours are those started on or after it, and revenue is the items’ shares on or after it. Omitted: from the start.","name":"from","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Last calendar day, inclusive. Omitted: no end."},"required":false,"description":"Last calendar day, inclusive. Omitted: no end.","name":"until","in":"query"}],"responses":{"200":{"description":"The agreement’s controlling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceControlling"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-items":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List fixed-price items","description":"The items the caller may see, oldest first. An item of a hidden agreement is hidden with it; a standalone expense stands on its project’s visibility.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"agreementId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","description":"Only items without an agreement — the standalone expenses."},"required":false,"description":"Only items without an agreement — the standalone expenses.","name":"standalone","in":"query"}],"responses":{"200":{"description":"The items.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceItem"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Add a fixed-price item","description":"An item is one amount billed outside tracked time, independent of any invoice.\n\nA `service` belongs to an agreement and names its people; an `expense` names none and may\nstand alone on a project. The item’s project belongs to its agreement’s customer, and every\nperson must be a live member. `amountMinor` is computed once from quantity and unit price.\n\nEvery day of the item must be open for tracking: a closed day answers **409** with\n`periodClosed`, and an owner or admin may write through it with an `editReason`, else **422**\nwith `reasonRequired`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFixedPriceItem"}}}},"responses":{"201":{"description":"The item.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceItem"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-items/revisions":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read a project’s standalone expenses’ history","description":"A standalone expense has no agreement to show its history on, so it is read here, by project: every write to an item of this project that has no agreement, newest first.","parameters":[{"schema":{"type":"string","format":"uuid","description":"A project’s standalone expenses’ history — the revisions of items without an agreement.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"A project’s standalone expenses’ history — the revisions of items without an agreement.","name":"projectId","in":"query"}],"responses":{"200":{"description":"The revisions, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FixedPriceRevision"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/fixed-price-items/{id}":{"get":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get a fixed-price item","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The item.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceItem"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Change a fixed-price item","description":"Changing quantity or unit price recomputes `amountMinor` once. Sending `days` or `people`\nreplaces the whole set. The kind never changes.\n\nThe days the item is on now **and** the days it would be on must be open; a project change\nneeds both projects open. Closed days answer as on create.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFixedPriceItem"}}}},"responses":{"200":{"description":"The item.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FixedPriceItem"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Fixed-price work"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete a fixed-price item","description":"Waiving an item is deleting it; the revision records who did. No time entry is touched: the hours stay on their task. Closed days answer as on create.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"},{"schema":{"type":"string","maxLength":500,"description":"Why an item on a closed day is being deleted anyway; a query parameter because a DELETE carries no body. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends."},"required":false,"description":"Why an item on a closed day is being deleted anyway; a query parameter because a DELETE carries no body. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends.","name":"editReason","in":"query"}],"responses":{"204":{"description":"Deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/hints":{"get":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.read"]}],"summary":"List your hints","description":"Newest first, and only the calling user's own hints — there is no way to read anybody else's.\n\nFiltered by `startedAt` like time entries, so a client showing one day asks for that day.\nA hint whose window spans midnight is therefore listed under the day it *started* on.\nPaginate with `startedBefore` set to the previous page's `nextCursor`.\n`q` is a rudimentary case-insensitive substring match against `subject` and `details`.\n\nEach hint carries a `taskSuggestion` when **you** have booked a hint with the same\n`subject` before: the task the most recent such entry belongs to, with how many of your\nearlier bookings agree. It is derived from your own resolved hints and nobody else’s, and\na subject nobody has booked simply has `null` — Timjami does not guess a task from a\nrepository name (ADR 0030).","parameters":[{"schema":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"},"required":false,"description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","name":"source","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":200,"description":"Case-insensitive substring match against subject or details."},"required":false,"description":"Case-insensitive substring match against subject or details.","name":"q","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","description":"false = only hints still to act on, true = only resolved ones."},"required":false,"description":"false = only hints still to act on, true = only resolved ones.","name":"resolved","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Keyset cursor from nextCursor.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Keyset cursor from nextCursor.","name":"startedBefore","in":"query"}],"responses":{"200":{"description":"The hints.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/HintWithSuggestion"}},"nextCursor":{"type":["string","null"]}},"required":["data","nextCursor"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.read"},"post":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Report a hint","description":"For collectors: a Claude Code hook, a browser extension, an editor plugin.\n\nThe hint is filed against the caller and the **active organization**, which is what\nkeeps one client’s evidence out of another client’s day. A collector reporting after\nleaving one organization and joining another needs its session pointed at the new one\nfirst, which `timjami login` does on its own — a user belongs to at most one at a time.\n\nReporting the same `source` and `externalId` again **widens the existing hint** —\n`startedAt` moves to the earlier value, `endedAt` to the later one, and\n`occurrenceCount` goes up — instead of creating another row. So a session that posts on\nevery message ends up as one hint spanning the session. A dismissed hint stays dismissed.\n\nReporting the same `clientHintId` again **replaces the hint instead of widening it** —\nmeant for a client that must retry a submission after a dropped connection without risking\na duplicate. Unchanged, the retry is a no-op; changed, it overwrites `subject`, `details`\nand the window, bumps `version`, and clears any resolution — the person has not seen the\ncontent that replaced what they accepted or dismissed. Independent of `externalId`\nwidening: a report with a `clientHintId` never widens, whether or not it also carries an\n`externalId`.\n\nA `sessionKey` keeps every report its own observation and only groups the session’s\nreports into one stretch when hints are read; it cannot be combined with a `clientHintId`.\n\nAnswers `200` when an existing hint was matched (widened or replaced) and `201` when a\nnew one was created.\n\n**Reporting also enforces retention.** Your hints older than the organization’s\n`hintRetentionDays` are deleted as part of this request — including ones already turned into a\ntime entry, which is untouched. Expiry rides along with reporting because Timjami has no\nscheduler (ADR 0025).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportHint"}}}},"responses":{"200":{"description":"An existing hint was widened or replaced.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hint"}}}},"201":{"description":"The reported hint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hint"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/hints/evaluate":{"post":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Evaluate selected hints with the configured LLM","description":"Runs only for this explicit request and only while both the organization and person currently consent. Returns transient suggestions; it changes no hint or time entry.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluateHints"}}}},"responses":{"200":{"description":"Transient, reviewable suggestions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HintEvaluation"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"429":{"description":"Too many evaluation requests from this person recently.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"The configured LLM provider could not be reached or is not configured.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/hints/subjects":{"get":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.read"]}],"summary":"List the distinct (source, path) pairs in your hints","description":"The categorization screen's worklist: every `(source, path)` pair your hints\ncurrently show, with how much of it you have, ordered by observed seconds —\ndeliberately not by `occurrenceCount`, which a re-polling collector can inflate\nwithout there being any more to see (tracked separately, not fixed here).\nGrouped by `path`, not by the flat `subject` alone, so two things sharing a leaf\nlabel under different parents stay two rows (TIM-48).\n\nCarries no classification: pair this with `GET /hint-rules` to see which of these\nalready have one. A pair can appear here with no rule, and a rule can exist for a\npair that stopped appearing here once its hints aged out of the retention window —\nthe two lists answer different questions on purpose.","responses":{"200":{"description":"The observed subjects.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/HintSubject"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.read"}},"/api/v1/hints/sources":{"get":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.read"]}],"summary":"List the distinct sources across your hints","description":"The `source` slugs your own hints currently carry, alphabetically — what the\nhints list’s „Nach Quelle filtern\" suggestion is built from. `hintSourceSchema`\nis an open vocabulary (ADR 0024): a custom collector reporting an unlisted slug\nappears here exactly like a built-in one the moment it has reported anything,\nand one that stopped reporting (or aged out of retention, ADR 0025) drops out on\nits own.","responses":{"200":{"description":"The observed sources.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,31}$","description":"Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector.","example":"claude-code"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.read"}},"/api/v1/hints/{id}/dismiss":{"post":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Dismiss a hint, or take that back","description":"Dismissing clears any link to a time entry: a hint is either time, or not relevant, never both. The entry itself is untouched.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DismissHint"}}}},"responses":{"200":{"description":"The hint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hint"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/hints/{id}/link":{"post":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Record that a hint became a time entry","description":"Called after creating or correcting an entry from a hint, so the hint stops being\noffered. `null` unlinks it again.\n\nThe entry must be one of **your own** in the active organization. Linking clears a\ndismissal, for the same reason dismissing clears a link.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkHint"}}}},"responses":{"200":{"description":"The hint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hint"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/hints/{id}":{"get":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.read"]}],"summary":"Get a hint","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The hint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hint"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.read"},"delete":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Delete a hint","description":"Hints are deleted, not archived: nothing is attributable to them, and they are the most privacy-sensitive rows Timjami stores, so \"delete\" means gone.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The hint was deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/hint-rules":{"get":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.read"]}],"summary":"List your hint rules","description":"Your own declared answer to \"what does this subject mean\", for every\n`(source, subject)` pair you have classified — never anybody else’s: a rule is\na judgement about your own hint stream, exactly as private as the hints it\nclassifies (ADR 0024).\n\nResolve a `taskId` against the task list you already have, the way a stale\n`taskSuggestion` resolves to nothing rather than an unresolvable id: this endpoint\ndoes not say whether the task is still live (see `GET /hints` `taskSuggestion`).","responses":{"200":{"description":"Your hint rules.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/HintRule"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.read"},"put":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Declare (or replace) what a subject or path means","description":"One rule per `(source, path)` pair — sending the same pair again replaces the\nclassification rather than adding a second rule. A `path` covers everything\nunder it (a rule on `Workspace → Team` also covers `Workspace → Team → Issue`);\nomitting `path` behaves as a path of length one built from `subject`, folded\ncase-insensitively for backward compatibility.\n\n`presence` means the subject is evidence that you worked, not what on: it\ncarries no task and the panel offers no suggestion for it. `task` names one\ntask outright, and always outranks the derived suggestion `GET /hints` would\notherwise offer for the same path. `ignore` hides it from the panel. `project`/\n`customer` narrow the task picker to that project or customer without naming a\ntask outright — evidence one level up from `task`. `absence` is the declared\nopposite of `presence` — evidence that you did *not* work; the panel offers it\nbeside a collector-reported `signal: absence` hint, dismiss-only.\n\nThe target field matching `kind` is required and every other target field is\nrefused. `taskId`/`projectId` must be one you are assigned to (or may write any\nproject) — the same visibility and assignment rule new time entries follow;\n`customerId` only has to exist, since a customer carries no visibility or\nmembership model of its own (invariant 51 only restricts projects and tasks).\nAn **archived** target may still be named: naming is not booking (invariant 54),\nso the rule stands and simply offers nothing to preselect once its target is\narchived.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HintRuleInput"}}}},"responses":{"200":{"description":"The rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HintRule"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/hint-rules/{id}":{"delete":{"tags":["Hints"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:hints.write"]}],"summary":"Delete a hint rule","description":"Never archived — a rule is a setting, not a record. Deleting it does not touch any hint it classified; the subject simply goes back to unclassified.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"The rule was deleted."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:hints.write"}},"/api/v1/imports/harvest":{"post":{"tags":["Imports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Import clients, projects, tasks and project members from Harvest","description":"Owner/admin only. Repeated imports match records by the Harvest id recorded on a previous run — falling back to the name within the parent only when no such link exists yet — and record which Harvest client, project and task each one came from, so a later run recognizes a Harvest rename and an export can find the same resource again. A resource this run’s Harvest response no longer lists at all is archived, the same as one Harvest itself marked inactive. A project’s Harvest user assignments are matched by email to somebody this organization already knows — every person, whether or not they can sign in, and never created here; an unmatched Harvest person is reported in `unmatchedEmails` rather than failing the import, and a membership this import previously added is removed once Harvest stops asserting it. `POST /imports/harvest/users` is what creates the missing people; running this import again afterwards assigns them. Given `accountId`/`accessToken`, those are used for this request only and never stored. Omitted, the caller’s own stored connection (`POST /harvest`) is used instead.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string","minLength":1},"accessToken":{"type":"string","minLength":1}},"required":["accountId","accessToken"]}}}},"responses":{"200":{"description":"Import counts.","content":{"application/json":{"schema":{"type":"object","properties":{"customers":{"type":"object","properties":{"created":{"type":"integer","minimum":0},"updated":{"type":"integer","minimum":0},"archived":{"type":"integer","minimum":0}},"required":["created","updated","archived"]},"projects":{"type":"object","properties":{"created":{"type":"integer","minimum":0},"updated":{"type":"integer","minimum":0},"archived":{"type":"integer","minimum":0},"skipped":{"type":"integer","minimum":0}},"required":["created","updated","archived","skipped"]},"tasks":{"type":"object","properties":{"created":{"type":"integer","minimum":0},"updated":{"type":"integer","minimum":0},"archived":{"type":"integer","minimum":0}},"required":["created","updated","archived"]},"links":{"type":"object","properties":{"customers":{"type":"integer","minimum":0},"projects":{"type":"integer","minimum":0},"tasks":{"type":"integer","minimum":0}},"required":["customers","projects","tasks"]},"members":{"type":"object","properties":{"assigned":{"type":"integer","minimum":0},"removed":{"type":"integer","minimum":0},"unmatched":{"type":"integer","minimum":0}},"required":["assigned","removed","unmatched"]},"unmatchedEmails":{"type":"array","items":{"type":"string"}}},"required":["customers","projects","tasks","links","members","unmatchedEmails"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/imports/harvest/users":{"post":{"tags":["Imports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create people from Harvest’s own user list","description":"Owner/admin only (`member.manage`). Reads Harvest’s user list and answers the\nthree-way question about every address, matched by email — case-insensitively,\nfresh on every run (ADR-2026-08-17-08M4J) — against **every person this**\n**organization knows**, not only the people who can sign in.\n\nSomebody **both** systems carry is `existing` and is left exactly as they are, in\nboth systems. Somebody **only** Timjami carries is named once in\n`withoutHarvestAccount` and likewise left alone. Somebody **only** Harvest carries\nis `creatable`, and `created` once this run takes them over.\n\nTaking somebody over creates **one `person` row with no account**: a subject of\ntime this organization asserts — assignable to a project, bookable, in every\nreport, readable as a colleague — who is not a login. No `user`, no `member`, no\ninvitation, nothing to sign in with, and nothing withheld either: better-auth never\nhears of them (ADR-2026-08-31-08RPY). How such a person could later be *given* an\naccount is deliberately not decided yet, and no route here does it.\n\nTwo answers are reported rather than guessed: `ambiguous` when more than one\nHarvest account or more than one person carries the address, and `no-email` for a\nHarvest account with no address at all. A Timjami account elsewhere carrying the\naddress is deliberately **not** reported — the address is provenance rather than\nidentity here, it blocks nothing, and answering would name somebody outside this\norganization.\n\nA Harvest account Harvest itself has deactivated is importable and reported as\n`active: false`, deliberately not filtered: somebody who left is exactly the subject\nwhose past hours are still wanted.\n\n`harvestUserIds` narrows the run to a selection — every Harvest person is still\nlisted, and a `creatable` one left out answers `not-selected` — and omitting it\ntakes over everything creatable. `preview: true` computes the same answer and writes\nnothing. A repeated run creates nobody twice: somebody an earlier run created is\nrecognized by the link it recorded, before the address is compared at all, so a\nrenamed address in Harvest still answers `existing`.\n\nThis import assigns nobody to a project: run `POST /imports/harvest` again\nafterwards and its member sync picks the new people up by the same email match.\nGiven `accountId`/`accessToken`, those are used for this request only and never\nstored; omitted, the caller’s own stored connection is used.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string","minLength":1},"accessToken":{"type":"string","minLength":1},"preview":{"type":"boolean","default":false},"harvestUserIds":{"type":"array","items":{"type":"string","minLength":1}}}}}}},"responses":{"200":{"description":"What happened, or would happen, to every Harvest person.","content":{"application/json":{"schema":{"type":"object","properties":{"preview":{"type":"boolean"},"users":{"type":"array","items":{"type":"object","properties":{"harvestUserId":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"active":{"type":"boolean"},"status":{"type":"string","enum":["existing","creatable","created","not-selected","ambiguous","no-email"]},"personId":{"type":["string","null"],"format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"required":["harvestUserId","email","name","active","status","personId"]}},"created":{"type":"integer","minimum":0},"withoutHarvestAccount":{"type":"array","items":{"type":"object","properties":{"personId":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"email":{"type":"string"}},"required":["personId","name","email"]}}},"required":["preview","users","created","withoutHarvestAccount"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/organization/demo-data":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get demo-data status for the active organization","responses":{"200":{"description":"The current demo-data status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoDataStatus"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create demo data in the active organization","description":"Creates one tagged, removable set. Requires the owner or admin role.","responses":{"201":{"description":"The generated demo-data status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoDataStatus"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Remove demo data from the active organization","description":"Deletes tagged rows only. Generated ancestors needed by real data are preserved and promoted.","responses":{"200":{"description":"What was deleted and preserved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoDataCleanup"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/domain":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Google Workspace domain of the active organization","responses":{"200":{"description":"The domain claim.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomain"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"put":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Claim a Google Workspace domain","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"domain":{"type":"string","minLength":1,"maxLength":253}},"required":["domain"]}}}},"responses":{"200":{"description":"The unverified claim.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomain"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Enable or disable automatic Google membership","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"autoJoinEnabled":{"type":"boolean"}},"required":["autoJoinEnabled"]}}}},"responses":{"200":{"description":"The updated claim.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomain"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Remove the organization domain claim","responses":{"204":{"description":"The claim is absent."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/domain/verify":{"post":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Verify the DNS challenge now","responses":{"200":{"description":"The checked claim.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomain"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/features":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Read the organization's own feature switches","description":"Open to every member.\n\nEach key is `true` while the organization has the feature on, which is the default.\nThis is the organization’s own choice only; whether a feature is in effect also\ndepends on the operator, and `GET /me` `organizationFeatures` answers that.","responses":{"200":{"description":"The switches.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationFeatureSwitches"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Switch the organization's own features on or off","description":"Requires the `owner` or `admin` role and a signed-in session — never a token.\n\nOnly the keys sent change; `{}` changes nothing, and repeating a switch is harmless.\nSeveral keys are written in one transaction.\n\nSwitching a feature off hides it and refuses its routes, but **its data stays**:\nswitching it on again brings everything back.\n\nSwitching `absences` back on concerns every connected client approved in this\norganization with `thymian:planning.read`, which then reaches absences. By default its\ntokens are withdrawn: the client is answered 401 and asked to approve again, and its\ngrant and switches stay. `planningReadConsent: extend` keeps them working instead.\nEither way each person concerned, other than the caller, is told by email.\n\nThe write is session-only because the switch is attributed to the person who made it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrganizationFeatureSwitches"}}}},"responses":{"200":{"description":"The switches after the change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationFeatureSwitches"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, a member without the `owner` or `admin` role, or an access token or connected client: the write is session-only.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/features/absences/planning-readers":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read who is concerned when absences are switched back on","description":"Requires the `owner` or `admin` role and a signed-in session — never a token.\n\nThe persons whose connected client was approved with `thymian:planning.read`, holds a\nlive token and could read planning now: `planning` is on, and they are still members\nwith the `owner`, `admin` or `planning` role. Switching `absences` back on lets those\nclients reach absences, so `PATCH /organization/features` asks with\n`planningReadConsent` whether they are asked to approve again or approved as they\nare. Empty while absences are on.","responses":{"200":{"description":"The persons concerned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanningReadersAwaitingAbsences"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/login-policy":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Whether the active organization requires Google-only sign-in","responses":{"200":{"description":"The current policy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationLoginPolicy"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Require or stop requiring Google-only sign-in for organization members","description":"Enabling refuses (409) unless the organization domain is currently verified and the\ncaller's own account already has a linked Google account — the evidence\nADR-2026-08-17-08KG9 requires so an owner can never lock the organization out by\nactivating this. Enabling revokes every other active session belonging to a member\nof the organization; the caller stays signed in.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}}}},"responses":{"200":{"description":"The updated policy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationLoginPolicy"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/settings":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get settings of the active organization","description":"Created with defaults on first access, so this never 404s for an organization you belong to.","responses":{"200":{"description":"The settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationSettings"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"patch":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Update settings of the active organization","description":"Requires the `owner` or `admin` role.\n\n`hoursPerDay` is organization-wide and deliberately not overridable per user: two members must\nnever see different day rates for the same time entry (ADR 0012).\n\n`hintRetentionDays` bounds how long observations reported by collectors are kept. Shortening it\ndoes not delete anything by itself — expired hints go when their user next reports one, because\nthere is no scheduler (ADR 0025).\n\nChanging `currency` does not convert existing amounts — each time entry carries the currency it\nwas recorded in.\n\nChanging `tourEnabled` or `jamiEnabled` to `true` answers 403 `feature-not-enabled`, naming\nthe feature, while the organization does not have that feature in effect; for `jamiEnabled`,\neither `jami` or `jami-notes` in effect is enough. Sending the value\nalready stored is accepted unchanged, and switching either off is always accepted\n(ADR-2026-09-28-12X97, ADR-2026-09-30-17KCB). Changing `tourEnabled` from `false` to\n`true` offers the tour to the members who have no progress in it yet, in the same\ntransaction: they already use the application, so they are asked rather than walked\nthrough it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrganizationSettings"}}}},"responses":{"200":{"description":"The updated settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationSettings"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or `feature-not-enabled`: `tourEnabled` or `jamiEnabled` changed to `true` while the organization does not have that feature in effect (for `jamiEnabled`, neither `jami` nor `jami-notes`).","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:organization.write"}},"/api/v1/organization/member-avatars":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Avatars of the active organization","description":"Keyed by person id; each URL is the final profile picture selected by the server:\na Timjami upload first, then the identity provider’s picture. A member without\neither is absent, so clients can draw a monogram.\n\nA separate endpoint rather than embedding this in better-auth’s\n`organization.listMembers()`: an avatar lives outside better-auth’s tables\nentirely. The canonical member-profile loader combines active membership, the\nidentity-provider image and the profile-picture store in a fixed query count.","responses":{"200":{"description":"Avatars of the active organization’s members.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberAvatars"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/members":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The active organization’s roster","description":"One row per **person** this organization knows: the person id everything else here\nkeys people by, the display name and email, and — when they hold one — the\nmembership id a role change addresses, the role, and when they joined.\n\n`memberId`, `role` and `joinedAt` are `null` together for somebody with no access\ngrant: a person a Harvest import created, or one whose membership has ended. They\nare on the roster because the records name them — hours, projects, reports — and\n`member` stays better-auth’s and stays the only grant there is\n(ADR-2026-08-31-08RPY).\n\nThe web app reads its member pickers and the Team page from this instead of\nbetter-auth’s `organization.listMembers()` — the roster is a domain read since a\nperson became a domain entity, and the name follows the person’s display rule\nrather than a raw account name.","responses":{"200":{"description":"The members of the active organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationMembers"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/member-google-accounts":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Which of the active organization’s members have a linked Google account","description":"A member absent from the list simply has none linked — useful for an owner\nweighing whether to enable the Google-only login policy without locking a\ncolleague out by surprise (invariant argued in\nADR-2026-08-17-08KG9).\n\nLike `member-avatars`, a separate round trip rather than embedding this in\nbetter-auth’s `organization.listMembers()`: the linked account lives outside\nbetter-auth’s membership tables, and a person can be in more than one\norganization.","responses":{"200":{"description":"User ids of the active organization’s members with a linked Google account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberGoogleAccounts"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/running-timers":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Who in the active organization is currently running a timer","description":"For the header's running-timer avatars — presence only, deliberately never the\nrate or the amount `GET /time-entries?running=true` carries for the same rows.\n\nEveryone with a running timer is listed. What they work on is subject to the same\nvisibility as everywhere else time entries are read (invariant 51): a colleague's\nentry on a private project you cannot see appears with `task` and `project`\n`null` — that somebody is working, and for which customer (customers are not\nprivate), is not secret; what exactly they work on is.","responses":{"200":{"description":"Presence of everyone currently running a timer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationRunningTimers"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/organization/members/{personId}/daily-report":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get a member's daily report subscription","description":"Requires member.manage. Returns only the three report preferences of a current member in the active organization. The recipient can also change these in their own profile.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Person identifier (UUID v4) — a person, not a login.","name":"personId","in":"path"}],"responses":{"200":{"description":"The daily report settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberDailyReport"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Update a member's daily report subscription","description":"Requires member.manage. An enabled result requires current recipient membership and report.readOthers, including schedule-only changes. Support sessions cannot enable a subscription. Delivery uses the account email and current recipient permissions; no other preferences are accessible.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Person identifier (UUID v4) — a person, not a login.","name":"personId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberDailyReport"}}}},"responses":{"200":{"description":"The updated daily report settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberDailyReport"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/customer-portal":{"get":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Get customer portal settings and invitations","responses":{"200":{"description":"Portal administration.","content":{"application/json":{"schema":{"type":"object","properties":{"branding":{"type":"object","properties":{"displayName":{"type":["string","null"],"maxLength":500},"accentColor":{"type":"string","pattern":"^#[0-9a-f]{6}$"},"welcomeMessage":{"type":["string","null"],"maxLength":500}},"required":["displayName","accentColor","welcomeMessage"]},"projectAccess":{"type":"object","properties":{"mode":{"type":"string","enum":["all","selected"]},"selectedProjectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"availableProjects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"}},"required":["id","name","customerId","customerName"]}}},"required":["mode","selectedProjectIds","availableProjects"]},"budgetAccess":{"type":"object","properties":{"selectedBudgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}},"availableBudgets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"name":{"type":"string"},"kind":{"type":"string","enum":["monthly","total"]},"validFromMonth":{"type":"string"},"validUntilMonth":{"type":["string","null"]},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"}},"required":["id","name","kind","validFromMonth","validUntilMonth","customerId","customerName"]}}},"required":["selectedBudgetIds","availableBudgets"]},"grants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"customerName":{"type":"string"},"email":{"type":"string","format":"email"},"acceptedAt":{"type":["string","null"],"format":"date-time"},"revokedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","customerId","customerName","email","acceptedAt","revokedAt","createdAt"]}}},"required":["branding","projectAccess","budgetAccess","grants"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/customer-portal/projects":{"put":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose projects visible in the customer portal","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"mode":{"type":"string","enum":["all"]},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":0}},"required":["mode","projectIds"]},{"type":"object","properties":{"mode":{"type":"string","enum":["selected"]},"projectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"minItems":1}},"required":["mode","projectIds"]}]}}}},"responses":{"200":{"description":"Updated project access.","content":{"application/json":{"schema":{"type":"object","properties":{"mode":{"type":"string","enum":["all","selected"]},"selectedProjectIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}}},"required":["mode","selectedProjectIds"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/customer-portal/budgets":{"put":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose budgets shared in the customer portal","description":"Replaces the whole list. A shared budget is shown only to its own customer's contacts, and only while the portal shows every project it covers.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"budgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"maxItems":500}},"required":["budgetIds"]}}}},"responses":{"200":{"description":"Updated budget access.","content":{"application/json":{"schema":{"type":"object","properties":{"selectedBudgetIds":{"type":"array","items":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"}}},"required":["selectedBudgetIds"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/customer-portal/branding":{"put":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Update customer portal branding","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":["string","null"],"maxLength":500},"accentColor":{"type":"string","pattern":"^#[0-9a-f]{6}$"},"welcomeMessage":{"type":["string","null"],"maxLength":500}},"required":["displayName","accentColor","welcomeMessage"]}}}},"responses":{"200":{"description":"Updated branding.","content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":["string","null"],"maxLength":500},"accentColor":{"type":"string","pattern":"^#[0-9a-f]{6}$"},"welcomeMessage":{"type":["string","null"],"maxLength":500}},"required":["displayName","accentColor","welcomeMessage"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/customer-portal/invitations":{"post":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Invite a customer contact","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"email":{"type":"string","format":"email"}},"required":["customerId","email"]}}}},"responses":{"200":{"description":"Invitation sent.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["sent"]}},"required":["status"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/customer-portal/invitations/{id}":{"delete":{"tags":["Customer portal"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke a customer portal invitation","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Invitation revoked."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"customer-portal"}},"/api/v1/organization/tracking-window":{"put":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Set how far back the organization records time","description":"Requires the `owner` or `admin` role and a signed-in session — never a token.\n\n`trackingOpenMonths` counts the **completed months that stay open beside the current\none**: `2` in August keeps June and July editable in full, on the 1st and on the 31st\nalike, and closes May. `null` removes the limit, which is the default — an\norganization that never sets it is unaffected by this rule entirely.\n\nThe limit is conjunctive with each project’s `closedThroughDay` and never replaces it:\na write needs both to allow it. Owners and admins may still book or correct a single\nentry behind the limit per request with `editReason`, which lands on that entry’s\nrevision. **Absences have no such override**, for anybody: they keep no history, so a\nretroactive change would owe a reason with nowhere to live.\n\nNothing is recalculated, deleted or moved when the limit changes, and no invoice, total\nor budget figure is affected — this decides what may still be *written*, nothing else.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTrackingWindow"}}}},"responses":{"200":{"description":"The updated settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationSettings"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/billing-escalation":{"put":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Set the global billing-escalation recipient and delay","description":"Requires the `owner` or `admin` role and a signed-in session — never a token.\n\n`delayDays` counts calendar days after a proposal’s due date before\n`recipientPersonId` joins the rule’s own responsible people, and before weekly\nrepeats begin. `recipientPersonId` must be a live member; `null` leaves the\nobligation visibly unset in Settings rather than silently pointing at nobody.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBillingEscalation"}}}},"responses":{"200":{"description":"The updated settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationSettings"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/organization/icon":{"get":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Get the active organization's icon","description":"Readable by any member; only an owner or admin may change it.","responses":{"200":{"description":"The organization's icon, or `null`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationIcon"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"},"put":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Set the active organization's icon","description":"Replaces the icon, or sets the first one. Idempotent, hence PUT.\n\nRequires the `owner` or `admin` role.\n\nThe image must arrive ready to store: a **square** PNG or WebP within the\ndocumented size limits — identical rule to a customer or project icon. The\nserver neither crops nor resizes; the client does that\n(docs/decisions/0020-icons-are-normalised-in-the-browser.md).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutIcon"}}}},"responses":{"200":{"description":"The new icon.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceIcon"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:organization.write"},"delete":{"tags":["Organization"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Remove the active organization's icon","description":"Idempotent: an organization with no icon still answers 204.\n\nRequires the `owner` or `admin` role.","responses":{"204":{"description":"No icon is set."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:organization.write"}},"/api/v1/reports/dashboard":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"The organization's live management dashboard","description":"Requires the `owner` or `admin` role. Returns one consistent snapshot of provisional\nrecorded revenue and today’s attendance across the complete organization.\n\nRevenue is the sum of the current valuation of entries starting in the requested\norganization-local day, week and month. It includes running timers measured at `asOf`,\nis not an invoice or accounting figure, and reports billable time without a rate\nseparately instead of treating it as zero.\n\nAttendance resolves every member’s dated working-time model, including monthly pro-rata\ndays, then removes absences while the organization has them on. `notStartedPersonIds`\ncontains only people who were scheduled, have recorded no time and, while absences are\non, have no absence covering the day.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"The organization-local day to inspect. Defaults to today in the organization's time zone."},"required":false,"description":"The organization-local day to inspect. Defaults to today in the organization's time zone.","name":"day","in":"query"}],"responses":{"200":{"description":"The organization dashboard snapshot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDashboard"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/dashboard/widgets":{"post":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The figures a dashboard's widgets ask for, in one read","description":"Requires the `owner` or `admin` role and a session: the request vocabulary follows the\nweb app’s widgets and is not public API (ADR-2026-10-13-08726).\n\nEvery request is answered from one entry read over the union of their windows, valued\nonce at `asOf`. An entry counts in the window it starts in, with its whole value; a\nrunning period ends with `day`, and its comparison stops at the same wall-clock point of\nthe previous period. Revenue is provisional, like `GET /reports/dashboard`.\n\nA kind gated by a capability or an organization feature the caller lacks is answered\n`{ kind: \"withheld\", requested, reason }` instead of its figure, and its source is not\nread; the rest of the batch is answered as asked (ADR-2026-10-27-10CQS).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardWidgetsRequest"}}}},"responses":{"200":{"description":"One result per request, in request order; none for a request of a kind this server does not know.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardWidgets"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/reports/company-metrics":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"The weekly „Kennzahlen\": two rates and revenue per FTE of sold capacity","description":"Readable by **every member** — these are figures the organization publishes to itself.\nThree of them over the four whole weeks before the current one, each with the same\nfigure over the four weeks before those, so a client can render a trend without asking\ntwice.\n\n`fte` and `warnings` are absent for a caller without `report.readOthers` — the first\nbecause the organization publishes one set of figures rather than the inputs behind\nthem, the second because it names colleagues (ADR-2026-09-11-10ZC4,\nADR-2026-09-11-12G8M).\n\n**Interim.** These definitions reproduce one organization’s existing weekly post rather\nthan expressing a decision about what Timjami measures. See ADR-2026-08-28-08WY9 for\nwhat is expected to change and what would trigger changing it.\n\nThe window does not move during a week: Monday through the following Sunday all return\nthe same one, and a Sunday still running never counts as a finished week. It follows the\norganization’s `weekStartsOn` and its time zone.\n\nBoth rates cover internal people flagged `inTeamMetric` alone and are percentages from\n0 to 100. `revenuePerFte` covers every `internal` person — those outside the measured\ngroup included — and is integer minor units already divided by the capacity. That\nasymmetry is intended: a freelancer’s revenue was not produced by the internal capacity\nthe denominator measures.\n\nCapacity is resolved per day over the window itself rather than from a monthly figure:\nevery internal person’s dated working time, scaled by their `billableCapacityShare`,\nagainst the organization’s default working-time model. A contract that changed inside\nthe window therefore lands on the right days. Absences do not reduce it — this is\ncontracted, sold capacity, not time worked.\n\nA figure whose comparison window produced no value is reported as `null` throughout\nrather than alone, and never as zero. `warnings` names the four ways a figure could be\nunderstated or the denominator too generous.","responses":{"200":{"description":"The weekly company metrics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyMetrics"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/company-metrics/months":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"The „Kennzahlen\" by calendar month","description":"Readable by **every member**. The same figures as `GET /reports/company-metrics`,\nfolded over whole calendar months instead of four finished weeks — the shape a monthly\nspreadsheet is filled from.\n\nA caller without `report.readOthers` receives neither `warnings`, which names\ncolleagues, nor `fte`, `revenueMinor`, `targetRevenueMinor` and `measuredSeconds`, which\nare left out to keep one published set of figures rather than to protect them —\n`revenueDeviationMinor` stays, and capacity and the undivided total remain derivable\nfrom the figures beside it (ADR-2026-09-11-10ZC4).\n\n**A second window, not a second definition.** The entries, the valuation, the two\npopulations and the capacity model are identical; only the bounds differ. A month’s\nfigure will therefore rarely equal any week window’s, and neither is wrong. See\nADR-2026-09-07-095QW, and ADR-2026-08-28-08WY9 for the figures themselves.\n\nRows come back newest first and include the month the request falls in, marked\n`complete: false`: its figures cover the days so far and are not comparable with the\nfinished months below them.\n\nCapacity is resolved over each month’s own days, so a short month is not reported as a\nrevenue movement, and `warnings` is per month for the same reason — a colleague without\na working-time model inflates only the months they were there for.\n\n`billableRateDeviation` and `revenueDeviationMinor` measure against the organization’s\nconfigured targets and are `null` where none are configured. The rate deviation is `0`\ninside the corridor, negative below its floor and positive above its ceiling; the revenue\ndeviation compares the month’s undivided total against the per-FTE target times that\nmonth’s capacity.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":36,"default":12,"description":"How many calendar months to return, 1–36."},"required":false,"description":"How many calendar months to return, 1–36.","name":"months","in":"query"}],"responses":{"200":{"description":"The company metrics by calendar month.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyMetricMonths"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/company-metrics/weeks":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"The „Kennzahlen\" by whole week","description":"Readable by **every member**, and narrowed for a caller without `report.readOthers`\nexactly as the monthly table above is (ADR-2026-09-11-10ZC4). The same figures as\n`GET /reports/company-metrics`, folded over single whole weeks — the finest of the three\nreadings, and the only one the published four-week figure decomposes into: four\nconsecutive rows whose `complete` is true are exactly what that read covers. See\nADR-2026-09-11-07NGQ, and ADR-2026-08-28-08WY9 for the figures themselves.\n\nA week starts on the organization’s own `weekStartsOn`, like the four-week window, so\n`weekStartDay` is not necessarily a Monday. That is deliberately not the ISO week a plan\nallocation is keyed by: a plan is shared between people who may display the week\ndifferently, while these rows are one organization reading its own published figure.\n\nRows come back newest first and include the week the request falls in, marked\n`complete: false`: its figures cover the days so far and are not comparable with the\nfinished weeks below them.\n\nCapacity is resolved over each week’s own days and `warnings` is per row, as in the\nmonthly table. Note that a single week is a noisier row than a month by nature: a public\nholiday sells the same capacity while less time is tracked, and a monthly allowance\nreaches a week as its calendar-day share. Nothing here smooths either.\n\n`billableRateDeviation` and `revenueDeviationMinor` measure against the organization’s\nconfigured targets and are `null` where none are configured. The revenue target is the\nagreed monthly figure at one week — `× 12/52`, the same conversion the four-week window\nuses at four weeks — while the rate corridor is unconverted.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":104,"default":13,"description":"How many whole weeks to return, 1–104."},"required":false,"description":"How many whole weeks to return, 1–104.","name":"weeks","in":"query"}],"responses":{"200":{"description":"The company metrics by whole week.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyMetricWeeks"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"company-metrics","x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/monthly-statistics/export":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Download hours and revenue by month, person, customer and project as CSV","description":"Requires the `owner` or `admin` role: it names every person’s revenue, and those roles see\nprivate projects, so no project is silently missing. See ADR-2026-10-01-15X25.\n\nOne row per month, person, customer, project and currency, for every whole month in\n`[from, until]` — at most 36. A month is the month the work\nwas done in, never the month it was invoiced; an allowance („Pauschale\") counts in its own\nmonth, and both its hours and its revenue are also shown on their own.\n\n**Billed** means a live claim of a **transferred** invoice or an `external` claim\n(„Extern abgerechnet\"); a draft counts as open. A billed or written-off stretch is priced\nat its frozen rate and filed under the currency it was settled in; everything else at\ntoday’s rate. **Umsatz** is open plus billed and never includes written-off money, which\nhas its own columns; `Umsatz offen` is `Umsatz − Umsatz abgerechnet`. A billed or\nwritten-off stretch is billable even if its task is not any more.\n\n`Stundensatz` divides revenue by the billable hours **with** a rate less the written-off\nones; `effektiver Stundensatz` by every hour. Both are empty where the divisor is zero.\nMoney rounds once per row, status, source and rate. Running entries count nowhere.\n\nColumns: `Monat`, `Person-ID`, `Person`, `Kunde-ID`, `Kunde`, `Projekt-ID`, `Projekt`, `Währung`, `Stunden gesamt`, `davon Pauschale (h)`, `abrechenbar mit Satz (h)`, `abrechenbar ohne Satz (h)`, `nicht abrechenbar (h)`, `Anteil abrechenbar (%)`, `Umsatz`, `davon Pauschale (Umsatz)`, `Stundensatz`, `effektiver Stundensatz`, `abgeschrieben (h)`, `Umsatz abgeschrieben`, `abgerechnet mit Satz (h)`, `abgerechnet ohne Satz (h)`, `Umsatz abgerechnet`, `offen (h)`, `Umsatz offen`.\n\n`personNames`, `customerNames` and `projectNames` set to `false` each drop that name\ncolumn from the header and every row; the matching id column always stays, so rows still\njoin back and two people with the same name are never merged.\n\nHours are decimal with four places, money in major units, the share in percent with one\nplace. Semicolon-delimited, comma decimals, UTF-8 with a byte-order mark — Excel’s\nGerman-locale default, like `GET /time-entries/export`.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The first month, `YYYY-MM`, inclusive."},"required":true,"description":"The first month, `YYYY-MM`, inclusive.","name":"from","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The last month, `YYYY-MM`, inclusive."},"required":true,"description":"The last month, `YYYY-MM`, inclusive.","name":"until","in":"query"},{"schema":{"type":"string","default":true,"description":"`false` leaves out the `Person` column; `Person-ID` stays."},"required":false,"description":"`false` leaves out the `Person` column; `Person-ID` stays.","name":"personNames","in":"query"},{"schema":{"type":"string","default":true,"description":"`false` leaves out the `Kunde` column; `Kunde-ID` stays."},"required":false,"description":"`false` leaves out the `Kunde` column; `Kunde-ID` stays.","name":"customerNames","in":"query"},{"schema":{"type":"string","default":true,"description":"`false` leaves out the `Projekt` column; `Projekt-ID` stays."},"required":false,"description":"`false` leaves out the `Projekt` column; `Projekt-ID` stays.","name":"projectNames","in":"query"}],"responses":{"200":{"description":"One row per month, person, customer, project and currency.","headers":{"Content-Disposition":{"schema":{"type":"string"},"required":true}},"content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/hours-by-project":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Tracked hours by project in a window","description":"Requires the `owner` or `admin` role. The report covers the complete organization,\nincluding private and archived projects that have tracked time in the window.\n\nOne row per project with an entry starting in `[from, until)`, ordered by tracked\nseconds descending. Running entries contribute no seconds until they are stopped,\nmatching every other duration summary. `totalSeconds` is the sum of the returned rows.","parameters":[{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"}],"responses":{"200":{"description":"Tracked hours grouped by project.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ProjectHours"}},"totalSeconds":{"type":"integer"}},"required":["data","totalSeconds"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/hours-by-user":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Every member's tracked hours in a window","description":"Requires the `owner` or `admin` role — this is a management question, not a read\nevery member gets, unlike the unfiltered `GET /time-entries` (invariant 19).\n\nOne row per person who has at least one entry in `[from, until)`; a member with\nnone is simply absent, not a row of zeroes. `totalSeconds` excludes running\nentries the same way `/time-entries/summary` does. `lastEntryAt` is the start of\nthat person's most recent entry *inside* the window, and it does move when a\ntimer is merely running, since that entry is more recent than any finished one.","parameters":[{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"}],"responses":{"200":{"description":"Every member's hours.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UserHours"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/projects-by-user":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Every member's project count","description":"Requires the `owner` or `admin` role, the same gate as `hours-by-user` — how many\nprojects somebody is assigned to is a management question, not a read every member\ngets about every colleague.\n\nA snapshot of `project_member` right now, not a window — there is no `from`/`until`.\nOne row per person assigned to at least one project; a member with none is simply\nabsent, not a row of zero (invariant 19).","responses":{"200":{"description":"Every member's project count.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UserProjectCount"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/person-activity":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"What one person worked on in a window","description":"Finished entries in `[from, until)`, folded into project, task and note totals plus\na per-organization-local-day breakdown. Defaults to the caller; naming a *specific\nother* person’s `personId` requires `time.readOthers` (the owner or admin role) —\nreading your own activity needs neither, which is why this route sits beside\n`hours-by-project` and `hours-by-user` rather than sharing their gate\n(ADR-2026-09-08-16W39).\n\nA running entry contributes no seconds, no note, and no day until it is stopped —\nthe same rule every other duration summary in this API follows. `perDay` carries only\ndays that have time; a day with none is simply absent, never a row of zero. A day, a\n`firstDay` and a `lastDay` are the *organization’s* local calendar day, resolved from\n`startedAt` — never UTC, which would misfile a late evening under the next day.\n\nA task’s `notes` never carries more than the five notes with the most seconds;\n`notesTruncated` says whether more existed. An entry with no note, or a blank one,\ncontributes its seconds to the task’s total and to nothing under `notes`.\n\nThis report carries no money: no rate, no amount, no currency (ADR-2026-09-08-16W39).\n\nThe deprecated `userId` alias naming an account this organization has no person for\nanswers a `200` with an empty report and `personId: null`, exactly like `GET\n/time-entries` answers the identical alias — never an error, and never a hint about\nwhich ids exist.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The subject. Defaults to the caller; naming a specific *other* person requires `time.readOthers` (the owner or admin role) — the same gate the entries list uses for the same parameter.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"The subject. Defaults to the caller; naming a specific *other* person requires `time.readOthers` (the owner or admin role) — the same gate the entries list uses for the same parameter.","name":"personId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"The person's activity.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonActivity"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/reports/hours-over-time":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Hours over time on one project or customer","description":"Finished entries on one project or one customer in `[from, until)`, summed into weeks,\nmonths or quarters and split by person, by project (customer only) or by settlement\nstatus. Every bucket of the range is present, an empty one as zero; without `from` the\nrange starts at the subject’s first entry. A running entry contributes nothing until it\nis stopped. Buckets are the *organization’s* local days, and weeks start on its\n`weekStartsOn`. Only projects the caller can see count: a private one for its members\nand for `project.readPrivate`. This report carries hours only: no rate, no amount, no\ncurrency (ADR-2026-10-24-174SX).\n\n`series` is ordered by all-time seconds on the subject, whatever the window, so a\nseries keeps its place as the period changes.\n\nWithout `report.readOthers` (the owner or admin role), `groupBy=person` answers two\nseries only, `self` and `others` — every colleague folded into `others`, even when that\nis one colleague — and no other person’s id or name appears anywhere in the answer.\n\n`groupBy=settlement` splits every second into `billed` (an invoice, drafts included, or\nan external claim), `written-off`, `open` (unclaimed on an hourly task) or `not-hourly`,\nand is refused with a `403` without `invoice.read` — never answered as „nothing to\nbill\". With neither `from` nor any hours the answer is empty, labelled `month`.\n\nThe window must lie within 1 January 2000 and 31 December 2099 in the organization’s\nzone, and `until` must be after `from`; anything else is refused with a `422`. The\nwidest window is therefore 400 quarters.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The project to read. Exactly one of `projectId` and `customerId`."},"required":false,"description":"The project to read. Exactly one of `projectId` and `customerId`.","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"The customer to read, across every project of it the caller can see. Exactly one of `projectId` and `customerId`."},"required":false,"description":"The customer to read, across every project of it the caller can see. Exactly one of `projectId` and `customerId`.","name":"customerId","in":"query"},{"schema":{"type":"string","enum":["person","project","settlement"],"description":"What a bar is split by. `project` needs `customerId`; `settlement` needs `invoice.read`."},"required":true,"description":"What a bar is split by. `project` needs `customerId`; `settlement` needs `invoice.read`.","name":"groupBy","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on `startedAt`, no earlier than 1 January 2000 in the organization’s zone. Omitted: from the subject’s first entry on.","example":"2026-07-29T09:00:00.000Z"},"required":false,"description":"Inclusive lower bound on `startedAt`, no earlier than 1 January 2000 in the organization’s zone. Omitted: from the subject’s first entry on.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on `startedAt`, after `from` and after 1 January 2000, and no later than 1 January 2100, in the organization’s zone.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on `startedAt`, after `from` and after 1 January 2000, and no later than 1 January 2100, in the organization’s zone.","name":"until","in":"query"}],"responses":{"200":{"description":"The subject’s hours, bucketed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HoursOverTime"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or `groupBy=settlement` without `invoice.read`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:read"}},"/api/v1/reports/tickets/days":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Tracked hours by day and external ticket","description":"The same hours `/reports/tickets` sums, cut finer: one row per **local day and ticket**, which is the shape a month is transcribed into another system in — on this day, this long on this ticket. Everybody’s hours on the ticket that day are one row, and `personIds` names who worked on it, as `/reports/tickets` does; pass `personId` for one person’s days.\n\nThe day is the **organization’s**, not UTC: a late evening files under the day whoever tracked it was living in, and `timeZone` travels beside the rows so the column is labelled with the zone the buckets were cut in. `from` and `until` still bound `startedAt` as instants, so pass the month’s own local boundaries and the window lines up with the days it is cut into.\n\nAn entry carrying two tickets appears under **both**, counted whole each time — it was one session of work that was about two of them, and apportioning its seconds between them would invent a division nobody tracked. Totals across rows therefore need not add up to the window’s tracked time.\n\n`rounding=carry` adds `carriedMinutes` to every row: whole minutes that add up to the window’s rounded grand total exactly, with each ticket within a minute of its own seconds after every day — its remainder is carried into its next day rather than lost to flooring each row.\n\nReadable by every member and narrowed exactly as `/reports/tickets` is (invariant 51), and, like it, **time only, never money**.\n\nOrdered by day, then by ticket, and capped at 2000 rows; `truncated` says the window held more, and carried minutes then balance over the rows returned. Running entries are in no figure until they are stopped.","parameters":[{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only entries tracked by this person.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only entries tracked by this person.","name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","maxLength":120,"description":"One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`.","example":"TIM-199"},"required":false,"description":"One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`.","name":"key","in":"query"},{"schema":{"type":"string","enum":["carry"],"description":"Absent: rows carry seconds only and `carriedMinutes` is null. `carry`: every row also carries whole minutes. All of them add up to the returned rows’ seconds rounded to the minute — the window’s total whenever `truncated` is false — and each ticket’s minutes stay within a minute of its own seconds after every day, its remainder carried into its next day. The remainder starts at `from`, so the total is exact for the window that was asked, not for a sum of smaller ones.","example":"carry"},"required":false,"description":"Absent: rows carry seconds only and `carriedMinutes` is null. `carry`: every row also carries whole minutes. All of them add up to the returned rows’ seconds rounded to the minute — the window’s total whenever `truncated` is false — and each ticket’s minutes stay within a minute of its own seconds after every day, its remainder carried into its next day. The remainder starts at `from`, so the total is exact for the window that was asked, not for a sum of smaller ones.","name":"rounding","in":"query"}],"responses":{"200":{"description":"Tracked hours grouped by day and ticket.","content":{"application/json":{"schema":{"type":"object","properties":{"rows":{"type":"array","items":{"$ref":"#/components/schemas/TicketDayReportRow"}},"truncated":{"type":"boolean"},"timeZone":{"type":"string","description":"The organization’s time zone, the one `day` was read in.","example":"Europe/Berlin"}},"required":["rows","truncated","timeZone"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"tickets","x-thymian-scope":"thymian:read"}},"/api/v1/reports/tickets":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Tracked hours by external ticket","description":"One row per ticket — per `(provider, key)` **pair**, never the key alone: `TIM-123` in Linear and `TIM-123` in Jira are two tickets, and merging them would invent a total nobody worked (invariant 73).\n\nReadable by every member, and narrowed the way the entries list is: hours on a project the caller cannot see are not in it, and their own hours always are (invariant 51). Two people therefore get different totals for the same ticket, which is the accepted cost of a member-readable sight.\n\n**Time only, never money.** A reference reaches no invoice line and is no grouping key for one, so there is no amount, no rate and no currency here.\n\nOrdered by tracked seconds descending and capped at 500 rows; `truncated` says the window held more. Running entries contribute no seconds — `durationSeconds` is not fixed until a timer stops — while they do count in `entryCount` and can move `lastStartedAt`.","parameters":[{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only entries tracked by this person.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Only entries tracked by this person.","name":"personId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"projectId","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Resource identifier (UUID v4).","name":"customerId","in":"query"},{"schema":{"type":"string","enum":["linear","jira","azure_devops","github","generic"]},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","maxLength":120,"description":"One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`.","example":"TIM-199"},"required":false,"description":"One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`.","name":"key","in":"query"}],"responses":{"200":{"description":"Tracked hours grouped by ticket.","content":{"application/json":{"schema":{"type":"object","properties":{"rows":{"type":"array","items":{"$ref":"#/components/schemas/TicketReportRow"}},"truncated":{"type":"boolean"}},"required":["rows","truncated"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"tickets","x-thymian-scope":"thymian:read"}},"/api/v1/reports/person-revenue":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"One named person's provisional revenue over a window","description":"Requires `revenue.readOthers` (the owner or admin role) for *every* subject —\nunlike `/reports/person-activity`, there is no own-data reading here at all, so\n`personId` is always required and always checked against the gate.\n\nThe figure is the sum of the current valuation of the subject’s billable entries in\n`[from, until)`, at current rates — provisional until an invoice is transferred, the\nsame wording `/reports/dashboard` uses. `unratedSeconds` reports billable time no rate\nresolved for, rather than treating it as zero. `includeRunning` folds the subject’s own\nrunning entry in, valued once as of the moment of the request.","parameters":[{"schema":{"type":"string","format":"uuid","description":"The subject. Always required — unlike `personActivityQuerySchema`, this endpoint has no own-data reading: naming yourself still requires `revenue.readOthers`.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"The subject. Always required — unlike `personActivityQuerySchema`, this endpoint has no own-data reading: naming yourself still requires `revenue.readOthers`.","name":"personId","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Inclusive lower bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Inclusive lower bound on startedAt.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Exclusive upper bound on startedAt.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"Exclusive upper bound on startedAt.","name":"until","in":"query"},{"schema":{"type":"string","default":false,"description":"Fold the subject's own running entry into the figure, valued once as of now — see runningIncluded on the result."},"required":false,"description":"Fold the subject's own running entry into the figure, valued once as of now — see runningIncluded on the result.","name":"includeRunning","in":"query"}],"responses":{"200":{"description":"The person's revenue over the window.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonRevenue"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/projection/changes":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Everything the analytics projection changed since your last pull","description":"Requires the `owner` or `admin` role, like every other read over the whole\norganization's data. Those roles see private projects, which is what makes the\nexport complete — a projection missing a project because of whose token the cron\nholds is wrong in the way nobody notices.\n\n**This request recomputes and stores.** Every month is recomputed, diffed against\nwhat was last delivered, and the new state persisted; tombstones past\n30 days are pruned. There is no scheduler in\nTimjami, so the pull is the clock.\n\nThe answer is every stored document — tombstones included — that changed at or\nafter `since`, **not** merely what this run changed. Advance your cursor only after\nyour own write succeeded: asking again with the same `since` repeats the delivery\ninstead of losing it, and every upsert is idempotent.\n\n**Act on `deleted`.** A document with `deleted: true` carries no body and means\ndelete this id — hours deleted or re-dated in Timjami otherwise stay in your index\nforever. A re-dated entry arrives as exactly that: a tombstone in the old month and\na document in the new one.\n\nPage with `cursor` while `nextCursor` is not null, keeping `since` unchanged. When\nit is null the delta is complete; store `nextSince` and pass it as the next pull’s\n`since`. `fullResyncRecommended` means your `since` reaches back further than the\ntombstones that would have explained it — call the full export instead of trusting\nthis answer.","parameters":[{"schema":{"type":"string","format":"date-time","description":"The previous pull's `nextSince`. Inclusive, so the last delta is re-delivered once — upserts make that free, and a strict comparison would lose a document whenever two runs stamp the same instant.","example":"2026-07-29T09:00:00.000Z"},"required":true,"description":"The previous pull's `nextSince`. Inclusive, so the last delta is re-delivered once — upserts make that free, and a strict comparison would lose a document whenever two runs stamp the same instant.","name":"since","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":5000,"default":1000},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor *within this delta*: pass the previous page's `nextCursor` back unchanged, together with the same `since`."},"required":false,"description":"Keyset cursor *within this delta*: pass the previous page's `nextCursor` back unchanged, together with the same `since`.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The changed documents.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ProjectionChange"}},"nextCursor":{"type":["string","null"]},"nextSince":{"type":"string","description":"The next pull's `since`: this run's own stamp."},"fullResyncRecommended":{"type":"boolean"}},"required":["data","nextCursor","nextSince","fullResyncRecommended"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/reports/projection":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"One month of the analytics projection, complete","description":"Requires the `owner` or `admin` role, for the reason the delta endpoint gives.\n\nThe month's current state in full: one document per valued stretch of every finished\nentry, non-billable time included. Tombstones are not in it — a full export is a\nstatement about what exists.\n\n**The first page recomputes and stores**, exactly as the delta does, and every page\nafter it reads what that run stored. That is what makes `syncedAt` usable: pass it\nas the next `since` and the delta continues from this export with nothing in\nbetween. It is also what keeps the pages consistent — recomputing per page would let\npage three describe a different world than page one.\n\nPage with `cursor` while `nextCursor` is not null. Use this endpoint for a first\nsync, for a rebuilt index, and for the nightly full run.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"The month to export, `YYYY-MM`."},"required":true,"description":"The month to export, `YYYY-MM`.","name":"month","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":5000,"default":1000},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged."},"required":false,"description":"Keyset cursor: pass the previous page's `nextCursor` back unchanged.","name":"cursor","in":"query"}],"responses":{"200":{"description":"The month's documents.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ProjectionDocument"}},"nextCursor":{"type":["string","null"]},"syncedAt":{"type":"string","description":"The stamp of the run this export reads. Pass it as `since` to continue with the delta."}},"required":["data","nextCursor","syncedAt"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:reports.read"}},"/api/v1/dashboards":{"get":{"tags":["Dashboards"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Every saved dashboard this person may open","description":"Requires the `owner` or `admin` role.\n\nYour own dashboards, whatever their visibility, plus every `shared` one in this\norganization. Somebody else’s `private` dashboard is not listed and not reachable.\n`isOwn` says whether you may change it; `ownerName` says whose it is.","responses":{"200":{"description":"The dashboards visible to the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Dashboards"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or lacking `report.readOthers`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Dashboards"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Save a new dashboard","description":"Requires the `owner` or `admin` role. The authenticated person becomes the owner and is\nthe only one who may change it afterwards.\n\n`visibility` defaults to `private`. `layout.widgets` is the ordered placement; each\n`widget` key is stored as sent and never checked against a list of known widgets, so a\nclient may save a layout this server has never heard of.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDashboard"}}}},"responses":{"201":{"description":"The saved dashboard.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Dashboard"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or lacking `report.readOthers`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/dashboards/{dashboardId}":{"get":{"tags":["Dashboards"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"One saved dashboard","description":"Requires the `owner` or `admin` role. Your own, or anybody’s `shared` one.\nSomebody else’s `private` dashboard answers 404 rather than 403 — naming it as forbidden\nwould confirm that it exists.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"dashboardId","in":"path"}],"responses":{"200":{"description":"The dashboard.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Dashboard"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or lacking `report.readOthers`.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Dashboards"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Change a dashboard you own","description":"Requires the `owner` or `admin` role, **and** ownership. Another admin reading a `shared`\ndashboard may not edit it: that is 403, because they can already see it and the honest\nanswer is „not yours\". A dashboard you cannot see at all is still 404.\n\nSend only what changes. A `layout` replaces the stored one whole — there is no per-widget\npatch, because the arrangement is the thing being saved.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"dashboardId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDashboard"}}}},"responses":{"200":{"description":"The changed dashboard.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Dashboard"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or lacking `report.readOthers`, or the dashboard is one a colleague saved and shared — visible, but not yours to change.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Dashboards"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Delete a dashboard you own","description":"Requires the `owner` or `admin` role, **and** ownership — the same split as `PATCH`: a\n`shared` dashboard somebody else saved is 403, one you cannot see is 404. Deleting a\nshared dashboard removes it for everybody; there is no copy left behind.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"dashboardId","in":"path"}],"responses":{"204":{"description":"The dashboard is gone."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization, or lacking `report.readOthers`, or the dashboard is one a colleague saved and shared — visible, but not yours to change.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/agent-usage":{"get":{"tags":["Reports"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Coding-agent token usage for a month or a day","description":"Tokens Claude Code and Codex spent in one month, by attribution, by model and by day.\nName `day` to narrow the month to one of its days.\nAttribution is derived when reading: the time entry the session’s hint was accepted\ninto comes first, else the longest matching hint rule of that agent naming a task,\nproject or customer, else unattributed. Usage attributed to an entry, task, project\nor customer the reader may not see counts as `hidden`.\n\nName `personId` to read a colleague’s usage; that needs the right to read their\ntime, and the answer never includes path labels.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])$","example":"2026-08","description":"A calendar month, `YYYY-MM`."},"required":true,"description":"A calendar month, `YYYY-MM`.","name":"month","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"One day of `month`, in the organization’s time zone. Omit for the month."},"required":false,"description":"One day of `month`, in the organization’s time zone. Omit for the month.","name":"day","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Person identifier (UUID v4) — a person, not a login.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Person identifier (UUID v4) — a person, not a login.","name":"personId","in":"query"}],"responses":{"200":{"description":"The month’s or the day’s usage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentUsageSummary"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"agent-usage","x-thymian-scope":"thymian:read"}},"/api/v1/collectors/keys":{"post":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create a key for a collector","description":"Creates the credential a collector reports with, bound to **you** and to the **active\norganization**. The key is in the response and nowhere else — it is stored hashed, so\nlosing it means creating another.\n\nWhat the key can do is one thing: `POST /api/v1/hints`. It cannot read hints back, and\nit cannot touch anything else in the API — which is why a collector gets one of these\nrather than a session token (ADR 0028).\n\nIt does not expire. Revoke it with `DELETE /collectors/keys/{id}`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCollectorKey"}}}},"responses":{"201":{"description":"The new key, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedCollectorKey"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List your collector keys","description":"Your own keys for the active organization, never anybody else’s. `lastRequestAt` is\nwhat answers \"is this thing actually reporting?\".\n\nThe key itself is not here and cannot be: only a hash is stored.","responses":{"200":{"description":"The keys.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectorKey"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/keys/{id}":{"delete":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke a collector key","description":"The collector stops being able to report immediately. Hints it already reported stay — they are observations about work that happened, not property of the key.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"The key was revoked."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/usage-keys":{"post":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Create a usage key for a coding agent","description":"Creates the credential a coding agent exports its token usage with, bound to **you**,\nthe **active organization**, one **source** (`claude-code` or `codex`) and a device\nlabel. The key is in the response and nowhere else.\n\nIt can do one thing: `POST /api/v1/otlp/v1/logs`, for its own source. It cannot report\nhints or read anything. It is limited to 20,000 requests per clock hour on each API\ninstance and does not expire; revoke it with `DELETE /collectors/usage-keys/{id}`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUsageKey"}}}},"responses":{"201":{"description":"The new key, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedUsageKey"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"agent-usage"},"get":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List your usage keys","description":"Your own usage keys for the active organization. `lastRequestAt` answers \"is this agent exporting?\".","responses":{"200":{"description":"The keys.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UsageKey"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"agent-usage"}},"/api/v1/collectors/usage-keys/{id}":{"delete":{"tags":["Collectors"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke a usage key","description":"The agent stops being able to export immediately. Usage it already exported stays.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"The key was revoked."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"agent-usage"}},"/api/v1/access-tokens":{"post":{"tags":["Access tokens"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Mint a personal access token","description":"Creates a credential bound to **you** and to the **active organization**, carrying\nexactly the scopes you name. The token is in the response and nowhere else — it is\nstored hashed, so losing it means minting another.\n\nA scope never widens what you may already do: the token’s authority is its scopes\nintersected with your role, so `thymian:reports.read` on a plain member’s token still\nanswers 403.\n\nSend it as `Authorization: Bearer <token>` or `X-API-Key: <token>`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAccessToken"}}}},"responses":{"201":{"description":"The new token, shown once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedAccessToken"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"get":{"tags":["Access tokens"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List your personal access tokens","description":"Your own tokens for the active organization, never anybody else’s — not even for an\nowner, because a token is a credential rather than organization data.\n\n`lastRequestAt` is what answers \"is this thing still in use?\" before revoking it. The\ntoken itself is not here and cannot be: only a hash is stored.","responses":{"200":{"description":"The tokens.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AccessToken"}}},"required":["data"]}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/access-tokens/{id}":{"delete":{"tags":["Access tokens"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Revoke a personal access token","description":"It stops working immediately. Anything it recorded stays — a time entry belongs to the person who tracked it, not to the credential that sent it.","parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"The token was revoked."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack":{"get":{"tags":["Collectors"],"summary":"The Slack connection, for this organization and for you","description":"Answers three questions at once: is this deployment set up for Slack, is a workspace connected, and are *you* connected to it. Only the last one decides whether your messages are observed.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Disconnect the workspace","description":"Removes the workspace and everybody’s link to it, and revokes the token at Slack — deleting our copy would leave a working credential behind.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/install":{"post":{"tags":["Collectors"],"summary":"Start connecting a Slack workspace","description":"Returns the URL to send the browser to. The workspace ends up bound to the organization that is active on this session.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/connect":{"post":{"tags":["Collectors"],"summary":"Start connecting your own Slack account","description":"Returns the URL to send the browser to. The Slack account that authorises is the one bound to you — which is why this is an OAuth round trip and not a name you type.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/status":{"post":{"tags":["Collectors"],"summary":"Start showing a running timer in your Slack status","description":"Its own round trip, because it is the only one that asks for permission to write your Slack profile — `users.profile.set` accepts a user token only, and a bot token cannot stand in. Somebody who only wants hints never grants it.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Collectors"],"summary":"How your status reads while a timer runs","description":"The template may use `{customer}`, `{project}` and `{task}`. The default names no customer: a Slack workspace routinely has guests and people working for someone else, and what you are billing is not always theirs to see. A field you omit is left as it is; send `null` to restore its default.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlackStatus"}}}},"responses":{"200":{"description":"The updated connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Stop showing timers in your Slack status","description":"Takes down a status that is still showing, forgets the user token and revokes it at Slack. A status you set yourself is left alone.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Switched off."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/presence":{"post":{"tags":["Collectors"],"summary":"Start reporting when Slack shows you as active","description":"Its own round trip, because it is the only one that asks for `users:read` — permission to read your own presence. Somebody who only wants channel hints never grants it, and reads are only ever about the person who authorised, never anybody else in the workspace.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Stop reporting when you are active in Slack","description":"Unlike the status, there is nothing to take down at Slack — presence is only ever read. The token is shared with the status and huddle features, so it is only forgotten and revoked once both of those are switched off too.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Switched off."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/huddle":{"post":{"tags":["Collectors"],"summary":"Start reporting when you are in a Slack Huddle","description":"Its own round trip, requesting the same `users:read` permission `presence` does — kept separate because being in a Huddle and showing as active are different evidence, and somebody may want to report one without the other.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Stop reporting when you are in a Slack Huddle","description":"Nothing to take down at Slack — a Huddle is only ever read as an event. The token is shared with the status and presence features, so it is only forgotten and revoked once both of those are switched off too.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Switched off."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/slack/identity":{"patch":{"tags":["Collectors"],"summary":"Whether your hints may carry what you wrote","description":"Turning this off also clears the message text already stored on your Slack hints. `POST /hints` coalesces `details` so a later report never erases it, which would otherwise leave the switch governing only future messages.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlackIdentity"}}}},"responses":{"200":{"description":"The updated connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlackConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Stop being observed","description":"Removes the link between your Slack account and this organization. Messages you write stop being recorded immediately; hints already reported stay until they expire, and can be dismissed or deleted like any other.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/google-calendar":{"get":{"tags":["Collectors"],"summary":"Your Google Calendar connection","description":"Whether this deployment is set up for Google at all, whether **you** have connected an account, and which of its calendars are observed. A connection with no calendars reads nothing — that is the state a fresh connection starts in.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleCalendarConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Collectors"],"summary":"Which calendars are observed, and what may be recorded about them","description":"A field you omit is left as it is. `calendarIds` replaces the whole selection, and an empty\narray is valid: connected, reading nothing.\n\nTurning `includeDetails` off also clears the location and description already stored on your\ncalendar hints. `POST /hints` coalesces `details` so a later report never erases it, which\nwould otherwise leave the switch governing only future events.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGoogleCalendar"}}}},"responses":{"200":{"description":"The updated connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleCalendarConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Collectors"],"summary":"Disconnect your Google account","description":"Revokes the grant at Google as well as deleting it here — a token we merely forgot our copy of still works. Hints already reported stay until they expire, and can be dismissed or deleted like any other.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/google-calendar/connect":{"post":{"tags":["Collectors"],"summary":"Start connecting your Google account","description":"Returns the URL to send the browser to. The Google account that authorises is the one bound\nto you, in the organization active on this session — which is why this is an OAuth round\ntrip and not an address you type.\n\nTimjami asks for two read-only scopes and no others: the list of your calendars, so the card\ncan offer it, and the events on them. Nothing it is granted can write to a calendar.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"Where to send the browser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleCalendarAuthorizeUrl"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/google-calendar/calendars":{"get":{"tags":["Collectors"],"summary":"The calendars you could observe","description":"Read from Google each time rather than stored: a calendar somebody was added to yesterday has to appear, and a stale copy would offer one they can no longer read.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"The calendars.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleCalendarList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/collectors/google-calendar/sync":{"post":{"tags":["Collectors"],"summary":"Read the calendar now","description":"The collector normally syncs by itself, riding along with a read of `GET /hints` — Timjami\nhas no scheduler, so the moment somebody looks at the panel is the moment their calendar is\nread. This is the same work, awaited, for the button that says so.\n\nUnlike the automatic one it is **not** throttled: a control that exists for \"it does not seem\nto be working\" must do something every time it is pressed.\n\nAnswers how many events became a hint, and why none did when none did. Reporting nothing is\nthe normal answer for a day with no meetings.","security":[{"SessionCookie":[]},{"BearerAuth":[]}],"responses":{"200":{"description":"What the sync found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoogleCalendarSync"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/easybill":{"get":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The Easybill connection","description":"`configured: false` hides the settings card and refuses an export.","responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Connect Easybill, or replace the key and service article","description":"The key is encrypted at rest — see ADR-2026-08-13-10F4L. Refused (409) while sevdesk is connected: an organization has at most one invoicing tool. `easybillServicePositionId` is the exception: omitting it **keeps** the article already chosen, so rotating a key cannot silently disable exporting (an export is refused while no article is set — ADR-2026-08-19-21X38). Given, it is read back from Easybill before it is stored, so this route then needs Easybill reachable. `PUT /easybill/service` is that field’s own route, and the one the web app uses.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectEasybill"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Disconnect Easybill","description":"Removes the stored key and every customer mapping. Documents already created in Easybill are unaffected — regenerate the key in Easybill’s own settings if it may have leaked.","responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/easybill/service":{"put":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the Easybill service article every exported line references","description":"The article carries the VAT rate, the unit and the Fibu-Konto: an exported position sends its id and no rate at all, and Easybill copies those three onto the document while Timjami’s description, quantity and price win (ADR-2026-08-19-21X38). An export is refused while none is chosen, rather than sending a document at 0 %. Its own route rather than a field on `POST /easybill`, because the picker it is chosen from can only read Easybill once a key is stored — and because rotating a key must not clear it. The id is read back from Easybill before it is stored, so this route needs Easybill reachable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetEasybillService"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/easybill/expense":{"put":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the Easybill article a fixed-price expense references","description":"A fixed-price `expense` (travel costs, material) usually carries another VAT rate or ledger account than a day fee, so its invoice line references this article rather than the service article. An item’s own `easybillPositionId` overrides it. Until one is chosen, exporting an invoice with an expense that has no article of its own is refused (422) rather than printing it at the service article’s rate. `null` clears it. Chosen from the same list as the service article (`GET /easybill/services`) and read back from Easybill before it is stored, so this route needs Easybill reachable. [ADR-2026-10-17-23L6T](../../../../docs/decisions/2026-10-17-23L6T.md)","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetEasybillExpense"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/easybill/services":{"get":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List Easybill service articles, for the settings picker","description":"Reads live from Easybill, through the organization’s stored key. One page of up to 1000 `SERVICE` articles — with `purpose=expense`, one page each of `SERVICE` and `PRODUCT` articles, because an expense article is often a product in Easybill — never a walk through every page: Easybill’s rate limit is per minute and shared with the exports. `pages` greater than `page` means the account has more than this answer shows. `vatPercent` is here to make the choice checkable and is stored nowhere — the article is what prices an exported line (ADR-2026-08-19-21X38). Easybill exposes no article id in its own UI, which is why this list exists rather than a field to paste one into.","parameters":[{"schema":{"type":"string","enum":["service","expense"],"example":"service"},"required":false,"name":"purpose","in":"query"},{"schema":{"type":"integer","minimum":1,"example":1},"required":false,"name":"page","in":"query"}],"responses":{"200":{"description":"One page of Easybill service articles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillServiceList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/easybill/customer-mappings":{"get":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"Every customer’s Easybill mapping","description":"Timjami’s own rows — nothing here reads Easybill. One answer for a whole screen rather than a request per customer; a customer with no mapping is simply absent.","responses":{"200":{"description":"The mappings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillCustomerMappingList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/easybill/customers":{"get":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List Easybill customers, for the mapping picker","description":"Reads live from Easybill, through the organization’s stored key. One page of up to 1000, never a walk through every page: Easybill’s rate limit is per minute and shared with the exports. `pages` greater than `page` means the account has more than this answer shows — narrow it with `companyName`, which is Easybill’s own filter and therefore finds no `personal` customer, whose name is not a company name.","parameters":[{"schema":{"type":"string","minLength":1,"example":"Muster"},"required":false,"name":"companyName","in":"query"},{"schema":{"type":"integer","minimum":1,"example":1},"required":false,"name":"page","in":"query"}],"responses":{"200":{"description":"One page of Easybill customers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillCustomerList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/customers/{id}/easybill":{"put":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Point a customer at a specific Easybill customer","description":"Set automatically on first export when exactly one Easybill customer matches by name. Use this to resolve an ambiguous match, or to point a customer at one that already has an address in Easybill instead of the bare stub an export would otherwise create. The id is read back from Easybill before it is stored, so a customer that does not exist there is refused here rather than at the next export — which means this route needs Easybill to be reachable.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetEasybillCustomerMapping"}}}},"responses":{"200":{"description":"The mapping.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EasybillCustomerMapping"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"},"delete":{"tags":["Easybill"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Forget the Easybill mapping","description":"The next export re-resolves it — by name again, or creates a new stub.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"}},"/api/v1/sevdesk":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"The sevdesk connection","description":"`configured: false` hides the settings card and refuses an export.","responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Connect sevdesk, or replace the token","description":"The token is encrypted at rest — see ADR-2026-08-13-10F4L. It is checked against the account before anything is stored, in this order, refusing on the first that fails: any authenticated read (a rejected token is 422 on `apiToken`), the bookkeeping version (only `2.0` is served — an account on 1.0 expects another payload), the account’s settings, the users, and each chosen part. Whether the account is on the Buchhaltung Pro plan, which the API needs, cannot be read; the settings card says so. Each choice is optional and **kept** when omitted, so rotating the token cannot silently disable exporting; the routes `PUT /sevdesk/service`, `/expense`, `/contact-person` and `/tax-rule` are the fields’ own. Refused (409) while Easybill is connected: an organization has at most one invoicing tool. The answer names the tax rule the account’s own settings suggest, and warns when the chosen one departs from them. [ADR-2026-10-22-09785](../../../../docs/decisions/2026-10-22-09785.md)","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectSevdesk"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnectionResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Disconnect sevdesk","description":"Removes the stored token and every customer mapping. Invoices already created in sevdesk are unaffected — revoke the token in sevdesk if it may have leaked.","responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/account":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"What the sevdesk account says about its tax rule","description":"Reads `GET /SevClient` live with the stored token: whether the account is a Kleinunternehmer (§19), the tax rule that suggests, and — when a rule is chosen — a warning if it departs from that. sevdesk does not compare the two itself, so this is where a person is told.","responses":{"200":{"description":"The account’s tax settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskAccount"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/contact-persons":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List the sevdesk users, for the contact-person picker","description":"Reads `GET /SevUser` live with the stored token. The chosen user’s name is printed on every exported invoice as „Ihr Ansprechpartner“.","responses":{"200":{"description":"The account’s users.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskContactPersonList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/parts":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List sevdesk parts, for the settings pickers","description":"Reads `GET /Part` live with the stored token: one page of up to 1000, never a walk through every page. `mayHaveMore` means the page was full and the account may have more. `vatPercent` makes the choice checkable and is stored nowhere — the export reads the chosen part’s rate at export time.","responses":{"200":{"description":"One page of sevdesk parts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskPartList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/service":{"put":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the sevdesk part whose tax rate time lines and services are exported with","description":"sevdesk copies nothing from a part onto a position, so the export reads this part’s `taxRate` at export time and sends it on every position and on the invoice head. Timjami stores which part, never a rate. An export is refused while none is chosen. The id is read back from sevdesk before it is stored, so this route needs sevdesk reachable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSevdeskServicePart"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/expense":{"put":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the sevdesk part whose tax rate an expense is exported with","description":"A fixed-price `expense` (travel costs, material) usually carries another tax rate than a day fee, so its position takes the rate of this part rather than the service part’s. Until one is chosen, exporting an invoice with an expense is refused (422). `null` clears it. Read back from sevdesk before it is stored.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSevdeskExpensePart"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/contact-person":{"put":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the sevdesk user printed as „Ihr Ansprechpartner“","description":"The user’s name is printed on every exported invoice. Chosen from `GET /sevdesk/contact-persons` and read back from sevdesk before it is stored.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSevdeskContactPerson"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/tax-rule":{"put":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Choose the tax rule every export sends","description":"Only rule `1` (taxable in Germany) and `11` (§19) can be chosen — the two verified on a draft. The answer warns when the choice departs from the account’s own `smallSettlement`, which sevdesk does not compare itself; the warning never refuses. sevdesk does compare the rule with each position’s rate, so a part whose rate does not fit the rule is refused at export time.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSevdeskTaxRule"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskConnectionResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/customer-mappings":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:invoices.read"]}],"summary":"Every customer’s sevdesk mapping","description":"Timjami’s own rows — nothing here reads sevdesk. One answer for a whole screen rather than a request per customer; a customer with no mapping is simply absent.","responses":{"200":{"description":"The mappings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskCustomerMappingList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.read"}},"/api/v1/customers/{id}/sevdesk":{"delete":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]}],"summary":"Forget the sevdesk mapping","description":"The next export re-resolves it — by exact name again, or creates a new contact.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-scope":"thymian:invoices.write"},"put":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Point a customer at a specific sevdesk contact","description":"Set automatically on first export when exactly one sevdesk **customer** has the customer’s exact name. Use this to resolve a 409 — for several customers of that name, or for a contact of that name that is not a customer (a supplier, say) — or to point a customer at one that already has an address in sevdesk instead of the bare contact an export would create. The id is read back from sevdesk before it is stored, so a contact that does not exist there is refused here rather than at the next export — which means this route needs sevdesk to be reachable.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSevdeskCustomerMapping"}}}},"responses":{"200":{"description":"The mapping.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskCustomerMapping"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/sevdesk/contacts":{"get":{"tags":["sevdesk"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List sevdesk contacts, for the mapping picker","description":"Reads `GET /Contact` live with the stored token — contacts of every category, each with its `categoryId`, so a supplier can be chosen when the automatic match refused one (ADR-2026-10-23-11AWL). One page of up to 1000, never a walk through every page; `pages` greater than `page` means the account has more than this answer shows. `name` narrows it with sevdesk’s own filter, a case-insensitive substring match.","parameters":[{"schema":{"type":"string","minLength":1,"example":"Muster"},"required":false,"name":"name","in":"query"},{"schema":{"type":"integer","minimum":1,"example":1},"required":false,"name":"page","in":"query"}],"responses":{"200":{"description":"One page of sevdesk contacts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SevdeskContactList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/personio-attendance-export":{"get":{"tags":["Personio"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Whether the Personio attendance export is active","description":"Read from the sync service: active means the target that writes attendances exists and is enabled. Readable by every member; turning it on or off is an owner’s or admin’s. **404** when this installation has no sync service configured; **503** when it cannot be reached.","responses":{"200":{"description":"The state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonioAttendanceExport"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Zynkey could not be reached or failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"put":{"tags":["Personio"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Activate the Personio attendance export","description":"Creates or replaces this organization’s Zynkey graph that writes tracked time to Personio as `WORK` attendances: one attendance per person and local day (the organization’s time zone), starting at the day’s earliest entry and lasting the sum of its entries, with local times and no offset — on the minute, as Personio keeps them: the start cut to its minute and the duration rounded to whole minutes, so a day’s exported working time differs by at most 30 seconds, while its start can lie up to a minute before the day’s earliest entry. Only time tracked on or after `exportFrom` (a local day, read in the organization’s time zone) is ever read; earlier time is never pulled in, and Timjami stores nothing of this day — every activation must state it again, and a later activation with a different day neither re-reads earlier time nor removes what Personio already has. A day whose rounded block would end at or past midnight is not written and shows as an error in Zynkey. Persons are matched through a Zynkey mapping that someone confirms there; a person without a confirmed counterpart is held back. Idempotent: repeating it converges on the same graph and re-enables it. Timjami mints the access token Zynkey reads with — for the caller, with `thymian:read` and `thymian:persons.read` — only after Personio accepted the credentials, forwards it to Zynkey, and revokes every earlier one of this organization once the activation succeeded; it is never returned, and never listed among the caller’s own access tokens. The caller must read everyone’s time on every project, private ones included (owner or admin), since the token reads as they do. The Personio client credentials become this organization’s OAuth client in Zynkey and are never stored, logged or returned by Timjami. The organization’s own name travels with them: every resource of this graph is labelled with it in the sync service, which serves every organization and whose ids name none of them. **422** on `exportFrom` when it cannot be resolved in the organization’s time zone — which is also what an organization time zone that names no zone answers, since the day is resolved in it before anything else, and nothing is written then; **409** when Zynkey refuses a write — including one against a configured scheduler id it does not know — nothing further is written; **422** on `personioClientSecret` when Personio issues no token for them — no Timjami token is minted then. That check reaches Personio only where a token has to be minted, which is the first activation of an organization: replacing the credentials of a client that already holds a live token answers with that token without asking Personio, so wrong replacements answer **200** and show at the next refresh instead; **503** when Zynkey is unreachable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivatePersonioAttendanceExport"}}}},"responses":{"200":{"description":"Activated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonioAttendanceExport"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Zynkey could not be reached or failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Personio"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Deactivate the Personio attendance export","description":"Disables the Zynkey target and readers of this organization’s export, and revokes the access token Timjami minted for it — whoever activated it, and also when Zynkey fails. Connections and the person mapping stay, so activating again resumes with a new token; attendances already written to Personio stay too. **204** also when the export was never activated.","responses":{"204":{"description":"Deactivated, or never activated."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"Zynkey could not be reached or failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/personio-person-mappings":{"get":{"tags":["Personio"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Read Personio person assignments and email suggestions","description":"Session and organization.write required. Reads every page from the active organization’s sync-service mapping set; no mapping state is stored in Timjami. Returns identity fields only. Suggestions never confirm an assignment. openCount includes every known Timjami person without a confirmed live counterpart, using the export’s resolution rule. Before the people readers have run, the list can be empty. 404 if the integration has not been provisioned; 503 on an incomplete or unavailable upstream response.","responses":{"200":{"description":"The complete person mapping list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonioPersonMappings"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"The sync service is unavailable or returned an incomplete answer.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/personio-person-mappings/{id}":{"put":{"tags":["Personio"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Confirm, change or revoke one Personio person assignment","description":"Session and organization.write required. The id names a mapping item in the active organization’s Timjami source. Both persons must belong to this organization’s mapping set. A changed assignment is revoked and replaced atomically in Zynkey; another person’s assignment is never displaced. expectedAssignment protects against a stale edit. A conflict requires reloading. Revocation stops future resolution; it does not delete attendances already written. The sync service schedules affected items for reprocessing.","parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePersonioPersonMapping"}}}},"responses":{"204":{"description":"Assignment updated in the sync service."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"503":{"description":"The sync service is unavailable or returned an incomplete answer.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/harvest":{"get":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"This person's Harvest connection","description":"`configured: false` hides the export screen.","responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"},"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Connect this person’s Harvest account","description":"Reads `/company` and `/users/me` with the given token. Refused for a duration-mode account — Harvest ignores `started_time`/`ended_time` there, and this exporter always sends both. A Harvest email that differs from this person’s Timjami email needs `confirmDifferentEmail: true`, turning the match into a confirmation rather than a guess. The token is encrypted at rest — see ADR-2026-08-13-10F4L.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectHarvest"}}}},"responses":{"200":{"description":"The connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestConnection"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"},"delete":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Disconnect this person’s Harvest account","description":"Removes the stored token. Entries already exported to Harvest are unaffected.","responses":{"204":{"description":"Disconnected."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/exports":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Export time entries to Harvest","description":"Exports this person’s own entries only — the Harvest user is whoever the connected token belongs to. A per-entry failure is reported in the 200 response, not thrown; only a batch that cannot run at all (no connection, duration mode, Harvest unreachable) is a problem response.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestExportRequest"}}}},"responses":{"200":{"description":"Per-entry results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestExportResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/exports/compare":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Compare already-exported time entries against their live Harvest counterpart","description":"Reads each entry’s linked Harvest time entry and reports whether it still matches what an export would send. An entry with no export link, or one currently running again, answers `failed` — only an established link is comparable at all (see the export ADR). Harvest’s copies are read as one range and matched by link id; an entry that range does not hold is read on its own, and those individual reads are budgeted, so a batch that meets too many of them reports the rest rather than exhausting the rate limit (ADR-2026-08-18-14GLB). `userId` compares a named colleague’s entries instead of the caller’s, with the caller’s own token and `time.readOthers` (ADR-2026-08-27-163AQ).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestCompareRequest"}}}},"responses":{"200":{"description":"Per-entry comparison results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestCompareResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/exports/adopt":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Correct time entries to what Harvest holds","description":"The other answer to a divergence `POST /harvest/exports/compare` reports: instead of re-exporting Timjami’s version, take Harvest’s. Reads each entry’s linked Harvest copy and writes back only the fields that actually differ — times, note, and the task the stored mappings resolve Harvest’s project and task back to. Harvest is never written to. An entry the two already agree on answers `unchanged`; an invoice billing these hours refuses the times and the task the way `PATCH /time-entries/{id}` does, while the note stays correctable. A per-entry failure is reported in the 200 response; only a batch that cannot run at all (no connection, Harvest unreachable) is a problem response. `userId` corrects a named colleague’s entries instead of the caller’s (`time.importOthers`, ADR-2026-08-27-163AQ). See ADR-2026-08-19-06UZS.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestAdoptRequest"}}}},"responses":{"200":{"description":"Per-entry results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestAdoptResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/imports":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Import one person’s Harvest time entries for a day range","description":"Reads one person’s Harvest entries — `user_id` is always named explicitly, never omitted, because Harvest answers an administrator’s token with everybody’s hours otherwise — and books each one as a Timjami entry. Without `userId` that person is the caller and this is unchanged from what it always was. With one, it is the named colleague: the caller’s own connected token still reads (the colleague needs no Harvest connection of their own), their Harvest account is resolved by email fresh on every request, and every entry whose mapped task falls outside a project that person is assigned to answers `target-unassigned` rather than being booked — `time.importOthers` for the run, `time.readOthers` for a preview (ADR-2026-08-27-163AQ). `from`/`to` are Harvest’s `spent_date` bounds, both inclusive, so the range is meant in Harvest’s time zone. A Harvest entry is taken over only when the stored project and task mappings resolve backwards to exactly one Timjami task on that Harvest project; everything else is reported with a reason instead of guessed. Nothing is updated: a second run over the same range skips what is already here. An entry whose Timjami chain is archived is taken over as well — the one place time may be booked onto an archived customer, project or task, because these hours were tracked elsewhere while it was live; the result names the archived level in `archived` rather than refusing (ADR-2026-08-24-11766). Being assigned to the project is still required. `preview: true` computes the same answer and writes nothing. `harvestTimeEntryIds` narrows the run to a selection out of the range: the range is still read and placed whole — a placement depends on the previous entry of its Harvest day — and the answer then holds one result per requested id, `not-in-range` included (ADR-2026-08-20-142A9). Sits beside `/harvest/exports` rather than under the structure import `/imports/harvest`, which is a different thing with different authority. See ADR-2026-08-18-07A3Q.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestImportRequest"}}}},"responses":{"200":{"description":"Per-Harvest-entry results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestImportResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/seconds":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Give a period’s linked entries the seconds Harvest publishes only as hours","description":"Harvest tracks to the second but publishes an entry’s boundaries only as `HH:mm`, so entries imported before `POST /harvest/imports` booked Harvest’s own duration carry whole minutes — and no comparison flags them, because both sides agree on every `HH:mm`. This reads the period once and gives every already-linked entry the placement a fresh import would give it: a pair inside the two minutes Harvest published, as long as Harvest’s own `hours` says, and starting no earlier than the previous entry of that Harvest day ended. **The period is the unit on purpose** — that last rule is a property of a day, so a list of entry ids cannot honour it: the entry that has to yield is usually one that needs no correction of its own. Only `started_at` and `ended_at` move; no task, no note, and nothing is written to Harvest. An entry that already carries seconds of its own is refused as `not-minute-precise` rather than truncated to what Harvest holds, and an entry an invoice bills answers `billed` unless the caller may move invoiced hours. `preview: true` computes the same answer and writes nothing. `userId` corrects a named colleague’s entries instead of the caller’s (`time.importOthers`, ADR-2026-08-27-163AQ). See ADR-2026-08-20-07ME9.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestSecondsRequest"}}}},"responses":{"200":{"description":"Per-entry results for the period.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestSecondsResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/summary":{"get":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"What Harvest itself says this period adds up to","description":"Harvest’s **own** total for a day range, read from its time report — hours, billable hours and billable amount. Deliberately not a sum of the per-entry `hours` the import preview already returns: that figure quantises 0.01 h per entry, this one once per period, and it is the number Harvest’s own reports show. `from`/`to` are Harvest’s own report bounds, both inclusive, so the range is meant in Harvest’s time zone — the same as `POST /harvest/imports`. Answers one person’s hours: the report endpoint takes no user parameter, so an administrator’s token reads the whole team and the answer is filtered down here — to the connected Harvest user, or to the `userId` named, whose Harvest account is resolved by email (`time.readOthers`, ADR-2026-08-27-163AQ). The amount comes per currency and is never added across currencies. If the Harvest account has **Time Rounding** switched on, Harvest rounds these figures according to that setting — Harvest’s API does not say whether it is on. See ADR-2026-08-19-14RSH.","parameters":[{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive. Handed to Harvest as its own report bound, so the range is meant in Harvest's time zone — not the organization's."},"required":true,"description":"Inclusive. Handed to Harvest as its own report bound, so the range is meant in Harvest's time zone — not the organization's.","name":"from","in":"query"},{"schema":{"type":"string","pattern":"^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$","example":"2026-08-14","description":"Inclusive, and never before `from`."},"required":true,"description":"Inclusive, and never before `from`.","name":"to","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Whose Harvest period to total up. Omit for the caller’s own. Naming somebody else requires `time.readOthers`; the report itself takes no user parameter, so the caller’s token reads the whole team and the answer is filtered down to this person’s Harvest account, resolved by email address.","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":false,"description":"Whose Harvest period to total up. Omit for the caller’s own. Naming somebody else requires `time.readOthers`; the report itself takes no user parameter, so the caller’s token reads the whole team and the answer is filtered down to this person’s Harvest account, resolved by email address.","name":"personId","in":"query"},{"schema":{"type":"string","pattern":"^[A-Za-z0-9]{32}$","description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","example":"9MMwtzBF5FrmCec54XqaxwdRU1LDhP17","deprecated":true},"required":false,"description":"Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`.","deprecated":true,"name":"userId","in":"query"}],"responses":{"200":{"description":"Harvest’s own totals for the range.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestPeriodSummary"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/links/{timeEntryId}":{"delete":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Forget a time entry’s Harvest export link","description":"Clears a stuck claim after an ambiguous failure that was checked by hand, or a link whose Harvest entry was deleted. The next export creates a new Harvest entry rather than updating the one that is gone.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"timeEntryId","in":"path"}],"responses":{"204":{"description":"Forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/links/by-harvest-entry/{harvestTimeEntryId}":{"delete":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Forget the link of a Harvest entry whose Timjami entry was deleted","description":"Deleting a time entry in Timjami leaves its Harvest link behind with no entry — a tombstone — and the import reports that Harvest entry as `thymian-deleted` instead of booking the hours a second time. This forgets the tombstone, which is somebody deciding those hours should come back after all: the next import over their range creates a new entry for them. Addressed by the **Harvest** id, because the Timjami id is what the deletion took. A **live** link is never removed here — it stays reachable only through `DELETE /harvest/links/{timeEntryId}`, so no call to this endpoint can set an entry Harvest already holds up to be created there twice. See ADR-2026-08-27-148DF.","parameters":[{"schema":{"type":"string","minLength":1,"example":"1234567890"},"required":true,"name":"harvestTimeEntryId","in":"path"}],"responses":{"204":{"description":"Forgotten. The next import may fetch these hours again."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/projects":{"get":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List Harvest projects, for the mapping picker","description":"Reads live from Harvest, through this person’s stored connection.","responses":{"200":{"description":"The Harvest projects.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/harvest/projects/{harvestProjectId}/tasks":{"get":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"List a Harvest project’s task assignments, for the mapping picker","description":"Reads live from Harvest, through this person’s stored connection.","parameters":[{"schema":{"type":"string","example":"20"},"required":true,"name":"harvestProjectId","in":"path"}],"responses":{"200":{"description":"The Harvest task assignments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestTaskList"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/projects/{id}/harvest":{"get":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:read"]}],"summary":"Read a project’s Harvest mapping","description":"The Harvest project this project exports to, or `null` when unlinked.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"200":{"description":"The mapping, or `null`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectLink"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest","x-thymian-scope":"thymian:read"},"put":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Point a project at a specific Harvest project","description":"Filled in automatically on import; use this for a project Harvest has no matching record for, or to correct a mismatch.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectMapping"}}}},"responses":{"200":{"description":"The mapping.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectMapping"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest","x-thymian-scope":"thymian:projects.write"},"delete":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Forget a project’s Harvest mapping","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest","x-thymian-scope":"thymian:projects.write"}},"/api/v1/projects/{id}/harvest/imports":{"post":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]}],"summary":"Import a project’s Harvest hours for every one of its current members","description":"Reads with the caller’s own Harvest connection — which must already carry Administrator/Manager rights in Harvest, since this reads every matched member’s hours, not just the caller’s own — and matches each current project member to a Harvest account by email, fresh on every run. `from`/`to` are Harvest’s `spent_date` bounds, both inclusive, in Harvest’s time zone, exactly as `POST /harvest/imports`. Every read is scoped to this project’s linked Harvest project, so a member’s hours on any other Harvest project never leak in. A whole-run failure (no connection, a dead one, duration-mode, no Harvest project link, or `GET /users` refused) is a problem response; a member with no or an ambiguous Harvest match, or whose hours Harvest refuses to this token, is reported per person and the run continues for the rest; a per-entry skip uses the same vocabulary `POST /harvest/imports` does, plus `target-unassigned` for a mapped task outside this project. `otherTrackers` additionally names everybody who tracked hours here in the range and is not among `members` — a person who tracked on this project but is not (yet) a project member, so this run never booked anything for them; a failure reading this diagnostic never fails the run. `preview: true` computes the same answer and writes nothing. See ADR-2026-08-27-09BL7, ADR-2026-08-27-13NS7.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectTeamImportRequest"}}}},"responses":{"200":{"description":"Per-member, per-entry results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestProjectTeamImportResult"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Conflicts with existing data.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest"}},"/api/v1/tasks/{id}/harvest":{"put":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Point a task at a specific Harvest project and task","description":"Filled in automatically on import; use this for a task Harvest has no matching record for, or to correct a mismatch.","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestTaskMapping"}}}},"responses":{"200":{"description":"The mapping.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestTaskMapping"}}}},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest","x-thymian-scope":"thymian:projects.write"},"delete":{"tags":["Harvest"],"security":[{"SessionCookie":[]},{"BearerAuth":[]},{"PersonalAccessToken":[]},{"OAuth2":["thymian:projects.write"]}],"summary":"Forget a task’s Harvest mapping","parameters":[{"schema":{"type":"string","format":"uuid","description":"Resource identifier (UUID v4).","example":"7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33"},"required":true,"description":"Resource identifier (UUID v4).","name":"id","in":"path"}],"responses":{"204":{"description":"Forgotten."},"400":{"description":"No active organization selected.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Not authenticated.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Not a member of the active organization.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"422":{"description":"Validation failed.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"x-thymian-feature":"harvest","x-thymian-scope":"thymian:projects.write"}}},"webhooks":{}}